09 复制与冗余:主从、多副本、Quorum、纠删码
开篇提问
先给你一个选择题:
你要存 1PB 数据,用单机存(假设存在这么大的单机),机器挂了数据就没了。为了不丢数据,你决定存副本。那么——存 3 份副本,你的可用性和成本各是什么?
不用算精确数字,先凭直觉:3 份副本意味着 3 倍成本。但可用性呢?是不是”挂 2 台才会丢数据”?
现在请往深里想一层,这一层才是关键:
“存 3 份”这件事本身,是不是就自动带来了高可用?如果数据存了 3 份,但客户端不知道哪份是最新的、或者 3 份之间的数据对不上,那这 3 份副本还有什么意义?
这就是复制技术的核心:复制本身不难,难的是”让多份副本保持有意义的秩序”。复制解决的是”数据别丢”,但它立刻引出了”副本之间怎么协调”——这条因果链,一路通向共识算法(第 12 章)。
子主题一:复制解决什么——可用性与持久性
先明确复制(replication)的目标,它有两类,经常被混淆:
目标一:高可用(Availability)。 一个节点挂了,其他节点能顶上,服务不中断。这是”在线”层面的目标。
目标二:高持久(Durability)。 数据不因硬件故障而丢失。这是”数据”层面的目标。
两者的区别很重要:
- 一个系统可能”高可用但低持久”——服务一直在线,但某次故障丢了一批还没同步的数据。
- 也可能”高持久但低可用”——数据都好好存着(比如异地备份),但主库挂了要人工切换,服务中断几小时。
复制对这两者的贡献方式不同:
- 持久性:只要有一份副本活着,数据就没丢。副本越多、越分散(跨机架、跨机房、跨地域),持久性越高。
- 可用性:需要”系统能自动把请求切到活着的副本上”。这不只需要副本,还需要故障检测(第 11 章)和** failover 机制**。
所以”存 3 份副本”自动获得的是持久性,而可用性需要额外的机制才能兑现。这是很多人对复制的第一个误解。
子主题二:复制的三种基本形态
形态一:主从复制(Primary-Replica / Master-Slave)
- 一个主节点接受写,把变更日志(binlog/WAL)发给从节点,从节点重放
- 优点:简单、明确,天然支持”读写分离”(写走主、读走从,分摊读压力)
- 缺点:主节点是单点(主挂了要 failover,可能丢数据)、主从延迟导致读从库可能读到旧值
形态二:多主复制(Multi-Leader / Master-Master)
- 多个节点都能接受写,彼此同步
- 优点:没有单点,写可用性高;适合多地域部署(每个地域一个主,本地写入延迟低)
- 致命缺点:写冲突。两个主同时改同一条数据,谁赢?需要冲突解决策略(最后写入者胜、版本向量、应用层合并)
- 适用:协同编辑(Google Docs 类)、多活数据中心(且冲突可控)
形态三:无主复制(Leaderless / Dynamo 风格)
- 没有主节点,客户端直接写多个副本,读也读多个副本
- 核心机制是 Quorum(法定人数):N 个副本,写需要 W 个确认,读需要 R 个副本响应,只要 W + R > N,就一定能读到最新值
- 优点:没有单点、容错性强、可调一致性(调 W 和 R 就是调一致性/性能的旋钮)
- 缺点:读时可能读到多个版本要比较(需要版本向量/向量时钟);写冲突依然存在
Quorum 的 W+R>N 为什么能保证读到最新? 直觉上:写确认了 W 个副本,读要读 R 个副本。如果 W+R>N,那么”写过的副本集合”和”读到的副本集合”必然有交集——交集里至少有一个副本有最新值。再用版本号挑出最新的即可。这是组合数学在分布式系统里最优雅的一次应用。
子主题三:同步复制 vs 异步复制
这是复制里最核心的一个选择,直接决定”一致性”和”性能”的取舍:
同步复制: 主节点写完,必须等从节点确认,才返回客户端成功。
- 一致性最强(主从数据一致)
- 性能最差(写入延迟 = 最慢那个副本的延迟)
- 可用性最差(任何一个从节点挂了,写入就阻塞——这非常反直觉:你加了副本想提高可用性,结果反而降低了写入可用性)
异步复制: 主节点写完就返回,从节点慢慢同步。
- 性能最好(写入延迟 = 主节点自己的延迟)
- 可用性最好(从节点挂了不影响写)
- 一致性最差(主从延迟窗口内,从库是旧值;主库挂了,未同步的数据就丢了)
半同步复制(折中): 等至少一个从节点确认就返回。
- 保证”至少有一个副本有完整数据”(持久性有保证)
- 延迟只受一个从节点影响
- 这是 MySQL、Kafka 等系统的常用配置——在”完全同步”和”完全异步”之间找了工程上最实用的点
选哪个的判断依据:你的写延迟预算是多少?能容忍丢多少数据? 金融级”不能丢”用同步或半同步;社交类”丢几条无所谓但要快”用异步。
子主题四:纠删码——比”多副本”更省的冗余
存 3 份副本,存储成本是 3 倍。有没有更省的方案?有——纠删码(Erasure Coding)。
原理(直觉版):把数据切成 k 块,用数学方法编码出 m 个校验块,总共存 k+m 块。只要任意 k 块还在,就能还原出完整数据。
- 典型配置:k=10, m=4 → 能容忍 4 块损坏,存储开销只有 1.4 倍(而 3 副本要 3 倍)
- 对比:达到”容忍 4 个故障”的持久性,3 副本方案需要 5 份(3 倍都不一定够),纠删码只要 1.4 倍
为什么纠删码这么省? 因为它存的是”数学关系”而不是”完整副本”——校验块编码了所有数据块之间的冗余信息,效率远高于简单复制。
代价是什么?
- 计算开销:编码/解码要算(虽然现代 CPU 有硬件加速)
- 重建代价巨大:坏一块要读 k 块来重建,重建时会产生大量网络/磁盘 IO(这是纠删码最痛苦的地方)
- 延迟:读数据如果某块不可用,要等重建
所以纠删码的现实定位是:冷数据、归档数据用纠删码(省成本、访问少),热数据用多副本(读得快、重建便宜)。 这是对象存储(S3、HDFS、Ceph)的标准分层策略——热数据 3 副本,冷下来后自动转纠删码。
子主题五:复制带来的连锁问题
复制不是”存完就完事”,它打开了潘多拉魔盒,引出了一整串新问题:
问题一:一致性(前面第 2 章的主题)。 副本之间不一致怎么办?这就是主从延迟、读写不一致的根源。
问题二:故障检测。 怎么知道一个副本挂了?“超时”不等于”挂了”——可能是网络慢、可能是 GC 停顿、可能是机器在重启。误判的代价很大(误判会导致不必要的 failover)。(详见第 11 章)
问题三:脑裂(Split Brain)。 主节点”疑似”挂了,系统选了新主;但旧主其实没挂,它还在接受写入。两个主同时写,数据彻底分裂。 这是复制系统最危险的故障模式,必须用 fencing( fencing token / 租约)来防护——让旧主在被认定死亡后,其写入被底层拒绝。
问题四:成员变更。 加节点、减节点时,怎么保证数据正确迁移、不出现双主?这需要成员变更协议(Raft 的 joint consensus 就是在解决这个)。
看到这条因果链了吗?复制 → 一致性问题 → 故障检测 → 脑裂 → 成员变更 → 共识算法。 第 12 章的共识,就是这条链走到终点的产物。复制是问题的起点,共识是它的答案。
子主题六:复制日志的四种实现方式——主库到底”发什么”给从库?
前面我们一直说”主库把变更日志发给从库,从库重放”。听起来很自然。但请把这句话停一停,问一个被 99% 的人跳过的问题:
主库发出的”变更”,具体是什么东西?是一条 SQL 语句?是一行的新值?还是磁盘页的字节?
答案是:四种都行,而且这四种选择决定了这套复制系统的能力边界。这是复制领域最”接地气”、也最影响线上稳定性的一个设计决策。
方式一:基于语句的复制(Statement-Based Replication)
最直接的想法:主库执行了什么 SQL,就把这条 SQL 原文发给从库,从库再执行一遍。
UPDATE accounts SET balance = balance - 100 WHERE user_id = 42;主库执行它,然后把这个字符串发过去。优点非常诱人:
- 极度紧凑。一条
UPDATE ... WHERE status = 0影响了 100 万行,日志里也只有一行 SQL。日志体积小、网络省、落盘省。 - 可读。DBA 打开 binlog 就能看懂”刚才发生了什么”,审计、排查问题极其方便。这是运维视角的巨大优势。
- 跨版本友好。SQL 是稳定的对外接口,主库 5.7、从库 8.0 大概率也能执行。
但它有一个致命的内在缺陷:它假设了”同一条语句在不同机器上执行,结果一定相同”。这个假设是错的。
具体有哪些坑?一个一个来,每一个都是真实线上事故的来源:
坑一:非确定性函数。 INSERT INTO logs VALUES (NOW(), ...) —— 主库在 10:00:00.000 执行,从库在 10:00:00.350 重放,两个库里存的时间戳差 350 毫秒。数据量小的时候没人发现,一旦有对账逻辑,全是差异。同理的还有 RAND()、UUID()、SYSDATE()(注意 MySQL 里 SYSDATE() 是非确定的而 NOW() 在 binlog 里会被特殊处理,这个区别本身就说明问题有多微妙)。
坑二:自增列与并发。 主库上 10 个连接并发插入,自增 ID 的分配顺序由主库的锁竞争时序决定。从库单线程重放时,顺序可能完全不同,于是同一行数据在主从库上拿到了不同的自增 ID。接下来所有引用这个 ID 的表全部错乱。这类问题一旦发生,是”静默”的——MySQL 不会报错,只是数据悄悄对不上。
坑三:触发器与存储过程。 主库上一个 AFTER INSERT 触发器写了另一张表。从库重放时,触发器会不会再次触发?如果触发,是不是在从库上产生了不同的副作用?如果存储过程中间还有 SELECT(读到了主库当时的某个状态),那从库读到的可能是另一个状态。
坑四:有副作用的操作。 调用 UDF 写文件、发 HTTP 请求、发消息——在从库上会原样再来一遍。
坑五:非确定的 ORDER BY … LIMIT。 DELETE FROM t ORDER BY score DESC LIMIT 10 —— 如果有多行 score 相同,“哪 10 行”是不确定的。主库删了 A 组,从库删了 B 组。数据发散。
业界的补救与结论: MySQL 早期尝试过”把不确定函数的求值结果也一起写进 binlog”(NOW() 记成具体的 unix_timestamp,RAND() 附带随机种子,LAST_INSERT_ID() 显式传递),但覆盖不全,永远存在漏网的表达式。于是从 MySQL 5.1 引入 row-based、5.7.7 起默认 binlog_format=ROW 之后,基于语句的复制就基本退出了主流。
一个狠类比: 基于语句的复制,是把菜谱传真给另一个厨师。菜谱很省纸,但”盐少许""炒至微黄""按今天的心情调味”这类依赖厨师当下判断的步骤,换个厨师、换个时辰,做出来的就是另一道菜。
方式二:基于行的复制(Row-Based Replication)
换个思路:别告诉从库”怎么做”,直接告诉它”结果是什么”。
日志里记的是行级变更事件:表 t、主键 id=42 的这一行,字段 balance 从 1000 变成 900(before image / after image)。从库不需要重新计算,直接按主键定位、改值。
优点:确定性强。 NOW() 的坑消失了——因为值已经在主库算完,从库拿到的就是一个具体数字。触发器、存储过程、并发自增的问题一并消失。而且从库重放更快:主库已经把”哪些行要改”这件事用执行计划算过了,从库不用再扫一遍索引,直接按主键点改。
缺点也同样鲜明:
缺点一:日志膨胀。 还是那个例子,一条 UPDATE 影响 100 万行,日志里就有 100 万条行变更事件。一次批量数据订正可能瞬间产生几十 GB 的 binlog,把磁盘打满、把网络打满、把从库延迟拉到小时级。这是 DBA 最常踩的坑:一条”看起来无害”的批量 UPDATE 打崩了整条复制链路。 所以业界铁律是:大批量变更要拆成小事务、分批执行。
缺点二:不可读。 row-based 的 binlog 肉眼完全看不懂,必须 mysqlbinlog -v 反解成伪 SQL 才能审计。运维体验倒退。
缺点三:DDL 还是语句。 ALTER TABLE 没有”行”可言,只能记语句。所以 row-based 实际是 mixed 模式。
一个极少被提到、但极其重要的坑: 基于行的复制要能唯一定位某一行。如果一张表没有主键也没有唯一键,从库要应用一条 UPDATE 事件,就退化成全表扫描逐行匹配。一张千万行的表,主库上改 1 行,从库要做一次全表扫描。主从延迟直接爆表。
这就是”为什么 DBA 死活要求每张 InnoDB 表都必须有显式主键”的根本原因之一——不是洁癖,是复制链路的硬需求。MySQL 8.0 甚至提供了 sql_require_primary_key 参数来强制这一点。
顺带提一句相关参数:binlog_row_image 有三个取值。FULL 记录完整的前后镜像(默认,安全但大);MINIMAL 只记录主键 + 真正变化的字段(省空间,但要求表必须有主键,且某些场景下无法满足幂等重放);NOBLOB 省略没变化的 BLOB/TEXT 字段(折中,专门解决”表里有个大 JSON 字段”的场景)。这几个取值就是”日志体积 vs 重放安全性”的旋钮。
方式三:WAL 传输(Physical Replication / WAL Shipping)
再激进一点:连”哪一行的哪个字段改了”这种逻辑语义都不要了,直接把存储引擎内部的物理变更发出去。
PostgreSQL 的 streaming replication、Oracle Data Guard 的 redo transport、SQL Server AlwaysOn,都属于这一类。日志里写的是:“第 N 号页面的第 X 字节开始的 Y 个字节,改成下面这段内容。”
优点:极致高效、字节级一致。 从库不做任何”重放逻辑”,就是跟着主库把磁盘页改一遍。没有 SQL 解析、没有执行计划、没有锁、没有 MVCC 判断——省掉了整个数据库引擎的上层开销。而且它保证的是物理字节完全相同,不是”逻辑等价”,连页级的碎片状态、索引的物理结构都一样。这对备份一致性、故障恢复的确定性极有价值。
代价:和存储格式强耦合。 这句话的具体含义是:
- 主库和从库必须相同的大版本(PostgreSQL 主 15.2、从 15.2,小版本都不一定能跨)、相同的 CPU 架构、相同的编译选项、相同的页面大小、相同的
wal_level、甚至相同的扩展插件集合。 - 不能跨版本滚动升级。 这是一个运维上的巨大限制:你想把数据库从 15 升到 16,不能”先把从库升了、切主、再升旧主”,只能停机升级或者走逻辑复制搭一套新的。
- 从库几乎不能有任何自主性。 从库想多建一个索引?不行,物理结构和主库不一致就无法继续接收 WAL。从库想用不同的表结构做 OLAP?不行。
- 顺带一提:PostgreSQL 的物理从库上跑长查询时,主库的
VACUUM可能会清理掉从库查询还需要看的元组版本,导致从库报 “conflicts with recovery” 并杀掉查询。这是**“从库是主库物理状态的实时投影”** 这一事实带来的直接后果,需要hot_standby_feedback/max_standby_streaming_delay来缓解。你看,物理复制的”好处”(严格一致)同时也是它的”麻烦”(没有自主权)。
再一个狠类比: WAL 传输,是把主库的神经电信号直接拷贝给从库。效率最高、保真度最高,但要求从库必须长着一个一模一样的脑子。脑子结构稍微不同,信号就是噪声。
方式四:逻辑日志复制(Logical Replication / Change Data Capture)
那么,能不能既保留”行级语义”(确定、可读、解耦),又不要”重放 SQL”(不确定)?
能——从 WAL 里把物理变更反解成行级逻辑变更,再发出去。 这就是逻辑复制 / CDC(Change Data Capture)。
PostgreSQL 的 logical decoding + wal2json/pgoutput 插件、MySQL 的 row-based binlog + Debezium/Canal、MongoDB 的 oplog、Oracle LogMiner,全部是这个路子。
它的价值在于解耦:
- 跨版本:PG 15 的主库 → PG 16 的从库,可以。终于能做滚动升级了。
- 异构目标:下游不一定是数据库,可以是 Kafka、Elasticsearch、ClickHouse、数据湖。这是**“数据库 → 数据管道 → 数仓/搜索/缓存”这条现代数据架构的底座**。你用的每一套”实时数仓”,底下基本都是 CDC。
- 可选择性:只订阅部分表、部分列,甚至过滤掉某些事件。
- 多订阅者:一份逻辑日志,多个下游各自消费,互不干扰。
代价也很实在:
- 解码开销。主库要额外花 CPU 把 WAL 反解成行变更。
- DDL 不同步。绝大多数逻辑复制方案不会同步
ALTER TABLE,下游表结构要人工对齐。这是 CDC 管道最常见的故障来源:上游加了一列,下游没加,同步直接失败或数据错位。 - 大事务的解码峰值。一个改了 500 万行的大事务,在物理 WAL 里是紧凑的页变更,解码成逻辑行变更后会膨胀数倍,且必须等事务提交后才能对外可见(要维护事务边界)→ 内存和延迟都出现尖峰。
- 复制槽(replication slot)风险:如果下游消费者挂了或消费太慢,主库的 WAL 会被复制槽”钉住”无法回收,磁盘会被撑爆。这是 PostgreSQL 逻辑复制最经典的生产事故。
第三个类比: 物理 WAL 是”神经电信号”,行日志是”菜谱”,逻辑复制是**“把神经信号实时翻译成菜谱,再用传真发给任何能读字的人”**。翻译要花钱,但从此谁都看得懂、谁都能用。
四种方式的选择建议
| 方式 | 日志内容 | 确定性 | 体积 | 是否要求同构 | 典型用途 |
|---|---|---|---|---|---|
| 基于语句 | SQL 原文 | 差(NOW/RAND/触发器) | 极小 | 否 | 早期 MySQL,已基本淘汰 |
| 基于行 | 行的前后镜像 | 强 | 大(批量变更会爆) | 逻辑同构即可 | MySQL 默认,主从复制主力 |
| WAL 传输 | 页级字节变更 | 极强 | 中等 | 严格同构(版本/平台) | PostgreSQL/Oracle 物理备库 |
| 逻辑日志 | 反解出的行变更 | 强 | 较大 + 解码开销 | 否(可异构) | CDC、跨版本升级、数据管道 |
一句话决策:数据库内的主从复制,用行复制;要求字节一致、不需要异构和跨版本,用 WAL;要跨版本、要喂下游系统,用逻辑日志。
子主题七:复制状态机——理解”复制”与”共识”关系的那把钥匙
现在把上面四种日志抽象掉具体形态,往上看一层。你会发现它们在做同一件事:
主库在向从库发送一个”操作序列”,从库按顺序执行它。
这句话背后,是整个分布式系统最重要、最优美的一个抽象。请认真对待下面这个定义:
复制状态机(Replicated State Machine, RSM):如果所有副本从相同的初始状态出发,以相同的顺序执行相同的确定性操作序列,那么它们必然到达相同的最终状态。
这个思想 Lamport 在 1978 年的《Time, Clocks, and the Ordering of Events》里就埋下了种子,而把它作为一个明确的范式确立下来,是 Fred Schneider 1990 年的经典综述《Implementing Fault-Tolerant Services Using the State Machine Approach》。
请注意,这个定义里有三个要件,缺一不可:
- 相同的初始状态(same initial state) —— 起跑线一致。
- 相同的执行顺序(same order) —— 顺序即一切。
- 确定性的操作(deterministic operations) —— 操作必须是”函数”,不能是”看心情”。
少任何一条,副本就会发散。
这个抽象为什么这么重要?因为它把”复制”问题彻底转化了
请跟着这条推理链走,它只有三步,但每一步都很锋利:
第一步:有了复制状态机,“让副本数据一致”就等价于”让副本执行过的操作日志一致”。
第二步:数据不用传了,传操作就行。而”操作日志”是一个全序的序列——第 1 条、第 2 条、第 3 条……
第三步:于是问题从”如何复制数据”变成了 “如何让所有节点对’下一条日志是什么、顺序如何’达成一致”。
而这最后一步,就叫共识(Consensus)。
看明白了吗?Raft、Paxos、Zab 这些算法,本质上不是”数据库复制算法”,它们是”分布式日志的顺序共识算法”。 Raft 复制的东西叫 log entry;那个被复制的”状态”只是把 log entry 一条条 apply 上去之后的结果。Raft 论文里那个词 state machine 就是在说这件事:日志是骨架,状态机是血肉。
于是整个现代分布式系统的骨架在你眼前清晰了:
- Kafka 的一个 partition,就是一个复制状态机:append-only 的 log + 单调递增的 offset(顺序),每个副本 apply 到自己的本地段文件(状态)。
- etcd / ZooKeeper,是复制状态机:Raft/Zab 复制日志,状态机是一棵内存中的树(ZooKeeper 的 znode 树)或一个 MVCC 键值存储(etcd 的 boltdb)。
- MySQL 主从,是复制状态机:binlog 是日志,InnoDB 的表空间是状态。
- Redis 主从,也是:AOF/命令流是日志,内存数据结构是状态。
- 区块链,依然是:区块连成的链是一条全网复制的日志,UTXO 集 / 账户余额树是状态。
所有复制系统都是复制状态机,区别只在于”日志的顺序由谁、用什么方式决定”。
用这个抽象回头看子主题六,一切都通了
现在你能从理论高度解释前面那些”坑”了:
为什么基于语句的复制有坑?因为它违反了”确定性”要件。 NOW()、RAND() 让”同一个操作”在从库产生了不同结果 → 操作不再是函数 → 状态机发散。这不是”MySQL 的一个小 bug”,这是范式层面的违规。所以基于语句的复制不是”实现得不好”,而是”在原理上就站不住”,它注定要被淘汰。
为什么 WAL 传输要求严格同构?因为物理复制的”状态”定义里包含了存储格式本身。 页面大小、字节序、索引的物理布局,都是”状态”的一部分。格式不同,就不是同一个状态机了,自然无法”从相同初始状态出发”。
为什么基于行的复制是主流?因为它恰好在”确定性”和”解耦”之间找到了平衡点——行变更是逻辑语义(不依赖存储格式),同时值已经算完(确定性)。
这就是”抽象”的力量:它让你不用死记硬背一堆坑,而是从三个要件出发,自己就能推导出哪些设计会出问题。
再往深挖一层:复制状态机保证的是哪种一致性?
这里有一个必须澄清的经典误解。
复制状态机保证的是:所有副本最终处于相同的状态。这句话的关键词是最终和相同。
它没有保证:“读操作能看到最近一次已完成写入的结果”(线性一致性,linearizability)。
为什么?因为在异步/半同步复制下,主库 apply 完一条日志就返回了,从库还没 apply。客户端此时读从库,读到的是旧状态——但这并不违反复制状态机的定义,因为从库”最终”会追上。
更深一层:即使在主从完全同步的系统里,如果你读的时候不经过日志(直接从主库内存状态读),也可能破坏线性一致性。原因在 failover 时会变得极其具体:网络分区后,旧主还以为自己是主,你从它读到了一个”它认为最新、但已经被新主推翻”的值。
所以有了 Raft 论文里的两个补充机制:
- ReadIndex:leader 处理只读请求时,先把自己的 commit index 记下来,然后向多数派发一轮心跳确认自己还是真正的 leader,等到 apply 追上这个 index 才返回。→ 读请求也走了一轮 quorum。
- Lease Read:用时钟租约优化掉那一轮心跳(在租约有效期内认定自己还是 leader),代价是依赖时钟(第 11 章的坑又来了)。
这条结论值得记住:复制状态机解决”副本一致”,但要拿到”线性一致”,你还需要在读路径上也做文章。
最后一个类比,请把这个画面记住
复制状态机就像:三个人各拿一本一模一样的账本,用一模一样的记账规则,按照电话里念出来的同一串交易顺序记账。
只要初始余额一样、规则一样、顺序一样,三个人的账本就永远一模一样。这里面”记”这件事一点都不难。
真正难的是——谁有权决定下一笔交易念什么、按什么顺序念?如果念的人突然不说话了,谁来接替,又怎么保证他念的顺序和前一个人接得上?
那个问题,叫共识。第 12 章见。
子主题八:主从延迟的深入——延迟从哪来,会把你的业务坑成什么样
读写分离几乎是每个后端工程师职业生涯里最早接触的”分布式”方案。它的口号是”写走主、读走从,分摊读压力”。
那么请先回答一个具体问题:
用户改了自己的昵称,前端返回”修改成功”,然后页面自动刷新——用户看到的是新昵称还是旧昵称?
在异步主从复制下,答案是:看运气。 运气好(从库已经同步完)看到新的,运气不好(延迟 200ms)看到旧的,然后用户疯狂点击、疯狂刷新、提工单。
这就是主从延迟。它看起来是个小问题,但它是线上”数据不一致”类工单的头号来源。我们来把它彻底拆开。
延迟从哪来?拆成三段
复制链路是一个管道,延迟在管道的三段里分别产生:
第一段:网络传输。 主库生成日志 → 网络 → 从库接收(relay log)。这一段的下限是物理距离:同城 1ms 级,跨城(北京↔上海)约 30ms,跨洲(中国↔美西)约 150ms。这是无法优化的物理常量。
第二段:从库落盘。 从库收到日志后要先写下来(MySQL 的 relay log、PostgreSQL 的 WAL 落盘)。这是 IO 操作,受从库磁盘性能影响。
第三段:从库重放(apply / replay)。这是最致命、也最容易被忽略的一段。
为什么重放是瓶颈?请把这个画面想清楚:
- 主库是多线程并发写的。 100 个业务连接在 100 个 CPU 核上并发执行事务,主库靠 MVCC + 行锁 + 组提交,把 100 个事务在极短时间内一起提交。假设主库写吞吐是 10 万 QPS。
- 传统从库是单线程重放的。 因为 binlog 是一个全序序列,从库必须严格按顺序一条一条执行——主库的并发度在复制管道里被强制串行化了。
- 如果从库单线程重放能力只有 1 万 QPS,而主库写入是 3 万 QPS,那么每秒就净增 2 万条的积压。延迟不是”抖动”,是”单调累积”,直到主库写入降下来(通常是深夜)才能追平。
这个”并发 → 串行”的塌陷,是主从延迟最本质的原因。
业界的演进就是一部”把重放并行化”的历史:
- MySQL 5.6:基于 schema 的并行复制。不同库的并行,同库的串行。→ 如果所有表都在一个库里,等于没优化。这个设计基本没用。
- MySQL 5.7:LOGICAL_CLOCK / 基于组提交的并行复制。洞察非常漂亮:主库上能分在同一组提交的事务,意味着它们之间没有锁冲突(否则不可能同时提交)→ 那从库就可以并行重放这一组。这是用主库运行时的”提交事实”来推断”无冲突”,零成本获得并行度。 但并行度受限于组提交的大小。
- MySQL 8.0:WRITESET / 基于行冲突检测的并行复制。直接计算每行的哈希(行主键 + 库名 + 表名),两个事务修改的行集合不相交就判定无冲突 → 并行重放。并行度大幅提升,
binlog_transaction_dependency_tracking = WRITESET是 8.0 上强烈推荐打开的。 - PostgreSQL 从 16 起引入 parallel apply 逻辑复制的并行应用。
还有几个隐形杀手,比主库写入更阴险:
- 从库上的长查询/长事务阻塞重放。 从库上跑一个 30 秒的慢查询,它持有某个行锁或 MVCC 版本,重放线程要改同一行 → 卡住。一个报表查询能把延迟打到分钟级。
- 主从结构不一致。 从库多建了几个索引(为了跑报表),导致从库每次写入都要维护更多索引 → 从库写得更慢。这是”主从延迟”最常见的非主库侧原因。
- 批量变更。 一条
UPDATE改 100 万行(见子主题六)→ 100 万个行事件要重放。
延迟会把业务坑成什么样?四种具体的失效
很多人以为延迟的后果就是”读到旧值”。太天真了。它至少能造成四种性质完全不同的一致性失效:
失效一:读己之所写失效(Read-Your-Writes Violation / 写后读不一致)
用户改了昵称 → 写主库成功 → 刷新页面 → 读从库 → 看到旧昵称。
这是最直观的,也是用户唯一能直接感知的一种。它的本质:用户对自己刚刚完成的写操作,失去了读到的保证。 从用户视角看,“系统骗了我”。
失效二:单调读失效(Monotonic Read Violation / 时光倒流)
这个更阴险,也更让人崩溃。场景:
- 用户第一次刷新,请求被路由到从库 A(延迟 50ms),读到了新昵称。
- 用户再刷新一次,请求被路由到从库 B(延迟 300ms),读到了旧昵称。
注意这里发生的事情:同一个用户,连续两次读,后一次反而更旧。 数据在他眼前”倒退”了。
这比”一致地读到旧值”严重得多——人能接受”还没生效”,但不能接受”刚才还生效,现在又不生效了”。后者会让人怀疑系统坏了(事实上确实坏了),并且会触发疯狂重试。
根因:读请求在延迟不同的多个从库之间随机轮询。
失效三:前缀一致读失效(Consistent Prefix Read Violation / 因果倒置)
场景:用户 A 发了一个帖子(写操作 W1),用户 B 回复了它(写操作 W2)。W1 和 W2 有因果关系(没有帖子就没有回复)。
现在观察者读:读从库 X 拿到了 W2(回复),读从库 Y 还没拿到 W1(帖子)→ 观察者先看到回复,后看到帖子。因果链断了。
在分片数据库里这个问题尤其严重,因为 W1 和 W2 可能在不同的分片上,两个分片的复制延迟独立,完全没有顺序保证。
失效四:分页/列表在延迟下”漏数据”和”重数据”
这个坑非常隐蔽,却频繁出现。场景:
- 用户看第 1 页(
ORDER BY created_at DESC LIMIT 0,20),从库位点是 P1。 - 主库插入了新数据,从库位点前进到 P2。
- 用户翻到第 2 页(
LIMIT 20,20),此时从库位点是 P2。
结果:第 1 页的末尾几条被新数据挤到了第 2 页 → 用户在第 2 页看到了重复内容;或者某条记录在翻页间隙被删除/位移 → 用户永远看不到它。
用户报障的说法是:“你们的列表会重复/会丢东西。” 而你的代码一行都没改。
怎么应对?五层手段,从便宜到昂贵
手段一:写后读走主库(read-after-write / read your writes)
最简单的工程解,也是绝大多数系统在用的。
机制:客户端(或中间件)记录”这个用户/会话最后一次写入的时间戳”。后续读请求带上这个时间戳,路由层判断:如果这个从库的位点还没追到那个时间点,就把这个请求路由到主库。
工程细节:
- 要有时间窗(比如 5 秒)。超过 5 秒就认为从库肯定追上了,回落到从库。没有时间窗的话,一个高频写的用户会把所有读都打到主库,读写分离就白做了。
- 更精细的做法是”按数据维度”而不是”按用户维度”:只对这个用户刚改过的那部分数据走主库,其他读仍然走从库。
- 代价:主库压力上升。所以要评估”写后读”流量占比。
手段二:等位点 / 等 GTID(wait-for-position)
这是更精确的做法,本质上是**“把异步延迟变成按需的同步等待”**。
机制:
- 客户端写入成功后,主库返回这次写入对应的位点(MySQL 的 binlog file + position,或者 GTID)。
- 客户端下次读从库前,先执行一个”等待”语句:
SELECT MASTER_POS_WAIT('mysql-bin.000123', 45678, 2)—— 等从库追到这个位点,最多等 2 秒。- 或
SELECT WAIT_FOR_EXECUTED_GTID_SET('3E11FA47-71CA-11E1-9E33-C80AA9429562:1-1000', 2)—— 等 GTID 集合。
- 追上了 → 读从库,保证读到自己的写。超时了 → 降级走主库。
优点:只对真正需要的请求付出等待代价,不需要的请求依然是纯异步的零开销。这是”按需付费”的一致性。
注意超时必须配,且超时后必须有降级路径(走主库或报错),否则从库一挂,请求全部雪崩在等待上。
手段三:保证单调读 —— 会话/用户绑定固定从库
解法极其朴素:同一个用户的读请求,永远路由到同一个从库(按 user_id 哈希,或者会话粘性),而不是随机轮询。
这样就不会出现”先读 A 拿到新值、再读 B 拿到旧值”的时光倒流。代价是失去了负载均衡的灵活性(某个从库可能热点)——但可以用”用户组哈希到从库组”来缓解。
注意这条原则迁移到分片数据库上同样成立,甚至更重要:分片 + 副本 两层路由,一致性问题会叠加。
手段四:因果/前缀一致 —— 需要显式传递因果
要解决”因果倒置”,你必须让系统知道 W1 和 W2 之间有关系。手段:
- 把有因果关系的写入放到同一个分片/同一个日志序列里(单一 partition 天然保序)。
- 或者用版本向量在客户端之间传递因果(客户端记住自己上一次读到的版本,写的时候带上它,服务端检查依赖是否满足)——这是 Cassandra 的 lightweight transaction、以及一些因果一致数据库(如 COPS、AntidoteDB)的做法。
这一层已经明显超出”主从复制”的能力范围了。它提醒我们一件事:
手段五(架构层):别让读放大打到从库
最容易被忽略的一层:很多时候”读压力大所以加从库”这个决策本身就是错的。先问三个问题:
- 是不是缺缓存?(90% 的读压力问题答案是缓存)
- 是不是缺索引 / 有慢查询?(加从库只会把慢查询复制 N 份)
- 是不是该做读写分离以外的拆分(按业务垂直拆、按用户水平拆)?
加从库是把读压力”分摊”,不是”消除”。 N 个从库意味着 N 倍的写入放大(主库要把日志发 N 份)、N 倍的存储成本、N 倍的运维复杂度。盲目加从库会把主库先拖垮。
最后两条实战经验
经验一:Seconds_Behind_Master 是个不可靠的监控指标。
MySQL 的这个字段是这样算的:从库当前系统时间 - 正在执行的事件里记录的主库时间戳。它有两个致命缺陷:
- 主库空闲时不更新。 如果主库一个小时没有写入,从库这个字段可能一直显示 0,而实际上从库可能已经断连、或者落后了一整个 relay log 没重放。
- 它依赖主从系统时钟一致。 从库时钟快了几秒,这个数字就变成负数或异常大。
- 它无法反映”积压量”,只反映”时间差”。积压 100 万条事件和积压 10 条事件,可能都显示”落后 5 秒”。
正确的监控姿势:监控位点差,即:
主库 binlog 字节位置 - 从库已执行的位置(字节差,反映积压量)- 或 GTID 集合的差集大小
- 或 PostgreSQL 的
pg_wal_lsn_diff(sent_lsn, replay_lsn)
并且告警应该配在”位点差持续增大”上(说明延迟在单调累积,永远追不上),而不只是”延迟 > 30 秒”(抖动一下就告警,会淹没真正的问题)。
经验二:如果你的业务无论如何都不能接受读到旧值,那就别做读写分离。
这句话值得加粗,值得贴在工位上。
读写分离的前提是:业务能容忍”有界陈旧”(bounded staleness)——即”最多读到 N 毫秒前的数据”。如果你的业务(余额、库存、订单状态、权限)对陈旧度零容忍,那么读从库就是一个持续存在的一致性 bug 源,无论你怎么打补丁(各种 wait、各种路由规则)都是在给一个错误的架构擦屁股。
正确做法是:这类强一致读,老老实实走主库(或走 Raft/Paxos 的强一致读);把它们之外的、真正能容忍陈旧的查询(报表、搜索、feed、历史记录)放到从库去。 这叫”按一致性需求分流”,而不是”按读写类型分流”。
子主题九:failover 的完整难题——从”判断主库死了”到”别让它复活”
主库挂了,切从库。听起来是一行命令、一个按钮、一个 K8s operator 的事。
真的吗?我们来做一次思想实验:假设现在让你手动执行这次切换,你会遇到几个”你无法确定的时刻”?
答案是四个,每一个都能让你的数据永久损坏。
难题一:怎么判断主库”真的”挂了?
第一个残酷事实,请接受它:
你永远无法区分”对端节点挂了”和”网络慢 / 网络分区 / 对端 GC 停顿 / 对端机器负载过高”。
从你的观察位置看,这两种情况的表现完全一样:没有响应。 这不是工程做得不够好,这是分布式系统的基本限制(第 11 章会从 FLP 不可能性和故障检测器的角度严格论证这一点)。
于是,所有实用的故障检测都是超时 + 心跳 + 租约:
- 主库周期性发出心跳,或周期性续约(lease)。
- 监控方/其他节点在超过 N 个周期没收到心跳后,判定它死亡。
那么这个超时时间该设多长?这是一个没有最优解的两难:
- 设小了(比如 2 秒) → 主库来一次 Full GC stop-the-world(3 秒),或者一次网络抖动,或者一次内核刷脏页卡顿 → 被误判死亡,触发一次 failover。
- 误判的代价被严重低估了:failover 本身是一个高危操作(选主、切流量、可能有数据丢失窗口)。为一次 3 秒的 GC 做一次 failover,是拿真金白银的风险去换一个不存在的故障。更糟的是,如果 failover 过程本身要 30 秒,那你这一轮折腾下来,停服时间比那次 GC 长 10 倍。
- 设大了(比如 60 秒) → 主库真的挂了,服务中断 60 秒才开始切换。RTO 直接被这个数字绑定。
工程上的做法(三条,缺一不可):
- 多探测器(multi-observer):从不同机架、不同可用区、不同网络的多个观察点同时探测。一个 observer 的网络抖动不该导致 failover。
- 多信号:不只看 ping/端口连通。还要看:进程在不在、磁盘能不能写、能不能完成一次真实的业务探测(比如往一张心跳表写一条记录)、从库侧还能不能收到新的 binlog。“能 ping 通”不等于”能服务”。
- 多数派判定:只有当 M 个 observer 中的多数认为它死了,才算死。这样单个 observer 的误判被多数派投票过滤掉。
- 给”疑似死亡”留一个降级中间态:不要”活 → 死”二值跳变,而是”活 → 疑似(suspect)→ 死(dead)“。进入 suspect 后先做保守动作(比如摘除读流量、暂停新写入),确认 dead 后才做激进动作(选新主)。这是 SWIM 协议(Consul/memberlist 用的故障检测协议)的核心设计。
一个生活类比: 你不能因为朋友 5 秒没回微信就叫失踪人口;但也不能等三天才报警。而且更要命的是——你打电话打不通,到底是他手机没电了,还是他把你拉黑了,还是信号塔坏了?你从”打不通”这一个事实里,分不清这三种情况。 这就是分布式故障检测的本质困境。
难题二:选出新主——选谁?
假设你已确认旧主死了。现在有几个从库,选谁当新主?
第一原则:选数据最新的那个(most up-to-date replica)。
为什么?因为在异步复制下,各从库的位点不一样。从库 A 追到了位点 1000,从库 B 只追到 950。如果你选了 B 当新主,那么 950~1000 这 50 个位点对应的写入,就永久丢失了——因为旧主大概率已经死了,那段数据只存在于一个死掉的磁盘上。
所以选主的核心是”比较谁的数据更新”,而各个系统的实现方式,恰好展示了它们日志抽象的差别:
| 系统 | 比较”新旧”的依据 |
|---|---|
| MySQL(GTID) | 比较 GTID 集合:谁的 GTID 集合是其他人集合的超集,谁最新。集合包含关系,不是数值大小。 |
| MySQL(非 GTID) | 比较每个从库已执行的各个 binlog 的位置,取”综合最靠前”的那个;跨多个源时很麻烦(这也是推 GTID 的原因) |
| Raft | 比较 (lastLogTerm, lastLogIndex) 二元组:term 大的更新;term 相同则 index 大的更新 |
| PostgreSQL | 比较 WAL LSN 的位置 |
| Kafka | 比较 LEO(Log End Offset),ISR 里的副本才有资格被选为 leader |
Raft 在这里的设计特别值得单独拿出来说,因为它把工程直觉写进了协议:
Raft 的选举有一个限制——只有日志足够新的候选者才能当选。 一个日志落后的节点发起选举,其他节点会直接拒绝投票。
为什么这条限制至关重要?因为 leader 必须拥有所有已提交的日志条目。如果让一个落后的节点当上 leader,它就会用自己残缺的日志去覆盖其他节点,直接造成已提交数据丢失。所以 Raft 宁可让这一轮选举失败(split vote),也绝不让落后节点当选。
这就是把”选数据最新的副本”这条工程直觉,变成了协议里的硬约束。 好的分布式协议就是这样:它把你认为”应该这么做”的事,变成”不可能不这么做”。
除了”最新”,选主还有一堆工程约束:
- 候选者必须存活且网络可达。
- 候选者的配置必须和新主角色兼容(比如不能是延迟副本、不能是只读副本、硬件规格够)。
- 优先级 / 权重(你可能有意识地指定”优先切到同机房的从库”,避免跨机房延迟)。
- 排除”反复故障”的节点(flapping 检测)。
新主上任后还有一步:追赶与补齐。 新主自身可能也缺一小段日志(别人有而它没有)。Raft 的处理是:新 leader 不会去补自己的日志,而是强制 follower 与自己的日志对齐(冲突部分截断覆盖)——前提是这个 leader 已经是”最新”的了。MySQL 的处理则不同:如果旧主后来恢复了且有多余数据,需要人工甄别处理。
难题三:避免脑裂(Split Brain)——复制系统最危险的故障
现在进入本章最危险的区域。
场景重现: 旧主没有真的挂。它只是和管理网络/其他副本之间发生了网络分区。它自己活得好好的,还在接受客户端写入。
与此同时,其他节点判定它死亡,选出了新主,把流量切了过去。
于是系统里同时存在两个”主”,都在接受写入。这就是脑裂(split brain)。
后果有多严重?两个主的数据从此走向不同的分支。 之后你想把它们合并?_merge 一个 MySQL 实例的 binlog 和另一个实例的 binlog?那基本是不可能的——主键冲突、约束冲突、业务语义冲突,全部要人工逐条判断。这就是为什么脑裂被公认为复制系统最危险的故障模式。
请注意它的性质:它破坏的是”正确性”,不是”可用性”。 一个服务宕机一小时,损失是确定的、可量化的。一次脑裂造成的双写,损失是未知的、可能永远无法完全修复的。可用性故障会自己恢复,正确性故障不会。
那么怎么防?三层防护,从软到硬:
防护一:租约(Lease)+ Fencing Token
机制分两半:
第一半(租约): 主库不是”永久”持有主身份,而是持有一个有期限的租约。比如 10 秒。主库必须在租约到期前续约(renew)。如果因为网络分区导致无法续约,租约到期 → 主库必须主动把自己降级为只读,或者干脆自杀(进程退出)。
这样,判定死亡的时间上界就是租约长度,而旧主在租约过期后会自己停止写入。
第二半(fencing token,这是关键中的关键): 每次写入都携带一个单调递增的令牌(在 Raft 里叫 term,在 ZooKeeper 里叫 epoch / czxid 的高位,在某些系统里叫 incarnation number)。存储层/资源层维护”我见过的最大 token”,只接受 token ≥ 当前最大值的写入。
于是:旧主被隔离、新主上任(token 从 5 变成 6)。旧主如果还在写,它带着 token=5 的请求到达存储层,存储层看到 5 < 6,直接拒绝。
这条设计的精髓在于:防护发生在”资源侧”(服务端),而不是依赖旧主”自觉”。
请把这句话读三遍:
Fencing 必须在服务端(资源侧)强制检查,不能依赖客户端或旧主自己的判断。
为什么?因为旧主已经”精神错乱”了——它真诚地相信自己是主。 你不能指望一个疯子自己按时吃药。一个因为 GC 停顿而”以为自己还活着”的进程,你让它检查”我还是主吗?“,它会真诚地回答”是的”(它只是没有收到最新的信息)。
所以 fencing token 必须由被写入的那个资源(存储节点、磁盘、锁服务)来检查,而不是由”发起写入的那个节点”来检查。这是 Martin Kleppmann 反复强调的一个设计原则,也是无数分布式锁 bug 的根源所在。
顺带点破一个经典陷阱: 很多人用 ZooKeeper/Redis 做分布式锁,拿到锁之后去写共享存储。但如果客户端 GC 停顿超过了锁的超时时间,锁被别人拿走了,这个客户端醒过来还会继续写。如果共享存储本身不检查 fencing token,这个锁就是完全无效的。 这就是为什么”锁服务 + 资源侧 fencing”必须成对出现。
防护二:多数派(Quorum)写入
这是 Raft / Zab 这类共识协议自带的结构性防护。
机制:任何写入必须获得多数派副本的确认才算成功。
被网络分区隔离到少数派那一侧的旧主,无论它多努力,都凑不齐多数派 → 写入直接失败,自动停止服务。
这一层的优雅之处在于:它不需要”检测故障”,也不依赖时钟和租约。 少数派在数学上不可能拿到多数派的确认,所以它自动失效。这是结构性保证,不是检测性保证。
顺带解释了一个你肯定想过的问题:为什么副本数总是奇数(3、5、7)?
因为奇数是为了形成明确的多数派边界。3 副本的多数派是 2,能容忍 1 个故障;4 副本的多数派是 3,还是只容忍 1 个故障,但成本贵了 33%。偶数副本不会给你更多容错,只会让你更贵。 4 副本唯一的意义是”在 3 副本和 5 副本之间微调成本/可用性曲线”,但它不形成新的容错等级。
防护三:STONITH(Shoot The Other Node In The Head)
前两层都是”让旧主写不进去”。第三层更直接:物理上把旧主干掉。
手段包括:
- 远程电源管理:IPMI / iDRAC / iLO 强制断电或重启。
- 云厂商 API:调用 API 强制关机、强制摘除网卡、释放弹性 IP。
- 存储层隔离:SCSI-3 Persistent Reservation(磁盘级别的排他锁)、光纤交换机 zoning 屏蔽 LUN。宿主机还活着,但它访问不到那块盘了。
这就是 STONITH 的字面意思:爆了另一个节点的头。听起来暴力,但逻辑是清晰的——宁可确定性地下线一个节点,也绝不冒两个节点同时写的风险。 前者是可用性损失(可控、可恢复),后者是正确性损失(不可控、可能不可恢复)。
而且顺序极其重要:必须先 fence 旧主,再让新主上任。
如果反过来(先让新主开始服务,再回头去 fence 旧主),中间就存在一个双写窗口:新主在写,旧主也在写。哪怕窗口只有 100 毫秒,也足够产生几十条冲突数据。
正确的顺序是:
- 检测到疑似故障
- 确认旧主已无法写入(租约过期确认 / STONITH 执行成功 / quorum 确认)
- 新主上任,开始接受写入
第 2 步失败怎么办?停在那里,不要继续。 宁可不切换(牺牲可用性),也不能双写(牺牲正确性)。在”停服”和”数据损坏”之间,永远选停服。
再一个类比: 公司 CEO 失联,董事会任命了新 CEO。结果旧 CEO 从深山里回来了,还在签合同。你需要的是——“他的签字权被银行系统直接作废”,而不是”我们相信他会自觉不去签”。
难题四:这个取舍无法回避 —— 异步复制 failover 必然丢数据
前面三道难题都有解法。第四道没有,它只能选择。
残酷事实:在异步复制下,failover 必然丢失数据。
推理很简单:主库写完就返回了”成功”,但日志还没到从库。此时主库永久损坏(比如磁盘物理损毁、机房火灾)→ 那部分数据永久消失。
而客户端已经收到了”写入成功”。这是对持久性承诺的直接违背——你告诉用户”存好了”,然后没了。
半同步只能缓解,不能根治。 这一点必须说清楚,因为很多人有误解:
MySQL 的半同步复制有一个致命的自动降级行为:如果主库在 rpl_semi_sync_master_timeout(默认 10 秒)内没等到任何从库确认,它会自动退化成异步复制继续提供服务。这个设计的初衷是”不能因为从库故障就停服”,但它的后果是:在从库故障或网络抖动的那些时刻,你其实处于异步模式,随时可能丢数据。
所以——“我们配了半同步”不等于”我们不丢数据”。 你必须理解它在什么条件下会降级。
于是,那个经典的两难浮出水面:
- 选择 A:保证不丢数据(偏向 CP) → 主库不可用期间,拒绝写入(或无限阻塞等待副本确认)。牺牲可用性。
- 选择 B:保证服务不中断(偏向 AP) → 接受未同步数据的丢失。牺牲持久性。
这两个选择由两个运维指标精确量化,它们是所有容灾方案的起点:
- RPO(Recovery Point Objective,恢复点目标):能容忍丢失多长时间的数据。
- RPO = 0:一条已确认的写入都不能丢。
- RPO = 5 分钟:可以丢最后 5 分钟的数据。
- RTO(Recovery Time Objective,恢复时间目标):能容忍服务中断多久。
- RTO = 0:自动切换,业务无感。
- RTO = 30 分钟:可以有 30 分钟的停服窗口。
这两个数字决定一切技术选型:
- RPO = 0 → 必须同步复制(多数派确认)→ 必须接受”副本不可用时写入失败”→ 可用性下降。
- RPO > 0 → 异步复制可用 → 性能好,但故障时会丢一个 RPO 窗口的数据。
- RTO ≈ 0 → 必须自动 failover → 必须把前三道难题(故障检测、选主、脑裂防护)全部做对,并且做过演练。
- RTO 宽松 → 可以人工切换。而人工切换其实是最安全的选择——人有全局视角、能兜底、能在信息不足时选择”不动”。自动 failover 最大的风险恰恰是”它在信息不足时选择了乱动”。
现代系统把这个取舍做成了可调的旋钮。 例如 MySQL Group Replication 的 group_replication_consistency 参数:
EVENTUAL:不等,写完就返回(快,但读可能陈旧)BEFORE:读之前先等所有前序事务 apply 完(保证读到最新)AFTER:写返回前等所有副本 apply 完(保证后续读都能看到)BEFORE_AND_AFTER:都等(最强,最慢)
它的价值在于:你可以按事务挑一致性级别。 转账用 BEFORE_AND_AFTER,点赞数用 EVENTUAL。这比”整个库统一选一个级别”合理得多。
最后一句实话,也是这小节最该被记住的:
先跟业务方确认 RPO 和 RTO,再去选技术方案。
工程师最容易犯的错,不是技术选型错了,而是从来没问过 RPO 就默认选了异步,然后在真正出事的那天,才发现业务方以为 RPO = 0。这个沟通只需要一次会议,但它能避免一次职业生涯级别的事故。
附:旧主”复活”的善后
还有一个常被遗忘的环节:旧主恢复后怎么办?
- 它可能有一堆从未被提交的日志(它接受了写入但没得到多数派确认)。这些日志必须被丢弃/截断。
- Raft 的做法:新 leader 强制覆盖 follower 的冲突日志段。
- MySQL 的做法:如果旧主在”死亡”期间真的写入了数据(比如配置了不安全的降级),需要人工甄别、提取、甚至丢弃。这就是为什么”不要随便让旧主原地重启后直接挂成从库”。
- 正确姿势:旧主恢复后,先以从库身份接入,让它的数据被新主覆盖/补齐,确认一致后再考虑是否重新提升。
failover 是一个完整的生命周期,不是一次切换动作。 检测 → 选主 → fence → 上任 → 追赶 → 旧主善后 → 客户端重连。任何一环漏掉,都会在半年后的某次事故里还给你。
子主题十:多主复制的写冲突——两个主同时改了一个购物车
主从复制的核心麻烦是”延迟”。多主复制的核心麻烦是——冲突。
先来个具体的:你的服务在上海和法兰克福各有一个主库,用户在飞机上过了个时区,前后两个请求分别打到了两个主库,同时修改同一个购物车。
谁赢?
更尖锐一点:如果系统自动决定谁赢,那它凭什么替用户做这个决定?如果它不决定,那系统返回什么?
先把”冲突”的形态说清楚——它比你想象的宽
大多数人以为冲突就是”两个人同时改同一个字段”。太窄了。完整的冲突谱系至少有五种形态:
形态一:并发写同一条记录的不同字段。
用户 A 在上海改了收货地址,用户 B 在法兰克福改了商品数量。理论上这两个修改可以合并(地址和数量互不干扰)。但如果你的写入语义是”整条记录覆盖写”(PUT /cart 而不是 PATCH /cart),那就只能二选一,另外一个的修改被静默丢弃。
→ 这提醒我们一件事:API 语义(PUT vs PATCH)就是一个分布式冲突决策。 你以为是前端接口设计的小事,其实它是复制架构的一部分。
形态二:唯一约束冲突(这是最经典的”无解”问题)。
两个主同时插入 username = 'alice'。两个本地的唯一索引检查都通过了。等到双向同步时,才发现冲突。
这是根本性的困难,请注意:要保证全局唯一性,就必须全局协调。你只有三条路:
- 全局共识:每次插入都走一次跨地域的共识/加锁。代价:每次写入多出 30~150ms 的跨地域延迟。
- 预分配号段(partitioned ID space):每个主库分配一段互不重叠的 ID 空间。比如 MySQL 的
auto_increment_increment=2+auto_increment_offset=1/2(主1 发奇数、主2 发偶数);或者雪花算法里的 worker_id / datacenter_id 位;或者号段模式(每个主预领 1000 个号)。这是工程上唯一实用的解法。 - 事后检测 + 人工/自动补偿。
形态三:跨记录的约束冲突(不变量被破坏)。 账户余额 100 元。上海扣 60,法兰克福扣 60。各自本地检查都”没透支”,加起来透支了 20。
这一条揭示了多主复制最深的限制:任何”跨数据的全局不变量”(余额不能为负、库存不能超卖、总额必须守恒),在多主无协调的情况下都无法保证。 这类需求只能在写入路径上做全局协调,或者事后对账补偿。
这就是为什么”多活”在支付领域那么难,而在社交领域相对容易——社交数据(发帖、点赞、评论)大多是”独立事实”,几乎不跨数据产生不变量;支付数据(余额、流水)处处是不变量。
形态四:因果顺序错乱。 用户 A 在主库 1 发帖,用户 B 在主库 2 回复。这两个事件有因果关系。但两个主库的复制延迟独立 → 观察者可能先看到回复、后看到帖子(这正是子主题八的”前缀一致读失效”,在多主下更严重)。
形态五:多步操作的语义冲突。 单个操作能合并,但一串操作未必。“下单”和”取消订单”如果顺序反了,语义完全相反。操作序列的合并比值的合并难一个数量级。
冲突解决策略,从”糙”到”精”——五层
策略零(最重要,但常被跳过):避免冲突(Conflict Avoidance)
最高明的冲突解决,是让冲突不发生。
具体做法:按用户/租户/地域分片,让同一份数据的写永远路由到同一个主。 业界叫”单元化”(cell-based architecture)或”SET 化”。
- 用户 U 归属单元 1(上海),他所有的写都走上海的主库。上海挂了,才整体切到备用单元(这时是单元级的 failover,而不是多主并发写)。
- 单元之间不需要实时双向同步写,只需要异步同步”用户归属关系”和少量全局数据。
支付宝的三地五中心、微信支付、微博的多活,底层思路都是单元化。 它们不是”解决了冲突”,而是**“通过业务分区设计,让冲突在架构层面不存在”**。
这一条比后面所有策略加起来都重要。 工程师的本能是”怎么解决冲突”,高手的本能是”怎么设计让冲突根本不发生”。
请记住这个优先级:避免冲突 ≫ 自动解决冲突 ≫ 人工解决冲突。
策略一:最后写入者胜(LWW, Last Write Wins)
机制:每个写带一个时间戳(或版本号),冲突时保留时间戳最大的,丢弃其他。
优点: 实现简单到令人发指;保证收敛(所有副本最终一致)。
但问题非常严重,而且是复合的:
问题 A:依赖时钟。 跨机器的时钟不可能完全同步(第 11 章会详细讲 NTP 的误差、时钟回拨、以及为什么”墙上时钟”不能用于排序)。如果上海的主库时钟快了 3 秒,那么法兰克福后发生的写入,会因为时间戳更小而被上海的旧写入覆盖。新数据被旧数据覆盖,且无人察觉。
(Cassandra 的 LWW 就有这个问题。它的 USING TIMESTAMP 甚至允许客户端自己指定时间戳,那是更大的坑。)
问题 B:静默丢数据(silent data loss)。这是 LWW 最本质的恶。
被丢弃的写入不产生任何错误、任何日志、任何告警。客户端收到了”写入成功”,然后数据没了。
这比报错糟糕得多。 报错你至少能重试、能告警、能监控。静默丢弃意味着:
- 你不知道它发生了
- 你不知道该配什么告警
- 用户也不知道(他们的操作”成功了”)
- 直到很久以后对账的时候才发现
工程师最怕的不是系统报错,是系统对你撒谎。LWW 就是一个会撒谎的机制。
问题 C:并发的定义不明确。 两个写的时间戳相差 1 毫秒,它们是”并发”还是”先后有序”?这取决于你的时钟精度和分辨率。你把正确性问题,外包给了一个你控制不了的物理量(时钟)。
结论:LWW 只适合”覆盖语义”(最后一次设置的值就是唯一有意义的值)——比如缓存、用户最后登录时间、某个开关状态。绝对不适合”累加/守恒语义”——余额、库存、计数器、任何”不能丢”的东西。
策略二:版本向量(Version Vector)——让系统拒绝裁决
机制:
- 每个副本维护一个向量
{node1: 3, node2: 7, node3: 5},每次写递增自己那个分量。 - 比较两个版本 A 和 B:
- 如果 A 的每个分量都 ≥ B,且至少一个 > → A 是 B 的后代,B 可以安全丢弃(A 已经包含了 B)。
- 如果 A 和 B 互有大小 → 真并发(concurrent conflict),系统不做裁决。
关键在最后一句:系统不裁决,而是把多个版本(siblings)都保留下来,一起返回给客户端,由应用层(或者最终由用户)决定怎么合并。 合并后再写回去,产生一个新版本,覆盖掉所有旧版本。
这就是 Amazon Dynamo 论文、Riak 的做法。
优点,只有一条但极其重要:绝不静默丢数据。 冲突的每一个版本都被保留、都被呈现。这是它相对 LWW 的本质优势。
代价:
- 向量随节点数膨胀。节点多了(几百个),向量本身就很大。
- 兄弟版本爆炸(sibling explosion):一个移动端 App 离线一周,期间产生了 200 次本地修改,回来同步时会产生一个巨大的冲突集合。
- 需要客户端配合实现合并逻辑 → 把复杂度推给了应用层。这既是优点(灵活)也是缺点(每个客户端都要写对,写错了就出问题)。
优化版本:
- Dotted Version Vector(点分版本向量):Riak 用来解决”向量膨胀 + 客户端标识混乱”的改进结构。核心思想是给每个 (client, server) 的因果历史分配一个”点(dot)“,用更紧凑的方式表达因果。
- 裁剪(pruning):定期清理掉已经被覆盖的历史版本。但裁剪必须小心——裁错了会把并发误判为因果。
一个精确的类比:
- LWW 是:两个人同时改了同一段文档,系统偷偷随机留了一个,另一个人永远不知道自己的修改没了。
- 版本向量是:系统把两个人的两个版本都摆在你面前,说:“你决定。” 它承认自己没有足够的信息替你做决定。
从工程哲学上讲,版本向量体现了一个重要原则:当系统无法确定性地做正确的事时,把冲突显式暴露出来,好过自作主张地丢弃数据。
策略三:CRDT —— 在数学层面消除冲突
如果说版本向量是”承认冲突、暴露冲突”,那么 CRDT 走的是完全不同的第三条路:
把数据类型设计成”数学上不可能产生冲突”的。
原理一句话:只要所有操作满足三个性质:
- 交换律(commutative):
a ⊕ b = b ⊕ a - 结合律(associative):
(a ⊕ b) ⊕ c = a ⊕ (b ⊕ c) - 幂等(idempotent):
a ⊕ a = a
那么——无论这些操作以什么顺序到达、重复到达多少次,所有副本最终都会收敛到同一个结果。
于是”冲突”这个概念本身被消解了。 不需要时钟、不需要向量、不需要协调、不需要应用层合并。收敛性由数学定理保证,而不是由协议保证。
这是本章最优雅的思想,值得单独体会一下:它把”分布式一致性”这个工程难题,转化成了一个”代数结构”的数学问题。
具体的 CRDT 例子:
| CRDT | 结构 | merge 操作 | 典型用途 |
|---|---|---|---|
| G-Counter | 每个节点一个只增分量 | 各分量取 max | 点赞数、播放数、任何只增计数 |
| PN-Counter | 两个 G-Counter 相减 | 分别取 max | 可增可减的计数 |
| G-Set | 一个集合 | 并集 | 收藏、标签(只增不删) |
| 2P-Set | 加集 + 删集(tombstone) | 两个集合分别并集 | 可删集合(但删了不能加回) |
| OR-Set | 元素带唯一 tag,只删除”被观察过的” tag | 并集 - 已删 tag | 真正可加回的集合(购物车) |
| LWW-Register | 值 + 时间戳 | 取时间戳大者 | 退化版,就是 LWW |
| RGA / Yjs / Automerge | 序列 CRDT | 基于唯一 ID 的全序插入 | 实时协同编辑(Google Docs、Figma、Notion 的底层) |
CRDT 的代价(必须诚实地说):
- 不是所有数据结构都能 CRDT 化。 凡是需要全局不变量的,都做不到:唯一约束(用户名不能重复)做不到、余额不能为负做不到、库存不能超卖做不到。CRDT 只能表达”满足交换律的局部操作”,不能表达全局约束。
- 元数据膨胀。 墓碑(tombstone)不能随便删除(删了就可能被”复活”),OR-Set 每个元素都要带唯一 tag,长期运行会积累大量元数据。
- CRDT 保证”收敛”,不保证”语义正确”。 这是最微妙的一点。举例:用户 A 把商品 X 加入购物车又删除,用户 B 也把 X 加入购物车又删除。CRDT 会收敛——但收敛到的结果,可能既不是 A 想要的也不是 B 想要的。数学上的正确性 ≠ 业务上的正确性。
- 调试极其困难。 当结果不对时,你没法”重现”——因为没有全局顺序可以复现。
一句话总结 CRDT:它把”正确性”的定义,从”符合某个全局串行顺序”改成了”满足数学收敛性质”。这是换了一套正确性标准,不是免费的午餐。
什么时候用 CRDT? 当你满足以下全部条件时:
- 数据可以表达为可交换的操作(计数、集合、序列插入/删除)
- 没有全局不变量约束
- 需要离线可用 / 极低延迟 / 高并发写
典型场景:协同编辑、多人协作白板、离线优先的移动应用、IoT 设备的状态同步、Redis 的 CRDT-Enabled 数据库(Redis Enterprise 的 Active-Active)。
策略四:应用层合并(Application-Level Merge)
最灵活、最贴合业务、也最贵的方案。
机制:把冲突的多个版本交给业务代码,由业务决定怎么合并。合并结果再写回。
几个真实世界的例子,每一个都体现了”业务语义”:
- 购物车 → 并集。 两个设备各自加了不同的商品,合并时取并集(都留着)。这是 Amazon 的经典做法——它不纠结”哪个是最后状态”,而是把购物车定义为”一个意图集合”。注意:这是通过”重新定义数据结构”来消除冲突,本质上已经是 CRDT 思路了。
- 协同文档 → 字符级操作变换(OT)或 CRDT。 Google Docs 早期用 OT(Operational Transformation),现代系统(Figma、Notion)多用 CRDT。
- 库存 → 转成两阶段流程。 不做”直接扣减”这种非交换操作,而是改成”预占(reserve)→ 确认(confirm)或回滚(cancel)“。预占是局部的,确认需要全局协调。这是用流程设计绕开冲突。
- 金融账务 → 写入”待处理队列”,人工介入。 听起来不”技术”,但它诚实。账务系统里,错误的自动合并比暂停服务昂贵得多。
应用层合并的设计原则(这两条违反必出 bug):
- 合并操作必须幂等。 因为重试、乱序、重复投递一定会发生。
- 合并操作最好满足交换律。 因为两个副本可能以相反的顺序收到这两个操作。
如果你的合并逻辑不满足这两条,那么它在重试和乱序下,一定会产生不一致。
本小节的结论:一张决策表
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| 可预先分区(用户/租户/地域) | 单元化,避免冲突 | 最便宜、最可靠 |
| 覆盖语义(缓存、状态位、最后登录时间) | LWW | 语义上”最新即正确”,丢弃无害 |
| 需要”绝不静默丢数据” | 版本向量 + 应用层合并 | 冲突显式暴露 |
| 计数/集合/协同编辑、离线优先 | CRDT | 数学保证收敛 |
| 有全局不变量(余额、唯一性、库存) | 必须全局协调(共识/锁),或事后对账 | 无自动解法 |
(关于要不要引入 CRDT,很多团队会在评审会上反复犹豫。判断标准其实很简单:你有没有全局不变量。 有 → CRDT 帮不了你,必须协调;没有 → CRDT 是最省事的方案。)
最后一句必须说的边界:没有任何自动策略能解决所有冲突。 凡是涉及”全局不变量”的约束,在多主架构下要么在写入路径上付出协调成本,要么在事后付出对账成本。这条边界不是技术能力问题,是分布式系统的本质限制。
子主题十一:Quorum 的深入——那个不等式没告诉你的事
子主题二里我们说过:W + R > N 就能保证读到最新值。 这是一个漂亮的不等式,鸽笼原理的优雅应用。
现在请你带着怀疑的眼光再看它一眼:
如果 W+R>N 这么好用,那为什么 Dynamo 系的系统(Cassandra、Riak)还经常被人吐槽”读到了脏数据”、“一致性很难推理”?是不是这个不等式在骗人?
不等式没骗人。但它只保证了它承诺的那一件事,而人们常常以为它保证了更多。这一节我们来划清它的边界。
边界一:Quorum 保证”你能看到最新值”,但没保证”你能认出它”
W+R>N 的数学含义是:写过的副本集合(≥W 个)和读到的副本集合(≥R 个)必然有交集。所以读到的 R 个里,至少有一个持有最新值。
但是——你怎么知道 R 个响应里,哪个是”最新值”?
答案是:你需要版本号。 每次写递增版本号,读的时候取版本号最大的那个。
于是这里有一个隐藏的依赖:如果你的版本号机制本身不可靠,W+R>N 就形同虚设。
最典型的反例就是 LWW + 不可靠时钟:Cassandra 用时间戳挑”最新”,而时间戳来自客户端/协调节点的墙上时钟。时钟偏移 3 秒 → 你可能挑出一个物理上更早的写入作为”最新值”。
你看:数学保证了”最新值就在你手里”,但你的识别机制把它扔掉了。 组合数学的保证,被一个有缺陷的比较函数废掉了。
这是分布式系统里一个反复出现的模式:一层做对了,但它的正确性依赖下一层也做对。
边界二:对并发写,W+R>N 完全无能为力
W+R>N 保证的是”读能读到最近一次成功完成的写”。
但如果两个写并发发生(互相不知道对方存在),那么:
- 副本 1、2 拿到了 A 值,副本 3 拿到了 B 值(W=2 时两个写各自凑够了自己的 W)
- 读 R=2 个副本,可能拿到 A 和 B 两个不同的值
这时候”谁是最新的”这个问题没有答案——因为它们是并发的,没有先后。
这正是子主题十的领域。Quorum 只负责”把候选值都送到你面前”,解决冲突是版本向量 / CRDT / 应用层的事。Quorum 和冲突解决是两件正交的事,必须成对使用。
边界三:写失败不会自动回滚——“脏值”会一直躺着
这个坑很隐蔽。场景:N=3, W=2。客户端发起写,协调者同时发给 3 个副本:
- 副本 1:成功
- 副本 2:成功
- 副本 3:网络超时 / 协调者挂了
假设协调者在收到 2 个成功 ack 之前崩了,客户端收到”写入失败”。
那么副本 1 和副本 2 上的那个新值,会自己消失吗?不会。
它已经写进去了,没有任何机制会去回滚它。它会一直躺在那里,直到被下一次成功的写覆盖。期间任何一次读到它的请求,都会看到一个”从未被确认过”的值。
所以 Dynamo 系系统必须有额外的清理机制,这就是下面两条:
读修复(Read Repair): 读的时候顺便做修复。协调者向 R 个(甚至全部 N 个)副本发请求,发现副本版本不一致 → 顺手把新值写回那些落后的副本。
- 代价:读请求变成了写请求,读延迟和写放大都上去了。
- 优化:只在一定比例的读请求上做修复(
read_repair_chance),或者只做”摘要比对”而不是全量比对(先比哈希,不一致才传数据)。 - 注意:读修复是”惰性”的——没人读的数据永远不会被修复。
反熵(Anti-Entropy): 后台进程定期比对副本差异并同步。
- 关键问题是:怎么高效地找出两个副本的差异?全量比对要传输所有数据,不可接受。
- 答案:Merkle Tree(哈希树)。
- 每个副本为自己的数据范围建一棵哈希树:叶子是数据块的哈希,内部节点是子节点哈希的拼接哈希。
- 比对时只传树根哈希。相同 → 完全一致,传输量 O(1)。
- 不同 → 只下钻到不一致的子树。
- 结果:比对成本从 O(数据总量) 降到 O(日志 n + 差异量)。
- 这是 Cassandra、Riak、DynamoDB 的后台同步机制,也是 Git、区块链、IPFS 的核心数据结构。Merkle Tree 是”用密码学哈希把’对账’变成廉价操作”的经典设计,值得单独记住。
边界四:Sloppy Quorum 与 Hinted Handoff —— 一致性的主动放弃
再看一个现实问题:N=3, W=2。现在 3 个副本里有 2 个挂了。怎么办?
严格按照 quorum 定义:凑不够 W=2 → 写入失败。 可用性下降。
Dynamo 的选择是:不让写入失败。 具体做法是 Sloppy Quorum(松散法定人数):
- 数据本来应该写到副本 {A, B, C}。
- 现在 A 挂了。系统把数据写到 {B, C, D}——D 本来不负责这份数据,但它是”下一个可用节点”。
- 同时附上一个 hint(提示):“这份数据本来属于 A,等 A 恢复了请你交给它。”
- A 恢复后,D 把数据移交回去(hinted handoff),系统恢复正常的 quorum 布局。
代价是什么?请你自己推一下:
如果数据写在 {B, C, D} 上,而读请求发给了 {A, E, F}(A 已恢复但还没收到 hint 移交的数据)→ 写集合和读集合可能完全没有交集。
W+R>N 的保证,在 sloppy quorum 下失效了。
这是一个明确的设计选择,不是 bug。 Dynamo 的口号就是”永远可写”(always writeable)——为了极致的写可用性,主动牺牲了一致性保证。Amazon 的理由很实在:购物车一时不一致,最多是用户看到商品少了;但”无法加入购物车”,是直接的收入损失。
这就是”可调一致性”的真实含义:W、R、N 不只是三个数字,它们是三个可以把”一致性 ↔ 可用性 ↔ 延迟”的权衡量化到每一次请求的旋钮。
Cassandra 的一致性级别就是这套旋钮的完整暴露:
ONE/TWO/THREE:等 1/2/3 个副本确认QUORUM:floor(N/2) + 1ALL:等全部LOCAL_QUORUM:只在本地数据中心内达到 quorum,跨 DC 用异步复制EACH_QUORUM:每个数据中心都要达到 quorumANY:连 hinted handoff 都算成功(最弱,但写入永不失败)
LOCAL_QUORUM 特别值得注意,它体现了”一致性的地理分层”:本地强一致(低延迟),跨地域最终一致(高延迟)。 这是现实中最常用的配置,它承认了一个事实:一致性强度和地理距离是绑定的,你只能在”本地”这个范围内负担得起强一致。
边界五:Quorum 不等于线性一致性
最后一个,也是最容易被误解的一个。
即使 W+R>N,且没有并发写、没有失败残留,Quorum 仍然不提供线性一致性(linearizability)。
为什么?线性一致性要求的不仅是”读到最新值”,还要求**“整个系统表现得就像只有一个副本、所有操作原子地瞬时发生”**。
举个具体的反例(N=3, W=2, R=2):
- 客户端 A 写入 x=1,成功(副本 1、2 拿到新值)。
- 客户端 A 读 x,从副本 1、2 读到 1。✓
- 客户端 B 同时写入 x=2,成功(副本 1、3 拿到新值)。
- 客户端 A 再读 x,从副本 2、3 读 → 副本 2 是 1,副本 3 是 2,取版本大的 → 读到 2。
- 但客户端 A 也可能从副本 2、3 之外的组合读到别的组合,而不同组合之间没有一个全局的顺序保证。
更本质的问题是:读到的”最新值”,在返回的途中可能已经被别人改了;两次读之间,系统没有维护任何全局顺序。
要拿到线性一致性,你需要额外机制:
- Raft 的 ReadIndex:读请求也要走一轮多数派心跳,确认自己还是合法 leader,且 apply 到了 commit index 才返回。
- Lease Read:用租约优化掉那轮心跳,代价是依赖时钟。
- ZooKeeper 的 sync() + 读:先 sync 追上 leader,再读。
这些机制的共同点是:读路径也要参与顺序的确定。 这一点我们在子主题七末尾已经预告过了。
常见 Quorum 配置速查
| 配置 | 语义 | 适用 |
|---|---|---|
| N=3, W=1, R=3 | 写极快,读要问所有人(且要挑最新) | 写多读极少、且必须读到最新 |
| N=3, W=3, R=1 | 写要所有人确认(慢、容错差),读极快 | 读极多写极少的配置数据 |
| N=3, W=2, R=2 | 默认甜点:容忍 1 节点故障,读写都还行 | 绝大多数场景 |
| N=5, W=3, R=3 | 容忍 2 节点故障 | 金融级、或需要容忍”故障 + 维护”叠加 |
| N=3, W=2, R=1 | 违反 W+R>N,可能读不到最新 | 一般不用(除非你明确知道自己在做什么) |
本小节的总结,一句话
Quorum 是”组合数学的工程化”——它用一个简单的不等式,把”一致性强度”变成了一个可以拨动的旋钮。这是分布式系统设计里最划算的一笔交易。
但它只解决了”副本之间读写的可见性”这一件事。 它没解决:并发写的语义(归冲突解决)、全局顺序(归共识算法)、失败残留的清理(归读修复/反熵)。
看清楚 Quorum 的边界,比记住 W+R>N 这个公式更重要。 因为真正的工程事故,从来不是出在”不知道这个公式”,而是出在”以为这个公式能解决所有问题”。
子主题十二:纠删码深入——Reed-Solomon 的直觉、k+m 怎么选、以及”重建风暴”
子主题四给了纠删码的结论:省空间,但重建贵。 这一节把这个结论拆开,让你能自己算、自己选。
先问一个直击灵魂的问题:
纠删码能省一半以上的存储成本,那为什么不是所有数据都用纠删码?为什么 3 副本至今还是热数据的标配?
Reed-Solomon 的直觉:不是公式,是几何
RS 码的标准讲解要搬出有限域、生成矩阵、Vandermonde 矩阵,劝退率极高。但它的核心直觉只需要一件事:多项式插值。
请跟着这个构造走:
第一步:把数据当成点。 假设 k = 10,我们把数据切成 10 块,记 d₁, d₂, …, d₁₀。
第二步:把它们看作一个多项式的采样值。 我们知道一件中学数学就学过的事:给定 k 个点,唯一确定一个次数 ≤ k-1 的多项式。
- 2 个点确定一条直线(1 次)。
- 3 个点确定一条抛物线(2 次)。
- 10 个点确定一个 9 次多项式。
所以:令 P(1) = d₁, P(2) = d₂, …, P(10) = d₁₀。这 10 个条件唯一确定一个 9 次多项式 P(x)。
第三步:过采样,得到校验块。 这个多项式 P 现在被唯一确定了。我们在更多点上求它的值:
- c₁ = P(11)
- c₂ = P(12)
- c₃ = P(13)
- c₄ = P(14)
这 4 个就是 m = 4 个校验块。
第四步:解码 —— 这就是全部的魔法。 现在假设丢了任意 4 块,比如 d₃, d₇, c₁, c₄。我们还剩 10 块:d₁,d₂,d₄,d₅,d₆,d₈,d₉,d₁₀, c₂, c₃。
关键洞察:这 10 块,仍然是这个 9 次多项式 P 的 10 个采样点(只是 x 坐标不同了:1,2,4,5,6,8,9,10,12,13)。
而”任意 10 个点唯一确定一个 9 次多项式”——所以我们能精确重构出 P。有了 P,再求 P(1)…P(10),原始数据就全回来了。
这就是 Reed-Solomon 的全部直觉:它把”冗余”变成了”对一个多项式过采样”。丢几个采样点无所谓,因为 reconstruct 一个多项式需要的点数少于你实际持有的点数。
工程实现的一个必要修正: 真实实现不在实数域上算,而在有限域 GF(2^w) 上算。为什么?因为实数会溢出、有精度误差、除法不封闭,而我们需要精确的、可逆的算术。GF(2^8) 意味着”每个符号是一个字节,所有运算在 256 个元素的有限域里封闭”。
这也解释了为什么纠删码需要硬件加速: 有限域上的乘法不是 CPU 原生的整数乘法,它需要”查对数表 + 相加 + 查反对数表”,或者用移位和异或的展开。开销不小。所以:
- Intel 在 AVX2 / AVX-512 里专门加了 GF 乘法的向量指令(如
GF2P8AFFINEQB)。 - 现代实现(ISA-L、Jerasure)能跑到几十 GB/s。
- 结论:有了 SIMD 加速,编码/解码的 CPU 开销已经不是纠删码的主要瓶颈了。 真正的瓶颈是 IO 和网络(马上讲)。
一个精确的类比:
- 3 副本 = 把同一份文件打印 3 份,放进 3 个抽屉。丢 2 份没关系,但你需要 3 倍的纸。
- 纠删码 = 把文件撕成 10 段,再算出 4 段”校验指纹”,一共 14 段放进 14 个抽屉。任何 10 段(不管是原段还是校验段)都能把文件拼回来。 纸只用 1.4 倍。
k 和 m 怎么选?一个三方权衡
这不是一个可以抄答案的问题,但可以把权衡结构讲清楚。三个变量互相拉扯:
维度一:存储开销 = (k + m) / k
- k=6, m=3 → 1.5x
- k=10, m=4 → 1.4x
- k=12, m=4 → 1.33x
- k=20, m=4 → 1.2x
- k 越大越省。 这个方向是单调的。
维度二:容错能力 = m(能容忍同时坏 m 块)
- m 越大越可靠。 这个方向也是单调的。
维度三:修复放大 = k(坏 1 块,要读 k 块来重建)
- k 越大,修复越贵。 这个方向和维度一直接冲突。
于是核心张力出现了,这是纠删码设计最重要的一句话:
k 大 → 省空间,但修复贵。k 小 → 修复便宜,但费空间。这就是纠删码的”不可能三角”。
(对比 3 副本:坏 1 块,只需从另一个副本直接拷贝 1 块。修复放大 = 1。 这就是多副本最大的优势。)
常见配置和它们的理由:
| 配置 | 开销 | 容错 | 修复放大 | 使用者/场景 |
|---|---|---|---|---|
| RS(6,3) | 1.5x | 3 | 6 | HDFS 的经典配置 |
| RS(10,4) | 1.4x | 4 | 10 | 对象存储最常见配置 |
| RS(12,4) | 1.33x | 4 | 12 | 大容量冷存储 |
| RS(3,2) | 1.67x | 2 | 3 | 小文件/延迟敏感场景的折中 |
关于小文件,有一个必须知道的结论:纠删码对小文件极不友好。
原因:一个 1KB 的文件,也要切成 k 块、生成 m 块,占用 (k+m) 个块的管理元数据。块数量爆炸、元数据开销巨大、读写都要跨 (k+m) 个节点协调。
所以真实系统都有”小文件阈值”:小于某个尺寸(HDFS 里比如 1 个 stripe 的大小)的文件,直接走多副本,不走纠删码。
顺带解释了 HDFS 那句名言”小文件是灾难” —— 不只是 NameNode 内存问题,也是纠删码问题。
m 的真正含义:不是”能坏几块盘”,是”能同时坏几个故障域”
这是一个绝大多数人搞错的点,值得单独强调。
假设你用 RS(10,4),看起来是”能容忍 4 块盘损坏”。但如果你的放置策略把 10 个数据块里的 5 个放在了同一个机架上呢?
那这个机架一挂(交换机故障、PDU 断电),你同时丢了 5 块 > m=4 → 数据永久丢失。你的”能容忍 4 块”完全失效了。
所以 m 的正确解读是:你能同时承受多少个”故障域”失效,前提是放置策略保证一个故障域最多只承载 1 个块。
正确的放置策略:k+m 个块,放到 k+m 个不同的故障域。
- RS(10,4) 跨 14 个机架 → 能容忍任意 4 个机架同时挂。
- RS(10,4) 跨 4 个机架(每机架 3-4 块)→ 可能连 1 个机架故障都扛不住。
结论:纠删码的可靠性参数,一半在 (k, m) 里,另一半在放置策略里。只调 k 和 m 而不管放置,等于白配。
为什么冷数据适合、热数据不适合?四条理由
理由一:重建代价的紧迫性不同。
热数据被频繁访问,坏一个块必须立刻重建(否则剩余副本压力陡增,可用性下降)。而 EC 的重建要读 k 块 → 冲击网络与磁盘。
冷数据几乎不被访问 → 不急着修,可以后台限速、错峰(比如只在凌晨跑)、慢慢来。“不着急”是冷数据最大的自由度。
理由二:读取路径的延迟。
多副本:读任意一个本地/就近的副本,一次磁盘 IO 搞定。
纠删码:正常情况下读 k 块中你需要的那几块(其实只要读包含目标数据的那些块),还好。但一旦发生”降级读(degraded read)“——某个块暂时不可用——就必须实时触发解码:读 k 块 + 计算 → 才能返回数据。
于是读延迟从”一次磁盘读”变成”跨网络读 k 块 + CPU 解码”。P99 延迟会非常难看。
热数据对 P99 极其敏感(它直接影响用户体验和 SLA),冷数据不在乎。
理由三:小 IO 的写放大(这一条最技术,也最致命)。
纠删码的最小操作单元是条带(stripe)。改 1 个字节,理论上也要维护整个条带的校验一致性,于是需要:
- 读旧的数据块(1 次读)
- 读旧的校验块(1 次读)
- 计算增量
- 写新的数据块(1 次写)
- 写新的校验块(1 次写)
这就是 read-modify-write:改 1 字节,触发 2 读 2 写。
频繁的小写(数据库、日志、消息队列的热数据)对 EC 是这个代价。而冷数据基本是”写完不读、一次性大块写”——正好是大块顺序写,EC 最擅长的模式。
理由四:修复窗口内的二次故障风险。
热数据:副本掉了要分钟级恢复(否则剩余副本扛不住流量)。
冷数据:可以小时级、天级恢复。但代价是——修复窗口越长,窗口内再坏一块的概率越高。
(这一条对 3 副本同样成立,只是 EC 的修复窗口天生更长,因为修复更慢。)
重建风暴(Rebuild Storm)——纠删码最痛的地方
现在把这个痛点完整地展开,因为它决定了纠删码能不能真的用在生产环境。
场景: 一个 1000 块盘的集群,用 RS(10,4),存了 5PB 数据。现在坏了一块 8TB 的盘。
要修复这块盘,需要做什么?
- 这块盘上有大约 5TB 有效数据,分布在 N 个条带里。
- 每一个条带,都要读取该条带的其他 k=10 个块,才能算出丢失的那个块。
- 所以总共需要读取 50TB 的数据。
这 50TB 的读流量,散布在另外 999 块盘上。
结果:
- 恢复带宽(网络)成为瓶颈,而不是磁盘。 整个集群的骨干网络可能被打满,持续数十分钟到数小时。
- 所有正常的业务 IO 被拖慢,延迟飙升。用户感受到的是”整个存储系统变慢了”,而不仅仅是一块盘坏了。
- 如果集群跨机架,这 50TB 大部分要跨机架传输 → 打满机架间的上行带宽(通常是最稀缺的资源)。
更糟的是相关性故障(correlated failure)——这不是玄学,是有统计数据的真实现象:
同一批采购、同一生产批次、同一机房环境、同一上架时间的硬盘,寿命高度相关。一块盘坏了,往往意味着这批盘都进入了故障高发期。
于是真实的故障序列是这样的:
- 第 1 块盘坏 → 开始重建(需要几小时的重建窗口)
- 第 2 块盘坏 → 重建压力加大,同时容错从 4 降到 3
- 第 3、4、5 块盘坏 → 超过 m=4 → 数据永久丢失
这才是最关键的洞察:
真正的风险不是”单盘故障率”,而是”修复窗口内的二次故障率”。决定系统可靠性的,是 MTTR(平均修复时间),不是 MTTF(平均无故障时间)。
这个结论对 3 副本同样成立,但 EC 的 MTTR 天生更长(因为一次修复要读 k 倍数据),所以 EC 把这把悬顶之剑压得更低了。
这也解释了一个反直觉的事实:纠删码虽然”能容忍 m 块故障”,但在某些配置下,它的实际可靠性可能不如 3 副本——如果它的修复速度慢到让二次故障概率显著上升的话。
四个缓解手段:
手段一:LRC(Local Reconstruction Code,局部重建码)
这是最优雅的解法,思路是”分层冗余”。
以 LRC(12, 2, 2) 为例:
- 12 个数据块,分成 3 组,每组 4 块。
- 每组算 1 个局部校验块(3 个局部校验)。
- 再算 2 个全局校验块。
- 总共 12 + 3 + 2 = 17 块,存储开销 17/12 ≈ 1.42x(和 RS(10,4) 的 1.4x 几乎一样)。
但它把修复放大从 12 降到了 4:
- 单个块故障(占所有故障的 99%+):只需读同组的 4 块 + 1 个局部校验 → 修复放大 = 4,而不是 12。
- 只有当同组内出现多块故障时,才需要动用全局校验。
存储开销几乎不变,但最常见的修复场景代价降到 1/3。 这是非常划算的交易。
代价:容错模式变复杂了。某些特定的 3 块故障组合(比如同组 2 块 + 某全局配置)可能无法恢复。你用”容错模式的规整性”换了”修复效率”。
使用者: Microsoft Azure(LRC 的发源地)、Facebook 的 f4 存储架构、HDFS 的 Xorbas。
手段二:修复限速 + 错峰 给重建流量设带宽上限,牺牲修复速度保业务 IO。
但这里有一个必须量化的取舍,不能拍脑袋: 限速 → 修复窗口变长 → 二次故障概率上升 → 可靠性下降。你需要用可靠性模型算出”限速到多少是安全的”,而不是随手设个 50MB/s。
手段三:增量修复 / 只修复真正丢失的块 避免”盘坏了就全盘重建”。只重建实际有数据丢失的条带块。
手段四:跨更多故障域分散放置 把 EC 条带的 k+m 个块跨更多机架/节点,让修复流量分摊到更多网络路径上,避免打满单条链路。
本小节的结论
3 副本 vs 纠删码,不是”哪个更好”,是”哪个更适合这段数据的访问模式”。
| 多副本(3x) | 纠删码(1.4x) | |
|---|---|---|
| 存储成本 | 3x | 1.4x |
| 修复放大 | 1 | k(10+) |
| 降级读延迟 | 低(读另一副本) | 高(实时解码) |
| 小写性能 | 好 | 差(read-modify-write) |
| 小文件 | 友好 | 不友好 |
| 适用 | 热数据、小文件、低延迟 | 冷数据、大文件、归档 |
现代存储系统的答案都是”两者都要 + 自动分层”:
- 新写入的数据 → 3 副本(或 2 副本),读写快、修复便宜。
- 冷却后(比如 30 天无访问)→ 后台自动转换成纠删码,省成本。
- 如果冷数据又被频繁访问 → 再升回副本层。
Ceph(EC pool + cache tiering)、HDFS(EC + 存储策略)、各大云对象存储(S3 的存储级别:Standard → Standard-IA → Glacier),全是这套。
“分层”这个词值得记住:它承认了”没有一种存储策略适合所有数据”,于是把选择权交给数据自己的访问模式。这是存储系统设计里一个反复出现的智慧。
子主题十三:业界对照——四种复制哲学,四种取舍
理论讲完了,现在把它放到真实系统里。你会发现一件很有意思的事:
所有系统的选择,归根结底都是在回答同一个问题:复制状态机的”顺序”由谁、用什么方式决定?
一、GFS / HDFS:多副本 + 单一主控 + “硬件一定会坏”
哲学:GFS 论文开篇第一句就是——“组件故障是常态,而不是异常。”
这是一个范式转换。以前的思路是”用昂贵的硬件和高可用中间件来保证单机可靠”;GFS 的思路是”假设硬件随时会坏,用软件(复制)来兜底,于是我可以用最便宜的机器”。
架构:
- 一个 master(HDFS 叫 NameNode)存元数据:文件 → 块的映射、每个块的位置、命名空间树。
- 多个 chunkserver(HDFS 叫 DataNode)存数据块,默认 3 副本。
关键设计:元数据在 master 的内存里,数据流不经过 master。
流程:客户端问 master”我要读文件 X 的第 3 块,在哪?” → master 返回 3 个副本的位置 → 客户端直接和最近的 chunkserver 通信,全程不经过 master。
这个设计解决了什么? 早期很多分布式文件系统把所有数据都通过一个中心节点转发,那个节点立刻成为带宽瓶颈。GFS 把”控制平面”和”数据平面”彻底分开:控制走 master(低频、小数据),数据走 chunkserver(高频、大数据)。
(这个”控制面/数据面分离”的思想,后来在 SDN、服务网格、对象存储里反复出现,是一个通用的架构模式。)
复制方式:流水线复制(pipelining)。
客户端要写 3 个副本,不是”客户端发 3 份”,而是:
- 客户端把数据推给最近的 chunkserver(比如同机架的 A)
- A 一边写入本地磁盘,一边转发给 B
- B 一边写,一边转发给 C
- C 写完,沿链返回 ack
好处:客户端的上行带宽只用了 1 份,网络流量按链路分摊。 而且利用了”机架内带宽 >> 跨机架带宽”这个物理事实。
放置策略:跨机架。 HDFS 默认”两份在同一机架,一份在另一机架”。为什么不全跨机架?
- 全跨机架(3 个机架)→ 容机架故障能力强,但写操作要跨 3 次机架网络,写带宽成本翻 3 倍。
- 2+1 → 写只需跨 1 次机架网络(流水线 A→B 同机架不跨,B→C 跨一次),同时容忍单机架故障。
- 这是一个精确的”写带宽 ↔ 容灾能力”权衡。
一致性:GFS 明确放弃了强一致。
GFS 提供的是 “defined / undefined” 这种宽松语义,它的 record append 是 at-least-once 语义——允许记录重复、允许记录间有填充(padding)。应用必须自己处理重复和不一致。
这是极其务实的妥协:为了吞吐和简化实现,把一致性问题推给应用层。 MapReduce 这类批处理应用不在乎(它们幂等、有校验),所以这个交易是划算的。
HDFS 则更严格一些:写入是强一致的(写完立即可见,不允许并发写同一文件),后来(3.0 起,HDFS-7285)加入了纠删码用于冷数据。
一个至关重要的教训藏在这里:master 本身的高可用是另一个问题。
- GFS 早期:master 靠 checkpoint + 操作日志持久化,加影子 master(shadow master)——只读、数据略有延迟、主 master 挂了可以顶上读。
- HDFS 的 NameNode 长期是单点,这是 HDFS 被吐槽了多年的问题。后来的 HA 方案(QJM,Quorum Journal Manager)本质是:把 NameNode 的 edit log 放进一个由 2N+1 个 JournalNode 组成的 Paxos 风格的 quorum 日志里,只有写入多数派才算成功。
看明白了吗?数据可以简单地多副本,但元数据(那个”决定顺序”的东西)必须靠共识。
这是本章最重要的一条工程规律,我们会在结尾再强调一次。
二、Ceph:CRUSH + 纠删码,用”计算”代替”协调”
哲学:去掉中心化的元数据表。
传统思路:客户端要读一个对象,得先去查一张”对象 → 位置”的表。这张表要么存在中心节点(瓶颈),要么存在所有节点(一致性难题)。
Ceph 的 RADOS 给出的答案是:不查表,算出来。
CRUSH 算法(Controlled Replication Under Scalable Hashing):
- 输入:对象的 ID(或名字)、cluster map(集群拓扑:有哪些机架、主机、OSD,各自的权重)、pool 的规则。
- 输出:这个对象应该放在哪几个 OSD 上。
- 这是一个确定性纯函数。
于是客户端只要拿到最新的 cluster map,就能自己算出答案,不需要问任何人。
这是”确定性函数 + 共享输入”的经典设计,值得单独品味:
如果所有参与者用同一个纯函数、作用于同一个共享输入,就能算出同一个答案——那么他们之间就不需要任何协调通信。
这是分布式系统设计里一个极其强大的技巧:用计算代替通信。 类似的思想还有:一致性哈希(客户端自己算该连哪台机器)、Redis Cluster 的 slot 路由、Kafka 的分区路由。
好处:
- 无单点、无中心表瓶颈。
- 可水平扩展到 EB 级。
- 扩容时数据自动重分布,且 CRUSH 被设计成最小化迁移量(新增一个 OSD,只有应该迁移到它上面的数据会迁移,不是全量 rehash)。这一点比朴素的一致性哈希做得更好(它有”placement group”这层间接性来分摊迁移)。
复制与纠删码: Ceph 支持两种 pool:
- replicated pool:多副本(默认 3)。
- erasure-coded pool:纠删码,k/m 可配(如 k=8, m=3)。
可以按 pool 选择,然后按数据冷热做分层(cache tiering)。
一致性: 每个 PG(placement group,是 OSD 的逻辑分组)有一个 primary OSD。客户端写 primary,primary 同步给各 replica,所有副本都落盘后才 ack → 强一致的副本写入。
PG 的 primary 是谁,由谁决定?由 monitor 集群(运行 Paxos)确认。
又一次印证了那条规律:数据路径可以完全去中心化(CRUSH 算出来),但控制路径(谁是这个 PG 的 primary)必须靠共识。
一个实践中的代价: CRUSH 重平衡(扩容、缩容、OSD 故障)时会触发大量数据迁移,产生”rebalance storm”,影响业务 IO。所以生产环境要限制重平衡的带宽和并发度(osd_max_backfills、osd_recovery_max_active 等),并且要在业务低峰期做扩容。
三、Spanner:Paxos 复制 + TrueTime,把强一致做到全球尺度
哲学:谁说分布式数据库必须是最终一致的?
Spanner 是 Google 2012 年的论文,它做了一件在当时被认为”不可能工程化”的事:在全球范围内部署,且提供外部一致性(external consistency,等价于线性一致性)。
结构:
- 数据分片成 tablet。
- 每个 tablet 一个 Paxos 组,通常跨 3~5 个数据中心(比如美国东西海岸 + 欧洲)。
- Paxos 保证组内日志顺序一致 → 这就是标准的复制状态机。
- 跨分片事务:两阶段提交(2PC),而 2PC 的协调者本身也是一个 Paxos 组 → 协调者不会单点故障。
注意最后这一点的价值: 传统 2PC 最大的问题是”协调者单点故障会阻塞所有参与者”。Spanner 的答案是:把协调者也复制了。 所谓”用复制解决单点问题”——一个朴素但极其有效的思路。
关键创新一:TrueTime —— 一个会告诉你”我不确定”的时钟。
Spanner 在每个数据中心部署 GPS 接收器和原子钟(两种不同失效模式的时钟源,互为备份)。TrueTime 的 API 不返回一个时间点,而是返回一个区间:
TT.now() → [earliest, latest]
含义:当前的真实时间,一定在这个区间内。 区间宽度 ε 通常 1~7 毫秒。
为什么是区间?因为 Spanner 诚实地承认了”我不知道精确时间”。 而绝大多数系统假装自己的时钟是精确的,然后在极端情况下被这个假设坑掉。承认不确定性,是设计可靠系统的第一步。
关键创新二:Commit Wait —— 用”主动等待”把时钟误差转化为延迟。
这是 Spanner 最漂亮的一步,请仔细看:
- 事务 T1 准备提交。取
s = TT.now().latest(区间上界)作为提交时间戳。 - 然后主动等待,直到
TT.now().earliest > s。 - 才向客户端返回”提交成功”。
等待时长约 2ε(几毫秒)。
这一步在干什么?它在保证一件事:
如果事务 T2 的开始时间,在真实时间上晚于 T1 的提交时间,那么 T2 的时间戳一定大于 T1 的时间戳。
为什么? 因为 T1 返回时,真实时间已经 > s(T1 的时间戳)。T2 在此之后才开始,它取的时间戳 ≥ 当时的 TT.now().earliest > s。所以 T2 的时间戳 > T1 的时间戳。
于是:时间戳的顺序 = 真实时间的因果顺序。这就是外部一致性(线性一致性)。
这一步的哲学意义极其深远,值得单独强调:
Spanner 用”主动等一小会儿”,把”时钟误差”从一个正确性隐患,变成了一个性能成本。
这是一个通用的设计技巧:当你无法消除不确定性时,用”等待不确定性窗口过去”来消除它的影响。 类似的技巧在网络协议里也有(等待一个 MTU/max 延迟窗口以确保没有旧包)。
(这一设计直接呼应第 11 章的时钟问题——第 11 章会告诉你为什么普通 NTP 做不到这一点,以及 Spanner 为了这 7 毫秒的 ε 付出了什么硬件代价。)
代价与定位:
- 需要专用硬件(GPS + 原子钟)。这是 Spanner 最大的落地门槛。
- 写延迟 = 跨地域 Paxos 往返(几十到上百毫秒)+ commit wait(几毫秒)。
- 成本远高于异步主从。
Spanner 证明了”全球强一致”是可行的,但它没有说这是便宜的。 后来的 CockroachDB、TiDB 等开源系统,用普通 NTP + HLC(混合逻辑时钟)来近似,牺牲了”外部一致性”的严格性(CockroachDB 提供的是可串行化但不保证严格意义的线性一致,除非额外做)。
四、区块链:拜占庭容错下的复制状态机
前面三个系统,有一个共同的隐含假设:
节点会”崩溃”,但不会”撒谎”。
节点要么正常工作、要么干脆挂掉、要么网络分区。它不会故意给你发错误的信息、不会对不同节点说不同的话、不会伪造数据。 这叫崩溃容错(CFT, Crash Fault Tolerance)。
现在,把这个假设拿掉。
如果节点故意作恶呢?它可能对 A 说”我投赞成票”,对 B 说”我投反对票”;它可能伪造一笔不存在的交易;它可能串谋;它可能试图把同一笔钱花两次(双花攻击)。
这叫拜占庭容错(BFT, Byzantine Fault Tolerance)——名字来自那个经典的思想实验”拜占庭将军问题”:几支拜占庭军队围攻一座城,将军们只能通过信使通信,其中可能有叛徒,如何就”进攻还是撤退”达成一致?
于是复制的难题从”保持副本一致”,升级为:
在一群互不信任、且可能有叛徒的节点之间,就一条日志的顺序达成一致。
比特币的答案,拆成三问:
问题一:谁有权追加下一条日志(区块)? 答:谁先算出一个满足难度要求的哈希,谁就有记账权。 这就是工作量证明(PoW)。
它的本质是什么? 用经济成本替代身份信任。
传统系统说”我信任这个节点,因为它在我的成员列表里”。比特币说”我不信任任何人,但我要让’获得记账权’这件事付出真实的、不可伪造的代价(电力 + 算力)。”
这是一个极其深刻的思路转换:把信任问题,转化成了成本问题。
(你不需要知道矿工是谁、在哪、是否诚实。你只需要知道:想作恶,你得先烧掉价值数亿美元的电。)
问题二:顺序怎么定? 答:最长链原则(严格表述是”累计难度最大的链”)。所有节点都认为:最长的那条链是”正确的历史”。
问题三:一致性是什么级别? 答:概率性最终一致。
这是比特币和前面所有系统最本质的区别:
- Raft / Paxos:一旦确认,就是确定的,永不回滚。
- 比特币:你的交易被打包进第 N 个区块,但理论上总有可能出现一条更长的链,把它覆盖掉。
随着后面新增的区块越来越多,被回滚的概率指数级下降。 这就是为什么交易所要等 6 个确认(约 1 小时):6 个确认后,回滚概率已经低到经济上不可行。
所以请精确地说:比特币提供的是”概率性保证”,不是”最终一致性”,也不是”强一致性”。 它永远不会给你一个”确定”的时刻——只是概率小到你愿意接受。
(这一点经常被误解。很多人说比特币是”最终一致”——不对。最终一致性承诺”最终会一致且不再变”;比特币承诺的是”最终不一致的概率趋近于 0”。这是两回事。)
安全假设的转变:
- Raft:假设不超过半数节点崩溃。
- 比特币:假设诚实节点掌握超过 50% 的算力。 攻击者要改写历史,需要掌握 >50% 算力(51% 攻击),且要持续付出巨额成本。
安全性从”信任特定节点”,变成了”信任算力的分布足够分散”。
与 Raft 的对照,极其有启发性:
| Raft / Paxos | 比特币 PoW | PBFT / Tendermint | |
|---|---|---|---|
| 成员模型 | 已知固定成员(许可) | 开放,任何人可加入 | 已知固定成员(许可) |
| 故障模型 | 崩溃容错(CFT) | 拜占庭容错 + 开放成员 | 拜占庭容错(BFT) |
| 容错阈值 | 2f+1 容忍 f 个崩溃 | >50% 算力诚实 | 3f+1 容忍 f 个拜占庭节点 |
| 一致性 | 强一致(线性) | 概率性 | 强一致(确定性最终) |
| 典型延迟 | 毫秒级 | ~10 分钟出块,确认要 1 小时 | 秒级 |
| 典型吞吐 | 万~十万 TPS | ~7 TPS | 千~万 TPS |
读这张表,最该注意到的是最后两行:
你付出的每一个数量级的性能,都是在为”不需要信任任何特定节点”买单。
- 从 Raft 到 PBFT:为了容忍”节点会撒谎”(而不只是崩溃),你从 2f+1 变成 3f+1,从一轮 RPC 变成多轮投票。代价:约一个数量级。
- 从 PBFT 到 PoW:为了容忍”成员开放,任何人都能加入”(这需要防止女巫攻击——一个人伪造一万个身份),你引入了工作量证明。代价:又一个数量级,而且从确定性强一致退化成概率性一致。
中间的选项值得特别留意:PBFT / Tendermint / HotStuff 这类”经典 BFT 共识”。
- 成员已知(许可链 / 联盟链),3f+1 个节点容忍 f 个拜占庭节点。
- 两到三轮投票即达成确定性最终(不像 PoW 要等确认)。
- 秒级最终性,千级 TPS。
- 这就是 Hyperledger Fabric、Cosmos(Tendermint)、以及新一代公链(用 HotStuff 变体的)的范围。
同样都是拜占庭容错,仅仅因为”成员是否开放”这一个假设不同,性能和一致性就差了两个数量级。 这说明了一件事:分布式系统的代价,主要由它的”信任假设”决定,而不是由它的算法技巧决定。
最后一句,把区块链放回本章的脉络:
区块链不是什么新东西,它就是”复制状态机”在”参与者互不信任”这个极端假设下的版本。 区块是日志,最长链规则是”顺序”的决定方式,PoW/PoS 是”谁有权追加下一条”的资格机制,UTXO 集 / 账户状态树是那个被复制的状态。
理解了复制状态机,区块链的绝大部分设计就不再是魔法——它只是在一个更恶劣的假设下,重新回答了”日志顺序由谁决定”这个老问题。
四种哲学的对照总结
| 系统 | 复制形态 | 一致性 | 关键信任/环境假设 | 主要代价 |
|---|---|---|---|---|
| GFS / HDFS | 主从 + 多副本 + 单一 master | 宽松(GFS)/ 写强一致(HDFS) | 硬件必然故障、应用能容忍重复 | 元数据 HA 需要额外方案 |
| Ceph | CRUSH 去中心放置 + primary 同步写 | 强(副本池) | cluster map 在所有节点一致 | 重平衡风暴;需要限流运维 |
| Spanner | Paxos 复制状态机 | 外部一致(线性) | 时钟误差有界(TrueTime 硬件) | 专用硬件 + commit wait 延迟 |
| 区块链 | BFT 复制状态机 | 概率性(PoW)/ 确定性(PBFT) | 多数算力或多数节点诚实 | 吞吐低、延迟高、能耗大 |
一条贯穿全部四个系统的规律,请记住它:
数据路径可以很朴素(多副本、流水线复制、CRUSH 计算),但”决定顺序”的那个角色,永远要靠某种共识或权威。
- GFS:master 是权威(所以它的 HA 要另想办法)
- Ceph:monitor 集群跑 Paxos 决定 primary
- Spanner:每个 tablet 一个 Paxos 组
- 区块链:全网竞争 + 最长链规则
这就是本章和下一章、下下章的连接点:复制引出”顺序”,顺序引出”共识”。
子主题十四:实操视角——怎么为你的数据选择副本策略
讲了这么多理论,回到你手上的那张表、那个服务。
请看着你的数据库,回答这三个问题:存几份?放哪里?同步还是异步?
如果你答不上来,或者答案是”运维给配的""默认的三副本”,那这一节就是为你写的。
第一步:先问 RPO 和 RTO,别问技术
这是唯一正确的起点。 所有技术选型都是这两个数字的函数。
-
RPO(Recovery Point Objective,恢复点目标):能容忍丢失多长时间的数据。
- RPO = 0:一条已确认的写入都不能丢。
- RPO = 5 秒 / 5 分钟 / 1 小时:可以丢相应窗口的数据。
-
RTO(Recovery Time Objective,恢复时间目标):能容忍服务中断多久。
- RTO ≈ 0:自动切换,业务几乎无感。
- RTO = 5 分钟:自动或半自动 failover。
- RTO = 1 小时:可以人工介入。
这两个数字如何映射到技术:
| RPO | → 要求的复制方式 | → 代价 |
|---|---|---|
| 0 | 同步复制(多数派确认) | 写延迟 = 最慢副本;副本故障时写入失败 |
| 秒级 | 半同步(且需理解降级行为) | 写延迟 = 一个副本;降级窗口内有风险 |
| 分钟级以上 | 异步复制 | 性能最好;故障丢数据 |
| RTO | → 要求的切换方式 | → 代价 |
|---|---|---|
| ≈ 0 | 自动 failover | 必须做对故障检测、选主、脑裂防护(子主题九),且必须演练 |
| 分钟级 | 自动/半自动 | 同上,但可以有人工确认环节 |
| 小时级 | 人工切换 | 最安全,人能判断、能兜底 |
关于”人工切换”,有一个反主流的观点值得说出来:
人工切换是最安全的选择。 自动 failover 最大的风险恰恰是——它在信息不足的时候选择了”行动”。 一个经验丰富的 DBA 在故障时刻能看到监控、能权衡”到底是网络抖动还是真挂了”、能在不确定时选择”再等 30 秒”。而一个配置不当的自动切换脚本,会在一次 3 秒的 GC 停顿里做一次危险的 failover。
所以:不要盲目追求”全自动”。在 RTO 允许的情况下,“自动检测 + 人工确认 + 一键执行”往往是最佳平衡点。
最重要的一句话,也是这小节最该被记住的:
先和业务方确认 RPO 和 RTO,再去选技术方案。
工程师最容易犯的错误,不是技术选型错了,而是从来没问过 RPO 就默认选了异步复制,然后在真正出事的那天,才发现业务方以为 RPO = 0。
这个沟通只需要一次 30 分钟的会议。它能避免一次职业生涯级别的事故。
第二步:副本数选几个?
2 副本:最危险的选择,请尽量避免。
为什么?因为它无法形成多数派。
两个副本,一个挂了,剩下一个怎么判断”是对方挂了还是我挂了”?它判断不了。 于是需要引入第三方仲裁(witness / observer / 外部一致性服务如 etcd / ZooKeeper),复杂度上升,而收益(相比 3 副本)几乎没有。
而且 2 副本的成本已经是 1 副本的 2 倍,却只能容忍 1 个故障(听起来还行?不,它连”1 个故障 + 1 个在维护”都扛不住)。
结论:除非有非常明确的设计(如本地 RAID1 镜像、或者有成熟的仲裁机制),否则避免 2 副本。 很多云数据库提供”基础版(2 副本)“,那是为了省钱,不是为了高可用。
3 副本:默认答案。
- 容忍 1 个节点故障。
- 能形成明确多数派(2/3),可以自决、可以做 quorum 写入。
- 成本可接受。
绝大多数系统的起点就应该是 3 副本。
5 副本:容忍 2 个故障。什么时候需要?
关键洞察:你永远要考虑”故障 + 计划内维护”的叠加。
3 副本在一台机器维护期间,实际只剩 2 副本。此时再坏一台 → 只剩 1 台 → 如果是 quorum 写入系统,写入直接失败。
所以:
- 3 副本:能容忍 1 个故障,但维护期间没有冗余了。
- 5 副本:能容忍 2 个故障,或者说”1 个故障 + 1 个在维护”。
金融级、支付级系统常用 5 副本,就是为了这个”叠加容错”。
奇数原则:
- 3 副本:多数派 = 2,容忍 1 个故障。
- 4 副本:多数派 = 3,容忍 1 个故障。成本贵 33%,容错一点没增加。
- 5 副本:多数派 = 3,容忍 2 个故障。
结论:需要多数派投票的系统,用奇数(3/5/7)。偶数副本不会给你新的容错等级,只会让你更贵。
持久性算账(给你一个量级感):
假设单盘年故障率 AFR ≈ 1~2%,且修复需要几小时。粗略的结论:
- 单副本:约 2 个 9(99%)
- 3 副本:约 9~11 个 9
- 纠删码 RS(10,4):可达 11 个 9 以上
但这里有一条比数字重要得多的警告:
副本防的是硬件故障,不是人祸。
一个 DELETE FROM users WHERE ... 忘写 WHERE 条件,会以光速同步到所有 3 个副本。一个 rm -rf 也会。你的理论持久性算得再高,也扛不住一次误操作被忠实复制 N 份。
所以复制 ≠ 备份。你必须额外具备:
- 不可变备份 / 版本控制(对象存储的 versioning、快照)
- 软删除 + 保留期(删了不是真删,保留 30 天可恢复)
- 异地、离线、不可变的备份副本(防勒索软件、防误操作、防”一个 bug 污染所有副本”)
这是无数公司用真实事故换来的教训:复制解决”硬件坏了”,备份解决”人做错了”。两者不可互相替代。
第三步:放在哪?(放置策略 / 故障域)
这个问题的本质是:
你希望”一次故障”能同时带走几个副本?
从弱到强排列:
| 放置 | 能防什么 | 不能防什么 | 典型延迟 |
|---|---|---|---|
| 单机多进程 | 进程崩溃 | 整机故障、内核 panic | <1ms |
| 单机架多机 | 单机故障 | 机架交换机/PDU 故障 | <1ms |
| 跨机架(rack-aware) | 单机 + 单机架 | 整个机房故障 | 1~2ms |
| 跨可用区(同城多机房) | 单机房故障(断电、火灾、挖断光缆) | 城市级灾难 | 1~3ms |
| 跨地域(region) | 城市级灾难 | 大范围灾难 | 30~150ms |
关键结论:跨可用区(AZ)是”高可用 + 性能可接受”的甜点区。
为什么?因为同城多机房的光纤延迟只有 1~3ms——这个量级的延迟,对绝大多数业务来说可以忽略,同步复制完全可行。而它换来的是”整个机房断电/火灾/光缆被挖断,服务不中断”。
这就是为什么云数据库的”高可用版”几乎都是同城三 AZ。 性价比最高的一个选择。
跨地域为什么难?
30~150ms 的延迟会直接叠加到每一次写入上。用户点一个”下单”,要等 100ms 的跨地域同步。这对绝大多数交互式业务是不可接受的。
所以跨地域通常是:异步复制的灾备(平时不提供服务,出事才切),而不是”同步多活”。
经典配置:三地五中心(2+2+1)
分布:城市 A 放 2 副本,城市 B 放 2 副本,城市 C 放 1 副本。
- 容忍任意一个城市整体不可用(还剩 3 副本,仍能形成多数派)。
- 多数派(3/5)在正常情况下,可以在 A 的 2 个 + B 的 1 个之间形成 → 需要 1 次跨城往返。
- 更优的设计是让多数派尽可能落在同城,把跨城延迟从关键路径上移除。
一条通用规律:多数派的地理分布决定了你的写延迟。 所以设计放置策略时,要问的是”我的多数派需要跨越哪几个地理位置”,而不是”我有几个副本”。
一个常被忽略、但会导致”假高可用”的陷阱:
你以为的 3 副本,可能实际在同一台物理机上。
- 云上:3 个”可用区”的 ECS,有可能被调度到同一个宿主机集群(虽然罕见)。
- K8s:3 个 Pod 被调度到同一个 Node 上(没配反亲和性)。
- 分布式存储:3 个副本被 CRUSH 调度到了同一个机架(没配 rack-aware 的 CRUSH rule)。
结果:一台机器断电,3 个副本全灭。你的”高可用”是纸糊的。
怎么办?两条都要做:
- 用反亲和性约束(K8s 的 podAntiAffinity、Ceph 的 CRUSH failure domain = host/rack、数据库的 placement rule)。
- 验证它。真的去停一台机器、真的去拔一个机架的交换机,看服务是否还活着、数据是否还在。
没有经过故障演练的高可用,都是假设。
第四步:同步还是异步?
子主题三讲过取舍,这里给可直接执行的决策:
| 地理距离 | 建议 | 理由 |
|---|---|---|
| 同机柜/同机房(<1ms) | 同步 | 延迟代价可忽略,直接拿 RPO=0 |
| 同城多 AZ(1~3ms) | 同步 | 甜点区,性价比最高的强一致 |
| 跨城(30ms+) | 按业务分流 | 核心链路(支付、订单)同步;非核心(日志、埋点、feed)异步 |
| 跨洲(100ms+) | 异步为主 | 同步的话每次写 100ms+,只有极少数业务能接受 |
混合策略(现实中最常见,也是推荐做法):
同城同步(三副本 quorum)保证 RPO = 0,跨城异步复制做灾备(RPO 秒级)。
这样既保住了低延迟,又有了城市级容灾能力。绝大多数互联网公司的核心数据库都是这个形态。
Kafka 的 ISR 机制:把”同步复制的可用性问题”优雅地解决了
这是一个非常值得学习的工程化设计,值得单独讲。
Kafka 的配置:acks=all + min.insync.replicas=2(假设 replication.factor=3)。
机制:
- 每个分区有一组副本。其中与 leader 保持同步的副本组成 ISR(In-Sync Replicas)。
- 判断”是否同步”的标准:在
replica.lag.time.max.ms(默认 30 秒)内追上了 leader 的最新位置。 - 追不上的副本,被踢出 ISR。
acks=all的含义是:等 ISR 中的所有副本都确认(而不是等所有副本)。- 如果 ISR 的大小 <
min.insync.replicas,写入直接失败(而不是悄悄降级)。
这个设计的精妙之处在哪?
它解决了”同步复制降低可用性”这个经典难题(子主题三的核心矛盾):
- 传统同步复制:一个从库慢了/挂了 → 整个写入阻塞。
- Kafka 的 ISR:一个副本慢了/挂了 → 把它踢出 ISR,剩下的 2 个继续提供
acks=all服务,写入不受影响。而被踢出的那个,在后台慢慢追,追上了再回到 ISR。
本质上:它把”同步复制的集合”变成了动态的。 你不再被”最慢的那个副本”绑架,而是由系统动态维护一个”健康的同步集合”。
而且它有一个重要的安全设计: 当 ISR 小于 min.insync.replicas 时,拒绝写入(而不是降级成异步)。这和 MySQL 半同步的”超时后自动降级为异步”形成了鲜明对比。
Kafka 的选择是:宁可不可用,也不悄悄降级。 这是一个明确的、值得学习的取舍——它保证了你配置的持久性承诺,不会因为一次网络抖动就失效。
这是”半同步”的一种更聪明的实现,值得你在自己的系统里借鉴这个模式。
第五步:别忘了验证——复制链路不是”配完就高可用”
最后一步,也是最常被跳过的一步。
必做一:定期故障演练(混沌工程)
不是”配完就高可用”,而是**“演练过才叫高可用”**。至少演练:
- 杀掉主库进程 → failover 是否在 RTO 内完成?数据是否一致?
- 拔网线 / 制造网络分区 → 会不会脑裂?fencing 是否生效?
- 让从库延迟暴涨(比如
STOP SLAVE,或者灌一个大事务)→ 业务是报错、降级、还是读到脏数据? - 杀掉一个副本 → 写入是否继续可用?RPO 承诺是否还成立?
- 演练旧主复活 → 它能否正确重新加入?有没有多余数据?
Netflix 的 Chaos Monkey、Chaos Mesh、各种云厂商的故障注入工具,都是为这个目的。
一个残酷但真实的说法:如果你没演练过 failover,那你实际上没有 failover,你只有一个”理论上应该能切换”的配置。
必做二:监控正确的指标
请注意”正确”二字,很多团队的监控是错的:
❌ 错误: 监控 Seconds_Behind_Master > 30 告警(理由见子主题八:主库空闲时不更新、依赖时钟、不反映积压量)
✅ 正确:
- 监控位点差(binlog 字节差 / LSN 差 / GTID 差集大小)
- 监控”位点差持续增大”(说明追不上,比瞬时延迟更严重)
- 监控”当前同步副本数 < 期望副本数” ← 这条最重要
为什么最后一条最重要? 因为它才是真正影响 RPO 的指标。
“一个副本挂了”本身不危险——3 副本挂 1 个,你还有 2 个,写入照样 quorum 成功,RPO 承诺依然成立。
危险的是”同步副本数从 3 降到 2 再降到 1” —— 这时你的持久性承诺实际上已经失效了,只是系统还在正常返回成功。这才是你该在半夜被叫醒的告警。
必做三:定期演练”恢复”
这条最容易被忽略,也最致命:
没演练过的备份,等于没有备份。
定期做:从备份还原到一台新机器,验证数据完整、可启动、业务能跑通。而且要计时——如果还原要 8 小时,那你的 RTO 就不是你写在文档上的”2 小时”。
本小节总结:三个问题
选择副本策略,本质上就是在回答三个问题:
- 你愿意为”不丢数据”付多少延迟?(→ 同步 vs 异步,RPO)
- 你愿意为”不停服”付多少成本?(→ 副本数、跨 AZ/跨地域,RTO)
- 你能容忍的最坏情况是什么?(→ 是丢 5 秒数据,还是停服 5 分钟?)
这三个问题没有标准答案,只有”你业务的标准答案”。
而最危险的架构,是那个”从来没被问过这三个问题”的架构 —— 它通常是一个默认的 3 副本 + 异步复制 + 一套没演练过的 failover 脚本,然后在某天夜里,用一次事故来告诉你答案。
演进脉络
- 早期:手工备份(磁带、冷备),无自动故障转移,RPO/RTO 以小时计。
- 1980s-90s:数据库主从复制成熟(binlog、WAL 传输),半同步复制出现。
- 2000s:Dynamo 论文提出无主复制 + Quorum + 向量时钟,把复制从”主从而已”扩展成”可调一致性”;GFS/HDFS 用多副本 + 廉价硬件实现大规模存储。
- 2010s:Raft 论文(2014)让共识算法变得可理解、可实现,复制状态机成为分布式系统的标准范式;纠删码在对象存储中大规模应用。
- 现代:云原生存储、跨地域多活、自动化 failover,复制的复杂度被托管服务(云数据库)封装,但底层矛盾(一致性 vs 延迟 vs 成本)从未改变。
- 近十年:复制日志从”物理”走向”逻辑”(CDC 成为数据管道底座);复制状态机成为标准范式(Raft 让共识可教学、可实现);纠删码从理论走向大规模落地(并用 LRC 等改进解决了重建放大);Spanner 用 TrueTime 证明了全球强一致可行;区块链把复制状态机推到了拜占庭容错的极端。
主线:复制技术的演进,是”不断提高自动化程度和降低成本”的过程——从手工备份到自动 failover,从多副本到纠删码。但它引出的核心难题(一致性、脑裂、成员变更),最终逼出了共识算法这个答案。
另一条隐藏主线:复制日志的形态,一直在”物理”与”逻辑”之间摆动。 WAL 传输最物理(最快、最耦合),基于语句最逻辑(最省、最不确定),行复制与逻辑复制在中间找平衡点。摆动的支点,永远是”确定性 vs 解耦”这一对矛盾。
还有一条:“谁决定顺序”这个问题,始终没有被绕开。 GFS 用 master、Ceph 用 monitor 跑 Paxos、Spanner 每个 tablet 一个 Paxos 组、区块链用 PoW + 最长链。数据路径可以去中心化,控制路径永远需要一个权威或一场共识。
本章思考题
【思考题】
-
“存 3 份副本”自动带来高可用吗?持久性和可用性的区别是什么?
-
Quorum 机制里,为什么 W+R>N 就能保证读到最新值?
-
同步复制为什么会”降低写入可用性”?半同步复制是怎么折中的?
-
纠删码比多副本省很多空间,代价是什么?为什么热数据通常不用纠删码?
-
基于语句的复制(statement-based)有哪些致命坑?为什么它必然被基于行的复制取代?
-
WAL 传输效率最高、保证字节级一致,为什么 PostgreSQL/Oracle 的物理备库仍然无法跨版本滚动升级?
-
什么是复制状态机?它的三个要件是什么?为什么说它是理解”复制”与”共识”关系的钥匙?
-
主从延迟最本质的来源是哪一段?为什么”主库并发写、从库单线程重放”会导致延迟单调累积?
-
“读己之所写失效”和”单调读失效”有什么区别?分别怎么解决?为什么后者更让用户崩溃?
-
failover 有四道难题。哪一道是”只能取舍、无法解决”的?为什么脑裂被称为复制系统最危险的故障模式?
-
为什么 fencing token 必须在服务端(资源侧)检查,而不能依赖旧主自己判断?
-
多主复制下,哪些冲突是”自动策略永远解决不了”的?LWW 最本质的问题是什么?CRDT 凭什么能消除冲突,代价又是什么?
-
W+R>N 保证不了什么?请至少说出三条边界,并解释读修复、反熵、Merkle Tree 各自解决什么问题。
-
请用多项式插值的直觉解释 Reed-Solomon 编码。k 和 m 该怎么选?“重建风暴”是什么,LRC 如何缓解它?
-
你的核心业务数据库要选副本策略。请给出完整的决策流程:先问什么?副本数几个?放哪里?同步还是异步?还应该做什么?
【参考答案】
-
不自动带来高可用。“存 3 份”直接获得的是持久性(只要有一份活着数据就不丢),而可用性需要额外的故障检测 + failover 机制才能兑现——没有自动切换,主库挂了服务照样中断。区别:高可用是”服务一直在线”,高持久是”数据不丢”。可能出现”高可用但低持久”(在线但丢未同步数据)或”高持久但低可用”(数据在但要人工切换)。
-
因为写确认了 W 个副本、读要读 R 个副本,若 W+R>N,根据鸽笼原理,“写过的副本集合”与”读到的副本集合”必然有交集,交集里至少一个副本持有最新值,再用版本号挑出最新的即可。这是组合数学保证的确定性结论,不是概率性的。
-
同步复制要求写必须等所有(或指定)从节点确认,任何一个从节点不可用(宕机、网络分区、慢),写入就阻塞——副本越多,任一副本故障的概率越高,写入可用性反而下降。半同步折中:只等”至少一个”从节点确认就返回,既保证至少一份完整数据(持久性),又只受一个从节点的延迟影响。
-
代价:编码/解码的计算开销;重建代价巨大(坏一块要读 k 块重建,产生大量网络/磁盘 IO);某块不可用时读取要等重建、延迟高;小写要 read-modify-write(改 1 字节触发 2 读 2 写);小文件极不友好。热数据不用纠删码,因为热数据访问频繁、更需要快速恢复、对 P99 延迟敏感,且常有小写——多副本读得快、重建便宜(修复放大=1,直接拷一份),而纠删码故障重建要读 k 块、降级读要实时解码。所以现实策略是热数据多副本、冷数据转纠删码,自动分层。
-
坑来自”同一条语句在不同机器上执行结果相同”这个错误假设:①非确定性函数 NOW()/RAND()/UUID(),主从执行时刻不同值就不同;②自增列在并发下的分配顺序由主库锁竞争决定,从库单线程重放顺序可能不同,导致同一行主从 ID 不同;③触发器/存储过程在从库可能重复触发或产生不同副作用;④有副作用的操作(写文件、发请求)会在从库重放;⑤非确定的 ORDER BY…LIMIT 导致主从不删同一批行。MySQL 尝试过把不确定函数的求值结果写进 binlog 来补救,但覆盖不全。本质原因:它违反了复制状态机的”确定性”要件——操作不再是函数,状态机必然发散。所以它不是”实现不好”,而是原理上站不住,注定被 row-based 取代。
-
因为 WAL 传输复制的不是逻辑语义,而是”第 N 号页面的第 X 字节开始的 Y 个字节改成什么”这种物理变更。这要求主备的”状态机”完全相同,而状态的定义里包含了存储格式本身:页面大小、字节序、索引物理布局、大版本、CPU 架构、编译选项、扩展插件集都必须一致。跨版本意味着物理格式定义变了,就不是同一个状态机,物理日志无法解释。后果:不能滚动升级、从库不能有多余索引、从库长查询可能被主库 VACUUM 打断。想跨版本/异构,就必须走逻辑复制(从 WAL 反解出行级变更)。一句话:WAL 传输给你的是”物理保真 + 极致效率”,代价是”强耦合 + 零自主性”。
-
复制状态机(RSM):如果所有副本从相同的初始状态出发,以相同的顺序执行相同的确定性操作序列,那么它们必然到达相同的最终状态。三要件:①相同初始状态;②相同执行顺序;③确定性操作(操作必须是函数,不能依赖 NOW()/RAND() 这类非确定因素)。它之所以是钥匙,因为推理链只有三步:数据一致 ⇔ 操作日志一致 → 日志是一个全序序列 → 于是问题从”如何复制数据”变成”如何让所有节点对下一条日志是什么、顺序如何达成一致”,而这最后一步就叫共识。所以 Raft/Paxos/Zab 本质上不是”数据库复制算法”,而是”分布式日志的顺序共识算法”——它们复制的是 log entry,状态机只是把 entry 一条条 apply 后的结果。Kafka partition、ZooKeeper/etcd、MySQL binlog、Redis AOF、区块链,全是这个范式。还要注意:RSM 只保证副本最终一致,不保证线性一致性,读路径还得配合(Raft 的 ReadIndex / Lease Read)。
-
最本质的是第三段——从库重放(apply/replay)。前两段(网络传输、从库落盘)是常量级开销,而重放存在一个结构性塌陷:主库是多线程并发写的(100 个连接、多核、组提交),从库传统上是单线程严格按序重放的,主库的并发度在复制管道里被强制串行化。若主库写吞吐 3 万 QPS 而从库单线程重放只有 1 万 QPS,则每秒净积压 2 万条,延迟是单调累积而非抖动,直到主库写入降下来(通常深夜)才追平。其他隐形杀手:从库长查询阻塞重放、从库多建了索引导致写得更慢、批量 UPDATE 产生百万级行事件。演进:MySQL 5.6 基于 schema → 5.7 LOGICAL_CLOCK(用主库组提交推断无冲突)→ 8.0 WRITESET(基于行冲突检测),PostgreSQL 16 起 parallel apply,本质都是在把重放并行化。
-
区别:读己之所写失效是”用户刚写完,读到了旧值”(用户对自己刚完成的写失去读保证);单调读失效是”同一用户连续两次读,后一次反而更旧”(时光倒流),根因是读请求在延迟不同的多个从库间随机轮询。后者更让用户崩溃,因为人能接受”还没生效”,但不能接受”刚才还生效、现在又不生效了”——后者会被判定为系统坏了并触发疯狂重试,比一致地读到旧值严重得多。解法:读己之所写 → 写后读走主库(客户端记录最后写入时间戳,路由层判断从库位点是否够,不够就走主库,且必须设时间窗否则高频写用户会把读全打到主库),或更精确的等位点/等 GTID(MASTER_POS_WAIT / WAIT_FOR_EXECUTED_GTID_SET,追上才读、超时降级走主库);单调读 → 按 user_id 哈希或会话粘性把同一用户绑定到固定从库。补充:实践上 Seconds_Behind_Master 不可靠(主库空闲时不更新、依赖时钟、不反映积压量),应监控位点差,并告警”位点差持续增大”。
-
四道难题:①判断主库真死(无法区分”挂了”与”网络慢/分区/GC”,超时设小了误判触发高危 failover、设大了 RTO 变差;要多探测器、多信号、多数派判定、suspect 中间态);②选新主(必须选数据最新的,MySQL 用 GTID 集合包含关系、Raft 用 (lastLogTerm, lastLogIndex) 且落后节点会被拒绝投票、PG 用 LSN、Kafka 用 LEO/ISR);③避免脑裂(租约+fencing token、多数派写入、STONITH,且必须”先 fence 再上任”);④异步复制下 failover 必然丢数据——这一道无法解决,只能取舍(RPO=0 必须同步、接受可用性下降;RTO≈0 必须自动切换、接受更高风险;RTO 宽松时人工切换反而最安全)。脑裂最危险,因为它破坏的是正确性而非可用性:两个主同时写导致数据分裂,之后几乎无法自动合并(主键/约束/业务语义全面冲突),而可用性故障会自己恢复、正确性故障不会。宁可停服,不可双写。
-
因为旧主已经”精神错乱”了——它真诚地相信自己还是主(它只是收不到最新信息,比如刚经历一次 GC 停顿)。让它”检查自己还是不是主”,它会真诚地回答”是的”。所以防护不能依赖发起写入的一方自觉,必须由被写入的资源(存储节点、磁盘、锁服务)强制检查:每次写入携带单调递增的 fencing token(Raft 的 term、ZK 的 epoch),资源侧维护见过的最大 token,只接受 token ≥ 当前最大值的写入,旧主带着旧 token 的请求被直接拒绝。这个原则同样点破了分布式锁的经典陷阱:客户端 GC 停顿超过锁超时、锁被别人拿走后继续写,如果共享存储本身不检查 fencing token,这把锁就是完全无效的。一句话:不能指望一个疯子自己按时吃药。
-
自动策略永远解决不了的是涉及”全局不变量”的冲突:①唯一约束(两个主同时插入同一 username,本地检查都通过,同步时才发现冲突——要保证全局唯一就必须全局协调,或用预分配号段如 auto_increment 步长/雪花 worker_id);②跨记录约束(两个主各自扣 60,各自没透支,加起来透支——余额不能为负、库存不能超卖这类不变量在多主无协调下无法保证)。这也是为什么多活在支付领域难、社交领域易:社交数据是独立事实,几乎无跨数据不变量。LWW 最本质的问题是”静默丢数据”——被丢弃的写入不报错、不告警、客户端收到成功但数据没了,你甚至不知道该配什么告警;此外它把正确性问题外包给了控制不了的墙上时钟(时钟偏移会导致新写入被旧写入覆盖),且”并发”的定义依赖时钟精度。CRDT 凭什么:把数据类型设计成数学上不可能冲突的——只要操作满足交换律、结合律、幂等,那么无论以什么顺序、重复到达多少次,所有副本必然收敛,冲突在数学层面被消解,不需要时钟、向量或协调(G-Counter 取 max、G-Set 取并集、OR-Set 带 tag、序列 CRDT 支撑协同编辑)。代价:不是所有结构都能 CRDT 化(凡需全局不变量的都做不了);墓碑和 tag 导致元数据膨胀;CRDT 保证收敛但不保证语义正确(数学正确≠业务正确);调试极难,因为没有全局顺序可复现。最高明的策略其实排在第一位:避免冲突(单元化/SET 化,让同一数据永远路由到同一个主)。
-
W+R>N 保证不了:①它只保证”最新值在交集里”,不保证”你能认出它”——还需要可靠的版本号,而 LWW + 不可靠时钟会把新写入判成旧值,数学保证被有缺陷的比较函数废掉;②对并发写完全无能为力,需要版本向量/CRDT/应用层合并(Quorum 与冲突解决是两件正交的事,必须成对使用);③写失败不会自动回滚,部分副本上的”从未被确认的值”会一直躺着,需要读修复或反熵清理;④Sloppy Quorum(写入”下一个可用节点”+ hinted handoff)会主动破坏交集保证,这是为写可用性牺牲一致性的明确设计选择;⑤Quorum 不等于线性一致性,要线性一致还需读路径也参与顺序确定(Raft ReadIndex 走一轮多数派心跳确认仍是 leader,或 Lease Read 用时钟优化)。读修复:读时顺手把新值写回落后的副本,代价是读请求变成写请求,且是惰性的(没人读的数据永远修不了)。反熵:后台定期比对同步。Merkle Tree 解决”怎么廉价找出差异”——叶子是数据块哈希、内部节点是子节点哈希的拼接哈希,比对时先只传树根,相同就 O(1) 结束,不同才下钻不一致的子树,把对账成本从 O(数据总量) 降到 O(日志量+差异量);它也是 Git、区块链、IPFS 的核心结构。
-
RS 直觉(多项式插值):把 k 个数据块当作某个次数 ≤ k-1 的多项式 P 在 x=1..k 处的采样值(k 个点唯一确定一个 k-1 次多项式),校验块就是在 x=k+1..k+m 处再求 P 的值(过采样)。丢失任意 m 块后,剩下的 k 块仍是这个多项式的 k 个采样点(只是 x 坐标不同),仍唯一确定 P,再求 P(1)..P(k) 即还原全部原始数据。真实实现在有限域 GF(2^w) 上做算术(实数会溢出、除法不封闭),所以需要 SIMD 加速(AVX2/AVX-512 的 GF 乘法指令),有了加速后 CPU 已不是瓶颈,真正的瓶颈是 IO 和网络。参数选择是一个三方权衡:存储开销 (k+m)/k 随 k 增大而降低;容错能力 m 越大越可靠;但修复放大 = k(坏 1 块要读 k 块),k 大省空间却修复贵——这就是纠删码的核心张力。还要注意 m 的真正含义不是”能坏几块盘”而是”能同时坏几个故障域”,前提是放置策略保证一个故障域最多承载 1 块(否则 RS(10,4) 若 5 块同机架,一个机架故障就永久丢数据)。重建风暴:1000 盘 RS(10,4) 集群坏一块 8TB 盘,要修复它需读约 50TB,散布在另外 999 块盘上,恢复带宽成为瓶颈(不是磁盘),打满骨干网、拖慢所有业务 IO;更糟的是相关性故障(同批次盘寿命相关),一块坏了往往一批接连坏,而修复窗口内的二次故障率才是可靠性的真正决定因素——决定可靠性的是 MTTR 而非 MTTF。LRC 缓解:在全局校验之上加一层局部校验,如 LRC(12,2,2) 把 12 块分 3 组、每组 1 个局部校验 + 2 个全局校验,开销 17/12≈1.42x 与 RS(10,4) 的 1.4x 相当,但单块故障(占 99%+)只须读同组 4 块,修复放大从 12 降到 4;代价是容错模式变复杂。其余手段:修复限速+错峰(但需量化,限速会拉长窗口推高二次故障风险)、增量修复、跨更多故障域分散放置。
-
完整决策流程五步:①先问 RPO 和 RTO,别问技术——RPO=0 意味着必须同步复制并接受副本不可用时写入失败;RPO>0 才可用异步;RTO≈0 必须自动 failover 且做对故障检测/选主/脑裂防护并演练;RTO 宽松时人工切换最安全(人能判断、能兜底,自动脚本的最大风险是在信息不足时选择行动)。②副本数:避免 2 副本(无法形成多数派,需外部仲裁,且抗不住”1 故障+1 维护”);3 副本是默认答案(容忍 1 故障、2/3 多数派);5 副本用于容忍”1 故障+1 维护”的叠加(金融级);多数派系统用奇数,偶数不增加容错等级只增加成本。还要记住复制≠备份——一个漏 WHERE 的 DELETE 会光速同步到所有副本,必须另有不可变备份/版本控制/软删除保留期。③放置(故障域):问”一次故障能带走几个副本”——跨机架防单机+单机架;跨可用区(同城 1
3ms)是性价比最高的甜点区,同步复制完全可行且防单机房灾难;跨地域 30150ms 通常只能异步灾备;三地五中心 2+2+1 容忍整个城市失效。注意”多数派的地理分布决定写延迟”;并用反亲和性约束防止 3 副本被调度到同一物理机(那是纸糊的高可用),且要真的停机验证。④同步 vs 异步:同机房/同城同步;跨城按业务分流(支付订单同步、埋点 feed 异步);跨洲异步为主。推荐混合策略:同城三副本 quorum 保 RPO=0 + 跨城异步灾备。Kafka 的 ISR 是更聪明的半同步——追不上的副本被踢出 ISR,acks=all 只等 ISR,且 ISR < min.insync.replicas 时拒绝写入而不是悄悄降级(与 MySQL 半同步超时降级成异步形成鲜明对比),这保证了持久性承诺不因一次抖动失效。⑤必须验证:定期故障演练(杀主、网络分区、从库延迟暴涨、旧主复活)并计时;监控正确的指标——监控位点差和”位点差持续增大”,最重要的是告警”当前同步副本数 < 期望副本数”(这才是真正影响 RPO 的指标,而”某个副本挂了”本身并不危险);定期演练备份还原并计时(没演练过的备份等于没有备份)。