专业书籍精读 · DDIA · 第 12 章
Designing Data-Intensive Applications · Ch 12 · Martin Kleppmann · 2017
你在淘宝上改一次收货地址,这个改动要传到好几套系统里:订单页读的那份、搜索框搜出来的那份、客服后台看的那份、晚上跑报表用的那份。前十一章讲的都是「一套系统内部怎么做好」,最后这一章,作者第一次把话说全:这么多套系统该怎么拼在一起,才不会互相打架?这不是总结,是他自己的主张。
常见做法是「一改就到处改」:程序改完主数据库,顺手把搜索那份也改了。听着天经地义,却有个隐蔽的毛病——两个人几乎同时改同一条数据时,主库可能先收到甲后收到乙,搜索那边偏偏先收到乙后收到甲。两边留下不同的最终结果,而且不会自己发现,更不会自己修好。
难点不在「写不进去」,而在没人负责排顺序:每套系统只看见自己收到的那几笔,各按各的先后办事。就像一家公司十几个部门各记各的账本,单看每本都没错,凑一起就对不上。系统越多线越多——十套系统两两相连最多九十条。
作者的答案朴素得有点意外:设一本唯一的流水账,所有改动先排队记进去,其他系统都只是这本账的「抄本」。顺序在账上定死一次,谁也别再自己发明;下游照着同一份顺序往下抄,快慢有别,但抄出来的内容一定一致。
这一招还顺手解决两件老大难:加新系统不再可怕——想上一套新推荐系统,让它从第一页重抄一遍即可;改错了可以重来——用新逻辑重放一遍、算出一份新抄本,跑对了再切过去,旧的原地不动、随时能退回。作者说得更狠:我们熟悉的「数据库」不过是把存、建索引、查捆在一个盒子里卖;把盒子拆开、各交给最擅长的工具,中间用这本流水账串起来,就得到一个摊开在整个公司里的大数据库。
还得保证一件事只算一次。用户付款时手抖点了两下,中间任何一层的去重都可能失灵。作者的答案是让最两头的人负责:下单时就给这笔操作发一个唯一「号码牌」,一路带着走,最后收单的地方认牌不认人,同一个号码只算第一次。
另一半答案更像生意人:不是所有规矩都得当场卡死。航空公司照样超售机票,做法是事后对账、发现了就赔偿改签——把「绝不出错」换成「出错能查出来、能补救」。这也正是这套架构诚实的代价:各个抄本天生有点滞后,换来可重建可扩展,付出的是「刚写完不一定马上到处可见」。全书最后作者还拐了个弯谈该不该做:算法拿历史数据做预测,容易把过去的偏见照抄进未来;攒下的用户数据与其当资产,不如当成随时可能炸的负债——不需要了就该删。
别让每套系统各写各的、各排各的顺序。设一本唯一的流水账,所有改动先排队进账,其他系统都当它的抄本;正确性别指望中间任何一层,靠两头认号码牌;实在卡不住的规矩,就事后对账、认错补偿。
想进到具体机制、记号和示意图? → 切到精读版
你以为终章是复习,其实它是宣言。Kleppmann 在这里给出他自己的答案:把「数据库」这个整装产品拆开(unbundling)——存储、索引维护、查询本就是可以分给不同工具做的三件事——再用一条有序、可回放的事件日志把它们重新串成组织级的数据流;而正确性别指望底层事务,要在两端做端到端保证,并把「一致性」拆成及时性与完整性两件性质不同的事分别对待。最后他还追问了一句工程书少见的问题:这些能力,我们该不该都用上。
本章是 Part III「派生数据」的收官,也是全书终章。它承接 Ch10、Ch11 的「不可变输入 + 派生结果」世界观,把 Ch5–Ch9 的复制、分区、事务、共识全部回收,落成一套组织级的系统组装法。现实对应任何一家同时跑着主库、搜索、缓存、数仓、特征库的公司——也就是几乎所有公司。
前十一章的潜台词一直是「选一个合适的工具」。但真实公司里从来不止一个:一个中等规模电商,主库 PostgreSQL、搜索 Elasticsearch、热点数据 Redis、分析在数仓、风控和推荐各有一份特征库——同一条「用户改了地址」的事实要出现在五六个地方。没有哪个数据库通吃所有访问模式,所以多套系统并存不是坏设计,是必然。
真问题于是变成:怎么让这五六份数据保持一致,且加第七个系统时不会崩溃?不解决就会得到工程师最熟悉的痛——搜索结果和详情页对不上、报表跟线上账目差几百块、每接一个新系统就要在应用里加一段脆弱的双写。更要命的是这类不一致不会自愈,它和「等一会儿就好」的复制滞后是两码事。
先把靶子立准。双写就是应用里那两行看似无害的顺序调用:先 db.update(...)、再 search.index(...)。它有两个病。竞态(race condition):两个客户端几乎同时改同一条记录,主库先收甲后收乙、搜索索引却先收乙后收甲,两边留下不同终值——这不是「等一会儿就一致」,而是永久错位。部分失败:第一个写成功、第二个写失败,两个系统就此分叉。用分布式事务包住两个写倒是可以,但那要求异构系统都支持同一套原子提交协议、还要在提交期间互相持锁等待。
解法是换个提法:不要让应用「同时写两个地方」,而是指定一个系统记录,所有写入先进去、按顺序落成一条事件流;其余系统一律降级为派生数据,只订阅这条流照做。关键收益是顺序只定一次——所有下游看到同一份先后关系,最终状态必然收敛。
作者的结论很实用:在缺乏被广泛支持的分布式事务协议的现实里,基于日志的派生是最有希望的整合方案——它用「确定性重算 + 幂等重试」换掉「加锁互斥 + 原子提交」,弱一档,却是异步的、宽容故障的。边界也得说清:地基是全序,而定序要靠单点或一次共识,吞吐卡在一台机器上;跨地域多活、各持独立状态的微服务、离线客户端都很难塞进一条全局队列——DDIA 到这里也只能说这仍是开放的研究问题。
派生数据靠「重算」,而算法有两种跑法:批处理把历史整体算一遍,流处理来一条算一条。Lambda 架构是当年流行的折中:批处理层慢但准、流处理层快但近似,查询时合并。DDIA 的批评很直接:同一套业务逻辑要在两个框架里各写一遍并保持等价,这是长期的维护税,两边结果的合并逻辑本身又是新的 bug 源。作者主张统一:既然日志能回放,「批」不过是「对一段有界历史跑同一套流算子」,一份代码、一套语义就够了。
这里藏着本章最实用的一招:重新处理(reprocessing)是应用演化的正经手段。要换一套推荐特征、改一次数据模型,不必冒险原地改表——用新代码从日志重放、建出一份并行新视图,两份并存一段时间,验证无误再切读流量,出问题原地切回。这把 Ch4「不停机演化 schema」的思路推到了整个派生层:迁移不再是一刀切的大爆炸,而是可回退的渐进过程。代价是日志得留得够久(几周到几个月),且重算历史要吃一波额外算力。
这一节是全章的思想核心。作者的观察是:一台数据库内部做的事,和一家公司里跨系统的数据流,本质是同一件事的两种尺度。
数据库内部有写入接口、存储引擎与预写日志、二级索引维护(写一条记录时顺手更新索引)、物化视图维护、触发器、复制日志、查询执行。拆开看:Kafka 这类事件日志 ≈ 复制日志;流处理器 ≈ 触发器与物化视图维护器;下游的搜索索引、缓存、OLAP 表 ≈ 各式各样的索引。于是整个组织的数据流就是一个摊开的超大号数据库——作者称之为「万物之元数据库(meta-database of everything)」。
顺着这个视角,整合异构系统只有两条路,各管一头:
foreign data wrapper 是典型)。不动写入侧、上手快;但每次查询都要跨系统现拉,性能与可用性受制于人。必须补上作者的克制,否则这章容易被读成布道:解绑不是要取代数据库。他明确说单个集成产品在它擅长的场景里更快也更简单;解绑赢的不是性能而是场景覆盖面——只有当没有任何一个软件能同时满足你所有需求时才划算。他还留了句遗憾:目前还缺一门「组装数据系统」的高级语言,我们能像 Unix 管道那样拼命令,却没法同样轻巧地拼存储系统。
把上面的思路推到应用层,会得到一个很好用的心智模型:任何一次「数据从写入到被看见」都由两段组成——写路径(write path)是写入时提前做的工作,读路径(read path)是查询时临场做的工作。而缓存、索引、物化视图,本质都是同一件事:把这条分界线往写路径方向挪。
这个模型解释了很多平时靠经验拍板的决定:全文搜索索引为什么要建?因为把「按关键词找」的活提前干了。为什么不给所有查询都建物化视图?因为每挪一格,写入侧的维护成本和滞后就多一分。选型不是「要不要缓存」,而是「这条边界该停在哪」。作者还把这条流一路推到终端设备:单页应用的本地状态就是一份服务端状态的副本,WebSocket 推送把写路径的终点延伸到了用户屏幕上。
组装完了怎么保证不出错?作者从一个尖锐的问题切入:事务真的够吗?
先看「恰好一次」。用户点了一次付款、页面卡住,他又点了一次。中间层的所有去重都不管用:TCP 的重传去重只在一条连接内有效;数据库事务保证的是这一笔要么全做要么不做,可用户提交的是两笔各自完全合法的独立事务;哪怕上了 2PC,跨系统原子提交也挡不住「用户手动重试」。
唯一有效的位置是两端:客户端发起操作时就生成一个唯一操作 ID(如 UUID),一路带到最终端点当去重键——同一个 ID 只认第一次。这正是 1984 年 Saltzer、Reed、Clark 的端到端论证(end-to-end argument)在数据系统里的复现:某些功能只有在掌握完整语义的两个端点上才能被正确实现,中间层做得再好也只是优化。推论很扎心:底层可靠性机制(TCP 校验、事务、复制)都有用,但都不足以单独保证应用层面的正确。
再看约束。「用户名不能重复」这类唯一性约束本质上需要共识(Ch9)。但日志架构里有个漂亮办法:按被约束的值分区(如按用户名哈希分区),同一个用户名的所有申请必然落进同一条队列,单线程消费者顺序处理,第一个成功、后来的一律拒绝——不用跨机器加锁就得到了确定性裁决。跨分区操作(从账户 A 转到 B)思路相同:先把「请求」本身作为一条带 ID 的事件原子写入日志,再由下游派生出两个账户的余额变更;效果等价于 2PC,形状却简单得多、也更容错。
最后是本章最值钱的区分:把「一致性」拆成两半。
作者的原话大意是:违反及时性只是「最终一致」,违反完整性却是「永久不一致」。价值在于——ACID 事务把两者捆在一起提供,代价高昂;数据流系统可以解开这个捆:靠确定性派生 + 端到端操作 ID 死守完整性,同时放松及时性。这正是它能避开跨分区协调、跑得又快又稳的根本原因,作者称之为协调回避(coordination-avoiding)——不是不协调,而是把昂贵的协调压缩到真正必要的那一小块。顺着这条线还有个反直觉的主张:很多约束不必当场卡死,航空公司超售、仓库缺货,做法都是先放行、事后对账、越界就补偿道歉。
最后是「信任,但要验证」:软件有 bug、硬件会静默损坏、人会误操作,别把「数据不会坏」当公理。事件溯源式系统天然占优——原始事件在、派生逻辑确定,随时能重放一遍与现有结果比对,这就叫审计(auditing)。作者对区块链态度审慎,但明确希望其中的密码学审计与完整性校验思路(Merkle 树等)进入主流数据系统。
全书最后一节画风一转谈伦理,分量不轻。三个要点:预测性分析会把历史里的偏见照抄进未来——用历史数据训练的模型把过去的歧视固化成「客观算法」,还因披着数据的外衣而更难申诉;反馈回路会自我强化——被判定高风险的人拿到更差的条件,于是更可能失败,反过来「验证」了算法;数据是负债不是资产——攒着的用户数据随时可能泄露、被滥用、触法,用不着了就该删。他也点破了「隐私政策即同意」的虚伪:用户没有真正的选择权,谈不上自由同意。
表 1 · 三种整合方式:分布式事务 vs 双写 vs 日志派生
| 分布式事务(2PC) | 应用双写 | 日志派生(本章主张) | |
|---|---|---|---|
| 顺序谁定 | 锁 + 原子提交,强制互斥 | 没人定,各系统按到达序各办各的 | 日志定一次,下游照抄 |
| 一致性强度 | 立刻一致 | 可能永久错位 | 最终一致(滞后毫秒 ~ 秒级) |
| 一个下游挂了 | 可能拖住所有参与者 | 当场分叉,需人工修 | 它自己 offset 落后,恢复后自动追上 |
| 接第七个系统 | 拉进事务,复杂度陡增 | 应用里再加一段双写 | 从日志头重放一遍,不动上游 |
| 异构系统 | 要求同一协议,现实几乎不可得 | 随便接,错法也随便 | 只要求下游能消费事件 |
| 适用 | 同构、强约束、规模可控(金融核心账务) | 基本别用 | 异构整合、派生数据(搜索/缓存/数仓/特征) |
表 2 · Lambda 架构 vs 批流统一
| Lambda 架构 | 批流统一(同一份日志 + 同一套算子) | |
|---|---|---|
| 做法 | 批处理层算准值 + 流处理层算近似值,查询时合并 | 只有流;「批」= 对一段有界历史跑同一套算子 |
| 代码 | 同一逻辑写两遍并长期保持等价 | 一套代码 |
| 合并成本 | 合并逻辑本身是新的 bug 源 | 无 |
| 纠错方式 | 等下次批处理跑完覆盖掉 | 重放日志重建视图,验证后切流量 |
| 代价 | 两套集群、两套运维 | 日志要留够久(常见几周~几个月);重算吃算力 |
表 3 · 两条整合路线:统一读 vs 统一写
| 联邦查询(统一读) | 解绑(统一写) | |
|---|---|---|
| 动谁 | 只加一层查询代理,写入侧不动 | 重构写路径,所有写先入日志 |
| 数据在哪 | 还在各系统里,查询时现拉 | 各系统持有自己那份派生副本 |
| 耦合度 | 查询时紧耦合:一个源慢/挂,查询就慢/挂 | 松耦合:下游异步跟进,互不牵连 |
| 上手成本 | 低,适合先止血 | 高,架构级投入 |
| 适用 | 临时跨库分析、遗留系统整合 | 长期演进的核心数据平台 |
表 4 · 及时性 vs 完整性:这条约束到底该不该强协调
| 约束 | 违反的后果 | 该守哪一头 | 现实做法 |
|---|---|---|---|
| 用户名唯一 | 两人同名,需人工改名 | 完整性 | 按用户名分区,同分区顺序裁决,先到先得 |
| 转账不凭空生钱 | 账目永久对不上 | 完整性,绝不让步 | 请求带 ID 原子入日志 + 确定性派生余额,效果等价 2PC |
| 看到最新余额 | 短暂读到旧值 | 及时性,可放松 | 接受秒级滞后;关键页面走主库读 |
| 机票座位不超售 | 超售需赔偿改签,业务可承受 | 可放松 | 故意超售 + 事后补偿,比强协调划算 |
| 库存不超卖 | 缺货需退款道歉 | 视客单价而定 | 低价高频:先放行后对账;高价稀缺:下单即锁库存 |
| 推荐特征新鲜度 | 推得不够准 | 可大幅放松 | 分钟级滞后完全可接受 |
一条贯穿性的判据:先问「违反了会不会自愈」。会自愈的(及时性)尽量放松,换吞吐、换可用性、换更简单的架构;不会自愈的(完整性)必须用确定性派生 + 端到端 ID 死守。把这两件事分开定价,是本章给架构决策最实用的一把刀。
这一章的主张今天已是现代数据平台的默认形态,只是换了名字:Kafka 当整合总线、Debezium 之类的 CDC 工具把数据库变更抽成流、Flink / Kafka Streams 当那个摊开的物化视图维护器、下游落到 Elasticsearch / ClickHouse / 数据湖表格式(Iceberg、Delta、Hudi)。你在中大型公司架构图上看到的「以 Kafka 为轴、下游一堆派生存储」,正是本章的解绑蓝图。面试和架构评审里,它能兑现成几句有分量的话:为什么反对双写、为什么「重放重建再切流量」比原地改表更安全、为什么「用户名唯一」可以不靠分布式锁,以及那把最好用的刀——把一致性拆成及时性与完整性分别定价。
N 个系统最多 N(N−1) 条有向管道,N=10 即 90 条),一条持久有序可回放的日志能把它压成「各写各读一条总线」——验证了「解绑靠统一写路径」。J. Kreps《The Log》, LinkedIn Engineering 2013 ↗① 一句话:终章是宣言而非总结——把数据库拆开,用一条有序可回放的事件日志重新组装成组织级数据流。
② 靶子是双写:两个系统各排各的顺序,一旦竞态或部分失败就永久错位、不会自愈。
③ 解法是指定系统记录 + 派生数据:顺序在日志上定一次,其余系统只订阅照抄;相比分布式事务,它用「确定性重算 + 幂等重试」换来异步与容错。
④ 批流本是一体:Lambda 的病是同一逻辑写两遍;统一到一条可重放的日志后,重新处理成为应用演化的正经手段——建新视图、并行验证、切流量、可回退。
⑤ 解绑:数据库内部的复制日志 / 触发器 / 索引维护,对应组织里的 Kafka / 流处理器 / 派生存储;整合两条路——联邦查询统一读、解绑统一写。而写路径与读路径的边界可移动:缓存、索引、物化视图只是同一条边界的三个位置,越往写侧挪,读越快、写越重、滞后越明显。
⑥ 正确性靠端到端:唯一能实现「恰好一次」的是客户端生成、贯穿全程的操作 ID;中间层去重(TCP、事务、2PC)都只是优化。
⑦ 最值钱的区分:及时性(会自愈)vs 完整性(永久不一致)。数据流系统解开 ACID 的捆绑——死守完整性、放松及时性,从而回避昂贵的协调。
⑧ 不是所有约束都要当场卡死:先放行、事后对账、补偿道歉常比强一致划算;同时要「信任但验证」——靠原始事件重放做审计。
⑨ 最后一节谈伦理:预测分析会固化偏见、反馈回路自我强化、用户数据是负债不是资产,不需要了就该删。