☰
数据库高可用与容灾实战(3):半同步复制与无损切换:RPO=0 能不能做到
2026/9/28 18:19:42 网站建设 项目流程

本篇问题

上一篇用 GTID 把切换的账算清楚了:账能对上,最多是"知道丢了什么";要"根本不丢",就得改变复制的确认语义——让事务在对客户端宣告提交之前,先确保二进制日志在远端活了下来。这就是半同步复制的立身之本,也是"RPO=0"这个承诺在 MySQL 体系里的唯一正统路径。但承诺有保质期:ACK 等不到会降级、ack 点选错会幽灵提交、本机落盘参数不牢则一切归零。本篇把这三个字面拆开:半同步的机制与时延代价、降级窗口的真实大小、以及"无损切换"还需要哪些配套件。

机制:把一次提交变成长途旅行

半同步(semisynchronous replication)在主库提交路径上插了一个等待点:事务的 binlog 写入并按sync_binlog落盘后,主库不立刻做引擎层提交,而是等至少rpl_semi_sync_master_wait_for_slave_count个副本的 ACK——副本 IO 线程把事件写进 relay log 并落盘,沿链路把"收到了"回传给主库的 semisync 等待器。等待有超时rpl_semi_sync_master_timeout(毫秒):超时不是失败,而是放行并整体退回异步,直到有 ACK 再次到达才恢复半同步。8.0 时代 AFTER_SYNC 是唯一模式(AFTER_COMMIT 被废弃),正是因为 AFTER_COMMIT 的 ack 点在引擎提交之后,主库回滚时副本已收到,会产生"客户端看到回滚、从库却存在"的幽灵事务——这个坑在后面的持久性矩阵里还会再见。

代价是三重的。其一,提交路径多出至少一个"主库落盘 + 网络往返 + 从库落盘 + 网络返程",同机房约 1~4 毫秒,跨城按 RTT 成倍放大;组提交会把同一批事务的等待合并,摊薄但摊不掉。其二,等待占住连接与会话,ACK 抖动直接表现为业务提交毛刺。其三,保护强度取决于"谁在 ACK":只要 ACK 来源失联,整条链就退回异步,而这恰恰是最需要保护的时刻。

实验一:降级是无声的,丢的是降级之后的全部

下面模拟 1000 txn/s 的稳定提交流:前半段半同步健康;10 秒时唯一 ACK 副本失联;13 秒时主库整机报废。看三个阶段——正常期的提交时延、超时降级瞬间、报废时刻的未保护集合。

