NoBug World NoBug World

DDIA 第一版精炼:数据系统的可靠性、扩展性与正确性

AI 辅助创作声明 本文的内容、结构或代码排版等, 100% 由 AI 辅助生成。

《Designing Data-Intensive Applications》不是一本教你怎么用某个数据库的书。它更像是一套数据系统的判断框架:什么时候该用关系模型,什么时候文档模型更顺;为什么 LSM-tree 写入快,B-tree 更稳;复制延迟会让用户看到什么怪现象;事务到底帮应用屏蔽了哪些并发问题;共识为什么难,又为什么绕不开。

这篇文章整理自 DDIA 第一版英文原文。我会把全书压缩成一份可以连续阅读的学习版资料,保留主线、术语、关键例子和工程权衡,但不会复刻原书的长篇展开。

0. 材料定位

《Designing Data-Intensive Applications》(后文简称 DDIA)不是一本教你使用某个数据库的书。它讲的是:当应用主要瓶颈不再是 CPU,而是数据量、数据复杂度、数据变化速度和系统协作复杂度时,工程师该如何理解数据系统。

原书有三条贯穿全书的目标:

  1. 可靠性:系统在硬件、软件、人为错误和网络故障下仍尽量正确工作。
  2. 可扩展性:负载、数据量和复杂度增长时,系统仍有合理的应对办法。
  3. 可维护性:系统能被人理解、运维、演化,而不是变成无法修改的遗留泥球。

全书结构分三部分:

  1. Part I Foundations of Data Systems:单机和基础数据系统,包括数据模型、存储引擎、编码与演化。
  2. Part II Distributed Data:分布式数据,包括复制、分区、事务、分布式故障、一致性和共识。
  3. Part III Derived Data:派生数据,包括批处理、流处理,以及未来数据系统的整合方向。

本文采用中度微压缩。会保留必要例子,例如 Twitter 时间线、LinkedIn 简历模型、LSM-tree、购物车并发写、医生值班 write skew、网络请求不确定性、ZooKeeper、Unix 管道、Kafka 风格日志和派生数据系统。

1. 全局知识地图

DDIA 的主线可以压缩成一句话:

数据密集型应用的核心,是在不断变化、不断失败、不断增长的数据世界里,用合适的抽象把复杂性控制在工程师能理解和系统能承受的范围内。

全书的知识依赖如下:

层次核心问题对应章节
系统目标什么样的数据系统算可靠、可扩展、可维护?第 1 章
数据表达用关系、文档、图,还是其他模型表达业务?第 2 章
数据存取数据落到磁盘后,数据库如何高效写入和查询?第 3 章
系统演化数据格式、服务 API、消息如何兼容新旧版本?第 4 章
数据副本多个副本如何保持同步?延迟会带来什么异常?第 5 章
数据拆分数据太大时如何分区、重平衡、路由请求?第 6 章
并发正确性事务到底屏蔽了哪些错误?隔离级别差在哪里?第 7 章
分布式现实网络、时钟、进程暂停为什么不可靠?第 8 章
强保证线性一致性、因果性、共识分别解决什么?第 9 章
离线派生批处理如何把大规模有界输入转成派生数据?第 10 章
连续派生流处理如何处理无界事件、状态和故障?第 11 章
系统未来如何用日志、派生数据、端到端正确性组合系统?第 12 章

推荐对照图片:

组合式数据系统

LSM-tree 段合并

购物车并发写的因果依赖

请求路由到正确分区

网络请求失败的不确定性

线性一致性与可用性的取舍

当前状态与事件流

2. 精炼学习正文

第 1 章:可靠、可扩展、可维护的应用

本章建立全书的判断标准。现代数据密集型应用通常不是 CPU 算不动,而是数据太多、变化太快、表示太复杂、系统组件太多。一个应用往往同时使用数据库、缓存、搜索索引、消息队列、批处理和流处理。当应用把多个通用工具组合起来,并对外提供一个统一 API 时,开发者其实已经在设计一个新的专用数据系统。

可靠性指系统在出错时仍继续正确工作。这里要区分 fault 和 failure:fault 是某个组件偏离预期,failure 是系统整体不能向用户提供服务。可靠系统的目标不是消灭所有 fault,而是防止 fault 放大成用户可见的 failure。

故障来源包括硬件、软件和人。硬件故障在大规模机器集群里是常态,例如磁盘损坏、电源故障、机器宕机。软件错误更危险,因为它往往是系统性和相关性的:同一个 bug 可能让所有节点在相同输入下同时崩溃。人为错误也很常见,尤其是配置错误、错误操作和发布问题。可靠系统需要测试、监控、隔离、回滚、灰度发布和自动化恢复。

可扩展性不是一句“这个系统可扩展”。正确问法是:如果负载以某种方式增长,我们有哪些选择?负载要用具体参数描述,例如请求数、读写比例、活跃用户数、数据量、热点 key 数量、扇出规模。性能也要量化,尤其是响应时间分位数,而不只是平均值。平均值会掩盖尾延迟;一个请求如果依赖多个后端调用,只要其中一个慢,整个用户请求就慢,这就是尾延迟放大。

可维护性分成三件事:

  1. 可运维性:系统要让运维人员容易观察、恢复、升级和管理。
  2. 简单性:通过抽象消除偶然复杂性,而不是堆砌功能。
  3. 可演化性:需求变化、平台变化、规模变化时,系统能被修改。

这一章的作用是给后文所有技术做评价标准。复制、分区、事务、共识、批处理、流处理都不是孤立技巧,它们都是为了在不同约束下实现可靠性、可扩展性和可维护性。

学习检查:

  1. 为什么“可扩展”必须先定义负载参数?
  2. fault 和 failure 有什么区别?
  3. 为什么系统性软件错误比单机硬件错误更危险?
  4. 为什么响应时间要看高分位数?

第 2 章:数据模型与查询语言

