跳到主要内容

主从复制与高可用

日志与崩溃恢复讲了复制的基本流程,这页是深层问题: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 线程单线程重放,天然跟不上。

并行复制的核心难点:怎么分发才能既并行又不破坏一致性。两条硬约束:

  1. 不能造成更新覆盖 —— 更新同一行的两个事务必须分发到同一个 worker,保持顺序
  2. 同一个事务不能被拆开 —— 必须在同一个 worker 上执行

演进路线​

版本策略粒度问题
5.5无(单线程)—延迟严重
5.6按库并行(DATABASE)库级单库场景完全无效,而多数业务只有一个主要库
MariaDB按组提交分组事务组必须严格按组顺序,一个组内事务多少不均会卡
5.7LOGICAL_CLOCK事务组处于同一个 prepare 阶段(能一起 group commit)的事务一定不冲突,可并行
5.7.22 / 8.0WRITESET行级按「修改的行」算 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 事件里记录的主库时间戳

三个失真来源:

  1. 主备系统时钟不同步 —— 时钟差直接计入延迟(MySQL 会在建立连接时做一次校正,但只是粗略的)
  2. 大事务的误导 —— 一个执行 10 分钟的大事务,binlog 里的时间戳是提交时写入的,备库开始执行它时 seconds_behind_master 瞬间跳到很大
  3. 不计算未处理的 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。

  1. 规定两个节点的 server_id 必须不同
  2. 备库执行主库的 binlog 时,生成的新 binlog 保留原始的 server_id
  3. 每个节点收到 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、表必须有主键、不支持外键级联、网络要求高(跨机房延迟会严重影响性能)。