Skip to content
主从复制与高可用

主从复制与高可用

MySQL 复制的原理与实操:从 binlog 回放三部曲到 GTID,从异步/半同步/组复制的可靠性账本,到主从切换、MGR/InnoDB Cluster 与脑裂防护。


为什么要主从复制

先把动机想清楚,后面每个设计决策都有出处:

  • 读扩展:互联网业务读写比常年 10:1 起步,把 SELECT 甩给从库,主库专心写。这是最初也最常见的动机。
  • 数据冗余:主库磁盘坏了,从库还有一份完整数据。但注意——复制只是"实时同步的冗余",不是备份:误删一张表会瞬间同步到所有从库(备份话题见 11-备份恢复与性能调优)。
  • 灾备与异地容灾:从库可以放在另一个机房甚至另一个城市,配合延迟复制还能抵御"人为事故"。
  • 在线备份:在一台从库上跑 mysqldump / XtraBackup,主库零干扰(同样指 11)。
  • 读写分离的载体:所有"从库扛读"的架构,底下都是这套复制机制。

典型拓扑是一主两从起步:

                    写(INSERT/UPDATE/DELETE)
                          |
                     +----v----+
                     |  主库   | server_id=1  binlog
                     +----+----+
             binlog 流 |        | binlog 流
                 +-----v---+  +-v---------+
     读(报表)-> |  从库A  |  |  从库B    |<- 读(线上查询)
                 +---------+  +-----------+
                              (从库B 开延迟复制 source_delay=3600,
                                当"后悔药",见第 11 章)

三个从库可以各承担一种职责(报表 / 线上读 / 延迟备份),这是中小规模下最经济的组合。


复制原理三部曲

MySQL 的复制本质是基于 binlog 的异步重放:主库把变更写进 binlog,从库把 binlog 事件下载下来、再执行一遍。整条链路上有三类线程:

主库                          从库
写事务 -> InnoDB 提交          IO 线程: 连主库,拉 binlog -> 写 relay log
      |                       SQL 线程: 读 relay log -> 回放事件 -> 落库
      v
binlog (由 dump 线程按位点推送)
  1. 主库 dump 线程:从库连上来后,主库为它起一个 dump 线程,从从库上报的位点开始,持续把 binlog 事件推过去。
  2. 从库 IO 线程:接收事件流,写入本地 relay log(中继日志),并把"已经收到主库 binlog 的哪个位置"记录下来(Read_Source_Log_Pos)。
  3. 从库 SQL 线程:从 relay log 读出事件逐条回放,回放进度记在 Exec_Source_Log_Pos。

两个位点之间的差,就是"收下来了但还没放完"的部分——从库延迟的直接来源。理解这一点,后面讲并行复制、延迟观测都是围绕"IO 收"与"SQL 放"的速度差展开的。

注意:relay log 是易失的工作队列,SQL 线程放完就可以删(relay_log_purge 默认开启)。不要把运维脚本写成依赖 relay log 内容的样子——它随时可能消失,真正的权威是主库 binlog 和 GTID 集合。

读懂 SHOW REPLICA STATUS:十个关键字段

SHOW REPLICA STATUS\G 是复制运维的第一现场。8.0.22+ 推荐这个新名字;老写法 SHOW SLAVE STATUS\G 在 8.4 仍可用,输出字段带 Slave_ 前缀(如 Slave_IO_Running),含义一一对应:

SHOW REPLICA STATUS FOR CHANNEL 'shop'\G   -- 省略 FOR CHANNEL 看默认通道
字段 读法
Replica_IO_Running 只有 Yes 且 Connecting/末尾无报错才算健康。No 表示拉取断了,看 Last_IO_Error
Replica_SQL_Running 回放线程状态。No 通常是 SQL 线程报错停下(也可能被你有意停住做恢复)
Seconds_Behind_Source 延迟秒数。SQL 线程停跑时为 NULL,不是 0;且有大事务时会失真(见"延迟的真相")
Retrieved_Gtid_Set IO 线程已收下的 GTID 集合——“收了哪些”
Executed_Gtid_Set 已回放进库的 GTID 集合——“做了哪些”。两者差集就是积压
Exec_Source_Log_Pos 主库 binlog 文件内的回放进度(字节位点)
Source_Host / Source_Port 当前挂的主库地址。排障时先确认这个——很多"诡异延迟"其实是连错了上游
Relay_Log_Space relay log 总占用字节。持续膨胀说明 SQL 线程跟不上或停了
Last_IO_Error / Last_SQL_Error 最近一次错误原文,1062/1032 这类冲突都在这里现形
Replica_Running_Transaction 8.0.26+:SQL 线程当前正在回放的事务(GTID + 起始时间),定位"卡在哪"比看延迟数字直接

