44.13 主从复制与读写分离

一句话

主从复制解决「数据有几份」,读写分离解决「读走哪份」;读的承诺由路由策略决定,写的承诺由复制模式决定——异步复制可能丢最近几毫秒到几秒的提交,半同步把 RPO 压到 0 却仍给不了「读到最新」——读写分离只能在读侧做文章,补不了写的持久性。

复制链路:三个线程、两段延迟

主库                                    从库
────                                    ────
用户 COMMIT
  │ 写 binlog(sync_binlog 决定何时落盘)
  ▼
binlog ──dump thread──发送──▶ IO thread ──写 relay log──▶ SQL thread ──回放──▶ 对读可见
        └──── 传输延迟 ────┘                └──────── 回放延迟 ────────┘
环节 谁在做 典型耗时 什么时候成为瓶颈
主库写 binlog 提交线程 0.1–1ms(sync_binlog=1 时含 fsync) 高并发小事务
传输 dump thread → IO thread 同机房 <1ms,跨城 30ms 跨地域、大事务
写 relay log IO thread <1ms 从库磁盘慢
回放 SQL thread / worker 单线程 2–5k 事务/s 大事务、并行度不足
从库可见延迟 ≈ 传输延迟 + relay log 积压量 / 回放速率

🔑 绝大多数「延迟几分钟」都不是网络问题,而是回放跟不上:积压量涨、回放速率不变,延迟就线性增长。加带宽没用,得加回放并行度或拆大事务。

binlog 三种格式:选错就是数据不一致

格式 记录什么 日志量 确定性 适用
STATEMENT 原始 SQL 最小(约为 ROW 的 1/10) ❌ NOW()、UUID()、RAND()、无 ORDER BY 的 LIMIT 在从库可能算出不同结果 基本淘汰
ROW 每行的 before / after image 最大 ✅ 回放是「按行赋值」,结果确定 默认,绝大多数场景
MIXED 默认 STATEMENT,非确定语句自动切 ROW 中 ⚠️ 取决于判定是否覆盖全 历史系统

量级算一遍:一张 1KB 宽的表更新 100 万行——

  • binlog_row_image=FULL:before + after ≈ 2GB binlog;
  • binlog_row_image=MINIMAL(只记主键与变更列):约 1GB;
  • 传输 2GB(100MB/s 链路)≈ 20 秒,但单线程回放 100 万行按 2000 行/秒算要 500 秒。

这就是「大事务」为什么是复制延迟的头号杀手:传输可以并行、可以加带宽,回放一个事务只能串行。

复制日志的三种形态(语句 / 行 / 逻辑)在数据库无关层面的对比,见 18.3 复制日志。

💡 ROW 也不是「绝对一致」的护身符:从库 read_only 没开、有人手工写了数据,或者用 sql_slave_skip_counter 跳过事务,都会让从库和主库分叉。格式解决的是「回放确定性」,不解决「人为破坏」。

半同步:RPO=0 的三个前提

模式 提交顺序 客户端看到提交时 主库崩溃后
异步 写 binlog → 返回客户端 → 后台发送 从库可能还没收到 丢最近未发送的事务
半同步 AFTER_COMMIT 写 binlog → 提交(对客户端可见)→ 等 ACK 从库可能还没收到 仍可能丢
半同步 AFTER_SYNC(5.7+) 写 binlog → 等 ACK → 提交 至少一个从库已收到 不丢(有前提)

⚠️ 很多人以为「开了半同步就是强一致」,其实 AFTER_COMMIT 版本和异步的丢失窗口一样,只是多了一次等待。必须确认是 AFTER_SYNC。

即使是 AFTER_SYNC,RPO=0 也有三个前提:

前提 不满足会怎样
ACK 的那个从库不一起挂 主从同机架 / 同可用区,一起断电就丢
relay log 真正落盘 sync_relay_log 很大时 ACK 只代表「在页缓存里」,OS 崩溃仍丢
没有超时退化 rpl_semi_sync_master_timeout(默认 10s)触发后自动退化为异步,此时 RPO>0 且没人告警

