18.1 复制模型:单主 / 多主 / 无主

一句话

复制模型只回答一个问题——谁可以接受写:单主把写收敛到一点,换来无冲突,代价是写不可扩、切换会丢;多主让每个地域都能写,代价是冲突必须解决;无主让客户端直写多个副本,可用性最高、语义最弱。

三种模型一张表看全

维度 单主(leader-based) 多主(multi-leader) 无主(leaderless)
谁能写 只有主 每个主都能写 任意副本(客户端或协调者)
读路径 主或从(从可能旧) 本地主 读 quorum 或最近副本
冲突 不产生(写已定序) 必然产生,要合并 会产生,要合并 + 读修复
写扩展 差(单点写) 好(按地域分) 好(按副本分)
故障切换 要选主(RTO 秒级到分钟级) 对端故障不影响本端写 无主可切,容忍少数副本
一致性上限 线性一致(同步复制时) 因果一致 / 最终一致 可调(W+R>N 只是“读到最新”的弱保证)
典型系统 MySQL 主从、PostgreSQL、Raft 系(etcd) 多地域双活、Galera 多主、Cassandra 多 DC Dynamo、Cassandra、Riak
主要风险 写单点、切换丢数据 写冲突、冲突解决错 读到旧值、修复不及时

三种模型分别对应哪些一致性模型,见 16.7 一致性模型谱系图;单主复制在 MySQL / PostgreSQL 里的具体形态见 44.13 主从复制与读写分离。

🔑 选模型的顺序应该是:先问“同一个 key 会不会在两地同时被写”。不会 → 单主;会且能合并 → 多主/无主;会但不能合并(余额、库存、唯一键)→ 必须单主或分区内单主。

单主:把写收敛到一个点

写请求全部进主库,主库把变更写进复制日志(binlog / WAL / Raft log),再同步或异步推给从库。它的好处是写操作天然有序:没有并发写,就没有冲突需要解决。

优点 代价
无写冲突,应用不用写合并逻辑 写吞吐受单主限制(垂直扩展或分片)
语义简单,读己之写容易保证(读主) 主故障要选主,切换期间写不可用
复制日志即真相源,可做 CDC、审计 从库有延迟,读到旧值(见 18.4 复制延迟与读一致性)

切换会丢多少:异步复制下,主挂了还没发出去的事务就丢了。丢的数量 ≈ 写速率 × 切换时间:

200 QPS × 30s 切换 = 6000 个已提交事务丢失(RPO 不是 0)

要让 RPO=0,只能让“提交”等到至少一个副本确认(见 18.2 同步、半同步、异步),代价是写延迟。选主细节在 18.5 故障检测与切换,共识选主见 21.5 Raft:选主。

多主:让每个地域都能写

多主的动机通常不是“写吞吐”,而是写延迟:北京的用户写北京的主,上海的用户写上海的主,谁都只等本地一次提交。

代价是冲突窗口 = 复制延迟。下面这笔账决定了多主能不能用:

同一 key 在两地各以 10 次/秒 写入,单向复制延迟 15ms
→ 每秒约有 10 × 10 × 0.015 ≈ 1.5 次“两边几乎同时写”的机会

如果这个 key 是“用户资料里的昵称”,冲突率 1.5 次/秒无所谓(LWW 或让用户选);如果是“账户余额”,这就是事故——1.5 次/秒的冲突,意味着每秒有 1.5 次资损风险。所以多主只适合“冲突可合并或可丢弃”的数据。

冲突解决 做法 适合 丢什么
LWW(时间戳最大者赢) 比较物理/逻辑时间戳 昵称、状态标记 静默丢写(17.3 有量化)
版本向量 检测并发写,交给业务 购物车、协作编辑 需要业务规则或人工
CRDT 结构本身可无冲突合并 计数、集合、点赞 只适用于特定数据结构
业务规则 按语义挑赢家(如“以先提交的为准”) 订单状态 需要逐业务设计

⚠️ 多主的“双写”还有个隐蔽问题:同一条记录可能在两地产生不同的自增 ID / 时间戳,合并时主键会冲突。多主系统要么用全局唯一 ID(UUID、雪花),要么按地域分段发号。

无主:客户端直接写多个副本

Dynamo 风格:没有主,客户端(或协调节点)把写发给 N 个副本,收到 W 个成功即认为写成功;读时问 R 个副本,取版本最新的。

参数 含义 常见取值
N 副本数 3
W 写成功需要的确认数 2
R 读需要的响应数 2
W + R > N 读与写的 quorum 必有交集 2 + 2 > 3 ✅

关键纠正:W + R > N 保证的是“读的 quorum 里至少有一个副本见过最新写”,它不等于线性一致——因为:

  • 写可能只到了 W 个副本,读的 R 个里恰好有旧值,读仍可能返回旧值(需要版本比较 + 读修复才能“纠正”);
  • 并发写没有全局顺序,仍要冲突解决;
  • 如果用了 sloppy quorum(故障时写到“临时副本”),交集假设还会被打破。

