DDIA · 数据系统全景与质量属性

Ch.1-2: 权威数据源与派生数据的世界观 + 快稳弹的度量 — 看延迟用分位数, 防过载靠背压, 容错是让 fault 不升级为 failure

数据系统全景: 一个权威数据源, 万千派生视图 OLTP 权威数据源 SoR 点查 · 事务 · 增删改单条 面向用户 · GB~TB 派生: 缓存 / 搜索索引 丢了可重建, 只加速读 派生: 物化视图 / 反规范化 预计算 + 持续维护 (物化) ETL / CDC 抽取管道 数据仓库 / 数据湖 只读分析副本 · TB~PB 聚合 · ad-hoc · 事件历史 OLAP / BI / ML 分析师只读查询 reverse ETL: 分析侧产出回流操作型系统 (ML 模型上线提供推荐) 五类积木: 数据库 · 缓存 · 搜索索引 · 流处理 · 批处理 — 数据密集型系统的标准件 质量属性: 分位数与尾延迟放大 某 API 实测: 100 个请求 p50=10ms ×98 p95=30 ×1 p99=200ms ×0.5 p999=2s ×0.5 均值 30ms "看着健康" — 但每 1000 个用户里就有 1 个等 2 秒 尾延迟放大: 并行调 20 个后端, 每个仅 1% 慢 整体至少一次踩尾部的概率 = 1−(1−0.01)²⁰ = 18.2% 请求越复杂 (扇出越多), 尾部越躲不掉 fault (部件故障) → 无人处理 → failure (系统违约) 容错 = 让 fault 停在 fault: 冗余 · 熔断 · 降级 · 快速失败 机械盘年故障 2~5% · 配置变更是宕机首因 (硬件仅 10~25%) — 故障是常态不是例外 过载防御链: 从背压到熔断, 逐层降级 流量 ↑ 超过容量 背压 backpressure: TCP/管道 下游把"处理不过来"传回上游 ① 限流 token bucket 令牌桶: 匀速发牌 + 突发额度 超出速率的请求在门口被拒 ② 负载丢弃 load shedding 保核心弃低优: 只拒绝非关键 快速失败 < 1ms, 不占工作线程 ③ 熔断 circuit breaker 下游连续失败 → 快速失败一段 给下游喘息窗口, 不继续鞭打 ④ 指数退避 + jitter 重试间隔 100ms→200→400ms±随机: 否则全员同一毫秒重试 = 重试风暴

世界观: 源与派生

  • • system of record: 每条事实恰存一份
  • • 派生数据: 缓存/索引/物化视图, 丢了可重建
  • • 物化 = 预计算并持续保存查询结果
  • • OLTP 服务用户, OLAP 服务分析师, 数仓居中

度量: 分位数不看均值

  • • p50 = 典型用户, p95/p99/p999 = 尾部
  • • 均值被极端值劫持, 也不暴露长尾
  • • 扇出 n 个下游, 尾部概率 1−(1−p)^n
  • • SLO 是目标 (p99<1s), SLA 是违约后果

防御: 四层过载阀门

  • • 背压: 下游限速上游 (TCP/Unix 管道)
  • • 限流 → 负载丢弃 → 熔断 → 指数退避
  • • 亚稳态失效: 重试风暴自持, 只能重启
  • • 混沌工程: 主动注入 fault 演练容错

💡 一句话理解

DDIA 的世界观是一句话: 一个权威数据源 (system of record), 万千派生视图 — OLTP 库是唯一的"账本", 缓存、搜索索引、物化视图、数仓都是"账本的影印件", 丢了随时重印。性能度量像地铁客流统计: 平均值说"每班车间隔 10 分钟", 但你等的那班可能 40 分钟 — 所以要看 p50/p99; 过载防御像游乐园分流: 门口限流 (token bucket)、临时关闭低优项目 (load shedding)、坏掉的项目先挂牌检修 (熔断)。

🧠 必知必会 必考 & 必会

