数据库主从切换后,用户投诉"刚下的订单不见了"。两台服务器日志里的时间戳对不上,排查半天发现是时钟漂移。明明加了事务,线上还是出现了脏数据。

这些事故的根因都一样:做了设计选择,却没理解选择的代价。

Martin Kleppmann 的《数据密集型应用系统设计》没有教你用哪个工具。它做的事更根本——把所有技术选择还原成同一类问题:你愿意用什么换什么。这篇文章是我读完之后,从几个"原来如此"的时刻出发,试着把这些道理讲清楚。


一、你的数据库不过是两棵树

我以前觉得存储引擎是黑盒。读完才发现,主流存储引擎就是在两棵树之间做选择,选择的代价用三种"放大"来衡量。

B-Tree:原地更新,代价是写放大

InnoDB 用 B+ Tree。更新一行数据时:

  1. 写 WAL 日志(保证崩溃恢复)
  2. 找到目标叶子页,修改内容
  3. 页满了就分裂成两页,还要更新父节点指针

一次逻辑写,磁盘上可能触发 3-4 次物理写。这就是写放大。写多读少的场景下,这个代价很疼。

LSM-Tree:顺序追加,代价是读放大

RocksDB、LevelDB 走了另一条路:不改旧数据,只往内存追加写,内存满了刷盘成 SSTable,后台慢慢合并。

写入快了,因为全是顺序写。但读取时要从新到旧查多层 SSTable,一个点查可能查 6-7 个文件。这就是读放大。布隆过滤器能缓解,但不能消除。

还有第三种:空间放大

LSM-Tree 的同一份数据新旧版本同时存在,直到 Compaction 合并。合并之前,磁盘用量可能远超实际数据量。B-Tree 也有空间放大:页分裂后平均利用率只有 67%。

怎么选

不是"看场景"这种废话。要回答三个具体问题:瓶颈在读还是写?能容忍哪种放大?数据增长速度是否允许空间放大持续一段时间?

物联网时序数据——每秒百万条写入,极少回查——LSM-Tree 的读放大完全可接受,B-Tree 的写放大会先拖垮你。金融账户余额——写入频率一般,读取和范围查询要求低延迟——B-Tree 的固定树高比 LSM-Tree 的多层查找可靠。

每种结构都在用一种放大换另一种放大的降低。没有谁更好。


二、"我写了但读不到"——复制延迟的三张面孔

MySQL 主从集群,读写分离,写主库读从库。上线第一天就有用户反馈:"我明明提交了评论,刷新页面却看不到。"

这不是 bug,是异步复制的固有属性。Kleppmann 把这类异常拆成了三种。

读己之写

写入主库后马上去从库读,但写入还没同步过去,读到了旧值。

解法:读自己可能写过的数据时走主库,或记住写入的时间戳,等从库同步到这个时间戳后再返回。

单调读

第一次查询命中了同步快的从库 A,读到新值;第二次命中了同步慢的从库 B,读到旧值。用户看到数据"倒退"了。

解法:同一用户的请求总是路由到同一个从库(按用户 ID 哈希)。

一致前缀读

两个有因果关系的事件写入了不同分区,一个从库先收到"果"再收到"因"。用户先看到回复,再看到原帖。

解法:确保有因果关系的写入进入同一个分区,或使用因果一致性协议。

要点

这三种异常不能通过调优消除,它们是异步复制的数学后果。能做的是:知道它们存在,判断业务能否容忍,不能容忍的地方用更高代价的方案(同步复制、读主库、共识协议)。

选择异步复制时只看到性能提升,没看到一致性降级——这才是事故的根源。


三、事务隔离:你以为的安全可能并不安全

"我用了事务,为什么还是出现了数据不一致?"

因为"事务"不等于"隔离","隔离"也不等于"可串行化"。

写偏序:可重复读防不住的 bug

医院值班系统,至少需要 1 名医生值班。A 和 B 同时请假:

事务1(A请假):                    事务2(B请假):SELECT COUNT(*) FROM on_call       SELECT COUNT(*) FROM on_callWHERE shift_id = 123;              WHERE shift_id = 123;→ 结果:2                          → 结果:2UPDATE on_call SET status='off'    UPDATE on_call SET status='off'WHERE doctor='A';                  WHERE doctor='B';COMMIT;                            COMMIT;

两个事务都通过了检查,结果 A 和 B 都请假了——没人值班。

如果把两个事务按某种顺序串行执行,这个结果不可能发生。这就是"不可串行化"。

可重复读为什么防不住

MySQL 默认可重复读(REPEATABLE READ)。名字容易误导,它保证的是你读到的同一行的值不会变。但写偏序的问题不是行变了,而是你基于读做的决策已经过时了

A 查的时候 B 确实在,B 查的时候 A 也确实在。各自读到的数据都是"正确的",但两个"正确"的读导出了一个"错误"的写。

可串行化才能真正隔离

可串行化意味着:并发执行的结果必须等价于某种串行执行的结果。

代价不低。真正的可串行化要么用锁(2PL,读写互斥,性能差),要么单线程执行(Redis 的做法,限制多),要么用 SSI。

SSI(可串行化快照隔离)是 PostgreSQL 的做法:先乐观执行,假设不会冲突,事后检测到可能导致不可串行化的依赖时,中止其中一个事务重试。绝大部分事务不会冲突,所以 SSI 性能接近快照隔离,只在少数争用场景才回滚。