一句话诊断口径:两个 Running 都是 Yes 是活着;两个 GTID set 相等是追平;Executed_Gtid_Set 里出现了 Retrieved 之外的 UUID,是漂移(errant transaction),见下文。

多源复制(一个从库挂多个主库)用 SHOW REPLICA STATUS FOR CHANNEL 'xxx' 分通道看,或查 performance_schema.replication_applier_status_by_worker 看每个 worker 的进度。


三种持久化级别的账本:异步、半同步、组复制

复制协议的选择,本质是在回答一个问题:“提交成功了"这句话,到底代表数据落在几台机器上了? 用两个业务语言度量:

  • RPO(Recovery Point Objective):灾难发生时最多丢多少秒的数据。等于"客户端收到 OK 时,数据还没同步到从库的那部分”。
  • RTO(Recovery Time Objective):从故障到服务恢复最多要多久。取决于切换自动化程度。

异步复制(默认):RPO 不为零

主库把事务写入 binlog、提交、立刻给客户端回 OK——不管从库收没收到。从库断线十天,主库也照常提交。

崩溃账本:主库在"已提交但 dump 线程还没把 binlog 尾部推出去"时断电,这段事务在主库 binlog 里有(下次开机自己恢复),但任何从库都没有。此时若强行把从库提主,这批已回 OK 的写入就丢了。异步复制的 RPO 天然是"主库挂 = 丢未同步部分"。

优点是零开销、零阻塞,这也是它成为默认的原因。

半同步复制:至少一台确认后再回 OK

半同步的语义是:主库等到至少一个从库确认"我收到并落盘了这条 binlog",才向客户端返回成功。注意 ack 的对象是"收到 binlog",不是"回放完成"——所以半同步不保证从库数据立即可查,只保证不丢。

8.0.22+ 插件与参数已改口 source/replica,旧 master 命名仍可用:

-- 主库侧(新命名;旧环境对应 rpl_semi_sync_master / semisync_master.so)
INSTALL PLUGIN rpl_semi_sync_source SONAME 'semisync_source.so';
INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so';  -- 两边都要装
SET GLOBAL rpl_semi_sync_source_enabled = ON;
SET GLOBAL rpl_semi_sync_source_timeout = 1000000;  -- 毫秒;超时自动退回异步

-- 从库侧
SET GLOBAL rpl_semi_sync_replica_enabled = ON;

关键取舍是 ..._timeout:所有从库都断了、或者 ack 等超时,半同步会自动降级为异步,主库绝不会被卡死。降级瞬间 RPO 回到不为零,所以要做监控(状态变量 Rpl_semi_sync_source_status 变 OFF 就该报警,8.0.22 旧名 Rpl_semi_sync_master_status 同理)。

再看 rpl_semi_sync_source_wait_point(默认 AFTER_SYNC,即"无损半同步"):

  • AFTER_SYNC:主库把事务写入 binlog 并 fsync 后先等 ack、后提交 InnoDB。等待期间该事务对其他会话不可见——所以即使此刻主库崩了,客户端只会收到错误、不会收到 OK,不存在"客户端以为写成了但新主上没有"的幽灵读。
  • AFTER_COMMIT(老行为):先提交再等 ack,等待期间数据已对其他会话可见,主库崩溃时可能丢"已可见"的数据。

生产上保持默认的 AFTER_SYNC 即可。半同步把 RPO 压缩到"至少一台从库有完整 binlog",代价是每次提交多一个网络往返——跨城链路慎用,或只给关键表配一个同城从。

注意:半同步只在"主库还没回 OK"的窗口内有保护作用。如果主库崩溃前已经把 OK 发出去了,那条事务一定已在某从库的 relay log 里;但如果业务用的是 WAIT_NO_REPLY 之类的异步客户端写法,保护逻辑不变,别把应用层的异步发送误当成数据库层的异步复制。

组复制(MGR):共识多数派后再回 OK

