单机数据库的容灾、读扩展、单表容量三个天花板,分别对应复制、高可用与分库分表。本章只讲它们的丢数据窗口与失效边界。

学习目标

读完本章,你应当能够:

  1. 推导异步、半同步(AFTER_SYNC / AFTER_COMMIT)与组复制各自的丢数据窗口,判断给定崩溃时刻会不会丢"已提交"数据;
  2. 画出主库 dump 线程、从库 IO(receiver)线程与 SQL(applier)线程的分工,解释 relay log 为什么必须存在,以及 GTID 相对位点加偏移量的三项优势;
  3. 对"主从延迟很大"按五个原因逐项排查,并用 WRITESET 说明并行复制为什么能做到行级粒度;
  4. 指出 Seconds_Behind_Master 的失真场景,改用 pt-heartbeat、GTID 差集等口径;
  5. 判断一次网络分区会不会造成脑裂,给出预防手段与切换后的一致性校验流程;
  6. 用行宽与 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、每表需显式主键、要求低延迟网络

半同步的两种时机是高频追问点,务必能画出时序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
AFTER_COMMIT(旧默认,5.7.2 之前)

  客户端 ── COMMIT ──▶ 主库:写 binlog → 引擎提交(其他会话此刻已能读到新值)
                        │ dump 线程发送事件
                        │ 等从库 ACK(最多 rpl_semi_sync_master_timeout,默认 10000 ms)
  客户端 ◀── 成功 ──────┘
  崩溃点在"引擎已提交"与"收到 ACK"之间:
    本地已提交、已被其他会话读取,但故障切换到没收到该事务的从库
    ⟹ 已提交数据凭空消失。这是最坏的一类不一致(读到过的东西后来又没了)

AFTER_SYNC(5.7.2 起默认)

  客户端 ── COMMIT ──▶ 主库:写 binlog → 发事件给从库
                        │ 等 ACK
                        │ 收到 ACK 后才提交引擎、才返回客户端
  崩溃点在等 ACK 期间:
    本地从未提交 ⟹ 不存在"已提交又丢失",客户端只看到失败
    但已 ACK 的从库上可能已有该事务 ⟹ 切换后它存在,客户端重试即产生重复写
    ⟹ AFTER_SYNC 把"丢数据"换成了"可能重复",而重复可以用幂等键解决

重要

半同步的准确表述是"至少一台从库收到 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

复制的执行体是三个线程加一个文件,模型不复杂,但每个组件的存在理由都是面试点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
       主库(source)                                    从库(replica)
 ┌───────────────────────────┐                  ┌──────────────────────────────────┐
 │ 客户端事务提交             │                  │                                  │
 │        │                  │                  │  ┌────────────────────────────┐  │
 │        ▼                  │                  │  │ IO 线程(receiver)        │  │
 │   binlog(按 event 追加)  │   ① 复制连接      │  │ · 向主库请求"从某位点开始   │  │
 │        │                  │ ◀───────────────▶│  │   的后续 binlog"           │  │
 │        ▼                  │   ② 事件流        │  │ · 收到的 event 顺序写       │  │
 │  dump 线程(binlog dump)  │ ────────────────▶│  │   relay log                │  │
 │  · 持锁读 binlog           │   ③ ACK(半同步) │  │ · 更新已拉取位点(receiver  │  │
 │  · 按 event 顺序发送       │ ◀────────────────│  │   status / slave_master_info)│ │
 │  · 收到 ACK 后推进位点     │                  │  └──────────────┬─────────────┘  │
 └───────────────────────────┘                  │                 ▼                │
  注意:dump 线程不读数据页、    │                │        relay log(本地文件)      │
  不重放事务,它只搬运 event      │                │                 ▼                │
                                               │  ┌────────────────────────────┐  │
                                               │  │ SQL 线程 / worker 池        │  │
                                               │  │ · 读 relay log,按事务重放   │  │
                                               │  │ · 记录已执行位点(applier    │  │
                                               │  │   status / slave_relay_log_ │  │
                                               │  │   info)                    │  │
                                               │  └──────────────┬─────────────┘  │
                                               │                 ▼                │
                                               │        存储引擎(重放后的数据)    │
                                               └──────────────────────────────────┘

relay log 为什么不能省。 直觉方案是"IO 线程收到事件直接交给 SQL 线程重放",省掉一次落盘。实际不能这么做,四个理由:

  1. 拉取与重放必须解耦。 二者速度不同:IO 线程受网络带宽限制(同机房 RTT 约 0.1~0.5 ms),SQL 线程受磁盘与锁限制。解耦后从库可以在主库切换、网络中断时继续重放已拉取的部分,而不是卡在网络读上。
  2. 位点必须能崩溃恢复。 relay log 是本地文件,SQL 线程执行到哪个位置可以持久化,重启后从该位置续跑;网络流没有"可回退的本地副本"。
  3. 并行重放需要随机访问。 多个 worker 要各自从 relay log 里取不同事务,必须有可随机读、可分段定位的本地文件,网络流只能顺序消费。
  4. 主库的 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,禁止在从库跑无索引聚合