RATE=1000.0# 业务提交速率 txn/sRTT=0.0015# 机房内单程网络时延(秒)RELAY_FSYNC=0.0004# 从库 IO 线程 relay log 落盘BINLOG_FSYNC=0.0002# 主库 binlog 落盘(sync_binlog=1)TIMEOUT=0.1# rpl_semi_sync_master_timeout 秒T_DIE=10.0# 从库此刻失联T_CRASH=13.0# 主库此刻整机报废ACK_ROUND=BINLOG_FSYNC+RTT+RELAY_FSYNC+RTT# AFTER_SYNC 一轮确认rows=[]semi_on=Trueforiinrange(int(RATE*T_CRASH)):arrive=i/RATEifsemi_onandarrive+ACK_ROUND<=T_DIE:# 正常半同步: 提交前拿到 ackdone,acked=arrive+ACK_ROUND,Trueelifsemi_onandarrive<=T_DIE+TIMEOUT:# 失联瞬间在途的事务: 一起卡到超时done,acked=T_DIE+TIMEOUT+BINLOG_FSYNC,Falsesemi_on=False# 超时触发, 半同步整体关闭else:# 降级为异步done,acked=arrive+BINLOG_FSYNC,Falserows.append((arrive,done,acked))print("异步提交往返 %.4fs; 半同步 AFTER_SYNC 提交往返 %.4fs (放大 %.0f 倍)"%(BINLOG_FSYNC,ACK_ROUND,ACK_ROUND/BINLOG_FSYNC))acked=[rforrinrowsifr[2]]blocked=[rforrinrowsifnotr[2]andr[0]<=T_DIE+TIMEOUT]asyncd=[rforrinrowsifnotr[2]andr[0]>T_DIE+TIMEOUT]print("%.1fs 从库失联: %.3fs 起第一批卡满 %.1fs 超时, 半同步 -> OFF"%(T_DIE,blocked[0][1],TIMEOUT))print("此后 %d 笔以异步延迟 %.4fs 提交 —— 业务无感, 但已不受保护"%(len(asyncd),BINLOG_FSYNC))lost=len(blocked)+len(asyncd)print("%.1fs 主库报废: 已 ack %d 笔安全; 未受保护 %d 笔 -> 实际 RPO = %.2f 秒写入量"%(T_CRASH,len(acked),lost,lost/RATE))BS_ROUND=BINLOG_FSYNC+2*0.0008# binlog server 独立落盘+回 ackbs_ok=[rforrinrowsifr[0]+BS_ROUND<=T_CRASH]print("\n加一台 binlog server(第三接收方, 确认往返 %.4fs):"%BS_ROUND)print(" 主库报废瞬间, %d/%d 笔已双写持久化, 在途仅 %d 笔"%(len(bs_ok),len(rows),len(rows)-len(bs_ok)))print(" 切换流程: 先等 binlog server 收口 -> 把新主缺的 GTID 段补放 -> 集群 RPO 回到 0")print(" 注意: AFTER_SYNC 的 ack 点是 relay log 落盘, 从库自己的 InnoDB 还没跑完, 读旧快照需另行处理")

运行输出:

异步提交往返 0.0002s; 半同步 AFTER_SYNC 提交往返 0.0036s (放大 18 倍) 10.0s 从库失联: 10.100s 起第一批卡满 0.1s 超时, 半同步 -> OFF 此后 2899 笔以异步延迟 0.0002s 提交 —— 业务无感, 但已不受保护 13.0s 主库报废: 已 ack 9997 笔安全; 未受保护 3003 笔 -> 实际 RPO = 3.00 秒写入量 加一台 binlog server(第三接收方, 确认往返 0.0018s): 主库报废瞬间, 12999/13000 笔已双写持久化, 在途仅 1 笔 切换流程: 先等 binlog server 收口 -> 把新主缺的 GTID 段补放 -> 集群 RPO 回到 0 注意: AFTER_SYNC 的 ack 点是 relay log 落盘, 从库自己的 InnoDB 还没跑完, 读旧快照需另行处理

三个结论。第一,半同步不是免费的:同机房一次确认往返 3.6 毫秒,是异步路径的 18 倍,跨城部署时这个数字要按 RTT 重新算一遍账。第二,降级是无声的:超时后半同步状态变 OFF,提交延迟立刻回到 0.2 毫秒,业务监控看不出任何异常,但从此每一笔提交都在裸奔——本例 3 秒的裸奔窗口就是 3 秒的 RPO。所以Rpl_semi_sync_master_status(8.0.24 起为Rpl_semi_sync_source_status)变 OFF 必须按 P1 级告警处理,而不是仪表盘上的一个绿灯装饰。第三,把 ACK 来源从"某台从库"换成"专职 binlog server / 第二机房接收方"后,同一灾难场景的在途未确认事务只剩 1 笔:RPO 的大小不取决于你开了半同步,而取决于 ACK 背后有几个独立的持久化落点。

实验二:先保证自己家不着火——本地持久性矩阵

半同步只解决"binlog 送到别处"这一半;另一半是"主库自己宣告提交的事务,重启后必须还在"。事务活着同时依赖两份日志:InnoDB redo 与 binlog,两把fsync旋钮(innodb_flush_log_at_trx_commit与sync_binlog)组成九宫格。下面把断电与进程崩溃两种灾难分别扫一遍。