无主的两个必备补丁:

补丁 作用 不做会怎样
读修复 读时发现旧副本,顺手把新值写回 旧值长期存在,直到反熵才修
反熵(Merkle 树) 后台比对副本差异 冷数据永远不修
hinted handoff 目标副本挂了,先写“临时副本”,恢复后转交 可用性下降(宁可拒绝写)

反熵与 gossip:把“最终”真正收敛

单主靠复制日志天然收敛(所有副本按同一顺序回放);无主与多主没有唯一日志,副本之间必须互相发现差异——这层“收尾工作”的标准做法就是 gossip。

形态 传什么 触发方式 消息量 解决什么
rumor mongering(谣言传播) 新写入的 key/事件 有更新就推 每轮 N × fanout 让新数据快速传开
anti-entropy(反熵) Merkle 树差异 → 具体 key 周期性 每轮 O(log n) 次比较 + 差异 key 修复漏传、冷数据
SWIM 成员状态(存活/可疑/死亡) 周期性 ping + 间接探测 每节点常数条/秒 成员发现与故障检测(见 35.4 故障检测)

gossip 的传播速度是对数级的,下面这段程序用 1000 次随机实验验证:

package main

import (
	"fmt"
	"math/rand"
	"sort"
)

// push 型 rumor mongering:每轮每个已感染节点随机推给 fanout 个节点
func trial(n, fanout int, rnd *rand.Rand) (rounds, msgs int) {
	infected := make([]bool, n)
	infected[0] = true
	count := 1
	for count < n {
		rounds++
		var newly []int
		for i := 0; i < n; i++ {
			if !infected[i] {
				continue
			}
			for k := 0; k < fanout; k++ {
				t := rnd.Intn(n) // 随机选一个节点推
				msgs++
				if !infected[t] {
					newly = append(newly, t)
				}
			}
		}
		for _, t := range newly {
			if !infected[t] {
				infected[t] = true
				count++
			}
		}
	}
	return rounds, msgs
}

func main() {
	rnd := rand.New(rand.NewSource(5))
	for _, n := range []int{100, 1000, 10000} {
		var rs, ms []int
		for i := 0; i < 500; i++ {
			r, m := trial(n, 3, rnd)
			rs = append(rs, r)
			ms = append(ms, m)
		}
		sort.Ints(rs)
		sort.Ints(ms)
		fmt.Printf("N=%-6d fanout=3  轮数 中位=%2d p95=%2d  消息 中位=%6d  每节点均摊=%.1f\n",
			n, rs[len(rs)/2], rs[int(0.95*float64(len(rs)))],
			ms[len(ms)/2], float64(ms[len(ms)/2])/float64(n))
	}
}

本机实测(macOS,Apple M1,Go 1.27):

N=100    fanout=3  轮数 中位= 6 p95= 7  消息 中位=   732  每节点均摊=7.3
N=1000   fanout=3  轮数 中位= 8 p95= 9  消息 中位=  8511  每节点均摊=8.5
N=10000  fanout=3  轮数 中位=11 p95=11  消息 中位=124530  每节点均摊=12.5
观察 结论
节点数 ×10,轮数只从 6 → 8 → 11 传播是 O(log N),不是 O(N)
每轮每节点发 fanout 条 总消息 ≈ N × fanout × 轮数,即 O(N log N)
1000 节点 8 轮、8511 条 若要求 1 秒内全员收到,间隔最多 1000ms / 8 = 125ms,约 8.5k 消息/秒

gossip 不是共识,这一点必须钉死:

维度 gossip 共识(Raft/Paxos)
保证 最终送达(概率 1),可能重复/乱序 一致性 + 有效性 + 终止性
是否产生“决定” 不产生 产生(日志位置上的值)
两个并发写 两条“谣言”都会传开,谁赢取决于合并规则 必须定序,只有一个赢
故障时 继续传,收不到也不影响 少数派不可用
消息量 O(N log N) O(N²)(或 O(N) 带 leader)

🔑 gossip 负责“让所有人最终知道”,不负责“让所有人同时同意”。Dynamo/Cassandra 的一致性是 quorum + 冲突解决给的,gossip 只负责成员与反熵;把 gossip 当共识用,分区时会出现两个“真相”(21.1 共识问题定义 把它列为反模式)。

参数怎么给:

参数 太小 太大
fanout 收敛慢(轮数变多) 消息量线性上升,带宽与 CPU 压力大
间隔 收敛快但消息多 收敛慢,故障发现滞后
Merkle 树分片粒度 比较次数多 单次差异传输量大,修复慢
每轮消息大小 — 超过 MTU 会分片,放大丢包影响(8.1 TCP/IP 与三次握手)