大表 DDLDDL 在从库上是单条事件,不可并行、不可拆分,执行时长与主库相当;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)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
 主库的提交流(binlog_transaction_dependency_tracking 决定依赖信息怎么算)

   会话 A: t1 改 user(id=1)        t3 改 user(id=1)     ← 与 t1 同主键 ⟹ 必须保序
   会话 B: t2 改 user(id=7)        t4 改 order(id=5)    ← 与 A 无交集 ⟹ 可与 t1 并行

   COMMIT_ORDER(默认):只按主库组提交分组。
     同组提交 ⇒ 无锁冲突 ⇒ 从库可并行。若主库 QPS 低、提交不重叠,
     一组里常常只有 1 个事务 ⟹ 退化成串行。粒度 = "组"。
   WRITESET:为每个事务算出写入行的哈希集合(transaction_write_set_extraction,
     XXHASH64),只有 writeset 有交集才保序 ⟹ 上例 4 个事务里 3 个可在从库并行。
     粒度 = "行",比按库并行(DATABASE)细几个数量级。
   WRITESET_SESSION:在 WRITESET 上追加"同一会话内必须保序",
     因为同一连接里 t1 与 t3 可能有业务因果(先插入后更新)⟹ 上例 A 的两个事务永不并行。

 从库 worker 池(replica_parallel_workers = 4;5.7 默认 0,8.0.27 起默认 4)

   worker0: t1 ──▶ t3        worker1: t2
   worker2: t4               worker3: 空闲
   replica_parallel_type = LOGICAL_CLOCK(8.0 默认;5.7 默认 DATABASE 按库并行)
   replica_preserve_commit_order = ON ⇒ 各 worker 仍按主库提交顺序进入 commit,
     否则从库上的可见顺序与主库不同,基于从库的一致性读会读到"未来"的数据

slave_parallel_type=DATABASE 的致命局限要能一句话说清:它按库分 worker,同一个库上的事务必须进同一个 worker。真实业务往往是"一个库、几十张表",于是并行度恒为 1;这也是它被 LOGICAL_CLOCK 取代的原因。WRITESET 的局限同样要能说:writeset 由主键或唯一键计算,没有主键的表算不出行标识,只能退化为与所有事务冲突(等于串行),所以"每张表都要有显式主键"不是风格建议而是并行复制的前提。

26.4 Seconds_Behind_Master 为什么不准

它的算法简单到可以一句话写出来:当前时间 − SQL 线程正在执行的这个 event 的时间戳(时间戳在 event 头部,由主库写入时打上)。由此得到三类失真:

失真场景现象原因
主库长时间无写入显示 0,甚至业务明显读到旧数据也显示 0SQL 线程空闲,没有"正在执行的 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 的算例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
 假设:主键 BIGINT 8 B,页 16 KB,页内可用约 16 200 B(扣页头页尾)
 非叶子节点:8 B 主键 + 6 B 页号 ≈ 14 B/条(含页内开销)⟹ 每页约 1170 条指针
 叶子节点(行宽 1 KB):16 200 / 1024 ≈ 16 行/页
   2 层(根 + 叶)  :1170 × 16        ≈ 1.9 万行
   3 层(根 + 中 + 叶):1170² × 16     ≈ 2.2 × 10⁷ ≈ 2000 万行   ← 那个数字的来源
 同样的 3 层,若行宽只有 100 B(16 200 / 100 ≈ 162 行/页):
   1170² × 162 ≈ 2.2 × 10⁸ ≈ 2 亿行
 行宽差 10 倍,阈值差 10 倍 ⟹ "2000 万"是行宽的结论,不是 MySQL 的限制

 真正决定性能的是热数据能否留在 buffer pool(默认 128 MB,线上常配到几十 GB):
   页在内存在命 ⟹ 一次访问约 100 ns;页不在内存落盘 ⟹ 一次 SSD 随机读约 100 μs
   两者差 3 个数量级 ⟹ 若热数据长期驻留,几亿行的单表也可以不拆

因此正确的判断顺序是:先看单表数据总量与热数据量、行宽、二级索引数量、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 去重后合并;或定时预聚合到汇总表(代价是时效与额外链路)

跨库深分页的"二次查询法"是最值得讲清的一个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
目标:SELECT * FROM product WHERE category = ? ORDER BY price LIMIT 1000000, 20
分片数 N = 4

第一次查询(每片取全局 offset 的 1/N,多取 20 行):
  分片 i: SELECT * FROM product WHERE category = ? ORDER BY price LIMIT 250000, 20
  得到 4 组结果 ⟹ 记每片第 250001 行的 price 为 p_i,取 min(p_i) 作为全局边界候选 p_min
  (因为全局第 1000001 行必然不小于各片第 250001 行的最小值)

