《设计数据密集型应用》学习总结:存量、流量、回路——每个设计决策都是三者的权衡
- 约 28 分钟读完 - 全文 11004 字《设计数据密集型应用》学习总结:存量、流量、回路——每个设计决策都是三者的权衡
目标读者:写过 CRUD、用过 MySQL/Redis/Kafka,但说不清它们"为什么这么设计"的工程师。
参考书籍:Martin Kleppmann《Designing Data-Intensive Applications》(DDIA,那只野猪书)
阅读目标:不背目录,而是拿到一个能套在任何存储/中间件上的分析框架——存量、流量、回路。
0. 写在前面:别背组件,背权衡
市面上的"中间件教程"有个通病:教你 MySQL 怎么用、Redis 怎么用、Kafka 怎么用,但从不告诉你为什么它们长得这么不一样。同样是存数据,Redis 敢把数据放内存,MySQL 不敢;同样是日志,Kafka 能当数据库用,而大多数人只拿它当管道。
DDIA 这本书最值钱的地方,就是把这些"为什么"串成了一条线。读完我把它提炼成三个词:
- 存量(State):数据怎么存——存储引擎(LSM/B-Tree)、编码格式、行存列存。
- 流量(Flow):数据怎么动——复制、分区、派生数据、流处理。
- 回路(Loop):系统怎么自我纠错——事务、隔离级别、共识、线性一致性。
一句话理解:任何一个数据系统的设计决策,都是在存量成本、流量吞吐、回路强度三者之间做取舍。 你要强一致(回路强),就得牺牲可用性或延迟(流量贵);你要写入快(流量大),就得接受读时合并或后台压缩的代价(存量结构变复杂)。
避坑指南 0:读完这本书最大的误区是"上来就选最强的一致性"。Kleppmann 自己说过大意如此的话:一致性模型的选择应该由业务能不能容忍异常决定,而不是由工程师的洁癖决定。能容忍就用最终一致,性能白捡。
和博客已有的 Redis/MySQL/MQ 笔记是"原理 vs 用法"的关系:那些笔记讲"这个命令怎么用、这个参数怎么调",这篇讲"这个组件为什么被设计成这样"。建议搭配阅读——看完这篇再回头看 Redis 的 RDB/AOF、MySQL 的 InnoDB redo log、Kafka 的副本协议,会有"原来如此"的感觉。
第一部分 存量:数据怎么存
1. LSM-Tree vs B-Tree:写的快和读的快,不可兼得
数据库索引结构就两大流派,区别一句话:
- B-Tree:读的时候顺藤摸瓜,树的高度就是磁盘寻道次数。写入是"就地更新"——找到那一页,改掉。MySQL InnoDB 就是这个。
- LSM-Tree:写入只往内存里的有序结构(Memtable)追加,内存写满了就整体刷到磁盘变成一个 SSTable(Sorted String Table,一段有序的文件)。读取时从最新的 SSTable 往最旧的依次找。LevelDB、RocksDB、Cassandra、HBase 都是这个。
SSTable 合并(compaction)是 LSM 的灵魂。因为同一个 key 的新旧版本散落在多个 SSTable 里,后台必须定期把它们归并排序、去重、丢掉被删除的墓碑标记,否则文件越积越多,读放大失控。
flowchart TD
W["写入请求"] --> L["WAL 追加日志<br/>(崩溃恢复用)"]
L --> M["Memtable<br/>(内存有序表)"]
M -->|"写满阈值"| S1["SSTable 1<br/>(不可变文件)"]
M -->|"再写满"| S2["SSTable 2"]
M -->|"又写满"| S3["SSTable 3"]
S1 --> C["后台 Compaction<br/>归并排序 + 去重 + 清墓碑"]
S2 --> C
S3 --> C
C --> S4["合并后的大 SSTable<br/>(旧文件丢弃)"]
R["读取请求"] --> M
R --> S3
R --> S2
R --> S1
R -->|"配合布隆过滤器<br/>跳过不含该 key 的文件"| MISS["快速判不存在"]
小数字例子感受写放大:假设每写 1 GB 数据,经过 3 层 compaction,每层都要重写一遍,实际磁盘写入可能是 5~10 GB。这就是"写放大",也是 RocksDB 调优的核心战场。
LSM 读取慢怎么补救?靠布隆过滤器(Bloom filter):一个很小的内存位图,能告诉你"这个 key 肯定不在这个 SSTable 里"(可能有误报,但绝不漏报)。查一个不存在的 key 时,布隆过滤器直接拦掉,不用真的去翻磁盘文件——这对"查缓存未命中再查库"的场景是救命优化。Cassandra、HBase、RocksDB 全都内置。
怎么选? 记住这张对照表:
| 维度 | B-Tree | LSM-Tree |
|---|---|---|
| 写入 | 就地更新,写放大相对低但随机 IO | 顺序追加,吞吐高但有 compaction 写放大 |
| 读取 | 稳定,一次查找几层树 | 要查多层文件,靠布隆过滤器和缓存补救 |
| 磁盘碎片 | 页分裂会产生碎片 | 文件整体重写,天然紧凑 |
| 典型代表 | InnoDB、PostgreSQL | RocksDB、Cassandra、HBase |
一句话理解:B-Tree 是"图书馆书架",书按位置摆好,找书快、插书要挪位置;LSM 是"先记在便签上,攒一批再上架",记便签飞快,但定期要整理书架,找书得翻几摞便签。
2. WAL:所有存储引擎的"保命符"
LSM 写 Memtable、B-Tree 改数据页,都是先动内存。断电怎么办?答案是 WAL(Write-Ahead Log,预写日志):任何修改先追加一条日志到磁盘,再改内存结构。崩溃后重放日志恢复。
这个思想无处不在:
- InnoDB 的 redo log 是 WAL;
- PostgreSQL 的 WAL 直接就叫 WAL;
- Redis 的 AOF 本质也是 WAL;
- Kafka 的消息日志、ZooKeeper 的事务日志,全是同一个套路。
一句话理解:WAL 就是把"随机写"变成"顺序写"——磁盘顺序追加比随机写快几个数量级,顺便白捡了崩溃恢复能力。
3. 行存 vs 列存:OLTP 与 OLAP 的分水岭
- 行存:一行的所有列连续存放。适合"查一个人的全部信息"(点查、事务),即 OLTP。
- 列存:一列的所有值连续存放。适合"统计一百万人的平均年龄"(聚合、分析),即 OLAP。
为什么列存分析快?小数字例子:一张 100 列的表,分析查询只用 5 列。行存要把 100 列全部从磁盘读出来再丢弃 95 列;列存只读那 5 列,IO 直接省 95%。而且单列数据类型相同,压缩率极高(比如"性别"列去重后就两个值,位图编码一下几乎不占空间)。
代表:Parquet、ORC、ClickHouse、DuckDB 是列存;MySQL、PostgreSQL 是行存。Snowflake、BigQuery 这类数仓全部站在列存一边。
4. 编码与演进: schema 变了,老数据怎么办
存量还有个隐藏维度:数据格式会随时间演化。JSON 灵活但没约束;Protocol Buffers / Thrift / Avro 这类二进制编码通过字段编号实现前后兼容——新增字段给新编号,删掉字段不动旧编号,新老代码就能和平共处。
避坑指南 1:滚动升级时"新代码读老数据"和"老代码读新数据"会同时发生。所以兼容性必须是双向的:向后兼容(新读老)+ 向前兼容(老读新)。只考虑一个方向,升级到一半必出事。
第二部分 流量:数据怎么动
单台机器存不下、扛不住、会宕机——这是"流量篇"要解决的三个问题。对应三板斧:复制(同一份数据放多台机器)、分区(不同数据放不同机器)、派生数据(数据从一个系统流向另一个系统)。
5. 复制:主从、多主、无主,三种政治体制
复制的目标是可用性和读扩展,但引入了全书最核心的矛盾:副本之间怎么保持一致。
主从复制(单主):所有写请求走 Leader,Leader 把变更日志同步给 Follower。读可以分摊到 Follower。简单、直觉,MySQL、PostgreSQL、MongoDB(副本集)、Kafka 分区副本都是这个思路。
但"Follower 的数据是旧版本"这件事,会带来一连串用户可感知的诡异现象:
- 你刚发的朋友圈自己刷新后消失了 → 需要读己之写(read-your-writes):用户写完之后,读请求必须走 Leader 或带版本号校验。
- 你的余额从 100 变成 80 又变回 100(读到了更旧的副本)→ 需要单调读(monotonic reads):一个用户的连续读请求固定打到同一个副本。
- 聊天里先看到回复再看到原帖(分区的数据流向不同副本速度不同)→ 需要一致前缀读(consistent prefix reads):有因果关系的写入,任何读者都应该按相同顺序看到。
sequenceDiagram
participant C as 客户端
participant L as Leader(主库)
participant F1 as Follower 1(同步副本)
participant F2 as Follower 2(异步副本)
C->>L: 写 x = 1
L->>L: 写本地 WAL 并提交
L->>F1: 同步复制:等 F1 确认
F1-->>L: ACK
L-->>C: 写入成功
L->>F2: 异步复制:后台慢慢发
Note over F2: 此时 F2 还是旧值 x = 0<br/>复制延迟窗口
C->>F2: 读 x
F2-->>C: x = 0(读到旧数据!)
Note over C,F2: 这就是"读己之写"被破坏的瞬间
同步 vs 异步是个硬权衡:全同步,任何一个副本宕机写入就卡死;全异步,Leader 宕机时没同步完的写入直接丢失。实践中的折中是"半同步"——一个副本同步、其余异步(MySQL 的 semi-sync 就是这个)。
多主复制:多个节点都能接受写,彼此同步。适合多数据中心就近写入,但写冲突是噩梦——两个机房同时改同一条数据,谁说了算?常见解法:LWW(最后写入者胜,靠时间戳,会丢数据)、CRDT(无冲突数据结构)、应用层解决。CouchDB 走这条路。
无主复制(Dynamo 风格):没有 Leader,写入时客户端把数据发给 N 个副本中的任意 W 个,读的时候从 R 个里读,只要 W + R > N 就能保证读到最新值(法定人数,quorum)。经典参数 N=3, W=2, R=2。
经典案例:Amazon Dynamo 论文与购物车。2007 年 Amazon 发表 Dynamo 论文,动机极其务实:购物车绝不能拒绝写入——用户加购物车被拒,就是真金白银的损失。所以 Dynamo 选择永远可写(AP),哪怕网络分区时也接受写入,事后再合并冲突版本(著名的"向量时钟"就出自这篇论文)。这篇论文直接催生了 Riak、Cassandra、Voldemort 一整代 NoSQL。
一句话理解:单主是"独裁",多主是"联邦",无主是"全民投票"。越民主写入越自由,但事后摆平分歧的成本越高。
6. 分区:数据切蛋糕的两种刀法
单机存不下就分区(分片)。两种切法:
按 key 范围分区:字典式切分,AM 一个分区、NZ 一个分区。范围查询(“查 7 月的所有订单”)天然高效,但热点问题明显——如果 key 是时间戳,所有新写入全砸在最后一个分区上。Bigtable、HBase 用这套。
按键的哈希分区:key 过一遍哈希函数打散。负载均匀,但范围查询要 scatter-gather 到所有分区,效率骤降。Cassandra、DynamoDB 用这套(Cassandra 实际上是"哈希定分区 + 分区内按 key 排序"的混合体)。
再平衡(rebalancing) 是分区系统真正的试金石:加机器、减机器、某分区过热,数据都要搬家。要求搬家期间读写不停、搬迁量最小。
flowchart LR
subgraph 扩容前["扩容前:3 节点,每节点 4 个虚拟分区"]
N1["节点 A<br/>v0 v1 v2 v3"]
N2["节点 B<br/>v4 v5 v6 v7"]
N3["节点 C<br/>v8 v9 v10 v11"]
end
subgraph 扩容后["扩容后:新节点 D 加入<br/>只搬走 3 个虚拟分区,其余不动"]
N1b["节点 A<br/>v0 v1 v2"]
N2b["节点 B<br/>v4 v5 v6"]
N3b["节点 C<br/>v8 v9 v10"]
N4["节点 D<br/>v3 v7 v11<br/>(从 A/B/C 各搬一个)"]
end
扩容前 -->|"固定数量虚拟分区策略<br/>只迁移少量 vNode"| 扩容后
主流策略就是图里这个:固定数量的虚拟分区(vNode)——分区数远多于节点数,加节点时从每个老节点"匀"几个虚拟分区过来。Riak、Cassandra 都这么干。反例是取模哈希(hash mod N):节点数一变,几乎全部 key 都要搬家,这就是一致性哈希要解决的原始问题。
避坑指南 2:分区键一旦选定,几乎不可更改。用 user_id 做分区键的系统,后来想做"按时间范围全局扫描",会发现每个查询都要打满全部分区。选分区键时想清楚:你的主要查询模式是什么,热点在哪。
7. 派生数据:一个数据系统的输出,是另一个系统的输入
Kleppmann 有个非常锋利的视角:缓存、搜索索引、数据仓库,本质上都是派生数据——它们都是从某个"真相源"(source of truth)加工出来的冗余副本。
一旦接受这个设定,很多架构问题豁然开朗:
- 缓存和数据库不一致?因为派生数据没有和源数据同步更新。
- ES 里的搜索结果和 MySQL 对不上?同理。
- 数仓报表延迟一天?因为派生管道是 T+1 的批处理。
于是问题从"怎么让缓存不失效"变成"派生管道怎么设计",这正是流处理要回答的,下文第 10 节展开。
第三部分 回路:系统怎么自我纠错
存量和流量解决了"存得下、流得动",剩下的问题是:当多个客户端并发操作、机器会宕机、网络会分区时,系统凭什么保证数据不出错? 这就是回路——一致性保障与反馈机制。
8. 事务与隔离级别:用"弱隔离"换性能,代价是异常
事务(ACID)是数据库给程序员的最大善意:把一堆操作打包成原子单元,要么全成要么全不成,并发的麻烦我来扛。但"我来扛"是有价格的——严格串行执行性能太差,于是数据库发明了隔离级别这个"滑杆",让你自己选扛多少:
| 隔离级别 | 防住的异常 | 防不住的异常 | 谁在用 |
|---|---|---|---|
| 读未提交 | 几乎啥都不防 | 脏读、一切 | 基本没人用 |
| 读提交(RC) | 脏读、脏写 | 不可重复读、写倾斜 | Oracle、PostgreSQL 的默认 |
| 快照隔离(SI) | 上面所有 + 不可重复读 | 写倾斜 | PostgreSQL 的 RR、MySQL InnoDB 的 RR |
| 串行化 | 一切 | 无 | 性能最好时的梦想 |
快照隔离(Snapshot Isolation) 值得单独说,因为它是现代数据库的主力。原理是 MVCC(多版本并发控制):每次写入不覆盖旧值,而是生成新版本,每个事务看到"它开始那一刻"的数据快照。读不阻塞写、写不阻塞读,性能极佳。InnoDB 的 undo log、PostgreSQL 的多版本堆表,都是 MVCC 实现。
写倾斜(write skew)是快照隔离最大的盲区,也是面试和实战最容易踩的坑。小例子:医院排班系统要求"至少有一名医生值班",当前有 Alice 和 Bob 两人值班。两人同时点"请假":
- Alice 的事务读到"2 人值班",判断"我请假后还剩 1 人,OK",提交;
- Bob 的事务同样读到"2 人值班"(快照!他看不到 Alice 未提交的修改),同样判断 OK,提交;
- 结果:两人都不值班了,约束被破坏。
注意这不是脏读也不是不可重复读——每个事务内部看到的数据完全一致,但两个事务基于同一份过期前提做出了互相冲突的决定。解法只有:升级到串行化,或者用 SELECT ... FOR UPDATE 物化冲突(把隐含的读前提变成显式行锁)。
避坑指南 3:凡是有"读取一个约束条件,然后据此决定写入"模式的代码(库存扣减、名额抢占、余额校验),在 RR/快照隔离下都可能写倾斜。别指望 ORM 救你,老老实实
FOR UPDATE或上串行化。
串行化的三条实现路线:
- 真的串行执行:单线程跑事务(Redis 单线程模型、VoltDB),简单暴力,内存够快时性能惊人;
- 两阶段锁(2PL):读写都加锁,性能差但正确,MySQL 的串行化级别就是这么实现的,也是死锁的主要来源;
- 可串行化快照隔离(SSI):快照隔离之上检测危险读写依赖,有冲突就中止重试。PostgreSQL 9.1+ 的串行化级别用的就是它,是近几十年事务领域最重要的学术成果落地。
9. 共识与线性一致性:分布式系统的"终极回路"
事务解决的是"单机/单系统内的并发正确性",共识(consensus)解决的是更难的问题:一群会宕机、消息会延迟的机器,怎么对"某件事"达成一致。
先纠正一个常见混淆:
- 线性一致性(Linearizability):让系统表现得"像只有一份数据"——任何人读到 x=1 之后,不可能再读到 x=0。它是对单个对象的读写一致性承诺。
- 共识(Consensus):多个节点对某个值/某条决策达成不可逆的一致,典型算法 Paxos、Raft、Zab。
两者的关系:要实现线性一致的存储,背后几乎必然要靠共识算法选主或定序。ZooKeeper(Zab)、etcd(Raft)就是共识系统的工业代表,整个 Kubernetes 的正确性底座就是 etcd 里的那套 Raft。
经典案例:Google Spanner 与 TrueTime。Spanner 要在全球范围提供外部一致性(比线性一致更强,跨事务的全局顺序),最大的拦路虎是:分布式系统里各机器时钟不可靠,你没法靠时间戳判断两个事务谁先谁后。Google 的解法粗暴而硬核——给数据中心装 GPS 接收机和原子钟,把时钟误差压缩到一个已知的小区间(TrueTime API 返回的不是一个时间点,而是"真实时间必然落在的这个区间")。提交事务时,如果发现误差区间还没过去,就故意等待(commit wait)直到确定。用硬件换确定性,用等待换正确性——这是"回路强度可以花钱买"的最著名示范。
FLP 不可能定理泼一盆冷水:在纯异步网络里,哪怕只有一台机器可能崩溃,也不存在确定性的、保证能终止的共识算法。工程上的出路是引入"部分同步"假设(网络总会恢复)和超时机制——所以 Raft/Paxos 的正确性不依赖时钟,但活性(liveness,能不能往前走)依赖。
极端案例:比特币与拜占庭容错。上面说的共识算法都假设节点"可能宕机但不会撒谎"(崩溃容错 CFT)。比特币面对的是更恶劣的环境:节点可能作恶(拜占庭将军问题)。中本聪的解法是放弃确定性共识,改用工作量证明(PoW)+ 最长链规则做概率性共识——一笔交易等 6 个区块确认,被推翻的概率指数级下降但永不为零。性能极差(比特币全网约 7 TPS,对比 Visa 峰值数万 TPS),换来的是"无许可环境下没人能篡改历史"。这是把回路强度推到极限、把流量吞吐牺牲殆尽的极端案例,也完美印证了我们的框架:一切都是权衡。
共识落地前,还有个更现实的问题:故障检测。分布式系统判断"某节点死了"的唯一办法是超时——但超时无法区分"节点真死了"和"节点只是 GC 停顿了 30 秒"(Java 应用的 Full GC、虚拟机的 steal time、网络拥塞都会造成这种"假死")。于是就有了脑裂(split-brain):老 Leader 以为自己还是 Leader,新 Leader 已经被选出来,两个"主"同时接受写入,数据互相覆盖。
工程解法是 fencing token(防护令牌):每次选主,共识系统发一个单调递增的编号,存储层只接受"编号比上次大"的写入。老 Leader 拿着旧编号来写,直接被拒。这个小机制在 ZooKeeper(zxid)、etcd(revision)里都有对应物。
业界掌故:Kleppmann 本人就是分布式锁话题的"圈内名人"——2016 年他发文《How to do distributed locking》批判 Redis 的 Redlock 算法在时钟跳变和进程暂停下不安全,引来 Redis 作者 antirez 的公开反驳,两人来回论战了好几轮,成为分布式领域最著名的公开辩论之一。争论的核心正是上面这些:时钟不可信、暂停不可测,所以任何分布式锁要么上 fencing token,要么干脆用共识系统。
10. 批处理 vs 流处理:同一枚硬币的两面
最后回到流量,看数据处理的两种世界观:
- 批处理:数据是有界的——一堆文件摆在那,MapReduce/Spark 跑一遍,输出结果。优点是吞吐高、可重跑、容错简单(任务挂了重跑);缺点是延迟以小时计。Hadoop/MapReduce 的黄金时代(Google 2004 年论文、Yahoo 孵化 Hadoop)就是这套世界观。
- 流处理:数据是无界的——事件源源不断地来,Kafka + Flink/Storm 逐条处理,延迟毫秒级。难点:乱序、恰好一次语义(exactly-once)、容错状态恢复。
Lambda 架构:流处理快但不准(可能丢、可能重复),批处理准但慢,那就两条都跑——流处理给实时近似结果,批处理定期用精确结果覆盖。维护两套系统、两份代码,运维成本翻倍,是出名的"说起来美、做起来痛"。
Kappa 架构:LinkedIn 的 Jay Kreps(Kafka 作者)提出的简化——只要流处理一条腿。历史数据重算?把 Kafka 的日志保留期拉长,让流处理程序从头重放一遍就行。这个架构能成立的前提,是 Kafka 把"消息队列"做成了"可重放的日志存储"。
flowchart TD
subgraph 数据源
DB[("业务数据库<br/>MySQL")]
APP["应用事件"]
end
DB -->|"CDC 变更数据捕获<br/>(Debezium 监听 binlog)"| K["Kafka<br/>(可重放的日志存储)"]
APP --> K
K --> S["流处理引擎<br/>Flink / Kafka Streams"]
S --> V1["派生视图:缓存 Redis"]
S --> V2["派生视图:搜索索引 ES"]
S --> V3["派生视图:数仓 ClickHouse"]
K -->|"日志长期保留<br/>需要重算时从头重放"| S
subgraph KP["Kappa / 批流一体"]
S
end
这张图里藏着 DDIA 全书最重要的思想之一:数据库和消息队列正在融合。Kafka 是可以当数据库用的日志(有保留期、可重放、有流处理);反过来,数据库的 binlog/WAL 通过 CDC(Change Data Capture)技术流出来,就变成了消息流。Debezium 监听 MySQL binlog 把每一次行变更变成 Kafka 消息,是当下"派生数据管道"的标准做法。
经典案例:Kafka 从 LinkedIn 内部管道到行业标准。Kafka 诞生时(2010 年前后)只是 LinkedIn 内部的一个活动流(activity stream)和日志管道,目标是扛住"每个用户的每个页面浏览都要记录"的巨大流量。它的关键设计——把消息组织成分区有序日志、消费者自己维护 offset、依赖磁盘顺序写+page cache 而非内存——让它意外地发现:这个东西不只是消息队列,而是"以日志为中心的存储系统"。Confluent 成立后,Kafka 成了流处理事实标准,Kreps 那篇《The Log: What every software engineer should know about real-time data’s unifying abstraction》(2013)把"日志是数据系统统一抽象"的思想讲透了——和 DDIA 第 3 章"日志结构存储"一脉相承。
11. 全书串讲:用三个词复盘四个案例
现在用"存量、流量、回路"的框架,把本文案例快速过一遍,你会发现它们只是同一个框架的不同参数组合:
- Kafka:存量用顺序日志(LSM 的近亲),流量靠分区+副本,回路选择"至少一次/恰好一次可配"的务实路线。→ 流量优先,回路可调。
- Dynamo:无主复制+永远可写,回路是最终一致+应用层冲突解决。→ 流量(可用性)压倒一切,回路最弱。
- Spanner:全球分区+同步复制,回路用 TrueTime 堆到最强(外部一致性)。→ 回路优先,花钱买。
- 比特币:回路要求拜占庭容错(最强敌对环境),代价是吞吐 7 TPS。→ 回路极限,流量牺牲殆尽。
没有银弹,只有取舍。 这就是 Kleppmann 反复想说、而无数"中间件选型指南"不会告诉你的事。
12. 带走的清单
读完 DDIA,做系统设计时问自己这几句话:
存量三问
- 写入多还是读取多?写多读少看 LSM(RocksDB/Cassandra),读多写少看 B-Tree(MySQL),分析查询上列存(ClickHouse/Parquet)。
- 崩溃恢复靠什么?找到那个 WAL——它一定存在,可能只是叫别的名字(redo log / AOF / binlog / journal)。
- schema 演进支持双向兼容吗?向前向后都要,滚动升级时缺一不可。
流量三问
- 复制延迟用户能感知吗?能,就按需要补读己之写、单调读、一致前缀读,别等用户来报 bug。
- 分区键选对了吗?对照主要查询模式和热点,选错分区键≈架构重来。
- 缓存/索引/数仓是不是派生数据?是,就把"同步管道"当成一等公民来设计(CDC + 流处理),而不是事后打补丁。
回路三问
- 业务能容忍什么级别的一致性?能用最终一致就别上线性一致,省下的都是真金白银。
- 有没有"读约束再写入"的代码?有,检查写倾斜——
FOR UPDATE或串行化,二选一。 - 选主/分布式锁靠什么?别自己造轮子,ZooKeeper/etcd 这类共识系统才是正解(Redis 分布式锁的争议——Martin Kleppmann 本人就是那篇著名批判文章《How to do distributed locking》的作者,还设计了"马丁小花"fencing token 方案)。
最后的避坑清单
- 别把默认隔离级别当成"安全级别"——PostgreSQL/MySQL 的默认都不是串行化,写倾斜在看着你。
- 别迷信"分布式 = 高可用"——复制和分区引入的故障模式(脑裂、时钟漂移、复制滞后)比单机复杂一个数量级,Jepsen 测试报告(Kyle Kingsbury 对各大数据库的"虐测")里全是血泪。
- 别在不了解权衡时引用 CAP——CAP 只说"网络分区时一致性和可用性二选一",而网络不分区时你还有延迟与一致性的权衡(PACELC 才是完整版)。
- 时间不可信:进程会 GC 暂停、线程会被调度、时钟会跳变。所有依赖"本地时钟先后关系"的分布式逻辑都值得怀疑——这正是 Spanner 要装原子钟的原因。
13. 结语:野猪书为什么是神书
市面上讲分布式的书,要么讲理论(一堆证明,读完不会写代码),要么讲产品(一堆配置,换个组件就过时)。DDIA 恰好卡在中间:它讲的是"不会过时的权衡"。
LSM 和 B-Tree 的取舍,二十年前成立,二十年后还会成立;复制滞后引发的那些"诡异现象",今天在 MySQL 从库上出现,明天就会在你的新数据库上换个马甲出现。工具会死,权衡永存。
顺带给个阅读路径建议:这本书 12 章不必按顺序啃——
- 日常写业务代码的:先读第 3 章(存储引擎)、第 7 章(事务),性价比最高;
- 做中间件/基础架构的:第 5、6 章(复制、分区)和第 9 章(共识)是主菜;
- 做数据平台/实时数仓的:第 10、11 章(批处理、流处理)直接对口;
- 第 8 章(分布式系统的麻烦)建议所有人都读,它是全书"劝退幻想"的一章,读完你会对时钟和网络产生健康的怀疑。
用一句话收尾,也是这本书给我最大的思维升级:
不要问"这个系统支持什么特性",要问"这个系统在存量、流量、回路上各做了什么取舍,这些取舍和我的业务匹配吗"。
能问出这个问题,这本书就没白读。