第 26 章 复制、高可用与分库分表
单机数据库的容灾、读扩展、单表容量三个天花板,分别对应复制、高可用与分库分表。本章只讲它们的丢数据窗口与失效边界。
学习目标
读完本章,你应当能够:
- 推导异步、半同步(AFTER_SYNC / AFTER_COMMIT)与组复制各自的丢数据窗口,判断给定崩溃时刻会不会丢"已提交"数据;
- 画出主库 dump 线程、从库 IO(receiver)线程与 SQL(applier)线程的分工,解释 relay log 为什么必须存在,以及 GTID 相对位点加偏移量的三项优势;
- 对"主从延迟很大"按五个原因逐项排查,并用 WRITESET 说明并行复制为什么能做到行级粒度;
- 指出
Seconds_Behind_Master的失真场景,改用 pt-heartbeat、GTID 差集等口径; - 判断一次网络分区会不会造成脑裂,给出预防手段与切换后的一致性校验流程;
- 用行宽与 buffer pool 算例判断是否需要分表,选路由规则,并为跨库 JOIN、跨库分页、分布式事务各给方案与代价。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
| binlog 的三种格式、两阶段提交与崩溃恢复 | 见第 21 章 21.4、21.6 节;本章只讲 binlog 怎么被从库消费 |
| B+ 树布局、页大小与树高估算方法 | 见第 23 章 23.2 节;本章只借用它推导分表阈值 |
| buffer pool 与页的换入换出 | 见第 20 章 20.3 节 |
| 快照读、当前读与行锁的持有范围 | 见第 22 章 22.4 节、第 24 章 24.2 节 |
| 多数派、共识与分布式事务理论 | 见第 34 章 34.3、34.5 节;本章只用结论 |
26.1 三种复制模式:丢数据窗口由"何时返回客户端"决定
复制的选型不是比谁更快,而是回答一个问题:主库返回成功那一刻,这条已提交的事务最多可能在几台机器上存在? 三个模式的差别全部落在"ACK 与 commit 的先后"上。
| 模式 | 何时返回客户端成功 | 丢数据窗口 | 关键约束 |
|---|---|---|---|
| 异步复制(asynchronous replication) | 主库写 binlog 并提交引擎后立即返回 | 主库崩溃时,所有"已提交但还没被 dump 线程送出"的事务全丢,窗口等于当时的复制延迟 | 默认模式;延迟越大风险越大 |
| 半同步复制(semi-synchronous replication) | 至少 rpl_semi_sync_master_wait_for_slave_count 台(默认 1 台)从库收到事件并 ACK 后才返回 | 取决于 wait_point:AFTER_COMMIT 会丢掉"本地已提交、别的会话已可见"的事务;AFTER_SYNC 不会产生已提交又丢失 | 只要求 1 台 ACK,不是多数派;超时后退化为异步 |
| 组复制(MySQL Group Replication, MGR) | 事务的写入集合经组内认证、且多数派成员已收到该事务后才提交 | 只要多数派存活就不丢已提交事务;少数派分区上的写入直接被拒绝 | Paxos 变体(XCom);仅 InnoDB、每表需显式主键、要求低延迟网络 |
半同步的两种时机是高频追问点,务必能画出时序:
| |
重要
半同步的准确表述是"至少一台从库收到 binlog 事件",不是"至少一台从库执行完"。因此它在故障切换后能保证数据不丢(AFTER_SYNC 下),但不保证新主上一定能读到——事件可能在从库的 relay log 里还没重放。想保证"切过去就能读",那是 MGR 的 group_replication_consistency=BEFORE_ON_PRIMARY_FAILOVER 或 AFTER 要解决的问题。
MGR 与半同步的本质区别在于谁能单方面决定提交。半同步里主库是唯一决策者,从库只回 ACK;MGR 里提交需要组内多数派认证(certification)通过,认证时会检查事务的写入集合(writeset)是否与并发事务冲突,冲突方直接被回滚。group_replication_single_primary_mode=ON(默认)时只有一个可写成员,冲突基本只发生在切换瞬间;OFF(多主)时必须同时开 group_replication_enforce_update_everywhere_checks=ON,此时间隙锁语义与 SERIALIZABLE 不被支持,冲突写入在 commit 阶段被拒——所以多主并不是"随便写哪个节点",而是把冲突检测推迟到了提交时。
26.2 线程模型、relay log 与 GTID
复制的执行体是三个线程加一个文件,模型不复杂,但每个组件的存在理由都是面试点。
| |
relay log 为什么不能省。 直觉方案是"IO 线程收到事件直接交给 SQL 线程重放",省掉一次落盘。实际不能这么做,四个理由:
- 拉取与重放必须解耦。 二者速度不同:IO 线程受网络带宽限制(同机房 RTT 约 0.1~0.5 ms),SQL 线程受磁盘与锁限制。解耦后从库可以在主库切换、网络中断时继续重放已拉取的部分,而不是卡在网络读上。
- 位点必须能崩溃恢复。 relay log 是本地文件,SQL 线程执行到哪个位置可以持久化,重启后从该位置续跑;网络流没有"可回退的本地副本"。
- 并行重放需要随机访问。 多个 worker 要各自从 relay log 里取不同事务,必须有可随机读、可分段定位的本地文件,网络流只能顺序消费。
- 主库的 binlog 会过期。 主库受
binlog_expire_logs_seconds约束会清理旧文件;从库把事件落地后,dump 线程才敢让主库释放更早的 binlog(Relay_Log_Space与主库清理策略是两件事)。
GTID 相对位点的优势。 位点式复制要记住 binlog file + pos(如 mysql-bin.000042:197023),GTID(global transaction identifier)把标识变成 source_uuid:transaction_id 的集合,从库只需 SOURCE_AUTO_POSITION=1,主库自动算出"我有哪些、你缺哪些"再补发。三项优势:
- 故障切换后自动找位点。 从库指向新主库时不必人工算位点,也就不会算错;位点式切换最常见的故障就是位点算错导致重复执行或跳过事务。
- 幂等。 事务的 GTID 一旦在
gtid_executed中,重复收到会被跳过,这正是 26.1 里 AFTER_SYNC 造成的"重复投递"能被消化掉的原因。 - 拓扑重建简单。
gtid_purged表达"这台机器上已经确定不存在的事务",当从库所需的 binlog 已被主库清理时报错信息明确,而位点式只会得到一个错位的沉默结果。
注意
面试官常追问的下一层是"开了 GTID 有什么代价"。答:必须 gtid_mode=ON + enforce_gtid_consistency=ON,后者限制了非确定性语句与(5.7 时代)CREATE TABLE ... SELECT 这类"一个语句产生多个 GTID 边界"的写法;另外 gtid_executed 集合会随事务数增长,RESET MASTER、gtid_purged 的误操作会让从库永久无法对齐,需要重建。
26.3 主从延迟的五个原因与并行复制
延迟是复制唯一的常态问题。回答"延迟大怎么办"之前,必须先定位是哪一类原因,因为五类的解法互不通用。
| 原因 | 机制(为什么慢) | 缓解手段 |
|---|---|---|
| 从库单线程重放与主库并发写入的矛盾 | 主库 32 个连接并发提交,从库只有一个 SQL 线程串行重放,重放速度上限被单线程锁死 | 开并行复制(replica_parallel_workers + LOGICAL_CLOCK/WRITESET);把大事务拆小增加可并行度 |
| 大事务 | 一个事务改几百万行,relay log 里是一串连续的 ROW event,从库只能整体重放且期间无法并行;期间 Seconds_Behind_Master 只增不减 | 拆批提交(每 1000~5000 行一个事务);监控 information_schema.innodb_trx.trx_rows_modified,超阈值就拦 |
| 从库承担大量读 | 从库的 CPU、buffer pool、IO 被慢查询占用,重放线程抢不到资源;报表类大查询甚至会把热点页挤出内存 | 读流量按权重分散到多台从库;报表走离线库或列存;给从库单独调大 buffer pool,禁止在从库跑无索引聚合 |
| 大表 DDL | DDL 在从库上是单条事件,不可并行、不可拆分,执行时长与主库相当;DDL 期间后续事务全部排队 | 用 ALGORITHM=INSTANT(8.0 加列等场景)或 gh-ost 类工具把 DDL 摊成行级变更;DDL 避开业务高峰 |
| 从库硬件弱于主库 | 磁盘 IOPS、CPU 核数、内存不足会直接压低重放吞吐;sync_binlog/innodb_flush_log_at_trx_commit 配置比主库更保守也一样 | 主从同规格;从库 innodb_buffer_pool_size 至少覆盖热数据;核对 binlog_group_commit_sync_delay 等提交相关参数 |
并行复制的关键是"哪些事务可以乱序重放"。从库乱序的前提是这些事务在主库上本来就不冲突,而 binlog 里恰好记录了判断依据(last_committed 与 sequence_number)。
| |
slave_parallel_type=DATABASE 的致命局限要能一句话说清:它按库分 worker,同一个库上的事务必须进同一个 worker。真实业务往往是"一个库、几十张表",于是并行度恒为 1;这也是它被 LOGICAL_CLOCK 取代的原因。WRITESET 的局限同样要能说:writeset 由主键或唯一键计算,没有主键的表算不出行标识,只能退化为与所有事务冲突(等于串行),所以"每张表都要有显式主键"不是风格建议而是并行复制的前提。
26.4 Seconds_Behind_Master 为什么不准
它的算法简单到可以一句话写出来:当前时间 − SQL 线程正在执行的这个 event 的时间戳(时间戳在 event 头部,由主库写入时打上)。由此得到三类失真:
| 失真场景 | 现象 | 原因 |
|---|---|---|
| 主库长时间无写入 | 显示 0,甚至业务明显读到旧数据也显示 0 | SQL 线程空闲,没有"正在执行的 event"可比较;8.0 中若 IO 线程中断则显示 NULL |
| 主从时钟不齐 | 虚高、偶尔为负被截断成 0 | 分子里混了主库时钟;主库时钟快于从库就凭空多出一段延迟 |
| 大事务与长事务重放 | 数值先冲高再突然回落 | 它只反映"当前正在执行的这一个 event"的时间戳,事务边界一跳就重新计算 |
| 重放队列积压但事件时间戳很新 | 显示很小,实际还有大量 relay log 未重放 | 它不度量 relay log 的积压量,也不度量"数据何时才对读可见" |
因此它既不是"从库落后主库多少秒",也不是"数据多久后可见"。真实可用的替代口径:
| 口径 | 度量什么 | 使用方式与代价 |
|---|---|---|
| pt-heartbeat | 端到端"写入可见延迟":主库定时把 NOW(6) 写进心跳表,从库读该行与本地时间求差 | 需要主从时钟大致同步(NTP);主库静默时依然准确,精度到毫秒,是延迟监控的默认选择 |
| GTID 差集 | 未重放的事务量:Retrieved_Gtid_Set 减 Executed_Gtid_Set | 需 GTID;差集为空说明已追平,非空时用 GTID_SUBTRACT() 看具体缺哪些;度量单位是事务不是时间 |
| 从库等待指定 GTID | 请求级一致性:WAIT_FOR_EXECUTED_GTID_SET('uuid:1-500', 1) | 直接在从库阻塞等待到该事务被重放,用于读写分离的写后读 |
| 业务侧埋点 | 真正的用户可见延迟 | 写入时记录时间戳(如 Redis/本地序列表),读到时比对;最贴近 SLA,但要业务改造 |
提示
面试话术模板:"Seconds_Behind_Master 度量的是 event 时间戳之差,不是复制延迟。主库静默时它显示 0,时钟漂移时它是错的,积压时它偏小。我们线上用 pt-heartbeat 看秒级延迟、用 GTID 差集看积压事务量,请求级一致性用 WAIT_FOR_EXECUTED_GTID_SET。"
26.5 读写分离下的延迟读问题
延迟在业务上的可观测形态主要有三种:写后读(同一会话写完立刻读,读到旧值,破坏 read-your-writes)、单调读(同一会话两次读,第二次反而更旧)、因果读(读到了 A 的评论,却没读到 A 的帖子)。解法按代价排序:
| 解法 | 机制 | 代价 | 适用边界 |
|---|---|---|---|
| 强制走主库 | 按业务标记(上下文/注解/Hint 管理器)把读路由到主库,常见策略是"写后 N 秒内该会话走主库" | 主库读压力上升;标记要靠 ThreadLocal 之类传递,异步线程池与跨服务调用会丢 | 写后立即读的强需求、单机流量可控 |
| GTID 等待 | 主库返回时带上本次事务的 GTID,读从库前执行 WAIT_FOR_EXECUTED_GTID_SET | 多一次等待与阻塞,可能超时;需要 GTID 与中间件配合 | 少量关键接口要求精确一致 |
| 半同步 / MGR 一致性级别 | 让写返回时数据已在从库(或多数派)上 apply,之后从任意节点读都一致(group_replication_consistency=AFTER) | 写延迟至少增加一个 RTT 与 apply 时间;吞吐下降 | 强一致要求高于延迟要求 |
| 会话粘滞 | 同一会话固定路由到同一从库 | 只解决"跨从库单调读",不解决写后读 | 用户级一致性要求弱 |
| 缓存最新值 | 写主库后把结果写入缓存(或读不到时的兜底查主库) | 引入缓存一致性问题(见第 27 章 27.6 节)与写放大 | 读多写少、可接受短暂脏读 |
| 中间件位点等待 | 代理记录最近写入的位点/GTID,只把读路由到已追上的从库 | 中间件复杂度与状态维护 | 统一收口在读代理层 |
26.6 高可用、故障切换与脑裂
| 方案 | 选主与切换机制 | 一致性 | 运维代价 |
|---|---|---|---|
| MHA(Master High Availability) | 管理节点经 SSH 探测与操作各节点:对比从库 relay log 位点、补差、提升最全的从库、其余从库重指向、漂移 VIP | 位点补差能力有限,依赖人工确认切换策略 | 需要 SSH 免密与集中管理节点,管理节点自身是单点 |
| Orchestrator | 中心化服务(自身用 Raft 做高可用)维护拓扑,按策略自动选主并重建复制关系,切换动作交给 hook(如改 ProxySQL/HAProxy) | 选主策略可配置(同版本优先、跨机房优先等) | 需要额外的拓扑与切换组件,规则要调好 |
| MGR + Router | 组内多数派自动选举新主,Router 感知拓扑变化后路由 | 多数派存活即不丢已提交事务;少数派分区直接拒绝写入 | 仅 InnoDB、每表主键、要求低延迟网络;流控会拖慢写入 |
| 云托管主备/只读实例 | 平台在探测到主库故障后自动完成切换,业务侧通过域名或代理重新建连 | 平台行为不完全透明,取决于所选复制模式与是否开启半同步/组复制 | 最省人力,但切换时机与日志保留策略不在自己手里 |
脑裂的成因是"判定与事实不一致":主从之间网络分区,主库自己仍在正常接受写入,而探测方(管理节点或另一侧集群)判定主库已死并提升从库,于是出现两个可写节点,双写造成的分叉无法用重放修复。预防手段分三类:多数派(MGR/Raft 下少数派无法提交,从机制上不可能双写)、fencing/STONITH(切换前先把旧主隔离掉:关机、断网卡、撤销 VIP、SET GLOBAL super_read_only=1 并回收写入账号)、仲裁与探测策略(引入第三方仲裁节点投票,多次探测并校验 read_only,避免单次网络抖动就触发切换)。云托管方案通常把 fencing 做在 VIP/代理层,这也是"切过去以后为什么必须重建连接"的原因。
切换完成后必须做一致性校验,因为补差逻辑可能漏事件:pt-table-checksum 在主库按块计算每行的校验和并写入结果表,该表随复制流到从库,从库据此比较出差异块;pt-table-sync 依据校验结果生成补齐 SQL(先用 --print 审阅,再 --replicate 执行)。两个工具都会给主库加压力,要用 --chunk-size、--max-load、--pause-file 限速,并且它们只能发现数据差异,不能发现结构差异。
26.7 分库分表的拆分维度与路由
垂直拆分先做,因为它不动数据行:垂直分库按业务域把表拆到不同实例(用户库、订单库、商品库),代价是原来一个库内的 JOIN 变成跨库、单库事务变成分布式事务;垂直分表按列拆,把大字段(详情、JSON、附件元信息)挪到扩展表,目的是让主表行更短——行越短一页放的行越多,树高与随机 IO 次数就越低(B+ 树原理见第 23 章 23.2 节)。
水平拆分才是解决"单表放不下"的手段,但"单表 2000 万行"不是阈值,它是某个假设下的估算结果。这个数字来自三层 B+ 树、行宽约 1 KB 的算例:
| |
因此正确的判断顺序是:先看单表数据总量与热数据量、行宽、二级索引数量、buffer pool 容量、慢查询中的随机 IO 占比,再决定拆不拆;只有"热数据装不进内存且 QPS 要求无法靠缓存缓解"时,水平拆分才是收益大于成本的选择。
| 路由规则 | 均匀性 | 范围查询 | 扩容成本 | 典型场景 |
|---|---|---|---|---|
| 范围(按 ID 段或时间区间) | 差,写入容易集中在最新分片 | 好,只扫相关分片;归档与清理简单 | 低:只在末端加新分片,不重分布历史数据 | 日志、流水、按时间归档的业务表 |
哈希取模(hash(key) % N) | 好 | 差:范围查询要并发扫全部 N 个分片 | 高:改 N 几乎要全量迁移;成倍扩容(N→2N)可只迁一半 | 用户、订单等以点查为主的表 |
| 一致性哈希 + 虚拟节点 | 好 | 差 | 中:只迁移约 1/N 的数据,但节点变动仍会引起部分迁移 | 分片节点数量动态变化的场景 |
| 基因法(把路由键片段编码进 ID) | 取决于 ID 生成器 | 差 | 与哈希取模相同 | 同一行需要支持两个维度查询(如按订单号与按用户都要能定位) |
| 广播/冗余表 | — | 好(全分片一致) | 低 | 字典表、配置表等小表 |
中间件定位差异:ShardingSphere 分客户端(ShardingSphere-JDBC,JAR 内嵌,SQL 改写、路由、归并在 JVM 内完成,无额外网络跳数,但与语言绑定、升级需发版)与服务端(ShardingSphere-Proxy,独立进程用 MySQL/PostgreSQL 协议对外,多语言友好,代价是多一跳且需独立运维)两种形态,且治理能力(读写分离、数据加密、影子库压测)围绕同一套内核;MyCat 是独立代理进程形态,生态与治理能力弱于前者,适合已有存量系统。选型时要问清"谁来解析 SQL、谁来做归并、跨语言需求有多强"。
26.8 分库分表带来的五个新问题
| 新问题 | 根因 | 常见解法与代价 |
|---|---|---|
| 跨库 JOIN | 关联的两张表按不同键分片,落在不同实例 | 冗余字段(把维度字段写进事实表,代价是更新时的多表一致性);广播小表(字典表全量复制到每个库);内存归并(各分片取回后应用内 JOIN,数据量必须小);ER 分片(强关联表用同一分片键,保证同库) |
| 跨库分页 | 每个分片都要先取到 offset + size 行才能确定全局第 offset+1 行 | 浅分页直接各分片取前 N 再归并(放大 N×分片数);深分页用二次查询法;或禁止跳页改用游标;或把排序结果预聚合到搜索引擎/宽表 |
| 全局唯一 ID | 各分片自增会重复,且 ID 必须与分片键联合设计 | 见第 34 章 34.6 节(号段、Snowflake、Leaf 与时钟回拨处理);此处只需记住约束:ID 生成策略决定分片键的可路由性 |
| 分布式事务 | 一次业务写跨多个实例,本地事务边界失效 | XA(强一致但阻塞,见 26.9);TCC(业务补偿,无锁等待但侵入大);Saga/本地消息表/事务消息(最终一致 + 幂等);理论对比见第 34 章 34.5 节 |
| 聚合与排序 | COUNT/SUM/AVG/GROUP BY/ORDER BY 都需要全局归并 | 中间件两阶段归并:各分片局部聚合后由中间件全局聚合;注意 AVG 不能相加,须改写为 SUM/COUNT 再归并,COUNT(DISTINCT) 需 GROUP BY 去重后合并;或定时预聚合到汇总表(代价是时效与额外链路) |
跨库深分页的"二次查询法"是最值得讲清的一个:
| |
26.9 XA 事务与为什么最终一致更受欢迎
MySQL 的 XA 实现就是教科书式两阶段提交:XA START 进入 ACTIVE,XA END 结束语句阶段进入 IDLE,XA PREPARE 完成阶段一并把状态持久化(此后崩溃可恢复),最后 XA COMMIT / XA ROLLBACK 完成阶段二。XA RECOVER 列出所有处于 PREPARE 状态的事务。
| |
三个问题是面试的固定追问点:
- 同步阻塞。
XA PREPARE之后行锁一直持到阶段二,其他事务写同一行会阻塞到innodb_lock_wait_timeout(默认 50 s);若协调者迟迟不决策,连接与锁会持续堆积。这是"强一致换吞吐"的直接代价。 - 协调者单点。 阶段一全部成功后协调者崩溃,参与者无法自行决定提交还是回滚,只能保留锁等待;恢复要靠协调者的日志重放或人工/补偿脚本,这段时间业务是挂住的。
- prepare 后的中间态很难处理。 事务既没提交也没回滚:改动对其他会话不可见(一致性读看不到),但锁被持有;此时做备份、主从切换或 DDL 都可能踩到它——切换走的从库可能根本没有这个 prepare(取决于复制模式),而旧主上它已被持久化,一提交就产生分叉。数据库内部还有一层两阶段提交(redo/binlog 的 prepare/commit,见第 21 章 21.6 节),两层叠在一起让排障更难。
互联网业务更倾向最终一致,原因不是"XA 不好",而是三笔账:XA 把可用性交给协调者、把吞吐交给锁、把故障处理交给人工;而业务侧通常已有幂等键与补偿能力,用 TCC/Saga/事务消息(本地消息表 + 至少一次投递 + 幂等消费)可以把"长事务"拆成"若干本地短事务 + 一个可重试的补偿流程",代价变成"短暂不一致窗口"而非"锁等待"。方案对比与各模式的不一致窗口见第 34 章 34.5 节。
26.10 在线扩容与数据迁移
扩容的本质是"让新库追上旧库,再把流量搬过去",且每一步都要能停下和回退。
- 影子表/新分片就位:先建新结构,不接流量。
- 全量 + 增量同步:全量用一致性快照(
mysqldump --single-transaction --master-data,或用 MyDumper 类工具)拿一个位点;增量用 binlog 订阅,canal 就是把自己伪装成 MySQL 从库、请求 dump 线程的事件流,解析成行变更后投递到消息队列,下游按行应用。全量位点与增量起点对齐后,新库才可能追平。 - 双写与校验:写请求同时写新旧两侧(双写不是原子的,必须配幂等键 + 失败重试 + 定期对账),同时按分片/主键区间分批做校验和比对,差异率降到阈值以下才允许切读。
- 灰度切流:读流量按用户/租户/百分比逐步切到新库(1% → 5% → 50% → 100%),每一步盯错误率与 P99 延迟;写流量不能两头都开,通常按"用户分片路由"让不同用户先迁到不同库,最后再把路由表整体切过去。
- 收尾与回退:确认追平后再停旧库写入;旧库与全量快照保留一段时间用于回退,回退预案要能覆盖"新库写入如何反向补偿"。
在这个流程里,gh-ost 与 canal 的位置不同:gh-ost 解决"表结构变更如何在线执行"——它把自己也当成一个从库去解析 binlog 得到增量变更(而不是用触发器),在主库上做的是 ALTER 的逐块拷贝,可以按负载阈值自动限速暂停,因此对主从延迟的影响比直接 ALTER 可控;canal 解决"数据如何搬运"——它是增量通道,把 binlog 变成可投递给新分片、搜索引擎、数仓的数据流。二者组合常见于"既要改结构又要换分片"的迁移,而分批校验与灰度切流是它们之上必须有的一层业务保障。
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 复制模式决定丢数据窗口 | 异步丢"已提交未送出";AFTER_COMMIT 会丢"已提交且已被读到";AFTER_SYNC 把丢失换成可能重复;MGR 由多数派保证不丢 | 认为"半同步就不丢数据",忽略了它只等 1 台 ACK 且超时退化 |
| relay log 不能省 | 它把"网络拉取"与"事务重放"解耦,并提供可崩溃恢复、可随机读的本地位点 | 以为从库直接重放主库 binlog 文件;说不出并行重放为什么需要本地文件 |
| GTID 的优势是位点可推导 | 故障切换、重复投递幂等、拓扑重建都靠 gtid_executed 集合判差 | 只背"GTID 比位点好",答不出它如何避免重复执行 |
| 延迟必须按原因分治 | 五类原因对应并行复制、拆事务、分离读流量、在线 DDL、主从同规格五种手段 | 一遇到延迟就答"开并行复制",但延迟来自大事务或从库慢查询时无效 |
Seconds_Behind_Master 只是 event 时间差 | 主库静默、时钟漂移、只盯着当前 event 三类场景全部失真 | 把它当成"从库落后多少秒",并据此判断读写分离是否安全 |
| 脑裂用多数派与 fencing 防 | 少数派不能提交 + 切换前物理或逻辑隔离旧主 | 只答"部署双主加 keepalived",说不出网络分区时如何避免双写 |
| 分表阈值取决于行宽与内存 | 2000 万行来自"行宽 1 KB、三层 B+ 树"的算例,行宽变小则阈值上移一个数量级 | 把 2000 万当成 MySQL 的硬限制,或答"分表能提升查询性能" |
| XA 的代价是锁与协调者 | prepare 后持锁阻塞、协调者单点、中间态难处理 | 认为"XA 是分布式事务的标准答案",说不出它在高并发下的具体阻塞点 |
自测题
Q1(计算)页 16 KB、页内可用 16 200 B,主键为 BIGINT(8 B),页号指针 6 B。请估算三层 B+ 树在行宽 1 KB 与行宽 120 B 时分别能容纳多少行,并说明"单表 2000 万行就该分表"这句话哪里不严谨。
答案与解析
非叶子节点每条 8 + 6 ≈ 14 B,每页约 1170 条指针;二层树 1170 × 每叶行数,三层树 1170² × 每叶行数。行宽 1 KB 时每叶约 16 行,三层约 1170² × 16 ≈ 2.2 × 10⁷;行宽 120 B 时每叶约 135 行,三层约 1170² × 135 ≈ 1.8 × 10⁸。
不严谨之处:结论被行宽绑定,而被省略的前提更关键——buffer pool 能否装下热数据。页在内存命中约 100 ns,落盘随机读约 100 μs,差三个数量级;热数据驻留内存时,几亿行的单表点查依然可以很快。错答典型:直接说"MySQL 单表超过 2000 万行性能就会急剧下降"。面试官会追问:“这 2000 万的假设是什么?如果我的表只有 5 列窄字段、buffer pool 有 64 GB 呢?”
Q2(判断)主库崩溃,其中一个已提交事务尚未被任何从库收到。分别判断在异步、wait_point=AFTER_COMMIT、wait_point=AFTER_SYNC 三种配置下,这个事务会不会造成"用户读到过、之后又消失"的不一致。
答案与解析
异步:主库提交即返回,用户确实读到过(同一主库上的其他会话也读得到),故障切换到从库后消失 ⟹ 会产生该不一致。
AFTER_COMMIT:主库先提交引擎、再等 ACK。崩溃点在提交与收到 ACK 之间时,事务已在本地提交且对其他会话可见,而提升的从库没有它 ⟹ 同样产生该不一致,这是它被 AFTER_SYNC 取代的原因。
AFTER_SYNC:主库等 ACK 后才提交引擎。崩溃时本地从未提交,其他会话从未读到 ⟹ 不产生"读到过又消失"。但要注意两个后续影响:已 ACK 的从库可能已经有该事务,切换后它存在而客户端收到过失败、可能重试 ⟹ 变成重复写;以及"已提交"的数据在从库上可能还在 relay log 里没重放,切换后读到旧值。
错答典型:答"AFTER_SYNC 就是零丢失",说不出它把丢失换成了重复;或答"半同步等从库执行完才返回",混淆了 ACK 收到事件与 apply 完成。面试官会追问:“那重复怎么解决?“答案是幂等键(唯一索引/去重表/状态机),与第 34 章 34.5 节的补偿思路一致。
Q3(分析)监控面板上 Seconds_Behind_Master 一直是 0,但业务反馈"下单后立刻查订单查不到”。请给出至少三种可能原因,并说明你会改用哪些指标确认。
答案与解析
可能的 0 值来源:① 主库写入稀疏,SQL 线程大部分时间空闲,没有"正在执行的 event"可比,于是显示 0;② 从库 IO 线程已断(8.0 会显示 NULL,但某些版本/代理层会把 NULL 显示成 0),此时根本不在复制;③ relay log 里有积压但 SQL 线程刚执行完的事件时间戳很新;④ 主从时钟漂移,主库时钟慢于从库导致计算出的差值被截断为 0。
确认手段:pt-heartbeat 看秒级端到端延迟;Retrieved_Gtid_Set 与 Executed_Gtid_Set 求差集看是否真的追平;SHOW REPLICA STATUS 看 Replica_IO_Running/Replica_SQL_Running 与 Last_IO_Error;从库上 SELECT 心跳表与本地时间比对。
错答典型:答"延迟为 0 说明复制正常,问题在读连接池没切换”。面试官会追问:“你怎么区分’真的追平’和’指标骗你’?“能报出 pt-heartbeat 与 GTID 差集就是加分点。
Q4(设计)订单表按 user_id 取模分成 4 个库,现在要支持"按创建时间倒序的运营后台列表,允许跳页到第 5 万页”。给出两种方案并比较代价;如果你还需要在这个列表上按商家维度筛选,问题会变成什么?
答案与解析
方案一:各分片取前 offset + size 行归并。第 5 万页、每页 20 条意味着 offset = 1000000,4 个分片各取 1000020 行 ⟹ 网络与内存放大 400 万行,不可接受;只适合前几十页的浅分页。
方案二:二次查询法——先各分片取 LIMIT 250000, 20,用各片第 250001 行的排序值中的最小值作为全局边界,再按该边界回查各片并归并。代价是多一轮查询与一次全分片扫描;收益是避免了 400 万行的放大。
方案三(工程上最常用):禁止随机跳页,改成游标翻页 WHERE create_time < :last ORDER BY create_time DESC LIMIT 20。代价是不能跳到任意页,收益是每个分片都只取 20 行。
加入按商家筛选后,新问题是"分片键是 user_id,而筛选维度是商家”,必须扫全部 4 个分片才能得到该商家的数据(分片裁剪失效),而且越权风险会出现——后台一次查询就会打满所有分片。可选解法:按商家维度另建一份数据(异构索引表/ES 宽表),代价是双写与一致性维护。
错答典型:只答"用 ES 就行",说不出为什么分片键决定裁剪、也说不出游标翻页不能跳页这个业务代价。面试官会追问:“预聚合表怎么保证和订单表一致?”
Q5(设计)支付回调里"写订单库(主库)后立刻在主库上读回校验,再去从库查列表"。上线后偶发列表里看不到刚支付的订单。请给出两种解法并比较代价;若要求 100% 写后读一致且可接受写延迟上升,选哪个?
答案与解析
解法一:写后读走主库——写入时把本次事务的 GTID 或"用户维度标记"放进上下文,该会话在 N 秒内的读强制路由主库。代价是主库读压力上升、标记需要跨线程/跨服务传递(ThreadLocal 在异步线程池会丢)。
解法二:GTID 等待——主库返回时拿到 gtid_executed 的新增部分,在从库上执行 WAIT_FOR_EXECUTED_GTID_SET('uuid:1-500', 1) 之后再读。代价是额外等待与可能超时,需要 GTID 与中间件支持,但只对关键请求付出代价。
解法三:半同步(AFTER_SYNC)只能保证数据不丢,不能保证从库已 apply,所以单靠它解决不了本问题;要"任意节点可读"需要 MGR 的 group_replication_consistency=AFTER(写等到多数派 apply 完再返回)。
若要求 100% 写后读一致且接受写延迟上升,选 MGR 的 AFTER(或半同步 + GTID 等待兜底),因为强制走主库无法覆盖"用户下一次请求打到另一台机器"的场景。错答典型:答"加缓存/延迟双删"却不解释缓存与从库两个旧值来源;或答"把从库延迟改成 0 就行",说不出延迟不可能被消灭,只能被绕过或等待。
面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 半同步复制和异步复制的区别是什么?半同步能保证不丢数据吗? | 提交返回时机与丢失窗口 | 错答"半同步等从库执行完才返回主库",或答"半同步保证不丢"。加分点:说清只等 1 台 ACK、超时退化为异步、AFTER_SYNC 把丢失换成重复 | 26.1 |
rpl_semi_sync_master_wait_point 的 AFTER_SYNC 和 AFTER_COMMIT 差在哪里?为什么现在默认 AFTER_SYNC? | 时序与可见性 | 错答"两个只是参数名不同"。必须能画出"引擎提交在前还是在后",并说出 AFTER_COMMIT 会产生"读到过又消失" | 26.1 |
| 从库为什么需要 relay log?直接把主库的 binlog 拿来重放不行吗? | 组件存在理由 | 错答"relay log 就是 binlog 的副本,为了备份"。加分点:解耦拉取与重放、崩溃恢复位点、并行重放需随机读、主库 binlog 会过期 | 26.2 |
| GTID 相对 binlog 位点好在哪? | 故障切换与幂等 | 错答"GTID 更短更好记"。必须说出自动找位点、重复事务跳过、拓扑重建清晰,并能补一句 gtid_purged 误操作会永久失联 | 26.2 |
| 主从延迟很大,你怎么排查和解决? | 归因能力 | 错答上来就"开并行复制"。加分点:先分类(单线程重放/大事务/从库读压力/DDL/硬件),按类给手段,并能报出大事务监控口径 | 26.3 |
WRITESET 为什么比按库并行(DATABASE)粒度细?它有什么前提? | 依赖判定机制 | 错答"WRITESET 就是更快的按库并行"。必须说出行级哈希集合判交、binlog_transaction_dependency_tracking,以及无主键表无法计算 writeset 会退化为串行 | 26.3 |
Seconds_Behind_Master 显示 0,是不是说明从库没延迟? | 指标口径 | 错答"是"。要能给出主库静默、时钟漂移、积压偏小三例,并说出 pt-heartbeat 与 GTID 差集替代 | 26.4 |
| 读写分离下刚写完就读不到,怎么解决? | 一致性方案谱系 | 错答只答"强制走主库"且说不出标记怎么跨请求传递。加分点:给出 GTID 等待、MGR 一致性级别、会话粘滞、缓存兜底及各自代价 | 26.5 |
| 主库挂了自动切换,会不会出现双主写入(脑裂)?怎么防? | 故障场景与隔离 | 错答"用 keepalived 就不会脑裂",说不出网络分区下判定与事实不一致的机制。加分点:多数派、fencing/STONITH、super_read_only 与账号回收 | 26.6 |
| 分库分表为什么常听说"单表 2000 万行"这个数字?它是硬阈值吗? | 容量估算 | 错答"是 MySQL 的限制"。必须能给三层 B+ 树算例并指出行宽与 buffer pool 才是决定因素 | 26.7 |
| 分库分表后跨库分页和跨库 JOIN 怎么办? | 方案与代价 | 错答"用 ShardingSphere 就自动解决了"。加分点:二次查询法步骤、冗余字段/广播表/ER 分片、聚合时 AVG 与 COUNT(DISTINCT) 的改写 | 26.8 |
| XA 事务有什么问题?为什么互联网场景更爱最终一致? | 2PC 缺陷与取舍 | 错答"XA 性能差"就结束。必须说出 prepare 后持锁阻塞、协调者单点、中间态在备份与切换中的麻烦,以及补偿方案换来了什么 | 26.9 |
| 数据库扩容怎么做到不停机? | 迁移工程 | 错答"主从切换就行"。加分点:全量+binlog 增量、双写幂等、影子表、分批校验、灰度切流与回退预案,并说清 gh-ost 与 canal 各自管什么 | 26.10 |
延伸阅读
- MySQL 官方手册 “Replication” 章:复制线程与
SHOW REPLICA STATUS字段定义、WAIT_FOR_EXECUTED_GTID_SET()的语义与超时行为。 - MySQL 官方手册 “Group Replication” 章:认证(certification)过程、
group_replication_consistency各级别的可见性保证、流控与成员驱逐超时。 - MySQL 官方手册 “XA Transactions” 节:
XA START/END/PREPARE/COMMIT状态机与XA RECOVER输出格式。 - Percona Toolkit 文档:
pt-heartbeat(延迟口径)、pt-table-checksum与pt-table-sync(切换后校验与修复)。 - 《高性能 MySQL》中复制、扩展与分片相关章节;gh-ost 与 canal 的项目文档(在线 DDL 执行与 binlog 增量订阅的定位差异)。