同步、半同步、异步的一般性权衡(延迟、可用性、持久性三者的取舍)见 18.2 同步、半同步、异步。

代价是写延迟:同城 RTT 0.5–2ms → 每条提交多一次往返,P99 上升 0.5–2ms;跨城 30ms → 写吞吐直接掉一个数量级(组提交可以摊薄,但摊不掉单次 RTT)。

🔑 半同步只保证「至少一个副本收到」,不保证「已回放」。所以它保护的是持久性,不是读一致性——从库照样可能读到旧值。

GTID:切换时不再算 binlog 位点

GTID = server_uuid:transaction_id      例:3E11FA47-...:1-1000
能力 没有 GTID 有 GTID
从库定位 记 MASTER_LOG_FILE + POS,人工算 SOURCE_AUTO_POSITION=1 自动找
主库切换 每个从库手算新位点,易错 全部 CHANGE REPLICATION SOURCE TO 指向新主即可
一致性校验 只能比对行数 WAIT_FOR_EXECUTED_GTID_SET / 比较 gtid_executed
故障恢复 旧主恢复后要人工对位点 看 GTID 集合差,能精确知道缺哪些事务

代价:gtid_purged 之后,落后太多的从库无法增量追,只能重做;enforce_gtid_consistency=ON 会禁用 CREATE TABLE ... SELECT 这类语句。

故障切换:选谁、丢多少、多久能写

自动切换的完整流程(故障检测 → 选主 → 提升 → 流量重定向)见 18.5 故障检测与切换。

选主顺序(唯一正确顺序):

  1. 按 GTID 选最全的从库(received 最大),不是按负载最低、延迟最小、机器最新;
  2. 先把它自己的 relay log 回放完(received − applied 的缺口),再开放写;
  3. fencing 旧主:super_read_only=ON、摘 VIP、必要时 STONITH——旧主只要还能写,就会脑裂(防护手段见 18.6 脑裂与防护,待补;判定「真死」的误判代价见 35.4 故障检测);
  4. 其他从库改指向新主(SOURCE_AUTO_POSITION=1),客户端 / 代理切换入口。

丢多少:

异步复制   丢失 ≤ committed − max(received)   ← 通常是最近 1ms–数秒的提交
半同步     丢失 = 0(三个前提都满足时)

可运行:异步 vs 半同步,切换时到底丢几条

主库 200ms 内提交 400 条(2000 事务/s),三个从库:A 回放 3 条/ms、B 2 条/ms、C 1 条/ms(C 的回放能力只有写入速度的一半,越落越远)。

package main

import "fmt"

// 一个 tick = 1ms;主库每个 tick 提交 2 条事务,共 200 个 tick = 400 条。
const (
	ticks     = 200
	perTick   = 2
	committed = ticks * perTick // 400
)

type replica struct {
	name     string
	lagTx    int // 网络延迟,折算成「落后多少条事务」
	rate     int // 每个 tick 能回放多少条事务
	received int // 已写入 relay log
	applied  int // 已回放(对读可见)
}

func newReplicas() []*replica {
	return []*replica{
		{name: "A", lagTx: 4, rate: 3},
		{name: "B", lagTx: 10, rate: 2},
		{name: "C", lagTx: 2, rate: 1}, // 回放能力只有写入速度的一半,越落越远
	}
}

// step 推进一个 tick:先投递(受网络延迟限制),再回放(受回放速率限制)。
// semi=true 时,主库等到「最先 ACK 的那个从库」才返回,所以只有它保证收到了全部事务,
// 其他从库该滞后还是滞后——这正是「有副本收到 ≠ 每个副本都追平」。
func step(reps []*replica, now int, semi bool) {
	sent := (now + 1) * perTick
	fastest := reps[0]
	for _, r := range reps[1:] {
		if r.lagTx < fastest.lagTx {
			fastest = r
		}
	}
	for _, r := range reps {
		if semi && r == fastest {
			r.received = sent // ACK 语义:这个从库已把 relay log 落盘
			continue
		}
		if v := sent - r.lagTx; v > r.received {
			r.received = v
		}
	}
	for _, r := range reps {
		if r.received-r.applied > r.rate {
			r.applied += r.rate
		} else {
			r.applied = r.received
		}
	}
}

