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 故障检测与切换。
选主顺序(唯一正确顺序):
- 按 GTID 选最全的从库(
received最大),不是按负载最低、延迟最小、机器最新; - 先把它自己的 relay log 回放完(
received − applied的缺口),再开放写; - fencing 旧主:
super_read_only=ON、摘 VIP、必要时 STONITH——旧主只要还能写,就会脑裂(防护手段见 18.6 脑裂与防护,待补;判定「真死」的误判代价见 35.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 是“参数 + 告警 + 切换流程”一起给的。
关联
- 前置:18.2 同步、半同步、异步
- 并列:37.4 读写分离
- 下游:44.14 分库分表
- 支撑:35.4 故障检测