OLTP vs OLAP
OLTP 按 key 点查少量记录、增删改单条, 面向最终用户; OLAP 扫海量行做聚合, 面向分析师。数据量与访问模式完全不同, 所以分库。
-- OLTP: 按 key 取一条
SELECT * FROM orders WHERE id = 42;
-- OLAP: 扫全年做聚合
SELECT date_trunc('day', ts), sum(amount) FROM orders GROUP BY 1;
data warehouse / lake
数仓是独立于 OLTP 的集中分析库, 经 ETL 摄取多源数据; 数据湖存一切原始文件 (Parquet/图像/视频), schema 自由 — 代价是容易变"沼泽"。
# 经典管道: 业务库 → ETL → 数仓 → BI
extract:  cdc_from(mysql)       # 从 WAL 抽变更
transform: clean + join         # 库外或库内 (ELT)
load:     warehouse / lake      # Parquet 落湖
SoR 与派生数据
system of record: 每条事实恰存一份、一切分歧以它为准; 派生数据由源变换而来、丢失可重建: 缓存、索引、物化视图、反规范化值、ML 模型。
# 判定法: 这个数据丢了要紧吗?
# 订单表丢了   → 要命 → SoR (备份/复制)
# 搜索索引丢了 → 重放源数据重建 → 派生
物化 materialization
预计算并持续保存查询结果 — 贯穿全书的枢纽概念: 时间线、物化视图、索引、counters 都是把"查询变计算"换成"查询变读取"。
# 首页时间线: 不实时查关注者 (慢), 预写 timelines 表
# 发推时: fan-out 到粉丝的 timeline (写扩散)
# 读时:    SELECT * FROM timeline WHERE user_id=? (点查)
星型模型
分析 schema: 中心是事实表 (每行一个事件 + 度量列), 环绕维度表 (产品/门店/日期); 雪花把维度再规范化, OBT 把维度折叠进事实表。
-- fact_sales: 每行一次购买
SELECT f.amount, d.week, p.brand
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.id     -- 维度外键
JOIN dim_product p ON f.product_id = p.id;
存算分离
云原生架构: 实例本地盘只当 ephemeral 缓存, 数据放独立存储服务 (S3/Aurora 式)。弹性扩缩、按需付费, 代价是每次读都要跨网络。
# 云数仓配置示意
compute:  autoscale 2~32 nodes    # 算力弹性
storage:  s3://lake/parquet       # 持久层独立
# 缩掉 compute 不丢数据 — 存算分离的意义
response time ≠ latency
response time 是客户端所见 (service time + 排队 + 网络); latency 只是"未被处理"的等待。响应时间必须在客户端测 — 服务端测不到排队。
# 服务端: handler 只花 2ms
# 客户端: 2ms + 排队 180ms + 网络 20ms = 202ms
# 服务端监控永远"健康" — 队头阻塞它看不见
percentile 分位数
p50=典型用户体验, p95/p99/p999=尾部。均值不代表典型体验, 也看不见长尾 — SLO 必须写在分位数上。
lat = [10]*98 + [200] + [2000]
mean = sum(lat)/len(lat)      # → 40ms, 看着还行
# sorted[98] → p99=200ms  sorted[99] → p999=2s
尾延迟放大
一次请求并行调 n 个后端, 至少一个进入尾部的概率 = 1−(1−p)^n。扇出越多越躲不掉 — 这是微服务尾延迟的数学根源。
p = 0.01   # 单个后端仅 1% 概率慢
for n in (1, 20, 100):
    print(n, 1-(1-p)**n)   # 1% / 18.2% / 63.4%
fault vs failure
fault=部件停止正常工作; failure=系统整体违约。容错的目标是让 fault 不升级为 failure — 磁盘坏一块 (fault) 不能变成下单不可用 (failure)。
# fault:   raid1 坏一块盘          (预期内)
# failure: "整个订单库不可写"     (违约)
# 容错设计问题: "这块盘坏掉, 系统还正确吗?"
亚稳态失效
负载回落后系统仍自持过载: 请求慢 → 超时 → 重试 → 更慢 → 更多重试。正反馈锁死, 加机器没用, 只能重启 — 防它靠退避+熔断+丢弃。
# 风暴闭环: timeout(1s) < p999(2s) 时必然触发
# 止血: 重启 (打破状态) → 修 retry 预算 → 熔断兜底
退避 + 熔断 + 背压
防重试风暴三板斧: 指数退避+随机抖动防同步重试; 熔断在下游病重时快速失败; 背压把过载信号传回上游限速。
delay = 0.1 * 2**n          # 指数: 0.1/0.2/0.4s
delay *= 1 + random()      # jitter 防同步踩点
# budget: 重试总量 ≤ 正常流量 10%
负载参数与 fan-out
描述负载的量 (QPS/读写比/粉丝数) 决定架构; 数量级变化必须重审架构。写扩散 fan-out: 一个事件投递给 N 个粉丝。
# 5 亿帖/天 ≈ 5800/s 均值, 峰 15万/s
# 轮询读: 200万 qps × 4亿次查找/s  → 扛不住
# 写扩散: 发推 15万/s × 平均粉丝数  → 选型依据