// failover 选「GTID 最全」的从库:先比 received,再比 applied。
func failover(reps []*replica) *replica {
	best := reps[0]
	for _, r := range reps[1:] {
		if r.received > best.received || (r.received == best.received && r.applied > best.applied) {
			best = r
		}
	}
	return best
}

func run(semi bool) {
	reps := newReplicas()
	for now := 0; now < ticks; now++ {
		step(reps, now, semi)
	}
	fmt.Println("--------------------------------------------------")
	if semi {
		fmt.Println("半同步复制:主库等 1 个从库 ACK 才返回(每条 +1 次 RTT)")
	} else {
		fmt.Println("异步复制:主库提交即返回,不等从库")
	}
	for _, r := range reps {
		fmt.Printf("  从库%s:received=%-3d applied=%-3d(回放速率 %d 条/tick)\n",
			r.name, r.received, r.applied, r.rate)
	}
	newPrimary := failover(reps)
	loss := committed - newPrimary.received
	backlog := newPrimary.received - newPrimary.applied
	fmt.Printf("  选 GTID 最全的从库%s 提升为新主:丢失 %d 条,切换后需先回放 %d 条才能对外服务\n",
		newPrimary.name, loss, backlog)
	fmt.Printf("  其他从库改指向新主后,还需回放各自缺口:")
	for _, r := range reps {
		if r != newPrimary {
			fmt.Printf(" %s=%d 条", r.name, newPrimary.received-r.applied)
		}
	}
	fmt.Println()
}

func main() {
	run(false)
	run(true)
	fmt.Println("--------------------------------------------------")
	fmt.Println("异步:RPO = 丢失 2 条(约 1ms 的写入);半同步:RPO = 0,代价是每条提交多一次 RTT。")
	fmt.Println("注意半同步只保证「有副本收到」,不保证「已回放」——切换时仍要先追 relay log。")
}

输出:

--------------------------------------------------
异步复制:主库提交即返回,不等从库
  从库A:received=396 applied=396(回放速率 3 条/tick)
  从库B:received=390 applied=390(回放速率 2 条/tick)
  从库C:received=398 applied=199(回放速率 1 条/tick)
  选 GTID 最全的从库C 提升为新主:丢失 2 条,切换后需先回放 199 条才能对外服务
  其他从库改指向新主后,还需回放各自缺口: A=2 条 B=8 条
--------------------------------------------------
半同步复制:主库等 1 个从库 ACK 才返回(每条 +1 次 RTT)
  从库A:received=396 applied=396(回放速率 3 条/tick)
  从库B:received=390 applied=390(回放速率 2 条/tick)
  从库C:received=400 applied=200(回放速率 1 条/tick)
  选 GTID 最全的从库C 提升为新主:丢失 0 条,切换后需先回放 200 条才能对外服务
  其他从库改指向新主后,还需回放各自缺口: A=4 条 B=10 条
--------------------------------------------------
异步:RPO = 丢失 2 条(约 1ms 的写入);半同步:RPO = 0,代价是每条提交多一次 RTT。
注意半同步只保证「有副本收到」,不保证「已回放」——切换时仍要先追 relay log。

三个结论:

  • 异步丢 2 条(GTID 398 vs 提交 400)。这 2 条是「客户端已经收到成功、但还没到任何从库」的事务——用户看到下单成功,切换后订单不存在。
  • 半同步丢 0 条,代价是每条提交多一次 ACK 往返;它把「丢 2 条」换成「写 P99 高 1 个 RTT」。注意:ACK 只来自最快的那个从库(C),它收到了全部 400 条,但回放仍落后 200 条——RPO=0 不代表切换后立刻能写。
  • GTID 最全 ≠ 立即可用:C 数据最全,但回放落后 199 条,切换后要先把 relay log 追完才能写;若改选「已回放最多」的 A,只丢 4 条但秒级可写。这是 RPO 与 RTO 的交换,必须由业务拍板,不能由运维按经验选。