数据模型影响的不只是代码怎么写,还影响我们如何思考问题。应用开发者把现实世界建模成对象或数据结构,再把这些结构存进关系表、文档、图或其他通用数据模型。数据库内部又把这些模型编码成磁盘和内存中的字节。每一层模型都隐藏下一层复杂性,也限制上一层能自然表达什么。

关系模型把数据组织成表和行。它的优势是通用、成熟、能表达多对多关系,并且查询语言 SQL 是声明式的。关系模型最初服务商业数据处理,但后来广泛适应了 Web、社交、电商、游戏和 SaaS 等场景。

文档模型适合自包含、一对多树状结构的数据。原书用 LinkedIn 简历说明:一个用户有多个职位、教育经历和联系方式,JSON 文档能把整个简历放在一起,读一个用户资料时局部性很好,应用对象和存储结构也更接近。

但文档模型在多对一和多对多关系上会变麻烦。比如地区、行业、学校、组织、推荐人都可能从普通字符串演化成实体引用。一旦关系变多,应用要么复制数据,要么自己做 join。历史上层级数据库也遇到过类似问题,后来关系模型正是为了解决多对多和查询灵活性而崛起。

关系数据库和文档数据库没有绝对胜负。判断标准是数据形状和访问模式:

  1. 数据主要是树状、整体读取、内部关系紧密:文档模型可能简单。
  2. 数据之间关系多、需要 join、需要灵活查询:关系模型更自然。
  3. 任意实体之间都可能连接,并且查询重点是沿关系遍历:图模型更合适。

图模型适合高度互联的数据。属性图把数据表示为顶点、边和属性;Cypher 用模式匹配查询图。三元组存储把所有事实表示为 subject、predicate、object;SPARQL 查询三元组;Datalog 用规则推导新关系。图查询语言的重要价值是让复杂路径查询更自然,而不是把递归 join 硬塞进 SQL。

查询语言也有重要差异。声明式查询语言让用户描述“想要什么”,由系统决定“怎么做”。SQL、Cypher、SPARQL 都体现了这一点。声明式语言更容易被优化器重写、并行化和优化。命令式查询虽然控制力强,但更容易把执行策略写死。

学习检查:

  1. 文档模型为什么适合一对多树结构?
  2. 多对多关系为什么会削弱文档数据库优势?
  3. 声明式查询语言为什么有利于优化?
  4. 图数据库和 CODASYL 网络模型有什么本质差异?

第 3 章:存储与检索

数据库最基本的任务是:写入数据,后来再找回来。应用开发者即使不写存储引擎,也需要理解底层原理,因为不同存储引擎适合不同工作负载。

本章从一个极简 key-value 数据库开始:写入时只追加到文件尾部,读取时扫描整个文件找最后一次出现的 key。追加写很快,读取很慢。要让读取变快,就需要索引。索引是从主数据派生出来的额外结构,它能加速读,但会拖慢写,因为每次写入也要更新索引。

第一类存储引擎是日志结构。最简单形式是追加日志加内存 hash index。每个 key 映射到文件偏移,读时用 hash map 定位。为了防止日志无限增长,需要把日志分段,对旧段做压缩和合并,只保留每个 key 的最新值。删除用 tombstone 标记。追加写、不可变段、后台合并让崩溃恢复和并发控制更简单。

Hash index 的限制是 key 必须放进内存,并且不适合范围查询。SSTable 通过让段内 key 有序来解决这些问题。SSTable 只需要稀疏索引,就能用有序性定位范围;多个 SSTable 可以像归并排序一样合并。写入先进入内存有序结构 memtable,memtable 达到阈值后刷成 SSTable。为了崩溃恢复,还要保留预写日志。这套思想形成 LSM-tree,被 LevelDB、RocksDB、Cassandra、HBase、Lucene 等系统使用。

第二类存储引擎是 B-tree。B-tree 把磁盘分成固定大小页,索引页保存 key 范围和子页指针,叶子页保存记录位置或记录本身。B-tree 更新是就地修改页,因此需要 WAL 保证崩溃恢复。B-tree 很成熟,广泛用于关系数据库。它通常读性能稳定,适合范围查询;但随机写和页分裂会带来写放大。

LSM-tree 与 B-tree 的核心权衡:

  1. LSM-tree 把随机写转成顺序写,写吞吐通常更好。
  2. LSM-tree 读取可能要查多个段,需要 Bloom filter 和合并策略优化。
  3. LSM-tree 后台压缩会竞争磁盘资源,压缩跟不上会造成问题。
  4. B-tree 读路径直接、范围查询强,但写入更随机。

后半章区分 OLTP 和 OLAP。OLTP 面向用户请求,单次查询通常访问少量记录,瓶颈常是磁盘寻址和索引查找。OLAP 面向分析,单次查询扫描大量行但只用少数列,瓶颈常是磁盘带宽和 CPU 内存带宽。因此数据仓库常用列式存储:每列单独存储,只读取查询需要的列。列式存储还适合压缩,例如位图编码和运行长度编码。

星型模式是数据仓库常见模型:事实表记录事件,维度表描述日期、产品、门店、用户等上下文。物化视图和数据立方体通过预计算聚合提高查询速度,但会降低灵活性并增加写入维护成本。

学习检查:

  1. 为什么索引会加速读但拖慢写?
  2. LSM-tree 如何把随机写转成顺序写?
  3. B-tree 为什么需要 WAL?
  4. OLTP 和 OLAP 的访问模式有什么根本差异?
  5. 列式存储为什么适合分析查询?

第 4 章:编码与演化

系统会不断演化。新增字段、修改格式、升级服务都要求旧代码和新代码共存。滚动升级让一部分节点先运行新版本,另一部分仍运行旧版本;客户端应用也可能长期停留在旧版本。因此数据格式必须支持两种兼容性:

  1. 向后兼容:新代码能读旧数据。
  2. 向前兼容:旧代码能读新数据,至少能忽略不认识的新增字段。