组复制走得更远:事务经由 Paxos 变体协议在多数派节点间认证,多数派确认后才能本地提交,理论上做到 RPO=0(少数派故障不丢数据)。它是下一章高可用架构的主角,此处先给账本视角:一致性最强,但对网络质量、节点数(必须理解 2n+1)、表有无主键的要求也最高。异步/半同步是"主从关系的属性",MGR 是"一组实例的关系"——概念层级不同。

三种协议速查:

协议 客户端收到 OK 时 主库崩溃 RPO 对从库的要求
异步 binlog 已落主库 丢未推送部分 无
半同步 AFTER_SYNC 至少 1 从已收 binlog 不丢已确认事务(超时降级除外) 至少 1 台开半同步
组复制 多数派已认证 少数派故障不丢 2n+1 台、表需主键

GTID 专节

GTID(Global Transaction Identifier)= 源实例UUID:事务序号,例如 3e11fa47-71ca-11e1-9e33-c80aa9429562:23,连续的序号会压缩成区间 :1-57。每个事务在全集群拥有唯一身份证号。

为什么 GTID 值得专门一节

没有 GTID 时,复制靠"binlog 文件 + 位点"定位——文件名字面量在每台机器上都不同,主从切换、重建从库都要人工"找位点",极易出错。有了 GTID:

  • 挂从不用找位点:SOURCE_AUTO_POSITION=1,从库自动从"缺的那个事务"开始拉。
  • 切换不丢数据:比较各实例的 Executed_Gtid_Set,谁是超集谁数据最全,提主有据。
  • 跳过坏事务姿势正规:注入空事务即可(见下),不再依赖危险的全局开关。

代价是若干约束,所以要用两个参数强制:

# my.cnf,主从都要配
gtid_mode = ON
enforce_gtid_consistency = ON   # 8.0 起默认 ON

enforce_gtid_consistency 拒绝无法包装成确定 GTID 的语句,典型受害者:CREATE TABLE ... SELECT(8.0 已改为安全实现,可用)、CREATE TEMPORARY TABLE 出现在事务中间、非事务引擎与事务引擎混写的语句。老代码迁移遇到 GTID consistency 报错,先怀疑这几类写法。

线上主从想补开 GTID,可以不停写在线推进:OFF → OFF_PERMISSIVE → ON_PERMISSIVE → ON,其中 PERMISSIVE 状态允许新旧事务混跑,是灰度过渡态。

errant transaction:从库自己写了东西

成因:从库上有人手动执行了写操作(改密码、清理测试数据、跑了一次 UPDATE),这个事务有从库自己的 UUID,主库和其他从库永远不会有它——Executed_Gtid_Set 里多出一个"外来 UUID",就叫漂移(errant transaction)。

危害:漂移本身可能只是"数据不一致",真正致命的是把这台从库提主——它带着别人没有的事务继续接收新写入,下游从库会陷入"数据对不上"的修复地狱。

排查:挨台执行 SHOW REPLICA STATUS\G,把所有实例的 Executed_Gtid_Set 抄下来对比,找出多出的 UUID 段。

清理姿势(按优先级):

  1. 能重建就重建:从干净主库用 clone/XtraBackup 重灌,一了百了(见下文与 11 章)。
  2. 事务少且确认是垃圾写入:注入空事务占位。空事务让 GTID 序号"被使用过但没有数据变更",效果等于跳过:
-- 在漂移的从库上执行:把外来事务 3e11...:101 用空事务占位
SET GTID_NEXT = '3e11fa47-71ca-11e1-9e33-c80aa9429562:101';
BEGIN; COMMIT;
SET GTID_NEXT = 'AUTOMATIC';
  1. 反过来,主库没有而从库也没有的"缺失区间"(中间空洞):同样逐段注空事务补齐,保持集合连续。

注意:SET GTID_NEXT 注入空事务是双刃剑——它只动 GTID 账本,不动数据。注入前必须先确认该事务的内容确实是"可以不回放"的(比如就是本地垃圾操作),否则你制造的是静默的数据不一致。分不清时,走第 1 条:重灌。

复制报 1062/1032 冲突想跳过错误事务,正规做法也是先 SHOW REPLICA STATUS 取出报错的 GTID,注空事务跳过(无 GTID 环境才有 sql_slave_skip_counter / 新名 replica_skip_counter 这种按条跳的粗办法)。


搭建实操:一主一从完整走一遍