延迟怎么调:四个旋钮

旋钮 做法 效果
并行回放 LOGICAL_CLOCK(按组提交并行)/ WRITESET(按行冲突检测),replica_parallel_workers=8 从 2–5k tx/s 提到约 2 万 tx/s
拆大事务 100 万行的 UPDATE 拆成 1000 行 × 1000 批 延迟从「尖峰 500s」变成「每批 0.5s」,可观测、可限流
减小 binlog binlog_row_image=MINIMAL 日志量减半,传输与回放都受益
从库硬件 回放是写密集;从库 IOPS 不低于主库 避免回放线程等 fsync

⚠️ replica_parallel_workers(旧名 slave_parallel_workers)不是越大越好:并行度受「主库组提交并发度」限制,设成 64 但主库每批只有 2 个事务,多出来的线程只增加调度开销。先看主库的组提交大小,再定并行度。

上线配置:source / replica 各一份

术语:MySQL 8.0.26+ 把 SLAVE 改名 REPLICA、MASTER 改名 SOURCE(SHOW SLAVE STATUS → SHOW REPLICA STATUS)。下文用新名,旧版本按对映关系替换。

# source(主库):/etc/my.cnf.d/replication.cnf
[mysqld]
server_id = 1
log_bin = /var/lib/mysql/binlog
binlog_format = ROW
binlog_row_image = FULL
gtid_mode = ON
enforce_gtid_consistency = ON
sync_binlog = 1
innodb_flush_log_at_trx_commit = 1
plugin-load-add = semisync_source.so
rpl_semi_sync_source_enabled = ON
rpl_semi_sync_source_timeout = 1000
rpl_semi_sync_source_wait_for_replica_count = 1
# replica(从库)
[mysqld]
server_id = 2
log_bin = /var/lib/mysql/binlog
relay_log = /var/lib/mysql/relay-bin
read_only = ON
super_read_only = ON
gtid_mode = ON
enforce_gtid_consistency = ON
sync_binlog = 1
innodb_flush_log_at_trx_commit = 1
log_replica_updates = ON
plugin-load-add = semisync_replica.so
rpl_semi_sync_replica_enabled = ON
replica_parallel_workers = 8
replica_preserve_commit_order = ON

两份都可以先用 mysqld --defaults-file=... --validate-config 校验(本机 26.7 实测通过),再重启。

参数 值 依据 配错的后果
sync_binlog + innodb_flush_log_at_trx_commit 双 1 每次提交都落盘,主库本地才可能不丢 掉电丢已 ACK 事务,半同步也救不了
binlog_format + binlog_row_image ROW + FULL 非确定函数/触发器下 STATEMENT 会分叉;MINIMAL 省日志但缺前镜像 主从悄悄不一致,几个月后才发现
gtid_mode + enforce_gtid_consistency ON + ON 切换按 GTID 自动定位,不用算位点 故障切换靠位点,错一位就丢/重放
rpl_semi_sync_source_timeout 1000ms 同城 RTT < 2ms,1s 足够;超时退化为异步 太长拖慢写入;太短频繁退化
rpl_semi_sync_source_wait_for_replica_count 1 至少 1 个从库确认;三副本可设 2(多数派) 设 0 = 半同步形同虚设;设 2 但只有 2 副本 = 永远等不到
replica_parallel_workers 8 受主库组提交并发度限制,先看组提交大小再定 设 64 而主库每批只有 2 个事务,只增加调度开销
replica_preserve_commit_order ON 并行回放仍要按主库提交顺序生效 从库顺序错乱,逻辑备份/切换出错
read_only + super_read_only 都 ON 挡住人为写从库 双写分叉,复制中断
# 复制状态:两个 Running 必须是 Yes;延迟别只看 Seconds_Behind_Source
mysql -e "SHOW REPLICA STATUS\G" | grep -E \
  "Replica_IO_Running|Replica_SQL_Running|Seconds_Behind_Source|Retrieved_Gtid_Set|Executed_Gtid_Set"

