主从复制与高可用
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 线程按位点推送)- 主库 dump 线程:从库连上来后,主库为它起一个 dump 线程,从从库上报的位点开始,持续把 binlog 事件推过去。
- 从库 IO 线程:接收事件流,写入本地 relay log(中继日志),并把"已经收到主库 binlog 的哪个位置"记录下来(
Read_Source_Log_Pos)。 - 从库 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 起默认 ONenforce_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 段。
清理姿势(按优先级):
- 能重建就重建:从干净主库用 clone/XtraBackup 重灌,一了百了(见下文与
11章)。 - 事务少且确认是垃圾写入:注入空事务占位。空事务让 GTID 序号"被使用过但没有数据变更",效果等于跳过:
-- 在漂移的从库上执行:把外来事务 3e11...:101 用空事务占位
SET GTID_NEXT = '3e11fa47-71ca-11e1-9e33-c80aa9429562:101';
BEGIN; COMMIT;
SET GTID_NEXT = 'AUTOMATIC';- 反过来,主库没有而从库也没有的"缺失区间"(中间空洞):同样逐段注空事务补齐,保持集合连续。
注意:
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,从库一个线程串行重放,天然追不上。并行复制解决"回放侧"的瓶颈,经历了三代:
- 库级并行(5.5/5.6,
DATABASE):不同 database 的事务并行,同库内串行。单库架构(绝大多数业务)等于没并行。 - 组提交级 LOGICAL_CLOCK(5.7):主库上"同一组提交"的事务,说明它们彼此无锁冲突,从库也并行回放。问题是并行度被组提交粒度卡住——主库组提交压缩率低时,从库能并行的事务很少。
- 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 线程正在回放的事件,在主库上发生在多少秒之前"。三个失真场景:
- SQL 线程停止 → 返回
NULL(或停留在停前的旧值,取决于版本与停止方式),千万别当成 0。 - 大事务执行中 → 数值在事务"开始时间"与"结束时间"之间跳变,看不到真实剩余。
- 空闲时 → 看起来 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 漂移失败都可能制造它。后果是两份互相分叉的数据,修复成本极高——多数团队在第一次遇到脑裂后才真正理解"隔离"比"选举"更重要。
防脑裂的三层手段:
- 共识层多数派:MGR/组复制从协议上保证"没有多数派就没有可写主"——少数侧自动只读。这是最干净的防法,也是节点必须奇数(2n+1)的原因。
- 隔离旧主(fencing / STONITH):切换发生前,先把旧主挡在门外:拔电源/重启/iptables 封禁/
SET GLOBAL super_read_only=ON+撤权。keepalived+VIP 方案里,健康检查脚本应包含"确认自身无法连通仲裁者则自杀退出"的逻辑,别让被分区的旧主继续接收应用写入。 - GTID 防双写兜底:即便真双写了,GTID 让每台实例的事务序号账本清晰可查,事后对账、挑边恢复有据可依——但没有多数派+隔离,GTID 只是"事后能收拾",不是"不出事"。
注意:自动选举的阈值(几次探测失败、探活间隔)是脑裂的隐形开关。参数设太敏感 → 网络抖动误切主;设太迟钝 → 真故障 RTO 变长。调前先想清楚你的故障画像:进程崩溃和机器失联的探测方式根本不同,一个
mysqladmin ping判不了半失联。
写后读旧数据(读写分离的经典业务 bug)
场景:用户下单成功(写走主库),页面跳转立刻查订单列表(读走从库),复制有 0.5 秒延迟 → “订单去哪了”。这不是 bug,是异步架构的物理规律。四种应对按优先级:
- 会话粘主:同一用户会话内,写过之后的读在短时间窗口(如 60s)内强制路由到主库。多数中间件/ORM 的分库中间件支持这种"写后读主"策略开关。
- 强制走主提示:对个别一致性敏感的 SQL 显式打主库——中间件层的 hint 标记写法(如给 SQL 加特定注释/路由标记)见
12-分库分表的路由控制思路。 - GTID 等待:写完拿到
Executed_Gtid_Set,读之前在从库执行SELECT WAIT_FOR_EXECUTED_GTID_SET('3e11...:1-101', 1)等到该事务回放——精确但要把 GTID 穿透到请求链路,工程量不小,关键路径才值得。 - 业务层兜底:写后立即展示的数据用本地状态渲染(前端乐观更新),不强依赖服务端读回。
顺带澄清概念边界:“缓存双写不一致”(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 五步"在测试环境走一遍;没演练过的自动切换,等于不知道它会在什么条件下误触发。
- 读写分离引入时,先把"哪些读必须走主"列成清单写进中间件规则,而不是上线后被用户投诉"查不到刚下的单"再补。