程序内存中的对象不能直接写入文件或发到网络,因为指针、对象布局和语言运行时只在本进程内有意义。编码是把内存数据转成字节序列;解码是从字节恢复数据结构。

语言自带序列化通常不适合长期数据和跨服务协议。它们绑定语言,兼容性弱,安全风险高,性能也可能差。JSON、XML、CSV 更通用、人可读,但有数字精度、二进制数据、schema 模糊等问题。二进制格式更紧凑,但如果只是把 JSON 变成二进制并保留字段名,收益有限。

Thrift、Protocol Buffers 和 Avro 代表 schema 驱动的二进制编码。Thrift 和 Protobuf 用字段 tag 代替字段名,所以新增字段只要使用新 tag,旧代码就能跳过未知字段。字段 tag 不能随便改,因为已有数据依赖 tag 含义。Avro 的特点是 writer schema 和 reader schema 分离,读取时按字段名匹配,并通过 schema resolution 处理差异。Avro 很适合数据文件和动态生成 schema 的场景。

编码格式不仅影响存储,也影响数据流动方式。原书讨论三类 dataflow:

  1. 通过数据库流动:一个进程写入编码数据,另一个进程后来读取。
  2. 通过服务流动:客户端编码请求,服务端解码并编码响应。
  3. 通过消息传递流动:生产者编码消息,消费者解码消息。

RPC 试图把远程调用伪装成本地函数调用,这是危险抽象。网络请求可能超时、丢失、重复、变慢;调用方不知道请求没到、响应丢了,还是对方已经处理但响应没回来。重试可能造成副作用重复。因此远程调用不应被设计得像普通本地函数一样透明。REST 的优势之一恰恰是没有完全隐藏网络协议本质,易调试、生态广。

消息队列介于 RPC 和数据库之间。它像 RPC 一样低延迟传递消息,也像数据库一样暂存消息。消息 broker 能缓冲、重投、扇出、解耦发送者和接收者。只要消息编码支持向前和向后兼容,生产者和消费者就能独立升级。

学习检查:

  1. 为什么滚动升级要求双向兼容?
  2. Protobuf 的字段 tag 为什么不能随便改?
  3. Avro 的 writer schema 和 reader schema 解决了什么问题?
  4. 为什么 RPC 的“位置透明性”是危险抽象?

第 5 章:复制

复制是把同一份数据保存在多个节点上。它的目的包括:提高可用性、让数据靠近用户、扩展读吞吐、支持离线操作。复制本身不难,难的是复制持续变化的数据。

单领导者复制中,所有写入先到 leader,再通过复制日志发送给 follower。读可以从 leader 或 follower 读取,但 follower 可能滞后。同步复制能保证 follower 收到后再确认写入,但如果同步 follower 挂了,写入会阻塞。异步复制延迟低、可用性好,但 leader 故障时可能丢失已经向客户端确认但尚未复制的写入。

leader 故障需要 failover:判断 leader 死亡、选新 leader、让客户端切到新 leader、让旧 leader 回来后承认新身份。这一步风险很高。异步复制可能导致新 leader 缺少旧 leader 上的写入;旧 leader 恢复后可能出现 split brain;过短 timeout 会误判故障,过长 timeout 会拖慢恢复。

复制延迟会带来三种用户可见异常:

  1. 读己之写:用户写完后马上读,却从滞后副本看到旧值。
  2. 单调读:用户先看到新数据,后来请求落到旧副本,又看到旧数据,仿佛时间倒退。
  3. 一致前缀读:多个分区复制速度不同,用户可能先看到回答,再看到问题。

这些不是抽象理论,而是用户体验和业务正确性问题。解决方式包括从 leader 读、根据用户会话固定副本、基于时间戳或复制位置路由读请求、等待副本追上等。

多领导者复制允许多个 leader 接受写入,适合多数据中心、离线客户端、协同编辑等场景。但它引入写冲突。冲突可以避免,例如确保某个记录只由一个 leader 写;也可以解析,例如 last write wins、应用自定义合并、CRDT 等。LWW 能收敛,但会静默丢弃并发写,不适合不能丢数据的场景。

无领导者复制中,客户端向多个副本写,也从多个副本读。通常用 n、w、r 描述:n 个副本,写入要 w 个确认,读取要 r 个响应。如果 w + r > n,理论上读集合和写集合有交集,从而能看到最新写。但现实中还有并发写、故障恢复、sloppy quorum、hinted handoff、时钟问题和读取修复等复杂性。

本章最重要的思维是 happens-before。两个操作不是看物理时间是否重叠,而是看一方是否知道另一方。如果 B 基于 A 的结果写入,则 A happens before B;如果双方互不知道,则它们并发。版本向量记录因果依赖,用来区分覆盖和并发冲突。购物车例子说明:并发添加商品时不能简单丢弃某个值,客户端或系统必须合并 sibling,并用 tombstone 正确表达删除。

学习检查:

  1. 同步复制和异步复制在故障时差异是什么?
  2. 为什么 failover 可能导致数据丢失或 split brain?
  3. 读己之写、单调读、一致前缀读分别解决什么异常?
  4. LWW 为什么会牺牲 durability?
  5. happens-before 如何定义并发?

第 6 章:分区

复制解决副本问题,但如果数据太大或负载太高,单个节点即使有副本也扛不住。这时要分区,也叫 sharding。每条记录属于一个分区,分区分布在多个节点上。目标是让数据和查询负载均匀分散,避免热点。

按 key range 分区会把连续 key 范围放到同一分区。优点是范围查询高效,适合有序扫描。缺点是容易产生热点,例如以时间戳为 key 时,所有新写入集中到“今天”这个范围。解决办法可能是把传感器 ID 放在时间戳前面,让写入按传感器分散,但读取多个传感器的时间范围就要发多个查询。