# 半同步还生效吗:status=ON 且 clients>=1;no_tx 持续增长 = 已退化为异步
mysql -e "SHOW STATUS LIKE 'Rpl_semi_sync_source_status'; SHOW STATUS LIKE 'Rpl_semi_sync_source_clients'; SHOW STATUS LIKE 'Rpl_semi_sync_source_no_tx';"

切换时先确认 Executed_Gtid_Set 已追平,再提升从库:

STOP REPLICA;
RESET REPLICA ALL;                 -- 清掉旧复制关系(会删 relay log,确认已回放完)
SET GLOBAL read_only = OFF;
SET GLOBAL super_read_only = OFF;

-- 旧主降级成从库,按 GTID 自动定位
CHANGE REPLICATION SOURCE TO SOURCE_HOST='10.0.0.12', SOURCE_AUTO_POSITION=1;
START REPLICA;

回滚路径:切换前先把旧主 SET GLOBAL read_only = ON(防双写);新主起不来就把它改回 OFF 恢复写,同时把新主按从库接回。没有这一步,切换失败就是两台都能写。

读写分离:复制模式给它划了三条边界

读写分离的路由方式、读一致性四档策略(全读从库 / 写后读主 / 位点等待 / 强制读主)、Seconds_Behind_Source(旧名 Seconds_Behind_Master)为什么不可信,都在 37.4 读写分离——那是它的主场。本节只补复制机制给它划的边界:

复制模式 RPO(能丢多少) 读一致性 写延迟 适用
异步 最近 1ms–数秒的提交 可能读到旧值 最低 大多数业务
半同步 ≈0(三个前提) 仍可能读到旧值 +0.5–2ms(同城) 订单、支付
组复制 (MGR) 0(多数派认证) 仍可能旧(除非读主) 最慢多数派的 RTT 金融核心

🔑 三条边界:① 读可能旧(由路由策略兜,见 37.4);② 写后立刻读可能读不到(由写后读主 / GTID 位点等待兜);③ 主库故障可能丢最近 N 条写(由复制模式兜,且只有半同步 / 组复制能兜住)。前两条是「读的问题」,第三条是「写的问题」——读写分离只能放大读能力,永远补不了写的持久性。

写能力本身不够时(单主写满),下一步不是加从库,而是分库分表(见 44.14 分库分表)或换分布式数据库。

复制与 WAL 的关系

binlog 是逻辑/行级的「做了什么」,InnoDB redo log 是物理的「页怎么改」。崩溃恢复靠 redo(见 44.2 WAL 与崩溃恢复,待补),主从复制靠 binlog;两阶段提交(redo prepare → binlog → redo commit)保证「binlog 里有的,主库自身也一定能恢复出来」——这是复制不丢数据的前提。

反模式

反模式 后果
用 STATEMENT 格式 + 非确定函数 主从数据悄悄分叉,几个月后才发现
以为「开了半同步」就是 AFTER_SYNC AFTER_COMMIT 下仍会丢已确认的事务
切换时选「负载最低 / 延迟最小」的从库 丢已提交数据,且切换后还要补数据
双主(主主)不加 fencing 脑裂双写,两份数据都「合法」
sync_relay_log 很大却宣称 RPO=0 主机断电丢 ACK 过的事务
半同步超时退化没有告警 已退化为异步,RPO 悄悄变大
binlog 保留 3 天,全备 7 天一次 恢复时 binlog 已被 purge,只能恢复到 3 天前
从库 read_only 没开 人为写入导致分叉
100 万行的大事务一次性提交 从库回放 500 秒,期间所有读都是旧数据
只监控 Seconds_Behind_Source 从库空闲时恒为 0,事故时监控全绿

