36.6 切换时的数据一致性
一句话
切换时的不一致只有两个来源——两个主都以为自己是主(脑裂)、新主不知道旧主写到哪(位点丢失);解法是 fencing 加位点,不是“切换前多同步几次”。
三个流行说法先拆掉
| 误区 | 现实 |
|---|---|
| 切换前多同步几次,丢的就少了 | 丢失窗口 = “最后一次复制成功”到“新主可写”,长度由滞后 + 检测 + 决策 + 提升四段决定,与同步次数无关。多同步几次只压第一段,压不掉后三段 |
| RPO=0 就不会丢数据 | RPO=0 只保证“已提交的不丢”,代价是“切的时候写不进去”:多数派模式下旧主凑不齐 quorum,写全部失败,用户看到的是报错 |
| 旧主恢复后接回去就行 | 旧主带着一段新主没有的历史回来,接回去就是第二次脑裂,而且这次是你主动造成的 |
切换时只有三种数据状态
| 状态 | 怎么产生 | 丢什么 | 怎么发现 | 怎么修 |
|---|---|---|---|---|
| 已提交未复制 | 主库本地 commit 已返回客户端,备库还没收到(异步) | 滞后那一段;客户端看到的是成功 | 比位点:备库的 GTID 集合 / LSN 落后主库多少 | 从旧主 binlog 补放;补不了按流水对账 |
| 已复制未提交 | 写已进备库 redo,但旧主没收到足够 ack 就超时,客户端拿到的是错误 | “客户端以为失败、库里却有”的那几条 | 拿客户端请求 ID / 幂等键回查 | 只能靠幂等重试,重放会变成成功两次 |
| 两边都写了 | 分区期间两个主各自接受写(多主 / 双写 / 脑裂) | 被覆盖的那一版,以及“谁是对的”这个判断本身 | 按主键比版本号、checksum 不相等 | 冲突解决或业务补偿,不能自动合并 |
第三种最贵:前两种是“数据少了”,第三种是“数据有两个版本”——少的能补,两份要拍板。
位点:新主必须从旧主的最后位点接上
三条规矩,违背任何一条都会造成第二种状态:
- 位点要在冻结旧主写之后读。 否则读到的是移动靶:读到 LSN 100 的瞬间,旧主已写到 108。
- 要“最后一条已确认写”的位点,不是“最后一条已复制”的位点。 两者之差正是“已提交未复制”那部分,也正是要补的。
- 位点载体不同,接法不同。
| 位点载体 | 系统 | 怎么读 | 切换时怎么接 |
|---|---|---|---|
| GTID 集合 | MySQL | SELECT @@global.gtid_executed | 新主的集合必须包含旧主已确认集合,缺的从 binlog 补 |
| LSN | PostgreSQL | pg_current_wal_lsn() | 分叉点之前的回卷,pg_rewind 把旧主拉回分叉点 |
| binlog file + pos | MySQL(老式) | SHOW MASTER STATUS | CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE / SOURCE_LOG_POS |
| offset | Kafka | kafka-consumer-groups.sh --describe | 消费者从 committed offset 续,auto.offset.reset 不能设 earliest |
(命令未验证:本机无 MySQL / PostgreSQL / Kafka。)
复制滞后 20 秒、写入 5000 TPS,滞后区间里就有 20 × 5000 = 10 万条已确认写没到备库——这是“切换前就注定要丢或要补”的量。
这只是第一段。检测 30 秒 + 决策 30 秒 + 提升与重连 30 秒,这 90 秒里旧主仍在接写(它不知道自己要被换掉),又是 90 × 5000 = 45 万条,同样不在新主上。
🔑 异步复制的真实 RPO 不是配置里的“滞后 20 秒”,是 110 秒、55 万条。想压这个数只有两条路:压滞后(加带宽、开并行复制),或压切换耗时(自动检测 + 预授权)。后者便宜十倍——滞后受带宽限制,决策时间只是流程问题。
三种复制模式下切换的丢失窗口
同一场故障,复制模式不同,丢的东西完全不同:
| 复制模式 | 切换瞬间丢什么 | RPO | 可用性代价 | 典型实现 |
|---|---|---|---|---|
| 异步 | 滞后期 + 切换耗时内所有已确认写 | 滞后 + 切换耗时(110 秒 / 55 万条) | 无,分区里照样写 | 默认选择,跨地域几乎只能用这个 |
| 半同步 | 未收到 ack 的那几条 | 0(前提是提升的副本收到过 ack) | 等 ack 的那几秒写超时 | MySQL rpl_semi_sync,超时后降级 |
| 同步 / 多数派 | 不丢已提交 | 0 | 少数派不可写,写延迟加一个 RTT | Raft 类复制、三副本多数派 |
传输层差别见 18.2 同步、半同步、异步,这里只看切换那一刻的表现。
⚠️ 半同步的命门在“超时降级”。MySQL 的 rpl_semi_sync_master_timeout 默认 10 秒(MySQL 8.0 的默认值,未在本机验证),超时后备库被踢出半同步集合,主库退回纯异步。RPO=0 只在降级之前成立,而真实故障常常正好跨过这个窗口:网络抖一下降级,半小时后真挂,这时它已是异步,所有人却以为它有 RPO=0。这比“从来没配过半同步”更坏。
fencing:让存储层拒绝旧主,不是劝旧主停
| 手段 | 可靠性 | 失效场景 |
|---|---|---|
| 发 RPC / 信号让旧主自杀 | 低 | 进程 GC 停顿(STW 几秒到几十秒)、网络分区、假死(收到信号卡在锁上) |
| 网关摘掉旧主的入口 | 中 | 只挡住新请求;已在处理的和本地直连的照写 |
| 撤销旧主的数据库账号 | 中 | MySQL 的权限对已建立的连接不生效,连接池不断开就一直能写(未在本机验证) |
| 存储层按 epoch 拒绝 | 高 | 需要存储层支持 epoch / lease |
三种“劝它停”的手段有个共同缺陷:都要求旧主配合。而旧主恰恰是状态不明的那个——可能正在 30 秒的 GC 停顿里,可能收不到通知,可能收到了却卡在锁上。“停止”指令排在它的执行队列里,前面还有 40 秒的活儿。
存储层拒绝不依赖旧主配合:它可以停顿、可以假死、可以收不到任何通知,但要写就得发请求,请求带 epoch,存储层一比就拒。这是“旧主完全不配合”时唯一成立的手段。
epoch 三条规矩:
- 单调递增,永不回收。 用 Raft 的 term 或一个单调计数器,不能用时间戳(时钟会回拨)。
- 每次提升必须 +1,且在允许新主写之前完成。 顺序反了就等于没有。
- 旧主的每一次写都带 epoch,读也带。 只挡写不挡读,旧主会读到新主刚写的值,再基于它做决策写出去——绕一圈还是脏数据。
存储层的 epoch 检查本质上是一种带过期时间的租约,租约机制本身见 21.11 领导者租约。判断只两个分支,run 函数每模拟一秒就调它一次:
// fencingStore 是存储层抽象:只认单调递增的 epoch
type fencingStore struct{ epoch int }
func (s *fencingStore) write(epoch, n int) (ok, rejected int) {
if epoch < s.epoch {
return 0, n // 低 epoch 一律不落地:不是"劝它停",是"让它写不进去"
}
return n, 0
}
脑裂的两种形态
| 形态 | 现象 | 危害 | 怎么认出来 |
|---|---|---|---|
| 真双主 | 两边都能写,两边都返回成功 | 数据分叉,必须合并冲突 | 同一主键在两个库都有一版,版本号不同 |
| 假双主 | 一边写不进去(或写进去又回滚),客户端却以为成功 | 更危险:没有冲突可查,但“成功”是假的 | 客户端成功率 − 服务端 commit 成功率 > 0 |
假双主的三种成因:写超时被上层当成成功(超时重试又失败,最后返回 OK);中间件重试若干次后吞掉错误返回成功;异步写队列积压后丢消息。
它比真双主难查:真双主留下两条带时间戳的记录,对账能发现;假双主的服务端指标全“正常”——commit 是零,错误率也是零,因为根本没写进来。唯一信号在客户端侧。切换窗口内必须同时看客户端成功率与服务端 commit 成功率,只看一边等于没监控。
冲突解决:为什么资金和库存不接受 LWW
| 方案 | 原理 | 适用 | 致命问题 |
|---|---|---|---|
| LWW(时间戳) | 后写覆盖先写 | 昵称、头像、购物车 | 时钟偏差窗口内的并发写必丢一个 |
| 版本向量 | 检测出哪些写是并发的 | 需要人工/业务判断冲突 | 只告诉你冲突,不给解 |
| CRDT | 数据模型天生可合并 | 计数、集合、点赞、在线状态 | G-Counter 只能加不能减,库存和余额用不了 |
| 业务补偿 / 对账 | 事后按流水修 | 资金、库存 | 要有完整流水 + 幂等,且只能修“算得清”的错 |
LWW 的数字:跨地域 NTP 偏差常态在 1–50ms(经验值,公网 NTP 源偏差更大)。单热点行按 5000 TPS 写,50ms 窗口内落了 5000 × 0.05 = 250 次写;LWW 只留时间戳最大的那条,其余 249 次更新被静默覆盖,而且覆盖掉的是已经对用户承诺成功的写。资金场景下这不是“少了一条日志”,是“账对不上且没有任何记录”。
🔑 结论:资金与库存不接受 LWW。它们只有三条路:单写(单元化,按用户归属收敛,见 36.4 两地三中心与多活 与 36.5 单元化 / Set 化架构);条件更新(
UPDATE ... WHERE balance >= x,让存储层做原子判断,冲突时直接失败而不是覆盖);或者分散到不同分片后永不同步写。哪些数据值得为 RPO=0 付这个代价,分级标准见 46.3 数据一致性分级。
切换后 30 分钟内必须跑完的 5 项校验
| # | 校验项 | 做法 | 判定 | 不过怎么办 |
|---|---|---|---|---|
| 1 | 行数 | 逐表 COUNT(*) 与旧主最后快照比 | 差 = 0,或差值 = 已确认丢弃量 | 差值说不清就回滚 |
| 2 | checksum | 分片 CRC32 / pt-table-checksum | 全部一致 | 定位到分片再修 |
| 3 | 位点 | 新主 GTID 集合 ⊇ 旧主已确认集合 | 包含关系成立 | 补放 binlog |
| 4 | 补放 | 把旧主最后一段 binlog 重放到新主 | 应用无冲突、无重复 | 冲突条目转业务补偿 |
| 5 | 抽样 | 按幂等键抽 1000 条比对字段 | 全等 | 扩大到全量比对 |
(命令未验证:本机无 MySQL。)
前 4 项 15 分钟内跑得完,第 5 项看数据量。没跑完这 5 项就宣布“恢复”的,等于把故障从“不可用”改成了“数据错” —— 前者用户会报错,后者用户过三天才发现。
客户端视角:三种“切完了但用户不对”
| 情况 | 机制 | 处理 |
|---|---|---|
| 读己之写失效 | 写进了新主,读还打到还没接上的旧从库 | 切换后同会话强制读主,读写分离的坑见 44.13 主从复制与读写分离 |
| 重试不幂等 | 切换窗口内超时重试,两个主各写一次 | 幂等键 + 去重表,见 25.4 幂等设计与去重 |
| 连接串漂移 | 连接池不断开,一直连旧地址;DNS TTL 还没到 | 切流后主动踢连接池;TTL 提前降到 60s |
这三件事都不在数据库里,却都表现为“切换后数据不对”。切换方案只写数据库那一半,用户侧的“不对”会持续到连接池自然过期——可能几小时。
可运行的切换模拟
四种策略,同一场故障(滞后 20s、5000 TPS、检测 30s、决策 30s、提升 30s、旧主在分区里继续接写到第 150 秒):
package main
import "fmt"
// 一次主备切换的时间轴(单位:秒),按"旧主仍在接写"的最坏情况建模
const (
tps = 5000 // 写入速率:条/秒
lagSec = 20 // 异步复制滞后:备库落后主库 20 秒
detect = 30 // 发现主库不可用
decide = 30 // 决定切换
promote = 30 // 提升新主 + 应用重连
oldStop = 150 // 旧主真正停止接写的时刻(分区里客户端还在往它打)
)
// 新主从 promoteAt 这一秒起可写;在此之前旧主是唯一能写的节点
const promoteAt = detect + decide + promote // = 90s
type strategy struct {
name string
quorum bool // 提升前旧主的写是否需要多数派确认(半同步/同步)
degrade int // 多数派等不到 ack 时降级为异步的秒数;-1 = 永不降级
fence bool // 是否在存储层按 epoch 拒绝旧主
}
type report struct {
lost, conflict, rejected int // 条数
window int // 双主窗口(秒):两边都返回成功的时长
}
// fencingStore 是存储层抽象:只认单调递增的 epoch。
// 旧主拿着 epoch=7,新主被提升时写入 epoch=8,此后旧主的写全部被拒。
type fencingStore struct{ epoch int }
func (s *fencingStore) write(epoch, n int) (ok, rejected int) {
if epoch < s.epoch {
return 0, n // 低 epoch 一律不落地:不是"劝它停",是"让它写不进去"
}
return n, 0
}
func run(s strategy) report {
store := &fencingStore{epoch: 7}
var lostSec, conflictSec, rejectedSec int
// 切换开始前就注定的损失:滞后那 20 秒的写已被旧主确认,却从未到达备库
if !s.quorum {
lostSec += lagSec
}
for t := 0; t < oldStop; t++ {
if t == promoteAt {
store.epoch = 8 // 提升动作的一部分:epoch 必须比旧主大
}
// ① 存储层 fencing 优先于一切:epoch 落后就写不进去
if s.fence {
if _, rej := store.write(7, tps); rej > 0 {
rejectedSec++
continue
}
}
// ② 旧主这次写要不要多数派确认
acked := true
if s.quorum {
if s.degrade < 0 {
acked = false // 严格多数派:分区里永远凑不齐 quorum
} else if t < s.degrade {
acked = false // 降级窗口内还在等 ack,客户端超时
}
}
if !acked {
rejectedSec++
continue
}
// ③ 这次写有没有落到"将要成为新主"的那个节点上
if !s.quorum || s.degrade >= 0 {
// 异步,或半同步已降级:旧主本地 commit 就算成功,新主没有这条
lostSec++
}
// ④ 新主已经可写,旧主也写成功 → 双主窗口
if t >= promoteAt {
conflictSec++
}
}
return report{
lost: lostSec * tps,
conflict: conflictSec * tps,
rejected: rejectedSec * tps,
window: conflictSec,
}
}
// pad 按显示宽度补空格:中日韩字符占两列,否则表头一定错位
func pad(s string, w int) string {
n := 0
for _, r := range s {
if r > 0x2E80 {
n += 2
} else {
n++
}
}
for n < w {
s += " "
n++
}
return s
}
func main() {
cases := []strategy{
{"异步复制,无 fencing", false, -1, false},
{"异步复制 + epoch fencing", false, -1, true},
{"半同步(严格多数派)", true, -1, false},
{"半同步(10s 后降级异步)", true, 10, false},
}
fmt.Printf("%s %10s %10s %12s %10s\n",
pad("策略", 26), "丢失条数", "冲突条数", "被拒写条数", "双主窗口")
for _, c := range cases {
r := run(c)
fmt.Printf("%s %10d %10d %12d %8ds\n",
pad(c.name, 26), r.lost, r.conflict, r.rejected, r.window)
}
// 真实 RPO:不是滞后 20s,而是"最后一次复制成功"到"新主可写"之间的全部写入
fmt.Printf("\n异步复制的真实 RPO = 滞后 %ds + 检测 %ds + 决策 %ds + 提升 %ds = %ds\n",
lagSec, detect, decide, promote, lagSec+promoteAt)
fmt.Printf("按 %d TPS 算,这段时间的写入是 %d 条 —— 它们都被旧主确认过\n",
tps, (lagSec+promoteAt)*tps)
}
本机运行结果(GO111MODULE=off go run main.go,Go 1.27.1 darwin/arm64):
策略 丢失条数 冲突条数 被拒写条数 双主窗口
异步复制,无 fencing 850000 300000 0 60s
异步复制 + epoch fencing 550000 0 300000 0s
半同步(严格多数派) 0 0 750000 0s
半同步(10s 后降级异步) 700000 300000 50000 60s
异步复制的真实 RPO = 滞后 20s + 检测 30s + 决策 30s + 提升 30s = 110s
按 5000 TPS 算,这段时间的写入是 550000 条 —— 它们都被旧主确认过
四行读数:
- 异步裸奔:85 万丢失 = 55 万永久缺失 + 30 万分叉。 前 55 万是滞后加切换耗时那 110 秒的写,新主上根本没有;后 30 万只在旧主上,等旧主恢复回来就是两份数据。双主窗口 60 秒 = 新主可写的第 90 秒到旧主真正停写的第 150 秒。
- 加 fencing:丢失从 85 万降到 55 万,冲突清零,代价 30 万写被拒。 少丢的 30 万正是双主窗口内的写——从“静默分叉”变成“客户端拿到错误”。fencing 只解决脑裂,救不回滞后,那 55 万一条也救不回。
- 严格多数派:0 丢失、0 冲突,代价是 75 万写全部失败。 这就是“RPO=0 用可用性买”的具体价格:110 秒的写入能力归零,客户端看到全是超时。
- 半同步降级:70 万丢失 + 30 万冲突 + 60 秒双主窗口,比纯异步还差。 因为团队以为有 RPO=0,就没准备对账和补放流程;真丢时连丢了多少都没人知道。
反模式
| 反模式 | 后果 |
|---|---|
| 靠时间戳 LWW 合并资金 / 库存数据 | 时钟偏差窗口内的并发写静默被覆盖,账对不上且无痕迹 |
| 切换完成后立刻打开双写 | 两个主同时接写,冲突量按秒增长,对账脚本追不上 |
| 不校验就宣布恢复 | 把“不可用”换成“数据错”,用户三天后才发现 |
| fencing 用客户端时钟 / 时间戳判断新旧 | 时钟回拨或偏差导致旧主被认为“更新”,fencing 直接失效 |
| 旧主恢复后不做回卷直接重新加入 | 第二次脑裂,且这次是新主与旧主对撞 |
| 把 binlog 补齐交给人工 | 补放窗口过了就补不了(binlog 被 purge),人工一定会拖到过期 |
| 只比对行数不比对内容 | 丢一条、多一条刚好抵消,行数相等但数据已错 |
| 半同步降级后无人知晓 | 以为有 RPO=0,实际已是异步,故障时按错误预期决策 |
关联
- 前置:36.4 两地三中心与多活、36.5 单元化 / Set 化架构、18.2 同步、半同步、异步
- 并列:21.11 领导者租约、46.3 数据一致性分级
- 下游:44.13 主从复制与读写分离、25.4 幂等设计与去重
自检
Q1 一个系统用异步复制,RPO 标称 5 秒,检测 20 秒、决策 15 秒、提升 10 秒。真实 RPO 是多少?如果只优化一项,先优化哪个?
▸ 答案:真实 RPO = 5 + 20 + 15 + 10 = 50 秒,是标称值的 10 倍。因为标称的 5 秒只是“复制滞后”,而切换期间旧主还在接写,那段写也会丢。先优化检测(20 秒最大),其次是决策——这两项是流程和自动化问题,成本远低于把复制滞后从 5 秒压到 1 秒所需的带宽。
Q2 加了 epoch fencing 之后,异步复制的丢失条数从 85 万降到 55 万,为什么不是降到 0?
▸ 答案:fencing 只能拦住“新主已经可写之后”旧主的写。55 万是滞后 20 秒加切换 90 秒里的写,发生在 epoch 递增之前——那时旧主还是唯一能写的节点,写是合法的。要消掉这 55 万只能从复制模式下手(半同步/多数派),代价是写失败。fencing 治脑裂,复制模式治滞后,两件事。
Q3 有人提议:“切换时先给旧主发一个停止指令,等它确认了再提升新主。”这个方案的问题在哪?
▸ 答案:它把正确性押在“旧主能及时响应”上,而旧主恰好是状态不明的那个——可能 GC 停顿 30 秒、可能网络分区收不到指令、可能收到但卡在锁上。等它确认,等于把 RTO 交给一个不可用节点决定;不等它,确认就是自欺。正确做法是存储层按 epoch 拒绝:旧主配不配合都写不进去,新主不必等任何人点头。
Q4 切换后 5 项校验全部通过,能不能宣布恢复?还差什么?
▸ 答案:5 项校验只覆盖“库里的数据对不对”,不覆盖“客户端能不能正确用”。还差两件事:一是连接侧——旧连接池是否已重连到新主、DNS 是否全量生效(否则还有流量打到旧地址);二是行为侧——读己之写是否成立、重试是否幂等。校验通过只说明数据面干净,用户面可能还在持续写重复单据。另外抽样只抽 1000 条,样本外的错误仍在,宣布恢复时要说明这个置信边界。