按 key hash 分区会打散 key,使负载更均匀。缺点是破坏 key 的顺序,范围查询必须扫多个分区。Cassandra 的复合主键是一种折中:第一部分 hash 定位分区,后续列在分区内排序,从而支持“某用户某时间范围的更新”这类一对多查询。

热点无法完全靠 hash 解决。如果所有请求都打到同一个 celebrity key,hash 仍然会把同一个 key 路由到同一分区。应用可能需要手动拆热 key,例如给 key 加随机前缀,把写入拆到多个 key,再在读取时合并。这降低热点,但增加读取和维护成本。

二级索引遇到分区后更复杂。两种策略:

  1. 文档分区索引,也叫 local index。索引跟数据在同一分区。写入只更新一个分区,但按二级索引查询要 scatter/gather 到所有分区。
  2. 词项分区索引,也叫 global index。索引按被索引的值分区。读取能直接定位索引分区,但写入可能要更新多个索引分区,且异步维护时可能短暂不一致。

重平衡是节点增减时重新分配分区。不要用 hash(key) mod N,因为 N 一变,大量 key 都要移动。常见策略是预先创建远多于节点数的固定分区,新节点加入时从旧节点“偷”一些分区;或动态分区,分区变大后拆分;或让分区数与节点数成比例。自动重平衡方便但危险,可能在节点短暂慢响应时触发大量数据迁移,放大故障。很多系统保留人工确认。

请求路由要解决:给定 key,客户端该连哪个节点?方案包括:请求发任意节点再转发;经过路由层;客户端自己知道分区映射。分区映射需要一致的元数据来源,很多系统用 ZooKeeper、配置服务器或 gossip 协议传播。

学习检查:

  1. key range 分区和 hash 分区各自牺牲了什么?
  2. 为什么 hash(key) mod N 不适合重平衡?
  3. local index 和 global index 的读写代价如何互换?
  4. 自动重平衡为什么可能放大故障?

第 7 章:事务

事务的作用是简化应用编程模型。它让应用可以把多个读写组合成一个逻辑单元:要么全部提交,要么全部回滚。没有事务,应用要自己处理进程崩溃、网络中断、磁盘满、部分写入成功、并发覆盖等错误组合。

ACID 常被当营销词使用,需要拆开理解:

  1. Atomicity 原子性:出错时能 abort,撤销本事务已经做的写入。这里不是并发意义的“不可分割”,更接近“可回滚性”。
  2. Consistency 一致性:数据库满足业务不变量。这个 C 很大程度是应用责任,数据库只能帮忙检查部分约束。
  3. Isolation 隔离性:并发事务互不干扰,理想情况是结果等价于串行执行。
  4. Durability 持久性:提交后数据不会轻易丢失,但绝对持久不存在,只能用磁盘、复制、备份降低风险。

弱隔离级别是本章重点。Read committed 防止脏读和脏写,但不防止 read skew。Snapshot isolation 给事务一个一致快照,通常用 MVCC 实现,可以防止 read skew,并让读写互不阻塞。但 snapshot isolation 仍可能出现 lost update、write skew 和某些 phantom 问题。

医生值班例子是 write skew 的经典说明:规则要求至少一名医生值班。两个医生同时读取到“还有两人值班”,各自把自己设为不值班。每个事务单独看都没问题,但最终没人值班,违反业务不变量。Snapshot isolation 无法阻止这种异常,因为两个事务写的是不同记录。只有 serializable isolation 能从根上防止。

Serializable 是最强隔离级别,让并发事务结果等价于某个串行顺序。实现方式有三种:

  1. 真正串行执行:简单可靠,但吞吐受单线程或单分区限制。适合每个事务很短、数据能按分区独立处理的场景。
  2. Two-phase locking,2PL:读写加锁,先获取锁再释放锁,能实现串行化,但可能阻塞、死锁、尾延迟高。
  3. Serializable snapshot isolation,SSI:乐观并发控制。事务先在快照上执行,提交时检测是否基于过期前提做了决定,如果不安全则 abort。它保留 snapshot isolation 的读写不阻塞优势,但在高冲突下 abort 成本会上升。

事务不是越强越好,也不是天然不可扩展。正确问题是:你的业务不变量、并发模式和故障模式需要数据库帮你屏蔽哪些复杂性?如果访问模式只是单对象读写,可能不用多对象事务。如果有反范式计数、跨行约束、余额转账、唯一性检查、库存扣减,事务能显著减少应用需要手写的错误处理。

学习检查:

  1. ACID 中哪个 C 主要是应用责任?
  2. Snapshot isolation 为什么仍会出现 write skew?
  3. lost update 和 dirty write 有什么区别?
  4. 2PL 和 SSI 是悲观还是乐观?各自代价是什么?

第 8 章:分布式系统的麻烦

单机程序通常给人一种确定性幻觉:要么工作,要么崩溃。分布式系统不同,它的核心特征是部分失败。某些节点正常,某些节点宕机,某些网络链路变慢,某些请求已经处理但响应丢失,系统处在一种调用方无法完全知道真实状态的世界。

网络是不可靠的。你发出请求后没有收到响应,无法区分:请求丢了、对方宕机、请求排队、对方处理了但响应丢了、响应正在路上。timeout 只能说明你等不下去了,不能说明对方没处理。因此所有跨网络协议都必须面对“不知道是否成功”的状态。

故障检测也不可靠。系统通常用心跳和 timeout 判断节点是否死亡,但网络延迟、GC pause、CPU 饱和、交换机队列拥塞都会造成误判。某个节点可能不是死了,而是极慢。极慢节点比干净宕机更难处理。

时钟也不可靠。Time-of-day clock 可能跳变,受 NTP 调整影响;monotonic clock 适合测量持续时间,但不能比较不同机器上的绝对时间。依赖物理时间戳做写入顺序会出错,因为时钟可能不同步。即使使用同步时钟,也应把读数看成一个置信区间,而不是精确点。