自检

Q1 同事说「我们开了半同步,所以主库挂了也不会丢数据」。要让他补充哪三个前提?

▸ 答案:① 必须是 AFTER_SYNC——AFTER_COMMIT 是「先提交再等 ACK」,客户端看到提交后主库崩溃仍会丢;② ACK 的那个从库不能和主库一起挂——同机架、同可用区、同电源的从库不算数,RPO=0 要求故障域独立;③ relay log 必须真正落盘(sync_relay_log=1 级别),否则 ACK 只代表「进了页缓存」,OS 崩溃照样丢。外加一条运维前提:超时退化要告警,rpl_semi_sync_master_timeout 触发后系统已悄悄退化为异步。

Q2 主库宕机。C 从库 GTID 最全(收到 398 条,主库已提交 400 条),但回放落后 199 条;A 从库回放最新(396 条),比 C 少 2 条事务。你选谁?

▸ 答案:选 C。已经提交给客户端的数据要尽量少丢:选 C 丢 2 条,选 A 丢 4 条——A 少的那 2 条也是客户端收到过「成功」的事务。代价是 C 要先把 relay log 回放完(199 条、按 1 条/ms 约 200ms)才能开放写,RTO 增加 200ms。只有业务明确接受「多丢 2 条换 200ms」时才选 A,且必须留下数据差异清单人工补偿。判据是 RPO 要求,不是谁恢复得快。

Q3 复制延迟 30 分钟,但从库 CPU 只有 10%、主库 CPU 70%。问题最可能在哪?

▸ 答案:不在算力,在串行。CPU 低说明回放线程大部分时间在等:等一个大事务回放完(单事务无法并行)、等锁、等磁盘 fsync,或者并行 worker 数远小于主库的组提交并发度。排查顺序:① 看 relay log 积压量与 Slave_SQL_Running_State 卡在哪个事务;② 找 binlog 里最大的事务(行数 × 行长);③ 看主库组提交大小与 replica_parallel_workers 是否匹配。加 CPU、加带宽都无效——回放的瓶颈是「一个事务只能由一个线程回放」。

Q4 binlog 保留期应该设多长?和备份策略是什么关系?

▸ 答案:binlog 保留期 ≥ 全备周期 + 最长恢复演练时间。恢复链路是「最近一次全备 + 从该全备位点重放 binlog 到目标时刻」,若全备 7 天一次,binlog 至少要留 8 天,否则最坏情况下只能恢复到 7 天前的全备点,RPO 从「秒级」变成「天级」。同时要校验:gtid_purged 不能超过全备的起点,否则从库无法增量追。云数据库的「自动备份 + binlog 备份」要一起看,别只看备份保留天数。

Q5 为什么说「读写分离解决不了写瓶颈」?什么情况下才轮到分库分表?

▸ 答案:读写分离只是把读分散到多个从库,写仍然打在唯一主库上:主库写 TPS 的上限(单机 5k–2 万 TPS 量级,取决于行大小与索引数)不变。判据:主库写 CPU 持续 >70%、单表 > 数千万行且 DDL 窗口不可接受、单库磁盘 >2TB、备份恢复超过 RTO——满足其中两条,才考虑分库分表(见 44.14 分库分表)。先确认瓶颈是「读多」还是「写多」:读多→读写分离 + 缓存;写多→分片。

Q6 主库 sync_binlog=1、innodb_flush_log_at_trx_commit=1、半同步也开了,能保证 RPO=0 吗?

▸ 答案:只能保证“主库本地不丢 + 至少 1 个从库确认收到”。还差四个缺口:① 半同步是 AFTER_SYNC 还是 AFTER_COMMIT(后者会丢已确认事务);② 超时退化为异步后没有告警;③ 从库侧落盘与回放进度(sync_binlog、sync_relay_log、Executed_Gtid_Set);④ 切换时选了没追平的从库。RPO=0 是“参数 + 告警 + 切换流程”一起给的。

关联