前提:主从都按 00-安装与配置 起好了 8.4 实例。8.0 起 log_bin 默认开启,不再需要手动配 binlog 开关。

1. 主库配置与授权

# 主库 my.cnf
server_id = 1            # 集群内每台唯一,必填
gtid_mode = ON
enforce_gtid_consistency = ON
binlog_format = ROW      # 8.0 起默认 ROW,显式写出防漂移
log_replica_updates = ON # 8.0 默认 ON:从库也写自己的 binlog,级联/重建才有料
-- 主库:建复制账号。复制权限名仍是旧词 REPLICATION SLAVE,没跟着改口
CREATE USER 'replicator'@'10.0.0.%' IDENTIFIED BY 'Strong_Pa55!';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'replicator'@'10.0.0.%';

replicator 用 caching_sha2_password 时,从库若不走 TLS,需要在挂从语句里加 GET_SOURCE_PUBLIC_KEY=1,否则会报认证 public key 获取失败。

2. 从库初始化:三条路径

路径 A:mysqldump(库小、能接受锁的场合)

# 主库导出,--source-data=2(8.0.26+ 新名;老名 --master-data=2,8.4 文档仍标注为弃用别名)把位点写进注释
mysqldump -uroot -p --single-transaction --source-data=2 \
  --set-gtid-purged=ON --routines --triggers --events \
  --all-databases > full.sql

# 从库导入后,从文件头部注释里直接抄出 CHANGE REPLICATION SOURCE 语句

参数细账见 11 章。--set-gtid-purged=ON 会把导出时刻的 GTID 集写进恢复文件,导入后 auto_position 无缝衔接——这是"为什么 GTID 下不用记位点"的最好示范。

路径 B:Clone plugin(8.0.17+,搭从首选)

-- 从库:确保 clone 插件可用(多数版本已内置,未装则 INSTALL PLUGIN clone SONAME 'mysql_clone.so';)
SET GLOBAL clone_valid_donor_list = '10.0.0.1:3306';
CLONE INSTANCE FROM 'cloner'@'10.0.0.1':3306 IDENTIFIED BY 'xxx';
-- 整库拷贝后从库需重启,再执行下面的挂从

主库侧给 cloner 账号 GRANT BACKUP_ADMIN。Clone 走网络拉数据文件、自带一致性快照,速度是物理级;局限是它拷贝时按"整实例"来(本地 clone 可指定 DATA DIRECTORY 拷单个表空间),且不是备份工具——没有"保留期、增量"概念。适合:快速搭从库、同大版本迁移。

路径 C:XtraBackup 物理备份:大库(几百 GB)冷/热备后恢复建从,命令在 11 章第 5 节,此处不重复。

3. 挂从与验证

-- 从库(8.0.22+ 新语法)
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST = '10.0.0.1',
  SOURCE_PORT = 3306,
  SOURCE_USER = 'replicator',
  SOURCE_PASSWORD = 'Strong_Pa55!',
  SOURCE_AUTO_POSITION = 1,        -- GTID 模式免位点
  GET_SOURCE_PUBLIC_KEY = 1,       -- 非 TLS 下 caching_sha2 需要
  SOURCE_CONNECT_RETRY = 5;
START REPLICA;

-- 老语法对照(8.4 仍可用,仅作读懂存量脚本用):
-- CHANGE MASTER TO MASTER_HOST='10.0.0.1', ..., MASTER_AUTO_POSITION=1;
-- START SLAVE;

验证:

SHOW REPLICA STATUS\G            -- 两个 Running 均 Yes
-- 主库造一笔写入,从库应几秒内可见:
-- 主库: CREATE DATABASE t; ... INSERT ...
-- 从库: SELECT ...

注意:容器化双实例起一主一从的 compose 写法在 00 章演示过,本章不再重复。真正容易踩的是同一镜像跑出的两台实例 server_id 相同——复制能连上但事件被从库判定为"我自己产生的"直接丢弃,症状是"一切正常却永远不延迟也永远没数据"。排障第一步查 server_id。

拓扑延伸:级联复制与 binlog 保留期

从库多了之后,主库的 dump 线程推送压力会线性上涨(每台从库一条独立流)。级联让部分从库挂到另一台从库下面:

