DDIA 读书分享:数据模型、事务与分布式系统的设计取舍
《数据密集型应用系统设计》(Designing Data-Intensive Applications,简称 DDIA)把数据库、缓存、消息日志和数据处理放在同一套问题框架中讨论:如何保存事实,如何扩展负载,如何在故障和并发下维持约定的行为。本文是配套读书分享文章,以 Martin Kleppmann 的第一版为基础,适合希望把数据库知识连接到系统设计的后端工程师。[1]
DDIA 是数据系统与分布式系统领域的经典著作,第一版主要包含三部分:数据系统基础,涵盖可靠性、可扩展性、可维护性、数据模型、存储引擎与编码演进;分布式数据,涵盖复制、分区、事务、分布式故障、一致性与共识;派生数据,涵盖批处理、流处理及数据系统的未来方向。[1]
本次分享从中选取了系统质量目标、关系与文档等数据模型、LSM Tree 与 B+ Tree、事务隔离、CAP、分片与一致性哈希、副本复制,以及批处理与流处理的基本思路,并结合 MySQL、Codis、Kafka、Elasticsearch 和 ZooKeeper 说明设计取舍。编码与 schema 演进、共识协议细节,以及批流处理的完整机制等内容未展开,建议结合原书继续阅读。
阅读时可以带着三个问题:数据模型为什么这样选?一次成功响应究竟确认了什么?组件故障后,系统如何恢复到可核对的状态?这些问题也可以接入 系统设计:技术方案写作、架构选型与设计评审 中的技术方案与评审流程。
配套课件与阅读路径
在线阅读 DDIA 公开版课件(PDF,18 页) · 下载可编辑课件(PPTX)

