主从复制与高可用
日志与崩溃恢复讲了复制的基本流程,这页是深层问题:GTID、并行复制、主备切换策略、延迟的真实测量。
GTID
Global Transaction Identifier —— 每个事务的全局唯一标识:
GTID = server_uuid : transaction_id
例:7e11fa47-31ca-11e8-b1e3-000c2918a8b4:1
gtid_set 用区间表示已执行的事务集合:uuid:1-5:8-10。
解决什么问题:传统复制靠「binlog 文件名 + 位点」定位,而主备切换时位点很难找。原主库的位点在新主库上是没有意义的(两台机器的 binlog 文件完全不同),运维必须手动找到「新主库上对应的那个事务」的位点,容易出错。
GTID 模式下的切换:
CHANGE MASTER TO MASTER_HOST=..., MASTER_AUTO_POSITION=1;
备库直接告诉新主库「我已经执行了这些 GTID」,新主库算出差集发过来。不需要知道任何位点。
GTID 的其他好处:
- 天然的幂等性 —— 备库遇到已执行过的 GTID 会直接跳过(内部是
set gtid_next后执行空事务) - 可以主动跳过某个事务:遇到无法执行的 binlog(如备库上已有冲突数据),用
set gtid_next='uuid:N'+ 提交空事务来占位,比老式的sql_slave_skip_counter精确得多
限制:不支持 CREATE TABLE ... SELECT(一个语句生成两个事务)、事务中不能混用事务表和非事务表。
🔥 并行复制的演进
备库的重放能力是主从延迟的主要瓶颈 —— 主库可以多线程并发写,备库早期只有一个 SQL 线程单线程重放,天然跟不上。
并行复制的核心难点:怎么分发才能既并行又不破坏一致性。两条硬约束:
- 不能造成更新覆盖 —— 更新同一行的两个事务必须分发到同一个 worker,保持顺序
- 同一个事务不能被拆开 —— 必须在同一个 worker 上执行
演进路线
| 版本 | 策略 | 粒度 | 问题 |
|---|---|---|---|
| 5.5 | 无(单线程) | — | 延迟严重 |
| 5.6 | 按库并行(DATABASE) | 库级 | 单库场景完全无效,而多数业务只有一个主要库 |
| MariaDB | 按组提交分组 | 事务组 | 必须严格按组顺序,一个组内事务多少不均会卡 |
| 5.7 | LOGICAL_CLOCK | 事务组 | 处于同一个 prepare 阶段(能一起 group commit)的事务一定不冲突,可并行 |
| 5.7.22 / 8.0 | WRITESET | 行级 | 按「修改的行」算 hash 做冲突检测,并行度最高 |
5.7 的 LOGICAL_CLOCK 原理:主库上能够同时处于 prepare 状态的事务,说明它们之间没有行锁冲突(否则会互相阻塞),所以在备库上也可以并行执行。实现上给每个事务打上 last_committed 和 sequence_number 标记,last_committed 相同的可以并行。
还提供两个参数人为制造更多并行机会:binlog_group_commit_sync_delay(延迟多少微秒才 fsync)和 binlog_group_commit_sync_no_delay_count(攒够多少事务才 fsync)。主库上刻意等一等,让更多事务凑成一组,备库就能并行更多 —— 用主库的一点延迟换备库的吞吐。
WRITESET 的原理:对事务修改的每一行算 hash(库名+表名+索引名+值),放进 writeset。两个事务的 writeset 没有交集就可以并行。这比 LOGICAL_CLOCK 的「组」粒度细得多,即使主库上是串行执行的事务,只要改的不是同一行,备库也能并行。
要求:表必须有主键或唯一索引(否则无法算行级 hash,退化为串行)。配置:
binlog_transaction_dependency_tracking = WRITESET
slave_parallel_type = LOGICAL_CLOCK
slave_parallel_workers = 16
:::tip 「表必须有主键」的又一个理由
无主键表在 WRITESET 并行复制下会退化成串行,是主从延迟的隐性原因。加上自增主键里 row_id 耗尽会静默覆盖数据 —— 「每张表必须有主键」有至少三个独立的硬理由。
:::
🔥 主备切换:可靠性优先还是可用性优先
主库故障或计划内切换时,有一个绕不开的取舍。
可靠性优先
1. 主库 A 改为只读(set readonly = true)
2. 等备库 B 的 seconds_behind_master 变成 0(追平所有数据)
3. 把 B 改为可读写
4. 业务流量切到 B
代价:第 1 步到第 3 步之间,系统处于不可写状态。等待时间取决于复制延迟,可能几秒到几十秒。
所以实际操作会先判断延迟 —— 延迟小于 5 秒才开始切换,否则先等。
可用性优先
跳过「等待追平」,直接把 B 改成可写并切流量。不可用时间接近 0,但可能数据不一致。
两种 binlog 格式的后果不同,这是关键区别:
binlog_format=mixed(或 statement) —— 不一致会静默发生。举例:主库插入了一条 (id=4, 'aa'),这条 binlog 还没传过去;切换后业务往新主库 B 插入 (id=4, 'bb')。之后 A 的 binlog 传到 B、B 的 binlog 传到 A:
- B 上执行 A 的语句 →
id=4变'aa' - A 上执行 B 的语句 →
id=4变'bb' - 两边数据永久不一致,且没有任何报错
binlog_format=row —— row 格式记录了完整的行镜像,重放时发现「要插入的行已存在」或「要更新的行镜像不匹配」会直接报错停止复制。
:::warning 结论 绝大多数情况应该选可靠性优先。 数据不一致的修复成本远高于几十秒的不可用。
同时这也是生产必须用 binlog_format=ROW 的理由之一 —— 它让不一致变成「可发现的错误」而不是「静默的脏数据」。
只有极少数场景(如只记录日志、丢几条无所谓的业务)才考虑可用性优先。 :::
🔥 延迟的真实测量
seconds_behind_master 不可靠,这是高频追问点。它的计算方式是:
备库当前系统时间 - 正在执行的 binlog 事件里记录的主库时间戳
三个失真来源:
- 主备系统时钟不同步 —— 时钟差直接计入延迟(MySQL 会在建立连接时做一次校正,但只是粗略的)
- 大事务的误导 —— 一个执行 10 分钟的大事务,binlog 里的时间戳是提交时写入的,备库开始执行它时
seconds_behind_master瞬间跳到很大 - 不计算未处理的 relay log —— 如果 IO 线程拉取很快、SQL 线程重放很慢,relay log 里积压了大量未执行的事件,
seconds_behind_master只反映「正在执行那条」的延迟,低估了真实落后量
更准确的办法:对比 GTID 集合
SHOW SLAVE STATUS\G
-- Retrieved_Gtid_Set:已经收到的
-- Executed_Gtid_Set:已经执行的
-- 两者的差 = 积压在 relay log 里的量
或者用 pt-heartbeat —— 主库上有一个表,定时写入当前时间戳;备库读这个表,用「当前时间 - 表里的时间戳」算延迟。这个值不受上述三个因素影响,是业界标准做法。
延迟的常见原因:备库机器配置低、备库承担了大量读查询、大事务(如一次删百万行、大表 DDL)、备库上有长事务阻塞重放、并行复制策略不合适、表缺主键。
读写分离的「过期读」
从库读到旧数据,和缓存一致性是同类问题。按可靠性从低到高:
| 方案 | 做法 | 代价 |
|---|---|---|
| 强制走主库 | 关键查询直接读主库 | 最简单最可靠,但失去读扩展 |
| sleep | 读之前 sleep(1) | 粗暴且不可靠(延迟可能超过 1 秒,也可能远小于 1 秒) |
| 判断延迟为 0 | 检查 seconds_behind_master=0 才读从库 | 不可靠(见上文),且该值精度只到秒 |
| 对比 GTID / 位点 | Retrieved_Gtid_Set == Executed_Gtid_Set | 比上一条准 |
master_pos_wait() | 从库上阻塞等到指定位点执行完 | 精确。返回 -1 表示超时,此时降级读主库 |
wait_for_executed_gtid_set() | 等待指定 GTID 执行完(5.7.6+) | 精确且和 GTID 模式配套,推荐 |
| 半同步复制 | 至少一个从库确认收到才返回 | 只保证「收到」不保证「执行完」,仍可能过期读 |
wait_for_executed_gtid_set 的完整流程(这是最优解):
1. 事务提交后,主库返回这个事务的 GTID(需要 session_track_gtids=OWN_GTID)
2. 应用把 GTID 带给从库
3. 从库执行 select wait_for_executed_gtid_set('<gtid>', 1)
4. 返回 0 → 该事务已执行完,可以安全查询
返回 1 → 超时,降级到主库查
双 M 结构为什么不会循环复制
主备互为对方的从库(双 M / 互为主备)时,A 的 binlog 传给 B,B 执行后也会生成 binlog 传回 A,看起来会无限循环。
解法:binlog 里记录了原始的 server_id。
- 规定两个节点的
server_id必须不同 - 备库执行主库的 binlog 时,生成的新 binlog 保留原始的 server_id
- 每个节点收到 binlog 时判断:server_id 和自己相同就丢弃
所以 A 的事务绕回 A 时,A 发现 server_id 是自己的,直接丢掉,循环终止。
:::warning 一个运维坑
如果为了修数据,在主库上用 set sql_log_bin=0 跳过 binlog,或者手动改了 server_id,这套机制就会失效,可能导致循环复制或数据不一致。
:::
主备一致性校验
pt-table-checksum —— Percona Toolkit 的工具。原理是在主库上逐块(chunk)计算数据的校验和,并把「计算校验和」这个语句本身通过 binlog 传到备库执行,备库算出自己的校验和存到同一张表。最后对比两边的值找出不一致的块。
pt-table-sync —— 找到不一致后用它修复。
这两个工具是生产主从环境的标配,面试提到能显示实战经验。
高可用方案的层级
| 方案 | 自动切换 | 数据一致性 | 说明 |
|---|---|---|---|
| 主从 + 人工切换 | ❌ | 取决于切换策略 | 最朴素 |
| MHA | ✅ | 尽力补齐 binlog | 曾经的主流,作者已停止维护 |
| MGR(组复制) | ✅ | 基于 Paxos,强一致 | 5.7.17+ 官方方案,多数派提交 |
| InnoDB Cluster | ✅ | MGR + MySQL Router + Shell | 官方的完整方案 |
| Orchestrator | ✅ | 配合半同步 | GitHub 开源,拓扑管理强 |
| 云数据库(RDS / PolarDB) | ✅ | 存储层共享或多副本 | 把这些问题交给云厂商 |
MGR 的要点:基于 Paxos 变体,事务提交需要**多数派认证(certification)**通过。单主模式下只有一个节点可写,多主模式下可以多点写入但有冲突检测开销。相比传统主从,它把「谁是主」「数据是否一致」从外部工具变成了数据库内置能力 —— 和 MongoDB 的副本集是同一个设计思路。
限制:只支持 InnoDB、表必须有主键、不支持外键级联、网络要求高(跨机房延迟会严重影响性能)。