主 ----> 从A(扛线上读)----> 从A1(报表)
    `--> 从B(延迟备份节点)

级联的前提是中转节点开了 log_replica_updates(8.0 默认 ON,前面配置里已写)。代价是延迟逐级累加:从A1 的延迟 = 主→从A + 从A→从A1,报表这种能容忍几十秒的场景才适合放链尾,核心读别上链路。

另一件必须在主库就定好的事:binlog 保留多久。从库断线重连后要能从主库拉回缺失的 binlog,主库过早清理就等于"从库一断就得重灌":

binlog_expire_logs_seconds = 604800   # 默认 30 天;按需缩到 7 天但要保证 > 最坏断线恢复时长

它同时决定了 11 章 PITR 能回放到多久以前——保留期太短,“恢复任意时刻"就只剩全量备份一个点。


并行复制演进

单线程 SQL 回放是延迟的最大来源:主库 64 个核并发写出来的 binlog,从库一个线程串行重放,天然追不上。并行复制解决"回放侧"的瓶颈,经历了三代:

  1. 库级并行(5.5/5.6,DATABASE):不同 database 的事务并行,同库内串行。单库架构(绝大多数业务)等于没并行。
  2. 组提交级 LOGICAL_CLOCK(5.7):主库上"同一组提交"的事务,说明它们彼此无锁冲突,从库也并行回放。问题是并行度被组提交粒度卡住——主库组提交压缩率低时,从库能并行的事务很少。
  3. WRITESET 依赖追踪(8.0.26 起成为默认,高吞吐场景首选):从库不再看"是不是同组提交”,而是直接看事务改动的行数据(write set)有无交集——只有更新同一行的事务才需要串行。依赖判定更细,可并行的事务比例大幅提高。注意版本口径:binlog_transaction_dependency_tracking 自 8.0.35 起被弃用,8.4 起 MTS 内部始终按 WRITESET 判定依赖,这个参数不再需要配置(8.0.26~8.0.4x 存量实例上仍按默认值 WRITESET 生效)。

相关参数(8.0.22+ 新名,旧名 slave_* 在 8.4 仍可用):

# 从库 my.cnf
replica_parallel_type = LOGICAL_CLOCK     # 8.0 默认
replica_parallel_workers = 8              # 旧名 slave_parallel_workers;0=单线程
binlog_transaction_dependency_tracking = WRITESET   # 8.0.26+ 默认;8.0.35 起弃用,8.4 内部恒为 WRITESET 可不配
replica_preserve_commit_order = ON        # 8.0 默认 ON

注意:replica_preserve_commit_order=ON 必须开着(默认就是)——它保证从库事务按主库顺序提交,从库上读到的状态才和主库的"某个瞬间"对应。关掉它并行度再高也不可靠:会出现"后半段事务先可见、前半段还没落"的中间态,业务读到不一致数据。

另外:replica_parallel_workers 不是越大越好。回放并行也要受行锁约束(真正冲突的行仍要排队),worker 开太多只是空转加开销;实践中 8-16 足够,先解决"单个大事务串行"的问题(见下节)再调这里。


延迟的真相

延迟(replication lag)是所有读写分离架构的阿喀琉斯之踵。先拆成因,再谈观测,最后是对策。

成因清单

  • 单库单表大事务:批量 UPDATE 百万行,主库执行 20 分钟,从库 SQL 线程必须完整串行重放这一个大事务——并行复制救不了"一个事务",它只并行"多个事务之间"。这是延迟最经典也最顽固的来源。
  • DDL:ALTER TABLE 在从库同样要跑完(09-Online-DDL与大表变更 讲过 ALGORITHM/LOCK 的选择对主从两端都成立),大表 DDL 期间延迟直接等于 DDL 时长。
  • 从库配置差异:从库机器差、innodb_buffer_pool_size 小、甚至从库磁盘是机械盘——回放速度天生慢半拍。
  • 从库在做别的事:报表查询、备份、或者它本身是级联拓扑的中间节点,回放线程被业务抢资源。
  • 带宽与网络:跨国链路,IO 线程拉取慢;Rpl_semi_sync 的 ack 也在抢这条线。
  • 只有个别从库延迟高:多半是该从库自身 SQL 线程问题;所有从库同时延迟高:看主库 binlog 产出是否突然暴涨(大批量任务、归档删除)。

观测:Seconds_Behind_Source 的局限

这个数字的含义是"SQL 线程正在回放的事件,在主库上发生在多少秒之前"。三个失真场景:

  1. SQL 线程停止 → 返回 NULL(或停留在停前的旧值,取决于版本与停止方式),千万别当成 0。
  2. 大事务执行中 → 数值在事务"开始时间"与"结束时间"之间跳变,看不到真实剩余。
  3. 空闲时 → 看起来 0,不代表追赶能力。

更可靠的判断是集合对比:Retrieved_Gtid_Set - Executed_Gtid_Set 的差集数量 ≈ 积压事务数;配合 performance_schema.replication_applier_status_by_worker 看每个 worker 当前事务(Replica_Running_Transaction 字段同源)。

对策清单

按投入排序:

  • 拆大事务:把百万行 UPDATE 改成按主键区间分批(WHERE id BETWEEN ? AND ? + LIMIT),每批几千到几万行。业务侧写循环控制,比任何数据库参数都有效。
  • 提高回放并行度:8.0.26+ 确认 binlog_transaction_dependency_tracking=WRITESET(8.4 起该参数弃用、内部恒为 WRITESET,只需设 workers),再设 replica_parallel_workers。
  • 从库扩容/独立资源:保证从库 buffer pool 不小于主库(它要装同样的数据),报表类压力移到专门从库。
  • 缓存分层扛热点:把"能容忍陈旧"的读放 CDN/Redis,从库只接要求新鲜度中等(秒级)的读。
  • 关键读强制走主:对一致性敏感的查询直接打主库;中间件层面的"强制提示"写法见 12-分库分表 的异构路由思路。
  • 大促/批处理前先制造"追平窗口":把批量任务安排在低峰,预留追赶时间。

数据不一致与复制错误

复制默认保证"事件等价回放",但以下情况会让主从数据悄悄分叉:从库本地写入(errant transaction,GTID 一节已讲)、5.7 以前的非确定性语句在 STATEMENT 格式下的重放差异(8.0 默认 ROW 格式后此风险大减)、复制报错后 SQL 线程被人为跳过。

binlog 校验和:8.0 默认 binlog_checksum=CRC32,主库写、从库 IO 线程校(source_verify_checksums=ON),防止网络/磁盘静默损坏导致的半途事件污染。它防传输坏,不防逻辑分叉。

主动校验用 pt-table-checksum(Percona Toolkit):思路是把主库按 chunk 分块算 CRC/哈希,通过复制通道把校验 SQL 也复制到从库执行,再从从库读回对比结果,差异写入一张 percona.checksums 表。要求校验账号有足够权限、经典配置要求 binlog_format=STATEMENT(新版可配合参数处理 ROW,但很多团队仍按 STATEMENT 会话级切换运行):

# 在主库执行;差异查从库 percona.checksums,修复用 pt-table-sync
pt-table-checksum --host=10.0.0.1 --user=chk --password='xxx' \
  --databases=shopdb --replicate=percona.checksums \
  --chunk-size=1000

注意:pt-table-checksum 会给集群增加查询负载,大表多时段错峰跑;它只读不改数据,但"只改校验库"的账号权限要给到 REPLICATION SLAVE+SELECT+对 percona 库的写入。

两类高频复制错误号的决策:

错误 含义 决策树
1062 Duplicate entry 从库已有该行,主库又插了一次 主库数据是权威:跳过(注空事务)或删掉从库多余行再 START REPLICA
1032 Can’t find record 从库缺该行,主库要改它 该行内容已不可信:删掉冲突行让 UPDATE/DELETE 变无害;若涉及业务关键数据,走"回滚重灌"

原则一句话:错得少、能定位到行 → 对齐(修数据);错得多、来源不明 → 重灌(clone/XtraBackup 重建该从库)。跳过(skip)只在确认该事务对目标数据集无害时使用,永远不要为"让它跑起来"而盲跳。两边都要在操作前停 SQL 线程(STOP REPLICA SQL_THREAD,IO 线程继续收不丢 binlog)。


高可用架构图谱

按自动化程度从低到高,各架构的历史位置与适用边界。

1. 手工主从切换(一切自动化的底图)

所有自动方案执行的都是这套 SOP,把它背下来比背任何工具参数都有用:

1. 停写:业务切只读/公告,或确认主库已死(不可恢复)
2. 对齐:比较候选从库的 Executed_Gtid_Set,选超集(数据最全)者为新主
3. 提升:对候选执行 RESET REPLICA ALL(GTID 模式下数据已齐),必要时
   SET GLOBAL super_read_only=OFF
4. 重挂:其余从库 CHANGE REPLICATION SOURCE TO SOURCE_HOST=新主; START REPLICA
5. 改路由:应用连接串/VIP/代理指向新主;原主修复后以从库身份回归

危险点在 2 和 5:对齐没做就提主 = 丢数据;路由没切干净 = 双主脑裂(见后文)。

2. 半同步 + keepalived VIP

keepalived 通过 VRRP 在主库所在机器上漂一个虚拟 IP:健康检查脚本探测 mysqld(进程、SELECT 1、半同步状态),失败则 VIP 漂移到候选主。应用永远连 VIP。

  • 优点:极简、无额外中间件层。
  • 致命弱点:VIP 漂移和"数据是否已同步"是两个独立判断——旧主网络失联但进程活着时,若健康检查误判,VIP 可能把写入放给一个落后于旧主的实例;旧主没被真隔离就形成双写。因此生产用这套必须配半同步(AFTER_SYNC)兜住数据、配 STONITH/fencing 兜住旧主。

3. MHA(历史地位)

MHA(Master High Availability Manager)在故障时自动从从库中选主、补放 relay log 里未执行的差异事务、再把其他从库挂过去,曾是手工切换的标准自动化工具。但它依赖 SSH 编排、需要额外 manager 节点,且上游早已停止实质维护。存量环境仍能见到;新系统不建议再引入,其"补日志减少丢数据"的思想被 MGR/半同步覆盖。

4. MGR / InnoDB Cluster(官方全家桶)

MGR 是组复制的产品化:多个 mysqld 组成一个 group,事务经认证(certification)后多数字派决定成败,单主模式下主库故障自动选举新主。

硬约束,上生产前逐条核对:

  • 每张表必须有主键(官方要求;无主键表行为不可靠,多主下冲突检测更无从谈起)。
  • 通信依赖多数派:n 台只有容忍 n/2 台故障的能力;网络分区时少数侧自动停写(只读/不可用)——这是防脑裂的设计而非缺陷。
  • 对网络延迟敏感,跨地域部署要实测;transaction_size_limit 默认限制大事务直接报错,正好把"大事务"挡在门外。

InnoDB Cluster = MGR + MySQL Shell(编排) + MySQL Router(路由)。典型创建流程:

# mysqlsh 连接候选主库
mysqlsh --uri admin@10.0.0.1:3306
Dba.checkInstanceConfiguration('admin@10.0.0.1:3306')   # 先体检再建,省一半排障时间
var ic = dba.createCluster('shopcluster')              # 在候选主上 bootstrap
ic.addInstance('admin@10.0.0.2:3306', {recoveryMethod: 'clone'})
ic.addInstance('admin@10.0.0.3:3306', {recoveryMethod: 'clone'})
ic.status()

# Router:一次 bootstrap 生成配置,之后应用连 Router
mysqlrouter --bootstrap admin@10.0.0.1:3306 --directory /opt/router
# 默认暴露 6446=读写(指向主)、6447=只读(轮询其余节点)

单主 vs 多主:单主(默认)写全打一个节点,故障自动选新主——99% 的场景选它。多主理论上都能写,但跨节点行冲突(certification 失败即回滚)、DDL 并发、表无主键的更新定位问题都让运维复杂度陡增,慎用。

Router 的读写分离是按"角色"路由:写走 primary、读在其他成员间分发;它解决的是"客户端不用感知谁是主",不解决"从库延迟"——刚在主库写完,经 Router 的只读端口仍可能读到旧数据,见下一节。

ProxySQL 类第三方代理思路类似但更灵活:用 hostgroup(主机组) 把后端分成"写组/读组",靠 query rules(按正则/函数把 SELECT 路由到读组)实现读写分离,还能在应用层不改造的情况下做连接复用。代价是多维护一个有状态组件。

5. 云托管一句话对比

  • RDS 类(阿里云 RDS、AWS RDS 双 AZ):本质是托管的传统主从,主备切换由平台探活完成,数据一致性靠半同步/同步复制档位选择。
  • PolarDB:计算存储分离,多个只读节点共享同一份分布式存储的 redo——不再有"每节点一份数据"的复制滞后问题,只读延迟降到存储同步级别。
  • Aurora:日志即数据库——计算层只产生 redo 流,存储层 6 副本跨 3 AZ 多数派确认即回 OK,RPO 接近 0,故障恢复是"重挂存储"而非"拷数据"。

选型逻辑不变:托管程度越高,你越不用管复制细节,但越受平台能力边界(切换策略、延迟语义)约束。


脑裂与防脑裂

脑裂(split-brain):一个集群同时出现两个"主",各自接收写入。网络分区、健康检查误判、VIP 漂移失败都可能制造它。后果是两份互相分叉的数据,修复成本极高——多数团队在第一次遇到脑裂后才真正理解"隔离"比"选举"更重要。

防脑裂的三层手段:

  1. 共识层多数派:MGR/组复制从协议上保证"没有多数派就没有可写主"——少数侧自动只读。这是最干净的防法,也是节点必须奇数(2n+1)的原因。
  2. 隔离旧主(fencing / STONITH):切换发生前,先把旧主挡在门外:拔电源/重启/iptables 封禁/SET GLOBAL super_read_only=ON+撤权。keepalived+VIP 方案里,健康检查脚本应包含"确认自身无法连通仲裁者则自杀退出"的逻辑,别让被分区的旧主继续接收应用写入。
  3. GTID 防双写兜底:即便真双写了,GTID 让每台实例的事务序号账本清晰可查,事后对账、挑边恢复有据可依——但没有多数派+隔离,GTID 只是"事后能收拾",不是"不出事"。

注意:自动选举的阈值(几次探测失败、探活间隔)是脑裂的隐形开关。参数设太敏感 → 网络抖动误切主;设太迟钝 → 真故障 RTO 变长。调前先想清楚你的故障画像:进程崩溃和机器失联的探测方式根本不同,一个 mysqladmin ping 判不了半失联。


写后读旧数据(读写分离的经典业务 bug)

场景:用户下单成功(写走主库),页面跳转立刻查订单列表(读走从库),复制有 0.5 秒延迟 → “订单去哪了”。这不是 bug,是异步架构的物理规律。四种应对按优先级:

  1. 会话粘主:同一用户会话内,写过之后的读在短时间窗口(如 60s)内强制路由到主库。多数中间件/ORM 的分库中间件支持这种"写后读主"策略开关。
  2. 强制走主提示:对个别一致性敏感的 SQL 显式打主库——中间件层的 hint 标记写法(如给 SQL 加特定注释/路由标记)见 12-分库分表 的路由控制思路。
  3. GTID 等待:写完拿到 Executed_Gtid_Set,读之前在从库执行 SELECT WAIT_FOR_EXECUTED_GTID_SET('3e11...:1-101', 1) 等到该事务回放——精确但要把 GTID 穿透到请求链路,工程量不小,关键路径才值得。
  4. 业务层兜底:写后立即展示的数据用本地状态渲染(前端乐观更新),不强依赖服务端读回。

顺带澄清概念边界:“缓存双写不一致”(Cache Aside/延迟双删) 是 Redis 与 MySQL 之间的问题,不是主从复制的问题,两回事,见 12/13 章相关讨论。


实战建议

  • 规模决策:日均几十 GB 写入、读 QPS 单机扛不住之前,一主两从(其中一台开延迟复制当后悔药)+ 半同步保护核心库 就是最优解,别过度设计。MGR/InnoDB Cluster 的真正收益是"自动切换 + RPO 可控",当你确实无法接受几分钟的人工切换 RTO、且有 3 台以上同规格机器时再上。
  • 复制账号、server_id、GTID 开关,这三项在每一台实例(含未来重建的) 配置里保持一致,用 00 章的模板或配置管理固化——复制排障时间的一半花在"某台配置不一样"上。
  • 先上 GTID,再谈自动化:没有 GTID 的切换 SOP 基本等于手工祈祷;gtid_mode=ON + enforce_gtid_consistency=ON 从第一台实例开始配。
  • 半同步只当"丢数据保险",不要指望它降延迟;监控盯 Rpl_semi_sync_source_status 和"两个 GTID set 差集",不要只盯 Seconds_Behind_Source。
  • 延迟治理从拆大事务开始,不在 replica_parallel_workers 上找奇迹。
  • 演练切换:每个季度把"手工 SOP 五步"在测试环境走一遍;没演练过的自动切换,等于不知道它会在什么条件下误触发。
  • 读写分离引入时,先把"哪些读必须走主"列成清单写进中间件规则,而不是上线后被用户投诉"查不到刚下的单"再补。