进程暂停是另一个隐蔽问题。GC、虚拟机暂停、操作系统调度、磁盘 I/O、信号处理都可能让进程长时间停止。进程恢复后可能以为自己还持有锁或租约,但其他节点已经认为它死了并把资源交给别人。为防止这种旧持有者继续写坏数据,需要 fencing token:每次获取锁时给出递增 token,资源端只接受更大 token 的写入。

本章还区分了系统模型:

  1. 同步模型:网络延迟、进程暂停、时钟漂移都有已知上界。现实互联网服务通常不满足。
  2. 部分同步模型:大多数时候表现良好,但偶尔延迟和暂停可以任意大。现实中最有用。
  3. 异步模型:不允许依赖时间假设,甚至没有时钟。理论上更严格,但能做的事少。

节点故障模型包括 crash-stop、crash-recovery 和 Byzantine。大多数数据系统假设非拜占庭故障,也就是节点不会恶意撒谎。但工程上仍应防御弱形式的“撒谎”:校验和、输入校验、多 NTP 源等。

安全性和活性是理解分布式算法的两个关键词。安全性是“坏事永远不发生”,一旦违反就无法撤销;活性是“好事最终会发生”,可能暂时不满足但未来还有希望。分布式算法通常要求安全性在所有故障下都保持,而活性可以在网络最终恢复、多数节点可用等条件下成立。

学习检查:

  1. 为什么 timeout 不能证明远程节点已经死了?
  2. 为什么物理时钟不适合直接决定事件顺序?
  3. fencing token 解决了什么问题?
  4. 安全性和活性有什么区别?

第 9 章:一致性与共识

本章讨论分布式系统能提供哪些强保证,以及这些保证的代价。

最终一致性只说:如果停止写入并等足够久,副本最终会收敛。它没有说明多久收敛,也没有保证写后立刻读能看到新值。应用如果不理解这个弱保证,很容易在故障或高并发时出现隐蔽 bug。

线性一致性试图给应用一个错觉:虽然系统有多个副本,但看起来像只有一个副本,所有操作都在某个瞬间原子生效。它是新鲜度保证:一旦某个读已经看到新值,之后所有读都不能再看到旧值。它很容易理解,适合锁、leader election、唯一性约束、跨通道时序依赖等场景。

线性一致性的代价是协调。网络分区时,如果要保持线性一致性,少数派分区必须不可用;如果继续接受读写,就可能破坏单副本幻觉。这就是 CAP 讨论的核心。但原书提醒:CAP 过度简化,真实系统还要考虑延迟、复制方式、故障检测、读写路径等。更实用的问题是:你愿意为哪些操作支付线性一致性的代价?

因果一致性弱于线性一致性。它不要求所有操作排成单一全序,只要求有因果依赖的事件保持顺序;没有因果关系的事件可以并发。Lamport timestamp 能给事件一个与因果一致的全序,但它不能解决所有问题。例如用户名唯一性不能只靠时间戳,因为两个节点并发接受同名注册时,需要做一个唯一决定。

Total order broadcast 是把所有消息按同一顺序交付给所有节点。它和共识等价:如果能做 total order broadcast,就能实现线性一致存储;如果有线性一致 compare-and-set,也能实现 total order broadcast。

分布式事务的 atomic commit 也归约到共识。Two-phase commit 让协调者询问所有参与者是否准备提交;如果都 yes,再决定 commit。问题是协调者在参与者 yes 之后崩溃,参与者会处于 in-doubt 状态,不知道该提交还是回滚,只能阻塞等待协调者恢复。因此 2PC 不是容错共识算法。

容错共识算法如 Paxos、Raft、Viewstamped Replication 的价值在于:只要多数节点正常并能通信,就能在保持安全性的前提下继续做决定。共识能用于 leader election、成员管理、分区分配、锁、租约、配置管理等。但它不免费:需要多数派,节点数通常为 3 或 5;少数派不可用;网络抖动会导致 leader 频繁切换;动态成员变更复杂。

ZooKeeper、etcd 这类协调服务把共识能力外包出来。它们不适合作通用大数据量数据库,而适合存小规模、低频变化的协调元数据:谁是 leader,哪个节点负责哪个分区,某个锁是否被持有。它们提供线性一致原子操作、全序操作编号、会话、临时节点、变更通知,帮助应用构建可靠协调。

学习检查:

  1. 线性一致性和串行化有什么区别?
  2. 为什么唯一性约束通常需要共识?
  3. 2PC 为什么可能阻塞?
  4. ZooKeeper 为什么不适合作普通业务数据库?

第 10 章:批处理

前两部分主要讨论在线系统:请求进来,系统尽快返回响应。第 10 章转向离线系统:读取大量有界输入,运行一个 job,产生输出。批处理通常没有用户在等待,性能指标是吞吐,而不是单次响应时间。

Unix 工具链是批处理哲学的原型。日志分析可以用 cat | awk | sort | uniq | sort | head 完成。这里重要的不是命令本身,而是设计思想:

  1. 每个工具只做好一件事。
  2. 输出可以成为未知下游的输入。
  3. 统一接口是文件和管道。
  4. 逻辑和连接方式分离。
  5. 中间结果透明,便于实验和调试。

MapReduce 把类似思想扩展到分布式文件系统。mapper 读取输入块,产生 key-value;框架按 key 分区、排序、shuffle,把相同 key 的数据送到同一个 reducer。reducer 聚合或 join 后输出结果。输入不可变,输出只在任务成功后可见,所以失败任务可以重试,失败输出可以丢弃。这给了批处理非常强的容错语义:可见结果像没有故障一样。