第二次查询(用 p_min 把"可能进入全局前 1000020 名"的行全部捞回来):
  分片 i: SELECT * FROM product WHERE category = ? AND price BETWEEN p_min AND p_i
          ORDER BY price
  各片返回行数远小于 250020 ⟹ 在内存中归并排序,取全局第 1000001~1000020 行

代价:多一轮查询与一次全分片扫描;收益:避免了每片取 1000020 行的网络与内存放大
工程上更常用的是"禁止跳页 + 游标":WHERE price > :last_price ORDER BY price LIMIT 20

26.9 XA 事务与为什么最终一致更受欢迎

MySQL 的 XA 实现就是教科书式两阶段提交:XA START 进入 ACTIVE,XA END 结束语句阶段进入 IDLE,XA PREPARE 完成阶段一并把状态持久化(此后崩溃可恢复),最后 XA COMMIT / XA ROLLBACK 完成阶段二。XA RECOVER 列出所有处于 PREPARE 状态的事务。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 阶段一:各参与者在 PREPARE 后都持有行锁,且都具备崩溃后恢复的能力
XA START 'xid-order-20240501-0001';
UPDATE account SET balance = balance - 100 WHERE id = 1;   -- 扣款
XA END 'xid-order-20240501-0001';
XA PREPARE 'xid-order-20240501-0001';   -- 关键点:锁不释放,但改动对其他会话仍不可见
-- 协调者在所有参与者都 prepare 成功后才决定提交
XA COMMIT 'xid-order-20240501-0001';

-- 协调者宕机后的排查:找出悬空的 PREPARE 事务(长时间不处理会一直占锁)
XA RECOVER;
-- 输出示例:formatID=1, gtrid_length=21, bqual_length=4,
--          data='xid-order-20240501-0001-0001'

三个问题是面试的固定追问点:

  1. 同步阻塞。 XA PREPARE 之后行锁一直持到阶段二,其他事务写同一行会阻塞到 innodb_lock_wait_timeout(默认 50 s);若协调者迟迟不决策,连接与锁会持续堆积。这是"强一致换吞吐"的直接代价。
  2. 协调者单点。 阶段一全部成功后协调者崩溃,参与者无法自行决定提交还是回滚,只能保留锁等待;恢复要靠协调者的日志重放或人工/补偿脚本,这段时间业务是挂住的。
  3. prepare 后的中间态很难处理。 事务既没提交也没回滚:改动对其他会话不可见(一致性读看不到),但锁被持有;此时做备份、主从切换或 DDL 都可能踩到它——切换走的从库可能根本没有这个 prepare(取决于复制模式),而旧主上它已被持久化,一提交就产生分叉。数据库内部还有一层两阶段提交(redo/binlog 的 prepare/commit,见第 21 章 21.6 节),两层叠在一起让排障更难。

互联网业务更倾向最终一致,原因不是"XA 不好",而是三笔账:XA 把可用性交给协调者、把吞吐交给锁、把故障处理交给人工;而业务侧通常已有幂等键与补偿能力,用 TCC/Saga/事务消息(本地消息表 + 至少一次投递 + 幂等消费)可以把"长事务"拆成"若干本地短事务 + 一个可重试的补偿流程",代价变成"短暂不一致窗口"而非"锁等待"。方案对比与各模式的不一致窗口见第 34 章 34.5 节。

26.10 在线扩容与数据迁移

扩容的本质是"让新库追上旧库,再把流量搬过去",且每一步都要能停下和回退。

  1. 影子表/新分片就位:先建新结构,不接流量。
  2. 全量 + 增量同步:全量用一致性快照(mysqldump --single-transaction --master-data,或用 MyDumper 类工具)拿一个位点;增量用 binlog 订阅,canal 就是把自己伪装成 MySQL 从库、请求 dump 线程的事件流,解析成行变更后投递到消息队列,下游按行应用。全量位点与增量起点对齐后,新库才可能追平。
  3. 双写与校验:写请求同时写新旧两侧(双写不是原子的,必须配幂等键 + 失败重试 + 定期对账),同时按分片/主键区间分批做校验和比对,差异率降到阈值以下才允许切读。
  4. 灰度切流:读流量按用户/租户/百分比逐步切到新库(1% → 5% → 50% → 100%),每一步盯错误率与 P99 延迟;写流量不能两头都开,通常按"用户分片路由"让不同用户先迁到不同库,最后再把路由表整体切过去。
  5. 收尾与回退:确认追平后再停旧库写入;旧库与全量快照保留一段时间用于回退,回退预案要能覆盖"新库写入如何反向补偿"。

在这个流程里,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 增量订阅的定位差异)。