课件署名为 wxquare,原分享日期为 2023-12-20,公开版于 2026-10-12 整理。公开版采用个人课件版式,重绘关键图表,并修订原稿中对事务、CAP、复制和组件机制的简化表述。PPTX 是可编辑规范源,PDF 是对应阅读导出。以下事件统计流程为教学案例,不代表某个线上系统的既有实现。
| 阅读任务 | 课件页码 | 正文重点 |
|---|---|---|
| 建立数据系统的观察框架 | 第 2–3 页 | 可靠性、负载描述、权威事实与派生数据 |
| 选择模型与存储引擎 | 第 4–6 页 | 访问模式、事务边界、图与列式模型、LSM 与 B+ Tree |
| 理解并发与分布式保证 | 第 7–11 页 | ACID、CAP、隔离异常、路由、迁移与副本 |
| 对照组件机制 | 第 12–16 页 | MySQL、Codis、Kafka、Elasticsearch、ZooKeeper |
| 串联数据处理与恢复 | 第 17–18 页 | 事件时间、迟到、去重、重放与设计问题 |
数据系统的目标与事实边界
DDIA 将可靠性、可扩展性和可维护性作为讨论入口。可靠性需要指定故障假设:单个进程退出、磁盘损坏、网络分区、错误部署分别可能怎样影响读写。可扩展性需要先描述负载,例如请求比例、热点分布、数据增长、查询范围及延迟分位数。可维护性则涉及监控、排障、理解系统与修改系统的成本。[1]
这些目标不能只用“高可用”“高性能”概括。例如订单创建需要防止重复业务事实,而商品详情可能接受短暂陈旧;二者可以有不同的读取路径和失败处理策略。
权威数据与派生数据
数据库中的订单事实、缓存中的详情、搜索索引中的商品文档和统计视图中的成交金额,承担的职责不同。设计应标明哪个数据源决定业务结果,哪些副本或投影能够从事实重建。
| 组件 | 主要职责 | 需要明确的失败语义 |
|---|---|---|
| 事务数据库 | 保存业务事实并检查约束 | 提交成功、失败与结果未知如何区分 |
| 缓存 | 降低重复读取成本 | 允许陈旧多久,失效与回源失败怎样处理 |
| 搜索索引 | 支持检索与过滤 | 索引更新滞后时,用户看到什么 |
| 消息日志 | 保存可异步消费的事件 | 重复投递、积压与保留期如何影响恢复 |
| 分析视图 | 为聚合与报表提供结果 | 迟到、重算与版本切换怎样核对 |
增加一个组件,同时增加了更新链路和失败组合。需要记录事实怎样传播、谁负责重试,以及怎样发现和修复差异。缓存与异步任务的具体设计可以结合 系统设计:技术方案写作、架构选型与设计评审 阅读。
数据模型与存储引擎
关系与文档:从访问模式开始
关系模型适合表达关联、共享实体以及需要集中检查的约束;文档模型适合把经常一起访问的聚合组织成嵌套结构。它们都需要处理 schema 演进,只是约束的落点和迁移方式不同。[1]
例如订单包含多个订单项,读取完整订单时可以考虑嵌套;商品与标签是多对多关系,而且标签独立修改时,拆开保存往往更容易控制更新。如果将共享事实复制进大量文档,就必须承担同步和核对成本。
“文档数据库不支持事务”不能作为类别结论。MongoDB 支持多文档事务,但事务能力仍取决于部署条件、读写关注设置和使用范围;分布式事务也有成本。选型应检查具体产品和版本,而不是仅比较 SQL 与 NoSQL 标签。[2]
图模型、列式与宽列的区别
图模型把节点和边作为主要对象,适用于关系遍历、路径和邻居查询。是否采用图数据库,要结合遍历深度、关系更新、索引及查询负载判断。
列式分析存储把同一列的数据组织在一起,聚合只读取需要的列,并利用编码与压缩减少扫描成本。它适合大量扫描和聚合的负载,但单条记录频繁更新是否合适,仍需核对具体实现。宽列模型则是另一种数据组织方式,不能与列式分析存储混为一谈。[1]
数据量大、字段多或数据稀疏,都只是输入条件。还需知道读哪些字段、怎样过滤、更新多少记录,以及结果需要多快可见。
LSM Tree 与 B+ Tree 的成本
LSM 引擎通常先写日志与内存结构,再生成有序文件,并通过后台合并管理文件层级。B+ Tree 通常按页维护索引,结合日志保证修改的恢复能力。二者都有写放大、缓存和后台维护成本,不能仅用“顺序写”和“随机写”判断最终性能。[1]
| 维度 | LSM Tree 需要关注 | B+ Tree 需要关注 |
|---|---|---|
| 写入 | MemTable、日志、刷盘、合并能力 | 页更新、日志、页分裂与刷盘 |
| 读取 | 需要查询的文件数、过滤器与缓存 | 树高度、页缓存、索引覆盖与访问局部性 |
| 空间 | 层级策略、压缩、删除回收延迟 | 页填充率、碎片与空间回收 |
| 尾延迟 | Compaction 与前台 I/O 竞争 | 缓存未命中、锁争用与页维护 |
验证应覆盖稳定状态与维护状态:持续写入、后台合并、恢复、冷缓存以及真实的热点分布。只测短时间峰值吞吐,可能看不到系统积累的维护成本。
事务与一致性的边界
ACID、CAP 与 BASE 回答不同问题
ACID 描述事务的原子性、一致性、隔离性和持久性。其中的一致性涉及业务不变量与约束,并不直接等同于副本读写的线性一致性。
CAP 讨论发生网络分区时,线性一致性与理论可用性无法同时满足。分析需要落实到某类操作:它是等待、拒绝,还是返回可能陈旧的数据?理论可用性与业务 SLO 的定义也不同,不能因为系统“有副本”就判断整个系统属于 AP 或 CP。[1]
BASE 是对基本可用、软状态和最终一致性的概括。最终一致性仍需要收敛条件:更新能够继续传播、冲突有处理规则、丢失的传播能够被修复。还需定义等待多久算异常,以及怎样发现没有收敛的记录。
读可见性与并发写入
常见异常包括脏读、不可重复读、幻读、丢失更新和写偏斜。隔离级别名称相同,也可能因数据库实现、查询方式和锁机制不同而呈现不同的行为。快照隔离不能普遍保证跨记录不变量。[1]
例如两名值班人员分别读取“目前有两人值班”,随后各自更新自己的记录为离岗。两次写入修改不同记录,却可能共同破坏“至少一人值班”的约束。这是写偏斜问题,需要约束、显式锁或可串行化事务等方案。
单个计数器可以优先用原子更新,减少应用读改写带来的覆盖:
1 | UPDATE counters |
版本条件更新可以检测并发冲突,但应用必须检查影响行数。影响行数为零时,要区分记录不存在、版本不匹配等情况,再决定重新读取、返回冲突或重试。
1 | UPDATE documents |
冲突重试也需要预算。涉及跨行规则时,单条 CAS 成功不代表整个业务约束成立;涉及外部支付时,数据库事务也无法自动回滚已经发生的外部副作用。
分片、复制与故障恢复
分片键决定查询与事务范围
哈希分片有利于按键点查,范围分片有利于局部扫描,但热点分布可能完全不同。按时间分区会让新写入集中在尾部,按用户分片也可能遇到少数大用户。分片键还会影响关联查询、跨分片事务和数据迁移。[1]
| 路由策略 | 获得的能力 | 需要承担的代价 |
|---|---|---|
| 哈希取模 | 简单、按键路由直接 | 节点数量变化可能产生大规模迁移 |
| 范围分片 | 支持范围读取与区间拆分 | 热点区间、边界调整和负载不均 |
| 一致性哈希 | 限制节点变化时的迁移范围 | 虚拟节点、权重、热点键和实际迁移流程 |
| 逻辑槽或映射表 | 将逻辑路由与物理节点解耦 | 元数据版本、路由切换和映射维护 |
一致性哈希通常把键分配给环上顺时针的后继节点,虚拟节点可以改善分布并配置权重。它解决路由与区间分配问题,迁移仍需复制、增量同步、核对和切换。一个热点键的访问压力,也不会因虚拟节点增加而自动分散。
副本模型与写入确认
单领导者把写入集中在主节点,需要处理复制延迟、切换与旧主隔离。多领导者允许不同位置接收写入,但必须处理冲突、因果关系和合并规则。无领导者通过多个副本读写和版本修复工作,需要明确读写集合、故障节点与冲突版本的行为。[1]
在固定副本集合下,R + W > N 可以建立读集合与写集合的交叠条件,但不能单独证明线性一致性。还需核对是否使用 sloppy quorum、并发写入怎样排序,以及版本比较、故障恢复和读修复如何工作。
以 MySQL 异步复制为例,增加副本可以分担允许陈旧数据的读取,但不会自动扩展一个主节点的写入能力。主节点故障时,未复制完成的已确认写入可能丢失;切换还需要隔离旧主、选择合适副本、核对状态,以及处理客户端看到的结果未知。半同步复制的接收确认,也不能直接等同于副本已执行事务或立即可读。
备份与副本的目标不同。错误删除可能传播到副本,备份恢复还需要验证可恢复的时间点、恢复耗时,以及重放后的业务核对方式。
对照组件理解机制
Codis:历史代理分片案例
Codis 通过代理将 Redis 客户端请求路由到不同 Redis Group,逻辑槽与物理 Group 的映射让应用不必直接感知每个节点。它适合用来观察路由、元数据与数据迁移的职责如何拆分。[3]
分析时应分别讨论代理高可用、协调存储、Group 的复制和槽迁移。代理继续提供服务,不代表数据复制已完成;一次故障切换也可能暴露异步复制窗口。该课件保留 Codis 作为历史学习案例,实际选型还应核对维护状态、协议兼容性和运维成本。
Kafka:分区日志与外部副作用
Kafka 的顺序保证以分区为单位,业务需要通过分区键组织相关事件,并检查消费者并发与重试路径。acks=1 只要求 Leader 确认,Leader 在副本复制前失效时可能丢失记录;acks=all 要求当前 ISR 集合确认。[4]
min.insync.replicas 决定在 acks=all 下接受写入需要多少 in-sync 副本。它不是“只等待这几个副本”:当前 ISR 中的副本都需要确认,阈值决定是否有资格接受写入。保证还要结合副本数、选主和错误处理理解。[5]
Producer 幂等性控制生产端重试造成的日志重复,不会自动保护外部数据库、支付或发货操作。消费者可以在业务事务中记录去重标识,并将结果与处理状态一起提交,仍需处理提交后确认失败导致的再次投递。
课件采用通用分区与副本图,不沿用旧版本中 ZooKeeper 管理 Kafka 元数据的图示。现代 Kafka 使用 KRaft;迁移与运行条件应以所用版本为准。消费侧除了 lag,还应观察最老消息年龄、处理速率、失败终态与业务核对差异。
Elasticsearch:复制成功与搜索可见性
Elasticsearch 将写入发送到主分片,再复制到 in-sync 副本。集群管理维护这组能够承担恢复责任的分片副本,不能借用 Kafka 的 ISR 机制解释它。[6]
写入确认与搜索可见性是不同阶段。Lucene 使用倒排索引和不可变 segment;refresh 让新数据能够被搜索,后台 merge 合并 segment。不能仅因为使用日志、追加写和合并,就把 Elasticsearch 等同于通用 LSM 键值存储。[7]
“主分片数创建后永远不能改变”也过于绝对。已有索引不能随意原地修改这个设置,但可在满足条件时通过 split、shrink 或重新索引形成不同的分片布局,代价涉及数据复制、资源需求与切换。split 本身有只读状态、健康状态和分片倍数等限制。[8]
ZooKeeper:写顺序与普通读
ZooKeeper 提供协调所需的有序更新、会话和版本等机制。普通读可以由连接的服务器处理,也可能返回旧数据;应结合顺序保证、会话以及具体调用方式讨论可观察结果。[9]
ZooKeeper 的写操作是线性一致的,普通读不能因此自动获得同样保证。需要跨客户端观察更新时,可结合 sync、版本检查及会话语义设计读取协议,并核对所用版本的保证。Zab 的原子广播与恢复形成有序历史,不能简单等同于两阶段提交,也不宜用几条选举口号代替完整协议。[10]
事件统计:一条可恢复的数据链路
以下是教学案例。假设系统接收展示、点击和成交事件,需要输出按分钟更新的指标;允许指标短暂滞后,但需要支持迟到更正和历史重算。具体吞吐、窗口长度、延迟目标和保留期应由实际负载确定。
- 采集事实:事件包含稳定的事件 ID、对象 ID、事件发生时间和 schema 版本。首先约定事件代表发生事实还是当前状态,避免重复解释。
- 保存可重放输入:接入层将事件写入日志,并明确受理成功的确认条件。客户端重试可能再次提交相同事件,事件 ID 需要跨重试保持稳定。
- 计算实时结果:流处理按事件时间聚合。迟到事件怎样进入窗口、多久停止更正、超过边界怎样处理,都要写入结果契约。
- 保存查询视图:结果以窗口和对象为键,采用幂等覆盖或受控版本更新;避免重放时把同一贡献重复累加。
- 离线重算与核对:批处理从保留的事实重算,比较实时结果和重算结果,记录差异原因,再通过版本切换发布修正。
批处理和流处理可共享事实输入,但具体恢复保证取决于日志保留、处理状态、检查点及输出协议。处理引擎宣称的 exactly-once 需要明确覆盖范围,不能直接推广为所有外部业务副作用只发生一次。[1]
| 失败位置 | 可能留下的状态 | 恢复措施与观察信号 |
|---|---|---|
| 写入成功后响应丢失 | 客户端不知道事件是否受理 | 使用稳定事件 ID 重试,统计重复输入 |
| 消费结果提交后进程退出 | 输入可能再次读取 | 幂等输出或原子处理状态,核对重复贡献 |
| 事件乱序或迟到 | 当前窗口结果不完整 | 迟到策略与更正版本,监控迟到分布 |
| 长时间积压 | 结果变旧,恢复窗口缩小 | 比较输入与处理速率、最老事件年龄和保留期 |
| 规则或代码错误 | 错误结果已经输出 | 保留事实与规则版本,重算、核对并切换视图 |
一份可评审的决策记录
在这个教学案例中,可以比较两种候选方案:周期性全量批处理,以及持续流处理加离线核对。假设读者要求分钟级更新,又接受短暂陈旧,后者更符合目标,但仍需验证运行成本。
| 决策维度 | 周期性全量批处理 | 持续流处理加离线核对 |
|---|---|---|
| 新鲜度 | 受运行周期和全量计算时间限制 | 持续更新,受积压与迟到影响 |
| 恢复 | 重跑任务与切换完整结果 | 检查点、重放、幂等输出与重算 |
| 成本 | 峰值扫描资源与周期调度 | 常驻处理资源与状态存储 |
| 复杂度 | 任务与结果版本较集中 | 事件时间、状态与两条链路核对 |
选择持续流处理,获得更及时的更新和局部重放能力,同时承担常驻资源、状态管理与核对责任;跨系统副作用只执行一次的保证仍需单独设计。可接受风险包括有界迟到、临时积压和规则修正期间的短暂差异。验证指标应包括端到端新鲜度、重放耗时、重算差异和资源成本;无法稳定满足更新目标或成本预算时,应重新评估窗口、输出要求或处理方案。
阅读资源与后续实践
中文阅读可使用 Vonng 的翻译仓库;其章节组织和版本应结合原书核对。[11] 需要深入某个协议或论文时,可以从原书参考文献仓库寻找原始来源。[12]
实际工作中,可以选一个数据链路,先画出权威源、副本和派生视图,再分别走一遍正常写入、响应丢失、副本切换和历史重放。每次都记录成功确认的条件、可能出现的中间状态及恢复责任,然后用 工程评审清单:架构、设计、代码与上线检查 复核方案。
参考资料
- Designing Data-Intensive Applications;Martin Kleppmann,第一版,O’Reilly Media,2017;本文按第一版主题组织。
- Transactions;MongoDB,官方文档;访问日期:2026-10-12。
- Codis;CodisLabs,项目仓库;历史架构学习资料;访问日期:2026-10-12。
- Producer Configs;Apache Software Foundation,Kafka 4.1 文档;访问日期:2026-10-12。
- Topic Configs;Apache Software Foundation,Kafka 4.1 文档;访问日期:2026-10-12。
- Reading and writing documents;Elastic,官方文档;访问日期:2026-10-12。
- Near real-time search;Elastic,官方文档;访问日期:2026-10-12。
- Split an index;Elastic,Elasticsearch API 文档;访问日期:2026-10-12。
- ZooKeeper Programmer’s Guide;Apache Software Foundation,官方文档;访问日期:2026-10-12。
- ZooKeeper Internals;Apache Software Foundation,官方文档;访问日期:2026-10-12。
- DDIA 中文翻译;Vonng 及贡献者,翻译仓库;访问日期:2026-10-12。
- References from Designing Data-Intensive Applications;Martin Kleppmann,原书参考文献仓库。