RATE=1000.0# 业务提交速率 txn/sOS_BINLOG_LOSS=30.0# sync_binlog=0 时 binlog 留在 OS 缓存的保守损失窗口(秒)defredo_win(mode,crash):ifmode==1:return0.0# 每次提交 fsync redoifmode==2:return1.0ifcrash=="断电"else0.0# 提交时写入 OS 文件不 fsync: 只惧断电return1.0# mode 0: redo 每秒刷盘, 进程/断电都丢约 1sdefbinlog_win(n):return0.0ifn==1else(OS_BINLOG_LOSSifn==0elsen/RATE)print("一笔事务要算'已提交', binlog 与 redo 必须同时活过灾难: 损失窗口=max(两者)")forcrashin("断电","mysqld 进程崩溃"):print("\n[%s]"%crash)forsbin(1,100,0):fortrxin(1,2,0):r,b=redo_win(trx,crash),binlog_win(sb)lost=max(r,b)diverge=r>b# binlog 比 redo 活得久: 副本可能已有、主库将回滚tag=" <-- 本地 RPO=0 的唯一组合"iflost==0andnotdivergeelse""print(" sync_binlog=%-3d redo_per_commit=%d -> 丢至多 %4.1fs 流量; 主从分叉风险: %s%s"%(sb,trx,lost,"有(binlog 领先 redo)"ifdivergeelse"无",tag))print("\n推论:")print(" 1) 1/1 只是'本地 RPO=0'的入场券, 机房整体报废仍要靠复制把 binlog 送到别处")print(" 2) sync_binlog=1 + redo=2 是隐蔽炸弹: 半同步 AFTER_SYNC 在 binlog 落盘后即 ack,")print(" 断电后主库回滚这些事务, 副本却已收到 -> 比丢数据更糟的幽灵提交")print(" 3) 从库侧同理: relay log / binlog 与 InnoDB 的落盘策略决定'ack 了是否真的在'")

运行输出:

一笔事务要算'已提交', binlog 与 redo 必须同时活过灾难: 损失窗口=max(两者) [断电] sync_binlog=1 redo_per_commit=1 -> 丢至多 0.0s 流量; 主从分叉风险: 无 <-- 本地 RPO=0 的唯一组合 sync_binlog=1 redo_per_commit=2 -> 丢至多 1.0s 流量; 主从分叉风险: 有(binlog 领先 redo) sync_binlog=1 redo_per_commit=0 -> 丢至多 1.0s 流量; 主从分叉风险: 有(binlog 领先 redo) sync_binlog=100 redo_per_commit=1 -> 丢至多 0.1s 流量; 主从分叉风险: 无 sync_binlog=100 redo_per_commit=2 -> 丢至多 1.0s 流量; 主从分叉风险: 有(binlog 领先 redo) sync_binlog=100 redo_per_commit=0 -> 丢至多 1.0s 流量; 主从分叉风险: 有(binlog 领先 redo) sync_binlog=0 redo_per_commit=1 -> 丢至多 30.0s 流量; 主从分叉风险: 无 sync_binlog=0 redo_per_commit=2 -> 丢至多 30.0s 流量; 主从分叉风险: 无 sync_binlog=0 redo_per_commit=0 -> 丢至多 30.0s 流量; 主从分叉风险: 无 [mysqld 进程崩溃] sync_binlog=1 redo_per_commit=1 -> 丢至多 0.0s 流量; 主从分叉风险: 无 <-- 本地 RPO=0 的唯一组合 sync_binlog=1 redo_per_commit=2 -> 丢至多 0.0s 流量; 主从分叉风险: 无 <-- 本地 RPO=0 的唯一组合 sync_binlog=1 redo_per_commit=0 -> 丢至多 1.0s 流量; 主从分叉风险: 有(binlog 领先 redo) sync_binlog=100 redo_per_commit=1 -> 丢至多 0.1s 流量; 主从分叉风险: 无 sync_binlog=100 redo_per_commit=2 -> 丢至多 0.1s 流量; 主从分叉风险: 无 sync_binlog=100 redo_per_commit=0 -> 丢至多 1.0s 流量; 主从分叉风险: 有(binlog 领先 redo) sync_binlog=0 redo_per_commit=1 -> 丢至多 30.0s 流量; 主从分叉风险: 无 sync_binlog=0 redo_per_commit=2 -> 丢至多 30.0s 流量; 主从分叉风险: 无 sync_binlog=0 redo_per_commit=0 -> 丢至多 30.0s 流量; 主从分叉风险: 无 推论: 1) 1/1 只是'本地 RPO=0'的入场券, 机房整体报废仍要靠复制把 binlog 送到别处 2) sync_binlog=1 + redo=2 是隐蔽炸弹: 半同步 AFTER_SYNC 在 binlog 落盘后即 ack, 断电后主库回滚这些事务, 副本却已收到 -> 比丢数据更糟的幽灵提交 3) 从库侧同理: relay log / binlog 与 InnoDB 的落盘策略决定'ack 了是否真的在'