MapReduce 中 join 是理解分布式算法的好例子:

  1. Reduce-side sort-merge join:两边输入都按 join key 发送到 reducer,同 key 汇合后 join。通用但 shuffle 成本高。
  2. Broadcast hash join:一边很小,复制到所有 mapper 内存中,大表流式扫描并查小表 hash。避免大表 shuffle。
  3. Partitioned hash join:两边已经按相同方式分区,可以每个分区本地 join。

批处理输出通常是派生数据:搜索索引、机器学习模型、推荐结果、报表、物化视图。输出最好整体替换或原子发布,避免下游看到半成品。批处理的函数应尽量无外部副作用,使重试安全。

MapReduce 的缺点是中间结果频繁落到 HDFS,故障恢复简单但性能差。Spark、Tez、Flink 等数据流引擎把计算表达成 DAG,减少不必要的落盘,在内存和网络中传递中间数据。它们在无故障情况下更快,但节点失败时可能需要重算更多中间状态。

图计算是批处理中特殊场景。PageRank、连通性、最短路径等算法需要反复沿边传播信息。MapReduce 每轮读写全量数据效率低,Pregel/BSP 模型让每个顶点维护状态,并在迭代轮次间传消息。它更适合大图,但如果图能放进单机内存,单机算法常常比分布式更快。

学习检查:

  1. 为什么批处理的输入有界性很重要?
  2. Unix 哲学如何影响 MapReduce?
  3. reduce-side join 和 broadcast hash join 分别适合什么场景?
  4. 为什么批处理容易提供强容错语义?

第 11 章:流处理

流处理是批处理在无界数据上的对应物。批处理读取固定大小输入,知道什么时候结束;流处理输入永不结束,只能持续处理事件。

事件是小的、不可变的、自包含对象,表示某个时间发生的事情。生产者产生事件,消费者处理事件,相关事件组织成 topic 或 stream。消息系统要回答两个基本问题:

  1. 生产者比消费者快时怎么办?丢弃、缓冲,还是反压?
  2. 节点崩溃或离线时消息会不会丢?

传统 AMQP/JMS 风格 broker 把消息投递给消费者,消费者 ack 后删除消息。它适合异步 RPC、任务队列、顺序不重要且不需要回放历史的场景。消费者崩溃未 ack 时,broker 重投消息;但在负载均衡和重投下,消息顺序可能被打乱。

日志型 broker 把 topic 分成 partition,消息追加到分区日志里。消费者按 offset 读取并记录进度,broker 保留消息一段时间或按空间保留,因此消费者可以回放历史。Kafka 是典型例子。日志型 broker 更像数据库复制日志和日志结构存储,很适合流处理和派生状态维护。

数据库和流之间有强关系。数据库的每次写入可以看作一条 changelog。Change Data Capture 捕获数据库变更并把它们发送给搜索索引、缓存、数据仓库等派生系统。Event sourcing 更进一步,把事件日志作为系统事实来源,当前状态由事件折叠出来。日志压缩让事件流保留每个 key 的最新值,从而可以重建表。

流处理用途包括:

  1. 复杂事件处理:识别事件模式。
  2. 流式分析:窗口聚合、指标、告警。
  3. 维护物化视图:持续更新搜索索引、缓存、时间线。
  4. 流式搜索:新事件匹配已注册查询。
  5. 消息传递和异步任务。

时间是流处理难点。事件时间是事件实际发生时间,处理时间是系统看到事件的时间。用处理时间开窗会引入处理速率变化造成的伪影。窗口可以是滚动窗口、跳跃窗口、滑动窗口、会话窗口。迟到事件会让“什么时候窗口完整”变难,这一点和专门讨论流处理的书有相同核心。

流 join 有三类:

  1. stream-stream join:两个事件流在时间窗口内匹配,例如搜索和点击。
  2. stream-table join:事件流与数据库变更日志维护的本地表 join,例如用户行为补充用户资料。
  3. table-table join:两个表的 changelog join,维护 join 结果的物化视图,例如 Twitter 时间线缓存。

join 的时间依赖很重要。税率、用户资料、关注关系都会随时间变化。历史事件应该 join 当时有效的状态,还是当前状态?如果多个流之间没有确定顺序,重跑同一输入可能得到不同结果。因此有时要保留版本 ID 或历史状态,类似数据仓库中的 slowly changing dimension。

流处理容错比批处理难,因为输出持续产生,不能等任务结束再统一发布。方案包括 microbatch、checkpoint、内部事务、幂等写。Exactly-once 更准确说是 effectively-once:任务可能重跑,但最终外部可见效果像只处理一次。外部副作用仍然困难,必须依赖原子提交、幂等操作或去重元数据。

学习检查:

  1. 日志型 broker 和传统消息队列的核心差异是什么?
  2. Change Data Capture 如何帮助系统集成?
  3. stream-table join 为什么需要维护本地状态?
  4. 为什么流处理 exactly-once 离不开幂等或事务?

第 12 章:数据系统的未来

最后一章把全书串起来。现实应用通常需要多个专用工具:OLTP 数据库、搜索索引、缓存、数据仓库、机器学习系统、推荐系统、消息队列。没有一个系统能高效满足所有访问模式,因此数据集成成为核心问题。

作者主张用“派生数据”理解系统集成。某些系统是 system of record,是事实来源;其他系统通过转换从它派生出来,例如索引、缓存、物化视图、统计模型。关键是要清楚输入和输出:数据先写到哪里?哪个系统从哪个系统派生?派生顺序如何保证?

直接同时写数据库和搜索索引容易不一致,因为两个系统可能以不同顺序处理并发写。更稳妥的做法是让写入先进入一个能决定顺序的系统,例如数据库日志或事件日志,再用 CDC 或事件流按相同顺序更新派生系统。这样搜索索引是数据库的派生结果,而不是另一个独立事实来源。

派生数据和分布式事务是两种保持一致的路线。分布式事务用锁和原子提交让多个系统同步完成;日志派生用事件顺序、确定性处理、重试和幂等维护结果。前者给出更强时序保证,但性能和故障隔离差;后者更松耦合、更健壮,但默认异步,不能自动保证读己之写。

