36.6 切换时的数据一致性

一句话

切换时的不一致只有两个来源——两个主都以为自己是主(脑裂)、新主不知道旧主写到哪(位点丢失);解法是 fencing 加位点,不是“切换前多同步几次”。

三个流行说法先拆掉

误区 现实
切换前多同步几次,丢的就少了 丢失窗口 = “最后一次复制成功”到“新主可写”,长度由滞后 + 检测 + 决策 + 提升四段决定,与同步次数无关。多同步几次只压第一段,压不掉后三段
RPO=0 就不会丢数据 RPO=0 只保证“已提交的不丢”,代价是“切的时候写不进去”:多数派模式下旧主凑不齐 quorum,写全部失败,用户看到的是报错
旧主恢复后接回去就行 旧主带着一段新主没有的历史回来,接回去就是第二次脑裂,而且这次是你主动造成的

切换时只有三种数据状态

状态 怎么产生 丢什么 怎么发现 怎么修
已提交未复制 主库本地 commit 已返回客户端,备库还没收到(异步) 滞后那一段;客户端看到的是成功 比位点:备库的 GTID 集合 / LSN 落后主库多少 从旧主 binlog 补放;补不了按流水对账
已复制未提交 写已进备库 redo,但旧主没收到足够 ack 就超时,客户端拿到的是错误 “客户端以为失败、库里却有”的那几条 拿客户端请求 ID / 幂等键回查 只能靠幂等重试,重放会变成成功两次
两边都写了 分区期间两个主各自接受写(多主 / 双写 / 脑裂) 被覆盖的那一版,以及“谁是对的”这个判断本身 按主键比版本号、checksum 不相等 冲突解决或业务补偿,不能自动合并

第三种最贵:前两种是“数据少了”,第三种是“数据有两个版本”——少的能补,两份要拍板。

位点:新主必须从旧主的最后位点接上

三条规矩,违背任何一条都会造成第二种状态:

  1. 位点要在冻结旧主写之后读。 否则读到的是移动靶:读到 LSN 100 的瞬间,旧主已写到 108。
  2. 要“最后一条已确认写”的位点,不是“最后一条已复制”的位点。 两者之差正是“已提交未复制”那部分,也正是要补的。
  3. 位点载体不同,接法不同。
位点载体 系统 怎么读 切换时怎么接
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 三条规矩:

  1. 单调递增,永不回收。 用 Raft 的 term 或一个单调计数器,不能用时间戳(时钟会回拨)。
  2. 每次提升必须 +1,且在允许新主写之前完成。 顺序反了就等于没有。
  3. 旧主的每一次写都带 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 条 —— 它们都被旧主确认过

四行读数:

  1. 异步裸奔:85 万丢失 = 55 万永久缺失 + 30 万分叉。 前 55 万是滞后加切换耗时那 110 秒的写,新主上根本没有;后 30 万只在旧主上,等旧主恢复回来就是两份数据。双主窗口 60 秒 = 新主可写的第 90 秒到旧主真正停写的第 150 秒。
  2. 加 fencing:丢失从 85 万降到 55 万,冲突清零,代价 30 万写被拒。 少丢的 30 万正是双主窗口内的写——从“静默分叉”变成“客户端拿到错误”。fencing 只解决脑裂,救不回滞后,那 55 万一条也救不回。
  3. 严格多数派:0 丢失、0 冲突,代价是 75 万写全部失败。 这就是“RPO=0 用可用性买”的具体价格:110 秒的写入能力归零,客户端看到全是超时。
  4. 半同步降级:70 万丢失 + 30 万冲突 + 60 秒双主窗口,比纯异步还差。 因为团队以为有 RPO=0,就没准备对账和补放流程;真丢时连丢了多少都没人知道。

反模式

反模式 后果
靠时间戳 LWW 合并资金 / 库存数据 时钟偏差窗口内的并发写静默被覆盖,账对不上且无痕迹
切换完成后立刻打开双写 两个主同时接写,冲突量按秒增长,对账脚本追不上
不校验就宣布恢复 把“不可用”换成“数据错”,用户三天后才发现
fencing 用客户端时钟 / 时间戳判断新旧 时钟回拨或偏差导致旧主被认为“更新”,fencing 直接失效
旧主恢复后不做回卷直接重新加入 第二次脑裂,且这次是新主与旧主对撞
把 binlog 补齐交给人工 补放窗口过了就补不了(binlog 被 purge),人工一定会拖到过期
只比对行数不比对内容 丢一条、多一条刚好抵消,行数相等但数据已错
半同步降级后无人知晓 以为有 RPO=0,实际已是异步,故障时按错误预期决策

关联

自检

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 条,样本外的错误仍在,宣布恢复时要说明这个置信边界。