🏭 生产实战 real world

场景 1 · 报表查询拖死线上库: OLTP/OLAP 分离改造

分析师一条全表聚合把 MySQL CPU 打满, 下单接口 P99 飙升 — 分析负载必须搬出 OLTP。

-- 症状: 这条 SQL 在业务库跑了 40s, 行锁+CPU 双杀
SELECT count(*) FROM orders
WHERE created_at >= '2026-01-01' AND status = 'paid';

# 改造: nightly ETL 把 orders 摄取到数仓 (列存)
# pipeline.yaml
source:    mysql.replica        # 从从库抽, 不碰主库
sink:      clickhouse.orders    # 列存, 扫描快 1000×
schedule:  "0 2 * * *"          # 低峰执行
# 效果: 同一查询 40s → 0.4s, OLTP 恢复平静

要实时就上 CDC (第 9 页), 这里 nightly 已解决 90% 的报表需求。

场景 2 · 从"平均耗时"到分位数监控的改造

大盘平均 30ms 一片绿, 客服投诉不断 — 把监控换成分位数视图。

import statistics

lat = load_client_side_rtt()          # 必须是客户端采样!
def pct(data, q):
    s = sorted(data)
    return s[min(int(len(s)*q), len(s)-1)]
print(pct(lat, 0.50), pct(lat, 0.95), pct(lat, 0.99))
# 10ms 30ms 200ms — 尾部真相出来了

# Prometheus 侧: histogram bucket 覆盖到 2.5s+
# Buckets: [.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5]

配套告警: p99 > 500ms 持续 5m, 而不是 avg。

场景 3 · 尾延迟放大: 详情页 20 个下游的组合账单

每个下游单独看都是"好服务", 详情页却 18% 的请求踩到慢请求。

backends, p_slow = 20, 0.01
overall = 1 - (1-p_slow)**backends
# → 0.182: 每次详情页有 18% 概率被一个慢下游拖住

# 缓解 1: 削减扇出 — 20 个下游合并成 4 个聚合服务
overall = 1 - (1-p_slow)**4          # → 3.9%
# 缓解 2: hedged request — 95 分位仍未返回就发第二份
# 缓解 3: 给非关键下游设 deadline, 超时降级不阻塞渲染

改造后详情页 P99 从 1.8s 回到 210ms。

场景 4 · 亚稳态失效止血: 重试风暴的正反馈锁死

依赖抖动 30 秒, 全网重试把系统按进过载状态, 流量恢复后依然瘫 — 只能重启, 然后补三件套。

# 事故还原: timeout=1s, 但过载时 p999=2.5s
# → 请求注定超时 → 全员重试 ×3 → 负载 ×4 → 永远过载

# 止血三件套 (改造后):
# 1) 退避+抖动: 100ms×2^n ± 50%, 打散重试时间轴
# 2) 熔断: 30s 窗口错误率 > 50% 且 ≥20 请求 → open 30s
# 3) 负载丢弃: 过载时先拒 /recommend 这类非关键请求
# 4) retry budget: 重试流量 ≤ 正常流量 10%

原则: 每层重试预算独立审批 — 三层各 ×3 就是 ×27。

场景 5 · 时间线选型: 写扩散 vs 轮询的数字对话

twitter 案例数字化: 用负载参数算清两种架构的天花板再动手。

# 负载参数: 5亿帖/天 ≈ 5800/s (峰 15万/s), 均粉丝 750
posts = 150_000          # 峰值发帖/s
avg_followers = 750
# 方案 A: 写扩散 — 发帖时投递到每个粉丝的时间线
fanout_write = posts * avg_followers     # ≈ 1.1 亿写/s ✗
# 方案 B: 读时聚合 (轮询) — 刷时间线时现查关注列表
poll_read = 200 * 10_000 * 4             # 200万qps×4亿查找 ✗
# 事实方案: 混合 — 普通用户写扩散, 大V (百万粉) 不扩散,
# 读时把大V的帖子 merge 进来

启示: 没有数字就没有架构 — 先算两头再选中间。

场景 6 · 数仓星型建模: 事实表 + 维度表落地

把业务库的多张表重组成分析友好的星型模型, 查询从 8 张表 JOIN 变 2 张。

-- 事实表: 每行一次购买 (事件), 度量 + 维度外键
CREATE TABLE fact_sales (
  id bigint, ts timestamp,
  product_id int, store_id int, date_id int,  -- 维度外键
  amount decimal(10,2), qty int              -- 度量
);
-- 维度表: 描述性参照
CREATE TABLE dim_product (id int, brand text, category text);
-- OBT 变体: brand 直接折叠进事实表 — 空间换扫描速度