矩阵里最扎眼的是第二行:sync_binlog=1配redo=2在断电下既丢 1 秒又制造分叉——binlog 活得比 redo 久,副本(或 binlog server)拿着主库准备回滚的事务当宝贝。工程上还要补一句:从库被提升的那一刻,它的gtid_executed只能代表"relay log 收到了",SQL 线程还没回放的部分要靠新主自己追;若从库也配了log_slave_updates + sync_binlog=1,这些事务才算真正双落盘。无损切换从来不是单个参数的胜利,而是主库、链路、从库三段持久性的短板效应。

那到底能不能承诺 RPO=0

把上面的实验拼成一句话:在"至少两个独立持久化落点 + 提交路径等待多数确认 + 任一落点故障可从另一落点继续"同时成立时,RPO=0 才成立。半同步 + binlog server 是工程上的近似解;把确认语义升级为多数派(Paxos/Raft 类共识,如 Group Replication/XPaxos 或外部共识存储)是更严格的解,代价是提交时延对最慢多数成员敏感、且需要仲裁数(3/5 节点、跨 AZ 部署)。用单台异步从库配"秒级切换"来宣称 RPO=0 的,是把平均值当成了上界。

常见陷阱与落地清单

  • 只看Rpl_semi_sync_master_status不看Rpl_semi_sync_master_no_tx / yes_tx:分不清"一直健康"与"频繁降级又恢复"。
  • rpl_semi_sync_master_timeout设成默认 10 秒:降级窗口白送 10 秒数据风险,宁可设小(如 1 秒)并配告警,也别让它默默吞掉承诺。
  • 从库read_only没配super_read_only:DBA 手工写进从库的一笔,既成游离事务又破坏 ack 语义(上一篇的账本问题复发)。
  • rpl_semi_sync_master_wait_for_slave_count大于存活副本数:永远等不到 ACK,全量超时降级,等于花钱买了个异步。
  • 把半同步当备份:ACK 保证的是"对方收到了",不是"历史可以回放",误操作照样瞬间复制到所有节点,备份体系见第 6、7 篇。
  • 跨城链路上开半同步不加压测:RTT 20ms 时提交时延放大到 2 万微秒级,先测业务 P99 再决定wait_count和机房布局。

半同步把丢数据的窗口压缩到"降级发生的那一瞬间",但要让这个瞬间尽可能晚、且在发生时被自动处置,就得有一套会探测、会选主、会执行切换的自动化系统。下一篇《数据库高可用与容灾实战(4):MHA 与 Orchestrator:故障探测、选主与自动切换》讲这套"自动驾驶"怎么装、又怎么防止它发疯。

参考来源

  • MySQL 8.0 Reference Manual:Semisynchronous Replication:https://dev.mysql.com/doc/refman/8.0/en/semisynchronous-replication.html
  • MySQL 8.0 Reference Manual:Binary Log Options and Variables:https://dev.mysql.com/doc/refman/8.0/en/replication-options-binary-log.html
  • MySQL 8.0 Reference Manual:InnoDB Startup Options and System Variables:https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html
  • MySQL 8.0 Reference Manual:MySQL Group Replication:https://dev.mysql.com/doc/refman/8.0/en/group-replication.html
  • Wikipedia:Quorum (distributed computing):https://en.wikipedia.org/wiki/Quorum_(distributed_computing)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询