实际建议:业务有"先读后写"的约束检查时,要么用 SELECT ... FOR UPDATE 显式加锁,要么开可串行化隔离级别。不要假设可重复读能保证正确性。


四、共识与多数派:数学比工程更可靠

Raft、Paxos、Zab 看起来复杂,核心思想一句话就说完了:

两个多数派必然有交集。

5 个节点,多数是 3。两个不同的值要同时被"多数节点"接受,意味着至少有一个节点同时接受了两个不同的值——不可能,因为每个节点同一轮只投一次票。

这就是共识算法正确性的根基。不是超时设得巧妙,不是 Leader 选举写得好,是集合论的必然。

对比一下:靠工程 vs 靠数学

处理分布式一致性问题有两种思路:

靠工程:用超时、重试、最终一致性来"尽量"保证正确。大多数时候能工作,但边界条件多,出了问题很难定位。比如:两个节点同时认为自己是 Leader(脑裂),各自接受了不同的写入,分区恢复后数据冲突。

靠数学:用多数派交集保证任何时刻最多只有一个合法的提案。Raft 就是这样——即使网络分区、节点宕机、消息延迟,只要多数节点可达,系统就不会做出矛盾的决定。这不是"大概率正确",而是"数学上不可能错"。

代价是:每次写入要等多数节点确认,网络延迟是硬约束;少数节点宕机能容忍,多数不可达时系统不可用;参与共识的节点不能太多(通常 3-9 个),否则投票延迟不可接受。

所以共识只用在真正需要强一致的地方——分布式锁、配置中心、元数据管理。拿 Raft 做用户数据存储,是大炮打蚊子,炮弹还很贵。


五、流与表的二象性

这是全书让我最兴奋的概念。

一个具体例子

你有一张用户表:

id | name  | city
---+-------+------
1  | Alice | Beijing
2  | Bob   | Shanghai

每次有人修改资料,MySQL 的 binlog 会记录变更事件:

INSERT user id=1, name=Alice, city=Beijing
INSERT user id=2, name=Bob, city=Shanghai
UPDATE user id=1, city=Shenzhen   -- Alice 搬到了深圳
DELETE user id=2                   -- Bob 注销了

如果你把这些变更事件从第一条开始全部重放一遍,最终得到的表,和当前的用户表一模一样。

表是流在某个时刻的快照,流是表的变更日志。它们是同一份数据的两种形态。

这个对应关系在实际系统中无处不在:

  • 数据库 binlog → "表"变成"流"
  • CREATE MATERIALIZED VIEW → "流"变成"表"
  • Kafka Connect 把 MySQL binlog 同步到 Elasticsearch → 把 MySQL 的变更流物化成了 ES 里的一张"表"
  • Flink SQL 从 Kafka 读数据写入 MySQL → 把 Kafka 的流物化成了 MySQL 的表

这为什么重要

它统一了批处理和流处理:

  • 批处理 = 对一个有界的流做快照,然后计算
  • 流处理 = 对一个无界的流持续计算,不断更新物化视图
  • 批处理是流处理的特例——当流有界时

这也解释了为什么 Lambda 架构(批 + 流两套系统)会被 Kappa 架构替代:不需要两套系统来处理同一份数据的两种形态,一个能同时处理流和表的统一引擎就够了。Kafka + Flink 的组合之所以流行——Kafka 存流,Flink 算流,两者配合就是"流→表"的完整闭环。

水位线

流处理里最容易犯的错:用处理时间代替事件时间。

用户 23:58 下单,消息队列积压,系统 00:02 才处理。按处理时间算这笔订单属于第二天,按事件时间属于前一天。窗口计算必须基于事件时间,否则数据不可重现。

水位线(Watermark)解决这个问题:它标记"事件时间早于此线的所有事件应该都已到达"。水位线之前的窗口可以安全触发,之后的迟到数据需单独处理。

水位线的设置本身就是一笔交易:越宽松,等待越长,但迟到数据越少;越严格,延迟越低,但可能丢数据。


六、回到一个字

全书读下来,我觉得可以用一个字概括:

  • LSM-Tree 用读放大换写放大的降低
  • 异步复制用一致性换延迟和可用性
  • 快照隔离用写偏序的风险换并发性能
  • 因果一致性用全序的放弃换可用性的保留
  • 严格水位线用延迟换正确性

每个设计决策都是一笔交易。大多数系统故障的根因是:做了交易,不知道代价是什么。

读完这本书,看到一个新的数据库或中间件,我不再问"它比 XX 好在哪",而是问"它用什么换什么"。前一个问题容易让你成为某个技术的粉丝,后一个问题才能让你做出靠谱的判断。


后记

这篇文章没有覆盖全书——编码演化、分区策略、分布式事务、批处理引擎等内容同样精彩,但我不想写成读书笔记。我只写了那些让我停顿了一下、重新审视过往经验的部分。

如果还没读过这本书,希望这篇能让你产生兴趣。如果读过,希望这篇能帮你把散落的点串成一条线。

最后一句,是对整本书最好的注脚:

"If you have a choice between the same operation being idempotent or non-idempotent, it's better to choose the idempotent one."

让系统在混乱中仍能保持正确——这是所有设计的终点。