分析查询从 8 JOIN 降到 2 JOIN, 列存下扫描量再降一个量级。

场景 7 · reverse ETL: 把模型分数送回业务库

推荐模型在数仓训练, 但线上服务只认业务库 — 分析产出需要回流。

# 流向: 业务库 → ETL → 数仓 → 训练 → score → 回流 → 业务库
# 回流用 outbox 思想 (详见流处理页), 不直连双写:
-- 业务库: score 表由管道写入, 业务只读
CREATE TABLE user_score (
  user_id bigint primary key, score real, updated_at timestamp
);
# 管道每天批量 upsert, 失败可整批重跑 — 保持"派生"属性

关键: 回流数据永远是派生 — 丢了重跑模型即可, 别当 SoR 维护。

场景 8 · 存算分离: 查询集群白天弹性扩容

报表集群只在白天忙 — 存算分离后 compute 按时扩缩, 账单减半。

# snowflake 式配置: 存储不动, 计算弹性
# warehouse = medium (4 nodes)     09:00~19:00 报表时段
# warehouse = xsmall (1 node)      夜间只跑轻任务
# storage = s3://lake/ (不变, 按量计费)

# 本地盘的角色: ephemeral 缓存
#   热分区缓存命中 → 免一次 S3 网络往返 (~50ms)
#   节点重建 → 缓存重新预热, 无数据丢失

迁移成本: 查询延迟对网络更敏感 — 热数据缓存命中率要进监控。

场景 9 · 混沌工程演练: 主动制造 fault 验证容错

容错逻辑写了不等于容错 — 每月例行注入故障, 验证 fault 不升级为 failure。

# chaos/experiment.yaml — 预发环境
target: payment-svc
fault: network_delay
params: {latency_ms: 2000, duration: 60s}
assert:
  - checkout p99 < 1s              # 熔断生效, 主流程不受伤
  - degrade_banner_shown == true    # 降级提示可见
  - error_rate < 0.1%

# 结论模板: fault 注入后系统行为 = 预期? 无责复盘记录

Netflix Chaos Monkey 随机杀实例的哲学: 与其等生产随机故障, 不如主动来。

场景 10 · SLO 与错误预算: 让可靠性变成可支配资源

SLO 定 100% 等于自缚手脚 — 错误预算才是"可以冒险"的度量。

# slo.yaml
objective: checkout availability
sli: http 5xx-free responses / total
target: 99.9%          # 月度 — 错误预算 = 43.2 分钟/月
burn_rate_alert:
  fast: 14.4x over 1h   # 快烧 → page
  slow: 6x over 6h      # 慢烧 → ticket

# 预算花完: 冻结发布, 只修可靠性 — 用制度而非吵架分配风险

