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),但每轮发送数也随之上升——收敛速度与带宽是直接换算关系,没有免费的加速。