落地参考:Cassandra 用 gossip 做节点成员与 schema 版本传播;Consul 用 WAN gossip 连接多数据中心(48.6 产品 (etcd / Consul / ZooKeeper / Nacos));etcd 不用 gossip,成员变更走 Raft。

三种模型的关键数字对比

指标 单主(同步) 单主(异步) 多主 无主(N=3,W=2,R=2)
写延迟 1 个 RTT(同城 1–3ms,跨城 30ms) 本地(<1ms) 本地(<1ms) 1 个 RTT(等第 2 个副本)
故障丢数据 0(有前提) 写速率 × 切换时间 冲突数据按策略丢 冲突数据按策略丢
写可用性 主不可用则不可写 主不可用则不可写 任一主可用即可写 少数副本可用即可写
冲突处理 不需要 不需要 必须 必须
读一致性 强(读主) 弱(读从) 本地强、跨域弱 quorum 读,可能旧

决策表

你的情况 选择 理由
余额、库存、唯一键 单主(或按 key 分区,每分区单主) 冲突不可合并,必须定序
单地域、读多写少 单主 + 读从库 简单,读可扩
多地域、写要本地低延迟、数据可合并 多主 / 无主 冲突用 CRDT 或业务规则兜住
跨地域强一致(RPO=0) 单主 + 同步复制(跨城) 付 30ms 写延迟换不丢
写入量超过单主 分片(19.1 分区键设计) 先分片,再谈多主
只需要最终一致的计数器/日志 无主 + CRDT 可用性最高,合并语义天然正确

反模式

反模式 后果
多主写余额,用 LWW 合并 静默丢写,账对不上
单主异步复制,却宣称 RPO=0 切换时丢几千条事务
无主系统只看 W+R>N,不做读修复 旧值长期存在,读结果取决于打到谁
多主用自增 ID 做主键 合并时主键冲突
把“多副本”当“多主” 副本再多,写还是单点
切换不做 fencing 旧主复活继续写,双主(见 48.3 分布式锁)

自检

Q1 单主异步复制,切换耗时 30 秒,写速率 200 QPS。切换会丢多少数据?如何把 RPO 降到 0,代价是什么?

▸ 答案:最坏丢 200 × 30 = 6000 个已提交事务。把 RPO 降到 0 需要“提交前至少一个副本确认”(半同步的 AFTER_SYNC 或全同步),代价是每次写多等一次复制往返:同城 1–3ms,跨城 30ms;而且同步副本一旦变慢,写延迟直接被它拖高(见 18.2 的实测 p99)。

Q2 为什么说多主适合“昵称”,不适合“余额”?用冲突窗口的量化解释。

▸ 答案:冲突窗口等于复制延迟。两地各以 10 次/秒写同一个 key、单向延迟 15ms 时,每秒约有 10 × 10 × 0.015 ≈ 1.5 次并发写机会。昵称冲突可以 LWW 或让用户重填,丢一次无感;余额冲突 1.5 次/秒意味着持续资损,而且 LWW 会静默丢掉其中一笔。结论:多主的前提是“冲突可合并或可丢弃”,不是“写延迟低”。

Q3 N=3, W=2, R=2 满足 W+R>N,是否意味着读一定能读到最新写?

▸ 答案:不意味着。W+R>N 只保证读的 quorum 与写的 quorum 有交集(至少一个副本收到过这次写),但读返回哪个版本取决于客户端/协调者的版本比较:如果 R 个副本里最新的那个没被选中,仍可能返回旧值;并发写还会产生多个版本。要真正“读到最新”,需要版本号比较 + 读修复,或者干脆走线性一致的读(又多一次协调)。这也是为什么无主系统通常只承诺最终一致(16.5 最终一致性)或读己之写。

Q4 一个团队已经做了跨城同步复制的单主,为什么还要做“按 key 分片”而不是“改成多主”?

▸ 答案:因为同步单主的瓶颈是写吞吐(所有写经过一个主),而多主解决的是写延迟,同时引入冲突。分片把写分散到多个独立的单主上,每个 key 仍然有序、无冲突,RPO 仍为 0;代价是要处理分片键设计、跨片查询与再平衡(19.1 分区键设计、19.7 再平衡与数据迁移)。只有在“同一个 key 也要多地低延迟写”时,才需要考虑多主。

Q5 1000 个节点的集群用 gossip 传播成员变更,fanout=3,实测 8 轮全员覆盖。如果要求 1 秒内覆盖,间隔最多设多少?消息速率大约多少?

▸ 答案:间隔最多 1000ms / 8 = 125ms。总消息量取实测中位 8511 条(前几轮只有少数节点感染,发送量并不是每轮 3000 条),这些消息要在 1 秒内发完,所以消息速率约 8.5k 条/秒。把 fanout 调大能让“每次翻倍”更快、轮数更少(传播底数从 4 变成 7),但每轮发送数也随之上升——收敛速度与带宽是直接换算关系,没有免费的加速。

关联