SLO 是内部目标, SLA 是对外合同 (违约赔钱) — 别把二者混着写。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 用平均值评估延迟 — 98 个 10ms 加 1 个 2s, 平均 40ms"健康". 原因: 均值对长尾不敏感. 正解: p50/p95/p99/p999 四线。
# 错: avg=40ms → "达标"
# 对: p999=2s → 每千次 1 个用户等 2 秒
坑 2 · latency 与 response time 混用 — latency 只是等待时间, response time 还含排队与网络. 原因: 概念不分. 正解: 对外谈 response time, 定位时才拆 latency。
# 错: "服务端 latency 202ms, 该查了"
# 对: service 2ms + queue 180ms → 查排队, 不是查代码
坑 3 · 只在服务端测响应时间 — 队头阻塞发生在客户端等待队列里, 服务端看不见. 原因: 采样点错位. 正解: 客户端/RUM 采样。
# 错: 只看 handler 耗时
# 对: 客户端 SDK 打点 端到端 RTT
坑 4 · OLTP 库直接跑大分析 — 一条聚合锁表拖死下单. 原因: 贪省事不搭管道. 正解: 分析出库 (数仓/从库/列存)。
# 错: 生产库跑全表 GROUP BY
# 对: ETL → 数仓, OLTP 只留点查
坑 5 · 报表直连业务主库 — 即使查询不慢, 也和交易抢连接池与 buffer pool. 原因: 没有隔离层. 正解: 从库或数仓, 哪怕 nightly。
# 错: BI 工具 JDBC 直连主库
# 对: 专用 replica + 连接白名单
坑 6 · 把缓存当权威数据源 — 缓存清了业务"丢数据". 原因: 源与派生不分. 正解: 缓存只可加速, 源头必须唯一且持久。
# 错: "Redis 里有, MySQL 不用存了"
# 对: Redis = 派生缓存, SoR 在库
坑 7 · 派生数据无重建手段 — 索引/缓存坏了没法重放源数据, 派生变"半永久事实". 原因: 没设计重建管道. 正解: 任何派生都有"从 SoR 全量重算"脚本。
# 错: 索引坏了 → 手工补数三天
# 对: rebuild_index(from=snapshot) 一键重放
坑 8 · 反规范化不维护一致性 — 冗余列各写各的, 数据悄悄分叉. 原因: 只想到读快. 正解: 派生值由同事务或 CDC 统一维护。
# 错: order.count 与 orders 表各写各的
# 对: 同事务更新 或 CDC 增量维护派生列
坑 9 · 星型模型过度雪花化 — 维度套维度, 分析师写 6 层 JOIN. 原因: 教条规范化. 正解: 分析库优先宽表/OBT, 空间换速度。
# 错: dim_product → dim_brand → dim_company …
# 对: brand 冗余进 dim_product 或 fact 表
坑 10 · 数据湖裸存 JSON 变沼泽 — 无 schema 无格式无元数据, 一年后没人读得动. 原因: 只管"存下来". 正解: 列式格式 + 表格式 (Iceberg) + 分区约定。
# 错: s3://lake/2026/xx.json 万行一文件
# 对: Parquet + Iceberg 表 + dt 分区
坑 11 · 把 HTAP 当银弹 — "一套系统全搞定"常是两套引擎披统一门面. 原因: 营销话术替代架构判断. 正解: 看实现 (是否真共享存储), 量级大仍分库。
# 错: "上了 HTAP 就不用数仓了"
# 对: 大流量仍 TP/AP 分离, HTAP 只救小规模
坑 12 · 负载参数量级变了不重审 — 用户量 ×100, 架构原样硬扛. 原因: 架构是"一次性决定"心态. 正解: QPS/数据量过档位就重新评审。
# 错: 单库分表方案用到 10 亿行
# 对: 量级过档 → 重新选型评审
坑 13 · p99 达标就高枕无忧 — p999 的 0.1% 恰是高频用户/大请求. 原因: 盯达标线不看尾部形状. 正解: 长尾另立专项 (hedged/降级)。
# 错: p99=80ms 达标收工
# 对: p999=4s 单独归因 (GC/重传/大查询)
坑 14 · 扇出不算尾部概率 — 20 个"99 分可靠"下游组成 18% 事故率. 原因: 概率直觉失灵. 正解: 用 1−(1−p)^n 预估再定扇出上限。
# 错: "每个都 99% 可靠, 没问题"
# 对: n=20 → 82% 全好 — 扇出收敛到 4~6
坑 15 · 重试无退避 — 立即重试 = 同步炮轰, 把抖动放大成风暴. 原因: 重试只有次数. 正解: 指数退避 + jitter + budget。
# 错: while fail: retry_now()
# 对: 0.1s×2^n ± rand, budget 10%
坑 16 · 无熔断硬打病重下游 — 下游已跪还在打, 线程池跟着陪葬. 原因: 调用无健康状态. 正解: 熔断器三态 (closed/open/half-open)。
# 错: 失败也照常全量调用
# 对: 错误率超阈值 → open 快速失败
坑 17 · 无界队列当缓冲 — 生产快消费慢, 内存默默涨到 OOM. 原因: 以为队列"总能消化". 正解: 有界队列 + 满则拒绝/降级 (背压)。
# 错: queue.append(job) 无限收
# 对: bounded(10000) + full → 429
坑 18 · 亚稳态靠加机器自救 — 过载自持时扩容只是多几台一起瘫. 原因: 没识别正反馈. 正解: 重启打断状态, 再补退避/熔断/丢弃。
# 错: 过载 → autoscale ×4 → 仍瘫
# 对: 重启 + 修风暴源头 (timeout vs p999)
坑 19 · SLO 定 100% — 没有错误预算, 任何变更都是违约. 原因: 目标越满越"安全"的错觉. 正解: 99.9% 留 43 分钟预算当变更额度。
# 错: SLO=100% → 发布即违反
# 对: 99.9% + burn rate 告警
坑 20 · 复盘追责个人 — "谁改挂的"文化让下次没人敢说真话. 原因: human error 是症状不是根因. 正解: blameless postmortem, 追系统改进项。
# 错: 复盘结论 = 当事人记过
# 对: 结论 = 流程/防呆改进 + 责任人跟进