# 第 26 章 复制、高可用与分库分表


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

## 学习目标

读完本章，你应当能够：

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、每表需显式主键、要求低延迟网络 |

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

```
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 把"丢数据"换成了"可能重复"，而重复可以用幂等键解决
```

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

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

```
       主库（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 已被主库清理时报错信息明确，而位点式只会得到一个错位的沉默结果。

> [!NOTE]
> 面试官常追问的下一层是"开了 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`）。

```
 主库的提交流（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，甚至业务明显读到旧数据也显示 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，但要业务改造 |

> [!TIP]
> 面试话术模板："`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 的算例：

```
 假设：主键 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` 去重后合并；或定时预聚合到汇总表（代价是时效与额外链路） |

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

```text
目标：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 状态的事务。

```sql
-- 阶段一：各参与者在 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 万行就该分表"这句话哪里不严谨。
<details><summary>答案与解析</summary>

非叶子节点每条 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 呢？"

</details>

**Q2**（判断）主库崩溃，其中一个已提交事务尚未被任何从库收到。分别判断在异步、`wait_point=AFTER_COMMIT`、`wait_point=AFTER_SYNC` 三种配置下，这个事务会不会造成"用户读到过、之后又消失"的不一致。
<details><summary>答案与解析</summary>

异步：主库提交即返回，用户确实读到过（同一主库上的其他会话也读得到），故障切换到从库后消失 ⟹ 会产生该不一致。

AFTER_COMMIT：主库先提交引擎、再等 ACK。崩溃点在提交与收到 ACK 之间时，事务已在本地提交且对其他会话可见，而提升的从库没有它 ⟹ 同样产生该不一致，这是它被 AFTER_SYNC 取代的原因。

AFTER_SYNC：主库等 ACK 后才提交引擎。崩溃时本地从未提交，其他会话从未读到 ⟹ 不产生"读到过又消失"。但要注意两个后续影响：已 ACK 的从库可能已经有该事务，切换后它存在而客户端收到过失败、可能重试 ⟹ 变成重复写；以及"已提交"的数据在从库上可能还在 relay log 里没重放，切换后读到旧值。

**错答典型**：答"AFTER_SYNC 就是零丢失"，说不出它把丢失换成了重复；或答"半同步等从库执行完才返回"，混淆了 ACK 收到事件与 apply 完成。面试官会追问："那重复怎么解决？"答案是幂等键（唯一索引/去重表/状态机），与第 34 章 34.5 节的补偿思路一致。

</details>

**Q3**（分析）监控面板上 `Seconds_Behind_Master` 一直是 0，但业务反馈"下单后立刻查订单查不到"。请给出至少三种可能原因，并说明你会改用哪些指标确认。
<details><summary>答案与解析</summary>

可能的 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 差集就是加分点。

</details>

**Q4**（设计）订单表按 `user_id` 取模分成 4 个库，现在要支持"按创建时间倒序的运营后台列表，允许跳页到第 5 万页"。给出两种方案并比较代价；如果你还需要在这个列表上按商家维度筛选，问题会变成什么？
<details><summary>答案与解析</summary>

方案一：各分片取前 `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 就行"，说不出为什么分片键决定裁剪、也说不出游标翻页不能跳页这个业务代价。面试官会追问："预聚合表怎么保证和订单表一致？"

</details>

**Q5**（设计）支付回调里"写订单库（主库）后立刻在主库上读回校验，再去从库查列表"。上线后偶发列表里看不到刚支付的订单。请给出两种解法并比较代价；若要求 100% 写后读一致且可接受写延迟上升，选哪个？
<details><summary>答案与解析</summary>

解法一：写后读走主库——写入时把本次事务的 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 就行"，说不出延迟不可能被消灭，只能被绕过或等待。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 半同步复制和异步复制的区别是什么？半同步能保证不丢数据吗？ | 提交返回时机与丢失窗口 | 错答"半同步等从库执行完才返回主库"，或答"半同步保证不丢"。加分点：说清只等 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 增量订阅的定位差异）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch26/  