批处理和流处理都是派生数据的工具。批处理适合重算、修复错误、迁移 schema、生成新视图;流处理适合持续维护低延迟派生状态。Lambda Architecture 曾经用批层保证正确、流层提供低延迟,但双写两套逻辑复杂。更理想的方向是统一批流:同一套处理逻辑既能处理历史全量,也能处理新事件。

“拆开数据库”是本章的重要思想。传统数据库内部已经包含很多组件:存储、索引、查询处理、物化视图、复制日志、事务、缓存。未来应用可能把这些组件拆开,用事件日志和数据流把专用系统组合起来。这不是降低一致性要求,而是把依赖关系显式化,让每个派生状态都能被重放、审计和修复。

正确性需要端到端思维。系统内部的 exactly-once 不足以保证业务操作 exactly-once。比如转账请求超时后客户端重试,如果没有 operation ID,就可能重复扣款。解决方式是让每个业务操作携带唯一 ID,服务端记录已处理 ID,重复请求返回同一结果。这种去重必须在业务端到端边界完成。

约束可以异步检查。唯一性约束通常需要共识,但很多业务约束不一定必须同步阻塞。系统可以先接受请求,随后异步检查,如果违反约束再补偿或道歉。这更接近现实世界业务流程,也更适合跨地域、高可用场景。但它要求业务能接受延迟发现问题,并有补偿机制。

作者还强调审计和验证。不要盲信系统承诺,要设计可核查的数据流:输入事件、派生状态、操作 ID、日志、校验规则都应支持事后验证。软件 bug、数据损坏、错误模型都会发生,系统应能发现和修复。

最后一部分讨论伦理。数据可以帮助改善服务、医疗、交通和科学研究,也可能造成歧视、操纵、监控和权力不对称。隐私不是“什么都不公开”,而是人有权决定向谁透露什么。工程师设计数据系统时,不只是优化指标,还在塑造社会中的信息权力结构。收集数据要考虑长期风险、误用、泄露、未来政权和用户尊严。

学习检查:

  1. 什么是 system of record?什么是 derived data?
  2. 为什么用日志派生搜索索引比双写更可靠?
  3. 批流统一想解决 Lambda Architecture 的什么问题?
  4. operation ID 为什么是端到端 exactly-once 的关键?
  5. 为什么数据系统设计还涉及伦理责任?

3. 核心概念表

概念中文译名核心含义
Data-intensive application数据密集型应用主要复杂度来自数据量、数据变化速度和数据复杂度的应用。
Reliability可靠性出现故障时仍尽量正确提供服务。
Scalability可扩展性负载增长时有合理方式维持性能。
Maintainability可维护性系统便于运维、理解和演化。
Fault故障某个组件偏离预期。
Failure失效系统整体不能提供所需服务。
Data model数据模型描述数据如何表示、关联和查询的抽象。
Document model文档模型以自包含文档表示数据,适合树状结构。
Relational model关系模型用关系、元组和声明式查询表示数据。
Graph model图模型用顶点和边表示复杂关系。
Index索引从主数据派生的加速查询结构。
LSM-tree日志结构合并树通过 memtable、SSTable、合并压缩实现高写吞吐。
B-treeB 树页式有序索引,广泛用于关系数据库。
OLTP在线事务处理面向用户请求,小范围读写,高并发低延迟。
OLAP在线分析处理面向分析查询,大范围扫描,高吞吐。
Encoding编码把内存结构转成可存储或传输的字节。
Backward compatibility向后兼容新代码能读旧数据。
Forward compatibility向前兼容旧代码能读新数据或忽略新增字段。
Replication复制同一数据在多个节点上保留副本。
Replication lag复制延迟follower 滞后 leader 或副本之间进度不同。
Quorum法定人数用多数读写交集提高一致性概率的机制。
Partitioning分区把数据集拆成多个子集分布到不同节点。
Hot spot热点某个分区或 key 负载异常集中。
Transaction事务把多个读写包装成一个可提交或回滚的逻辑单元。
Isolation level隔离级别数据库防止并发异常的强度。
Snapshot isolation快照隔离事务从一致快照读取,读写通常不互相阻塞。
Serializability串行化并发执行结果等价于某个串行顺序。
Partial failure部分失败分布式系统中部分组件故障、部分仍正常。
Linearizability线性一致性多副本看起来像一个原子单副本。
Consensus共识多节点在故障下对某个不可撤销决定达成一致。
Total order broadcast全序广播所有节点以同一顺序交付消息。
Batch processing批处理处理有界输入并产生派生输出。
Stream processing流处理持续处理无界事件流。
Change Data Capture变更数据捕获把数据库写入日志转成可消费事件流。
Event sourcing事件溯源把事件日志作为事实来源,当前状态由事件派生。
Derived data派生数据从事实来源通过转换生成的数据。
Idempotence幂等性操作执行多次与执行一次效果相同。

4. 方法、模型与框架汇总

4.1 选择数据模型

  1. 如果数据是局部树状对象,优先考虑文档模型。
  2. 如果关系多、join 多、查询灵活,优先考虑关系模型。
  3. 如果核心查询是多跳关系遍历,考虑图模型。
  4. 如果业务演化会让关系越来越多,不要过早把所有东西嵌进文档。

4.2 选择存储引擎

  1. 写多、key-value、可接受后台压缩:考虑 LSM-tree。
  2. 读多、范围查询多、成熟事务需求强:B-tree 常更稳妥。
  3. 大规模分析、少列多行扫描:列式存储更合适。
  4. 索引不是越多越好,每个索引都要付出写入成本。

4.3 设计复制策略

  1. 单 leader 简单,冲突少,但 failover 和复制延迟要处理。
  2. 多 leader 适合多数据中心和离线写,但冲突解析复杂。
  3. 无 leader 可用性强,但一致性弱,应用要理解 quorum、siblings、版本向量。
  4. 异步复制要明确:leader 故障时是否接受丢失已确认写入。

4.4 设计分区策略

  1. 先明确查询模式,再选 key。
  2. key range 支持范围查询,但可能热点。
  3. hash 分区分散负载,但牺牲范围查询。
  4. 热点 key 需要业务层拆分和合并。
  5. 二级索引要明确 local index 还是 global index。

4.5 判断事务需求

  1. 只做单对象读写,可以弱化事务。
  2. 有反范式计数、跨行约束、多对象一致性,事务价值很高。
  3. Snapshot isolation 不是 serializable,不能自动防止 write skew。
  4. 如果业务不变量关键,优先使用真正的 serializable 或显式锁/约束。

4.6 设计分布式协调

  1. 不要自己随手实现 leader election、锁、成员管理。
  2. 如果需要共识,使用成熟系统如 ZooKeeper、etcd 或数据库自带共识。
  3. 线性一致性只用在真正需要强新鲜度的操作上。
  4. 对租约和锁配合 fencing token,防止暂停节点复活后写坏数据。

4.7 构建派生数据系统

  1. 明确 system of record。
  2. 通过日志或 CDC 推导搜索索引、缓存、数据仓库、物化视图。
  3. 派生函数尽量确定性、幂等、可重放。
  4. 支持全量重算,方便 schema 迁移、bug 修复和新视图生成。

5. 关键结论

  1. 数据系统设计不是选择某个流行数据库,而是理解访问模式、故障模型、演化路径和正确性要求。
  2. 抽象能降低复杂性,但错误抽象会隐藏关键差异,例如把 RPC 当本地函数。
  3. 分布式系统最大难点不是机器多,而是部分失败、网络不确定、时钟不可靠和无法获得全局事实。
  4. 强一致性、事务、共识都不是免费午餐,但它们能显著简化应用正确性。
  5. 弱保证系统也能工作,但应用必须清楚自己承担了哪些复杂性。
  6. 批处理和流处理的共同点是生成派生数据;差异在输入有界与无界。
  7. 日志是贯穿全书的核心结构:复制日志、LSM 日志、CDC、事件溯源、分区日志、全序广播都依赖日志思想。
  8. 可靠数据系统不只要防机器故障,还要防软件 bug、人为错误、误用和未来演化。
  9. 端到端正确性不能只靠中间件承诺,业务操作 ID、幂等、审计和验证同样重要。
  10. 数据系统影响真实的人,隐私、歧视、监控和权力不对称也是工程责任的一部分。

6. 学习路径建议

第一次学习时,不建议按所有细节硬啃。可以分四轮:

第一轮:建立全局模型。

读第 1、2、3、5、7、8、9、11 章的本文精炼版,先掌握数据系统目标、模型、存储、复制、事务、分布式故障、一致性和流处理。

第二轮:补齐工程机制。

重点看第 4、6、10 章。理解编码兼容、分区与路由、批处理执行模型。

第三轮:串联日志与派生数据。

回看第 3、5、10、11、12 章,专门整理“日志”这条线:append-only log、WAL、replication log、CDC、partitioned log、event sourcing、total order broadcast。

第四轮:结合项目复盘。

拿一个真实系统问:

  1. system of record 是什么?
  2. 有哪些派生数据?
  3. 哪些路径是同步写?哪些是异步派生?
  4. 哪些读要求线性一致?哪些可以最终一致?
  5. 事务边界在哪里?
  6. 失败后怎么恢复?能否重放和审计?

7. 总复习题

  1. 数据密集型应用和计算密集型应用的核心差异是什么?
  2. 为什么平均响应时间不是好的性能指标?
  3. 文档数据库在哪些场景下比关系数据库更自然?什么时候会变糟?
  4. LSM-tree 和 B-tree 如何体现写优化与读优化的取舍?
  5. 为什么列式存储适合 OLAP,但不适合普通 OLTP 写入?
  6. 向前兼容为什么比向后兼容更难?
  7. RPC 为什么不能被当成本地函数调用?
  8. 异步复制 leader 故障时可能发生什么?
  9. 为什么版本向量能区分覆盖和并发冲突?
  10. key range 分区为什么容易产生时间热点?
  11. local secondary index 和 global secondary index 如何交换读写成本?
  12. Snapshot isolation 能防止哪些异常?不能防止哪些异常?
  13. write skew 为什么只有 serializable 才能彻底防止?
  14. 网络请求超时后,调用方到底知道什么、不知道什么?
  15. 为什么租约需要 fencing token?
  16. 线性一致性和串行化分别解决什么问题?
  17. 为什么 total order broadcast 和共识等价?
  18. 2PC 和 2PL 为什么名字相似但解决的问题不同?
  19. MapReduce 为什么能安全重试失败任务?
  20. 流处理为什么比批处理更难实现 exactly-once?
  21. CDC 和 event sourcing 有什么联系和区别?
  22. 派生数据系统如何替代一部分分布式事务需求?
  23. operation ID 如何实现端到端去重?
  24. 为什么数据系统设计需要考虑伦理和隐私?

8. 压缩说明

本文保留了原书的主要知识结构、核心概念、代表性例子和工程权衡,压缩了以下内容:

  1. 详细代码示例,仅保留其说明意义。
  2. 大量参考文献、脚注和历史细节。
  3. 多个相似系统实现的细节差异。
  4. 对图查询语言、编码格式、批处理框架、共识算法的长篇展开。
  5. 第 12 章中大量伦理论证细节,仅保留作者核心立场和工程含义。

如果要继续深入,建议回到原书重点重读这些章节:第 5 章复制、第 7 章事务、第 8 章分布式故障、第 9 章一致性与共识、第 11 章流处理、第 12 章派生数据系统。这几章是 DDIA 最能改变系统设计思维的部分。