MySQL 面试高频十五题:索引 · 事务 · 日志 · 并发 · 分布式

这十五个考点在面试里出现的频率高到几乎是必考,但它们不是散点,是三条链。

第一条,开发视角:索引怎么存 → 怎么找到数据 → 什么时候用不上索引 → 并发读写怎么互不干扰 → 锁加在哪儿。

第二条,DBA 视角:日志怎么保证不丢 → 内存怎么加速 → 出事了怎么恢复 → 死锁了怎么查。

第三条,架构视角:单机扛不住怎么拆 → 拆完怎么迁 → 迁完怎么把变更送出去。

把三条链走通,比单独背十五段答案有用得多。三辑交互图,各五个考点:

▶ 第一辑:索引与事务

▶ 第二辑:内核与运维

▶ 第三辑:并发与分布式

建议在电脑上打开。第一辑可以拖滑块算 B+ 树容量、切隔离级别看 MVCC;第二辑点选日志看它落在哪一层、拖时间轴看备份恢复到哪一秒;第三辑可以一步步走完一次死锁、输入 user_id 实时算它落到哪个分片。

三辑分别覆盖什么

第一辑(索引与事务) 第二辑(内核与运维) 第三辑(并发与分布式)
考点 B+ 树 · 回表 · 最左前缀 · MVCC · 间隙锁 三种日志 · Buffer Pool · 三种异常 · EXPLAIN · 备份恢复 死锁 · 索引下推 · 分库分表 · 不停服迁移 · CDC
对应题库板块 架构与存储引擎、B+ 树、索引设计、事务与锁 架构与存储引擎、日志与两阶段提交、SQL 调优、备份与恢复 事务与锁(进阶)、SQL 调优、架构设计、运维与高可用
覆盖题量 约 30 道(含重复考点) 约 28 道 约 30 道(含架构与运维大题)

为什么是这三组:第一组回答「一条查询为什么慢、为什么读到旧值」,第二组回答「数据为什么不会丢、内存里发生了什么、出事了怎么救」,第三组回答「高并发下会怎么死、单机扛不住怎么办」。前两组偏单机,第三组偏系统——你转 DBA,最后一定会走到这一层。

一、B+ 树:为什么是它,以及 3 层到底能放多少行

面试原题

「MySQL 的索引为什么用 B+ 树,不用 B 树、红黑树或者哈希?」

这题答「因为 B+ 树查询效率高」等于没答。真正要给出的是数量级上的差别。

先把容量算出来

InnoDB 的存储单位是页,一页 16 KB。B+ 树的非叶子节点只存「键 + 页指针」,不存数据。

  • 主键假设是 bigint:8 字节
  • 页指针:6 字节
  • 一条索引项 = 14 字节

一页能放 16384 ÷ 14 ≈ 1170 条。这就是 B+ 树的扇出(fan-out)。

叶子节点要存完整行。假设一行 1 KB,一页放 16 行。那么:

层数 容量 含义
2 层 1170 × 16 ≈ 1.9 万行 根节点直接指向叶子
3 层 1170 × 1170 × 16 ≈ 2190 万行 最常见的生产形态
4 层 1170³ × 16 ≈ 256 亿行 超大表才会到

也就是说,一张两千万行的表,查一行只需要走 3 层。而且根节点几乎必定常驻 Buffer Pool,中间层也很少被淘汰,真实物理 IO 通常只有 1~2 次。

为什么 B 树不行(这道题的关键对比)

B 树的非叶子节点也存数据。同样假设一行 1 KB,一页就只能放 16 条:

  • 3 层 B 树 = 16 × 16 × 16 = 4,096 行

同样 3 层、同样 3 次 IO,B+ 树能管 2190 万行,B 树只能管 4 千行。差了五千倍。

差别只有一个来源:B+ 树的非叶子节点不存数据,所以一页能塞下 1170 个指针而不是 16 个。

顺带回答另外两个:

  • 红黑树:二叉树,2000 万行树高约 log₂(2000万) ≈ 24,24 次 IO。而且每个节点一次磁盘寻址,缓存友好性差。
  • 哈希:只支持等值查询。范围查、排序、最左前缀全都不行。InnoDB 有自适应哈希索引,但它只是 Buffer Pool 上的加速层,不是主结构。

追问:为什么推荐自增主键

两个原因,都和上面的扇出有关。

第一,避免页分裂。 自增值总是插在最后,新页顺序追加。用 UUID 这种随机值,插入点散布在整棵树里,会不断触发页分裂和页重组。

第二,主键越短,扇出越大。 这条最容易被忽略。把 bigint(8 字节)换成 char(36) 的 UUID:

  • 36 + 6 = 42 字节/条 → 16384 ÷ 42 ≈ 390 条/页
  • 3 层容量 = 390 × 390 × 16 ≈ 243 万行

同样是 3 层,容量从 2190 万掉到 243 万,少了将近 9 倍。如果非要用 UUID,至少存成 binary(16)。

常见答错

  • 「B+ 树快是因为它平衡」——平衡只保证最坏情况不退化,不代表快。快的原因是层数少。
  • 「B+ 树固定 3 层」——层数取决于行大小。一行 4 KB 的表,叶子页只能放 4 行,3 层就只有 547 万行。

二、回表:多查一个字段,为什么代价翻倍

面试原题

「什么是回表?什么情况下可以避免?」

先把两种索引分清楚

表 t(id 主键, a, b),在 a 上建普通索引。

聚簇索引(主键索引):叶子节点存完整行。整张表的数据就挂在主键这棵树上。

二级索引(普通索引):叶子节点只存 索引列 + 主键值。idx_a 的叶子长这样:(5,1) (5,3) (7,2) (9,4)。

关键就在这——二级索引里没有 b。

两条 SQL 的差别

SELECT a FROM t WHERE a = 5;   -- 1 棵树
SELECT b FROM t WHERE a = 5;   -- 2 棵树

第一条:走到 idx_a,拿到 a=5 和主键 1、3。要的 a 已经在手里了,直接返回。这叫覆盖索引。

第二条:走到 idx_a,只拿到 1、3。b 不在索引里,必须拿主键再去聚簇索引查两次——这就是回表。

回表贵在哪

不是「多查一次」这么简单,是随机 IO。

二级索引按 a 排序,但回表时按主键去查,两者的顺序不一定一致。返回 1000 行,可能就是 1000 次随机读。这也是为什么 SELECT * 在大结果集上特别慢——它必然回表,还把用不到的列也捞出来。

三个避免办法

1. 覆盖索引——把要查的列加进索引。

ALTER TABLE t ADD INDEX idx_a_b (a, b);
-- 现在 SELECT a, b WHERE a = 5 不用回表了

代价:索引变大,占空间,写入时要多维护一棵树。

2. 索引条件下推(ICP)——MySQL 5.6+,把 WHERE 里的过滤条件下推到索引层先筛一遍,减少回表次数。

3. 延迟关联——大分页场景的经典写法:

-- 慢:先回表 100 万次,再丢掉 99 万条
SELECT * FROM t WHERE a = 5 LIMIT 1000000, 20;

-- 快:先在索引上定位 20 条主键,只回表 20 次
SELECT * FROM t
  JOIN (SELECT id FROM t WHERE a = 5 LIMIT 1000000, 20) x USING (id);

常见答错

  • 以为 SELECT id FROM t WHERE a = 5 要回表——不用,主键值本来就存在二级索引的叶子里。
  • 以为覆盖索引越多越好——每多一个索引,写入就多一份维护成本。

三、最左前缀:为什么 (a,b,c) 用不了 b

面试原题

「建了联合索引 (a,b,c),WHERE b = 2 能走索引吗?」

一句话原理

索引的记录是按 a 排序,a 相同再按 b,b 相同再按 c 存放的。

就像字典:先按拼音首字母排,首字母相同再按第二个字母。你要找「第二个字母是 m」的词,没法直接定位——因为每个首字母下面都有一堆 m。必须先确定前一列。

各种情况对照

条件 用于定位 说明
a = 1 a
a = 1 AND b = 2 a、b
a = 1 AND b = 2 AND c = 3 a、b、c 全用上,最优
a = 1 AND c = 3 a 跳过 b 就断了,c 只能 ICP 过滤
b = 2 无 没有 a 打头,整体定位不了
a = 1 AND b > 2 a、b b 是范围
a = 1 AND b > 2 AND c = 3 a、b b 用范围后,c 就断了
a > 1 AND b = 2 a 同上

重点澄清一个误解

「断了」不等于「完全没用」。

MySQL 5.6 之后有索引条件下推(ICP):虽然 c 不能缩小索引的扫描区间,但它可以在索引层先过滤掉不匹配的行,不必都回表。执行计划里会显示 Using index condition。

所以准确的说法是:不能用于定位,但可以用于过滤。

排序也能蹭索引

WHERE a = 1 ORDER BY b        -- 可以用索引有序性,无 filesort
WHERE a = 1 ORDER BY c        -- 不行,必须 filesort

原因同上:a = 1 这个前缀下,索引是按 b 有序的。c 只有在 b 也确定时才有序。

常见答错

  • 说「b = 2 完全用不上索引」——在某些情况下优化器会走全索引扫描(如果查询列都被索引覆盖),比全表扫快,但仍然是扫全部索引项。
  • 把「索引失效」和「索引不用于定位」混为一谈。

四、MVCC:同一行数据,两个人看到的不一样

面试原题

「MVCC 是什么?RR 和 RC 的区别在哪?」

这题的标准答案是三个词:隐藏列、版本链、ReadView。但要讲清楚,得按顺序来。

第一件:两个隐藏列

InnoDB 的每一行都有两个你看不见的列:

  • trx_id:最后修改这行的事务 ID
  • roll_pointer:指向 undo log 里这个版本的上一个版本

有了 roll_pointer,一行的所有历史版本就串成了一条链,方向是从新到旧。

第二件:版本链

假设初始 balance = 100,trx_id = 10。事务 T1(编号 20)执行 UPDATE ... SET balance = 200:

最新版本  trx 20 · balance = 200
            ↓ roll_pointer
undo 版本  trx 10 · balance = 100

注意:旧版本不是被覆盖掉,而是追加到 undo log 里,行上的 roll_pointer 指向它。这就是为什么长事务会撑爆 undo 表空间。

第三件:ReadView

ReadView 是你「能看见哪些事务」的快照,有四个字段:

字段 含义
m_ids 生成这一刻,还在活跃(未提交)的读写事务列表
min_trx_id m_ids 里的最小值
max_trx_id 下一个将要分配的事务 ID
creator_trx_id 创建这个 ReadView 的事务自己

判定规则,只有三条

拿着版本链从最新的往下走,对每个版本的 trx_id 判断:

  1. 小于 min_trx_id → 可见。这事务在我生成快照之前就提交完了。
  2. 大于等于 max_trx_id → 不可见。这事务在我之后才开。
  3. 在中间 → 看它是否在 m_ids 里:在 → 不可见;不在 → 可见(说明已经提交了)。

遇到第一个可见的版本就返回它。

RC 和 RR 的唯一区别

不是「隔离级别不同所以行为不同」,而是ReadView 什么时候生成:

ReadView 生成时机 结果
READ COMMITTED 每一条 SELECT 都新建 别人一提交,你下一条就能看到 → 不可重复读
REPEATABLE READ 第一次 SELECT 时建一个,整个事务复用 别人提交了你也看不到 → 可重复读

举个例子,T2 在 T1 提交前后各查一次:

  • RC:第一查读 100(T1 未提交),第二查读 200(T1 已提交)
  • RR:两次都读 100——因为 ReadView 没变,T1 在 T2 眼里始终是「活跃事务」

这就是 InnoDB 默认用 RR 的原因:同一事务内多次读,结果稳定。

但 MVCC 只管一半

这一点必须加进去,否则答案是不完整的:

MVCC 只对「快照读」生效。 普通 SELECT 是快照读;SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE 都是当前读,读最新版本并且加锁。

所以「RR 解决了幻读」这句话要拆开说:

  • 快照读下的幻读,靠 MVCC 解决
  • 当前读下的幻读,靠间隙锁解决

追问:长事务为什么危险

undo log 里的旧版本什么时候能删?当没有任何 ReadView 还需要它的时候(由 purge 线程清理)。

一个开了 10 分钟的事务,意味着这 10 分钟里所有被修改过的行,旧版本都不能删。版本链越拉越长,回滚段持续膨胀,purge 跟不上,最终磁盘涨满、查询变慢。

这也是「不要用 SELECT ... FOR UPDATE 做长流程」的实际原因。

常见答错

  • 说「RR 完全解决了幻读」——见上面的拆解。
  • 说「MVCC 让读写不阻塞,所以全都不加锁」——当前读照样加锁。

五、间隙锁:为什么会锁住一行不存在的记录

面试原题

「RR 隔离级别下,UPDATE ... WHERE id = 7(7 不存在)会加什么锁?」

背景:三种锁

锁 锁什么
Record Lock 索引上的一条记录
Gap Lock 两个记录之间的开区间(不含端点)
Next-Key Lock Record + Gap,左开右闭 (前一条, 本条]

InnoDB 在 RR 下默认用 Next-Key Lock。

举例:表里有 5、10、15、20

SQL 加锁范围 类型
WHERE id = 10 (5, 10] Next-Key Lock
WHERE id = 7(不存在) (5, 10) 纯间隙锁
WHERE id BETWEEN 8 AND 12 (5,10] + (10,15] 两个 Next-Key
WHERE id > 15 (15,20] + (20,+∞) 加最后一个间隙

第二行就是本题的答案:虽然 id = 7 这行不存在,但它落在 (5,10) 这个间隙里,InnoDB 会把整段间隙锁住——插入 6、7、8、9 全部阻塞。

为什么要锁不存在的行

为了防幻读。

如果只锁 id = 10 这一行,另一个事务插一条 id = 7 提交了,你的范围查询就会凭空多出一行。间隙锁的思路是:不让这个空隙里插进任何东西,从源头堵住。

一个必考特例

如果 id 是唯一索引,并且查询能命中唯一一行(WHERE id = 10),InnoDB 会把 Next-Key Lock 退化成 Record Lock——只锁那一行,不锁间隙。

因为唯一性已经保证不可能有第二行 id = 10,不需要防插入。

代价:为什么很多公司用 RC

间隙锁有两个麻烦:

  1. 间隙锁之间互相兼容——两个事务可以同时持有一段间隙锁,但都会阻塞插入。
  2. 插入被莫名卡住、偶发死锁,很大一部分是 next-key lock 造成的。

所以很多互联网公司把隔离级别降到 RC:间隙锁基本不生效,并发度明显提升,代价是可能出现幻读(业务上通常能接受,或者用其他手段兜底)。

常见答错

  • 说「间隙锁锁的是行」——锁的是间隙,是一段范围。
  • 说「RC 下没有幻读」——RC 有幻读,只是间隙锁少。

六、三种日志:位置决定归属

面试原题

「binlog、redo log、undo log 的作用和区别是什么?」

答题骨架

这题别一上来就背三个定义,先说位置——位置决定了归属,归属决定了用途:

  • binlog 在 Server 层,跟用什么引擎无关
  • redo 和 undo 在 InnoDB 层,换引擎就没了

这个开头一说,后面就顺了:因为 binlog 在 Server 层,所以主从复制、CDC 同步、按时间点恢复都靠它;因为 redo 在引擎层,所以它只管这一台机器的崩溃恢复。

三个关键差异

日志类型:redo 是物理日志,记「第 N 号表空间第 M 页偏移 X 处改成 Y」;undo 是逻辑日志,记「把 balance 改回 100」这样的反向操作;binlog 是逻辑日志,记「id=1 这行 a 从 1 变成 2」。

写入方式:redo 是循环写,文件大小固定,写满就回头覆盖(因为它只需要保证「最近的修改」能恢复);binlog 是追加写,一个文件写满换下一个(因为它可能要重放几个月前的)。这个差异常被追问「为什么 redo 不用无限增大」。

是否可关:redo 和 undo 不能关;binlog 能关(skip_log_bin),但关了就没主从、没法做时间点恢复。

必被追问:redo 为什么能保证不丢

因为 WAL(Write-Ahead Logging)——先写日志,再改数据页。

innodb_flush_log_at_trx_commit=1 时,事务提交前 redo 必须刷盘。而数据页是脏页,可以慢慢刷。这就是把「随机写 16KB 页」变成「顺序写日志」的关键,也是 InnoDB 比 MyISAM 抗崩溃的根本原因。

也是同一件事的另一面:为什么需要两阶段提交——redo 和 binlog 属于两个不同的层,必须保证「要么都写成功、要么都失败」,否则主从数据会不一致。

常见答错

  • 说「binlog 是 InnoDB 的日志」——这是最高频的错误,它是 Server 层的。
  • 说「undo log 只用于回滚」——它还支撑 MVCC 的版本链,少了它 RR 根本实现不了。

七、Buffer Pool:不是读磁盘,也不只是缓存

面试原题

「从 MySQL 获取数据,是从磁盘读取的吗?」

答题骨架

不是。先查 Buffer Pool(innodb_buffer_pool_size,建议物理内存 60%~70%),命中直接返回;未命中才把 16KB 的数据页从磁盘读进来。所以是「内存为主、磁盘兜底」,生产环境命中率应该 >99%。

但只答到这里,会被继续追问细节。要准备好三层:

第一层:三个链表。 free list(空闲页)、LRU list(缓存页)、flush list(脏页)。一次「读」的动作是:从 free list 取页 → 读入 → 挂到 LRU;一次「写」是:改内存页 → 进 flush list → 同时写 redo。

第二层:LRU 为什么分两段。 young 和 old 按约 5:3 分,新页先进 old 区,停留超过 innodb_old_blocks_time 后再次被访问才升到 young。这是为了防全表扫污染——否则一条扫两千万行的 SQL 会把所有热页挤出去,命中率一夜归零。

第三层:两个容易被忽略的组件。

组件 解决什么 限制
Change Buffer 非唯一二级索引的写,页不在内存时先记账,等页被读进来再 merge,把随机写变顺序写 唯一索引用不了,因为插入时必须读页校验唯一性
Doublewrite 16KB 页写到一半断电会「页断裂」,这时 redo 也救不了(redo 是物理日志,前提是页完整),所以先顺序写一份副本 占额外空间,不建议关

常见答错

  • 说「Change Buffer 对所有索引都有效」——唯一索引不行。
  • 说「有 redo 就不用 doublewrite」——redo 救得了「页写了一半」吗?救不了,这正是 doublewrite 存在的理由。

八、脏读 / 不可重复读 / 幻读:区别只在「行内容」还是「行数」

面试原题

「脏读、不可重复读、幻读分别是什么?」

答题骨架

三个定义谁都会背,但真正拉开差距的是说清不可重复读和幻读的区别:

不可重复读是同一行的内容变了;幻读是结果集的行数变了。

  • 脏读:读到别人还没提交的修改,而那个事务回滚了——你读到一个从未真正存在过的值。
  • 不可重复读:事务内两次读同一行,中间别人改了并提交——同一行,两次值不同。
  • 幻读:事务内两次范围查询,中间别人插入并提交——多出一行(或少了一行)。

隔离级别对照

级别 脏读 不可重复读 幻读
READ UNCOMMITTED 会 会 会
READ COMMITTED 防住 会 会
REPEATABLE READ(默认) 防住 防住 快照读防住,当前读靠间隙锁
SERIALIZABLE 防住 防住 防住

这里有个坑,必须拆开说

标准 SQL 定义里,RR 是防不住幻读的。InnoDB 之所以看起来防住了,是因为用了两套机制:

  • 快照读(普通 SELECT):靠 MVCC 的 ReadView,你的范围在快照里是固定的
  • 当前读(SELECT ... FOR UPDATE、UPDATE、DELETE):靠 next-key lock,不许往这个区间插

所以准确的说法是「InnoDB 的 RR 在绝大多数场景下没有幻读」,而不是「RR 这个级别本身能防幻读」。

常见答错

  • 直接说「RR 解决了幻读」——会被追问到卡住。
  • 把不可重复读和幻读搞混——记住「内容 vs 数量」。

九、EXPLAIN:type 是有等级阶梯的

面试原题

「怎么用 EXPLAIN 分析一条 SQL?」

答题骨架

按这个顺序看,不要一上来就说「看有没有走索引」:

1. type——有没有走索引,走到什么程度。 从好到坏:

system › const › eq_ref › ref › range › index › ALL

2. key——实际用了哪个索引。 注意可能和你想的不一样,优化器会选错。

3. rows——预估要扫多少行。 这个数字和实际返回行数的比值,才是判断好坏的依据。

4. Extra——关键提示。

值 含义 好坏
Using index 覆盖索引,没回表 好
Using index condition 索引下推生效 好
Using filesort 额外排序 数据量大就慢
Using temporary 用了临时表 通常 GROUP BY 没走索引
Using where 拿到行之后还要过滤 配合大 rows 就是慢的信号

最容易被误读的一级:index

type = index 看着「用了索引」,实际是把整棵索引从头扫到尾。如果表很大、过滤性又差,它和 ALL 差不多慢——只是扫的东西更小(只扫索引页,不扫数据页)。

判断标准永远是 rows 和实际返回行数的比值,不是「有没有 key」。

常见答错

  • 「有索引就一定快」——优化器可能主动放弃索引(回表成本 > 全表扫成本,通常发生在结果集占比很大时)。
  • 忽略 filtered 列——它表示这一层过滤后剩多少,配合 rows 才能估算真实扫描量。

十、备份恢复:两段拼接才是完整答案

面试原题

「误删了数据怎么恢复?」

答题骨架

完整答案是两段拼接,缺一不可:

  1. 恢复最近一次全备——回到某个时间点的状态
  2. 重放 binlog 到事故前一刻——把这段时间的变更补回来
# 1. 恢复全备
mysql < full_20261108.sql

# 2. 重放 binlog,停在误删之前
mysqlbinlog --stop-datetime="2026-11-12 14:31:59" \
  mysql-bin.000012 mysql-bin.000013 | mysql

决定成败的不是命令,是两件平时的事

第一,binlog 保留期必须覆盖「全备周期」。 如果你的全备是周备、binlog 只保留 7 天,那么周日到周三出事了刚好够;但如果这周备份失败了一次,或者事故发生在第 8 天,就直接断路了。binlog_expire_logs_seconds 默认 30 天(8.0 之前叫 expire_logs_days),要按你的实际情况算。

第二,必须定期做恢复演练。 备份文件存在不等于能恢复——版本不一致、字符集不对、GTID 冲突,都会让你在事故现场才发现。

物理备份 vs 逻辑备份

XtraBackup(物理) mysqldump(逻辑)
原理 直接拷数据页 导出 SQL 文本
速度 快,支持增量 慢,恢复要重建索引
适用 几百 GB 的大库 小库、单表迁移
跨版本 受限 可以

行业默认做法:大库物理、小库逻辑。

常见答错

  • 只说「用备份恢复」,不提 binlog——那就丢掉最后一次全备之后的全部数据。
  • 不知道有 GTID 时可以用 --include-gtids 恢复,不用人肉算位点。

十一、死锁:一条 1213 是怎么产生的

面试原题

「MySQL 死锁是怎么回事?你们线上遇到过吗,怎么排查的?」

答题骨架

死锁不是 bug,是并发加锁的必然结果。回答分四段,一段都不能少:

  1. 怎么形成——两个事务加锁顺序相反,等待图成环
  2. 怎么发现——InnoDB 主动找环,不是等超时
  3. 怎么处理——选代价最小的做牺牲者,抛 1213
  4. 怎么治理——统一加锁顺序、缩短事务、捕获重试

环是这样形成的

表 t 有 id=1 和 id=2 两行,两个会话交错执行:

步骤 session A session B 结果
① UPDATE t SET a=1 WHERE id=1 — A 持有 id=1 的 X 锁
② — UPDATE t SET a=2 WHERE id=2 B 持有 id=2 的 X 锁
③ UPDATE t SET a=1 WHERE id=2 — A 等待 B,单箭头
④ — UPDATE t SET a=2 WHERE id=1 B 等待 A,成环

到第 ③ 步还只是普通锁等待,靠 innodb_lock_wait_timeout(默认 50 秒)兜底就行。第 ④ 步之后性质变了:A 在等 B 释放,B 在等 A 释放,再等多久都不会解开。

判定条件:必须成环,不是等得久

InnoDB 的判定非常直接——每有一个事务进入锁等待,就从它出发,沿着「我在等谁」这条边递归往下找,如果能绕回自己,就是环。

所以「等了三分钟」不叫死锁,「等了三分钟而且对方在等我」才叫。

检测是有代价的(这一段最容易被忽略,也最能加分)

innodb_deadlock_detect 默认 ON。这个开关的代价是:每次有新线程进入锁等待,都要遍历一遍等待图,复杂度随并发线程数上升。

热点行上同时排着几百个请求时,死锁检测本身就能把 CPU 打满——这是很多大促前「系统看起来没异常,但 CPU 100%」的真实原因。

正确的解法不是关掉检测,而是降低单行并发:拆热点行(把一行库存拆成 10 行)、请求排队削峰、把 UPDATE 改成原子操作减少持锁时间。

关掉检测会怎样?死锁发现不了,只能等 50 秒超时。请求挂着不报错、连接池被占满,比快速失败难查十倍。所以生产上基本不开这个开关。

谁被回滚

不是随机的,也不是「谁后到谁死」,而是回滚代价最小的那个。

InnoDB 内部有个 trx_weight,约等于「该事务已加锁的行数 + 已修改的行数」。权重小的做牺牲者——因为回滚它需要撤销的操作更少。

被回滚的事务收到的是:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

这个错误是可以重试的。 业务代码捕获 1213 后重新执行整个事务,是标准做法。而 1205(lock wait timeout)重试往往无效——它说明有别的事务长期占着锁,重试一百次也一样。

线上排查的三条命令

-- 1. 最近一次死锁的完整现场:两条 SQL、各自持有什么锁、谁被回滚
SHOW ENGINE INNODB STATUS\G
--   看 LATEST DETECTED DEADLOCK 那一段

-- 2. 当前谁在等谁(8.0,比老的 INNODB_LOCK_WAITS 可靠)
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM performance_schema.data_locks;

-- 3. 当前运行中的事务,按开始时间排,先找长事务
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id,
       trx_rows_locked, trx_rows_modified, trx_query
FROM information_schema.INNODB_TRX ORDER BY trx_started;

两个最常见的成因

成因 典型场景 怎么破
加锁顺序不一致 两个事务分别按 id=1→2 和 id=2→1 更新同一批行。批量更新、转账、扣库存最容易踩 业务层统一顺序:按主键排序后再批量更新,这是最有效的一条
间隙锁互相占用 RR 下 UPDATE ... WHERE a=1 走非唯一索引,会锁住记录之间的空隙;两个事务从两侧插入,各占一段 换 RC、或让条件走唯一索引

必被追问

「为什么 RR 下更容易死锁?」

因为有间隙锁。RC 下 UPDATE ... WHERE a=1 只锁命中的行;RR 下还要锁住这些行前后的空隙。两个事务各自往不同方向插入,很容易各占一段间隙再互相等。

「怎么彻底避免?」

避免不了,只能降低概率。能做的事:

  • 按主键排序后批量更新(解决顺序不一致)
  • 事务尽量短,不要在事务里做 RPC、发消息、等锁
  • 唯一索引等值命中时只加记录锁、不加间隙锁——能走唯一索引就别走普通索引
  • 隔离级别降到 RC
  • 业务层捕获 1213 重试

常见答错

  • 只说「死锁是两个事务互相等待」就结束。要补「等待图成环」和「trx_weight 选牺牲者」。
  • 说「开死锁检测有性能问题,所以线上关掉」。方向反了——关了更难排查。
  • 说「死锁可以完全避免」。说不能避免、但能降低概率,才像做过事的人。

十二、索引下推 ICP:少回表的那一步

面试原题

「什么是索引下推?它解决什么问题?」

答题骨架

一句话:把「列在索引里、但不能用于定位」的条件下推到存储引擎,在索引层先过滤,不合格的行根本不回表。

关键是要把三件事分清楚,混在一起说就会被追问卡住:

动作 发生在哪 作用
定位 存储引擎,B+ 树查找 决定扫描区间的起点和终点,如 zip='95054'
过滤(ICP) 存储引擎,在索引条目上 判断这条索引记录要不要回表
过滤(Server 层) Server 层,拿到整行之后 判断这一行要不要返回给客户端

一个具体例子

-- 索引:idx_zip_name (zip, name)
SELECT * FROM users WHERE zip = '95054' AND name LIKE '%伟%';

zip 命中 8 条索引记录。name LIKE '%伟%' 前导通配,无法用于定位——不能缩窄扫描区间,8 条还是得全扫。

但 name 这一列就在索引里。

  • 没有 ICP(5.6 之前):8 条索引记录全部回表,把整行捞出来,交给 Server 层逐行判断 name。8 次随机 IO,6 次白读。
  • 有 ICP(5.6+ 默认):存储引擎在索引条目上就地判断 name,只有 2 条命中才回表。2 次随机 IO。
  • 如果条件是 address LIKE '%路%',address 不在索引里,下推不了,必须回表——这是 ICP 的边界。

怎么判断它生效了

看 EXPLAIN 的 Extra。三个值很容易混:

Extra 含义 回表
Using index 覆盖索引,只读索引就够 没有回表
Using index condition 索引下推,索引层先过滤 有,但变少了
Using where Server 层拿回整行后再过滤 有,且没优化

怎么定量验证(这里有个坑)

想用数字证明 ICP 省了多少次回表,别用 Handler_read_next——它统计的是索引条目的读取数,开不开 ICP 都是 8。它衡量的是扫描范围,不是回表次数。

正确做法是 EXPLAIN ANALYZE(8.0.18+),直接看 Index range scan 那一步的 actual rows:

ICP 关闭:actual rows = 8    ← 8 行全部交给 Server 层
ICP 开启:actual rows = 2    ← 索引层只放出 2 行

必被追问

「ICP 有什么限制?」

  • 只对二级索引有效。聚簇索引的叶子节点本来就有整行,无所谓回表,下推没有意义。
  • 条件是子查询、存储函数时无法下推。
  • 用上覆盖索引(Using index)时不需要它——那已经是不回表了。
  • MySQL 5.6 才有。

「它和覆盖索引什么关系?」

覆盖索引是不回表,ICP 是少回表。覆盖索引更彻底,但需要索引里包含查询用到的全部列,索引会变宽。两者可以叠加:覆盖索引生效时,ICP 自动就没必要了。

常见答错

  • 把 ICP 说成「把 WHERE 条件下推到存储引擎」。太笼统——任何索引条件本来就在引擎层执行。准确说法是「不能用于索引定位、但列在索引中的那部分条件」。
  • 拿 Handler_read_next 证明 ICP 生效。

十三、分库分表:先问「一定要分吗」

面试原题

「数据量大了怎么办?分库分表怎么分?分完之后会有什么问题?」

答题骨架

这题的正确开场不是「用 user_id 取模」,而是先反问一句:一定要分吗。因为分片是单行道,分完几乎回不了头。

顺序应该是:

索引优化 → 读写分离 → 加缓存 → 冷热分离 / 归档 → 分区表 → 最后才分库分表

「2000 万行」不是红线

单表 2000 万这个数字是经验值,不是硬指标。真正该看的是三件事:

  • 索引能不能装进 innodb_buffer_pool_size(装不进就退化成随机 IO)
  • B+ 树层数——3 层能放约 2190 万行,第 4 层才明显变慢
  • 单表备份恢复时间和 DDL 时长

一个 5000 万行的表,如果只按主键查、索引全在内存里,照样很快。反过来,一个 200 万行的表如果全是全表扫,也该优化了。

怎么分:先垂直,后水平

垂直拆分:按业务拆库(订单库、用户库、商品库),或按列拆表(把大字段 TEXT 拆到扩展表)。目的是「解耦」和「让热表变窄」。

水平拆分:同一张表按行拆到多个库/表。目的才是「突破单机容量」。通常说的分库分表指这个。

路由公式

4 库 × 8 表 = 32 个分片,user_id 做分片键:

shard = user_id % 32
db    = shard / 8      -- 整除
tbl   = shard % 8

user_id = 1001 → 1001 % 32 = 9 → db_1.t_1。

分片键的四条要求

要求 为什么 反例
高基数 取值太少分不开 用「性别」「状态」做分片键,32 个分片一半是空的
分布均匀 热点分片会被打穿 用「注册年份」,老用户全挤在一个分片
查询必带 不带就无法路由,只能广播 订单表用 order_no 分片,后台却全按 user_id 查
不可变 分片键一改,整行要跨库搬家 用手机号分片,用户换号就得迁数据

最常翻车的点:非分片键查询 = 广播

只要 WHERE 里没有分片键,中间件就没法算分片号,只能把这条 SQL 发到全部 32 个分片,再在内存里归并。平时看不出问题,一旦这个接口被高频调用,就是 32 倍放大。

标准解法叫基因法:生成 order_no 时,把 user_id % 32 这个「基因」编进订单号的固定几位。这样按订单号也能反解出分片号,一条 SQL 只打一个分片。

分完之后必然出现的五个新问题

问题 现象 常规解法
跨分片 JOIN 两张表分片键不同,没法在一台机器上 JOIN 冗余字段、绑定表(同分片键一起分)、业务层内存拼装
跨分片聚合 / 排序 COUNT、ORDER BY、GROUP BY 要内存归并 各分片算完再汇总;大屏类需求走预聚合表或数仓
深分页 LIMIT 100000, 20 要在 32 个分片各取 100020 条 改游标分页(WHERE id > 上次最后一条),或业务上禁止跳页
分布式事务 本地事务失效,跨库没法一起回滚 尽量避免跨分片写;必须跨时用 TCC / 本地消息表 / Seata
全局唯一 ID AUTO_INCREMENT 在各分片会重复 雪花算法(注意时钟回拨)、号段模式(Leaf)、Redis INCR

扩容:为什么是「翻倍」而不是「加一台」

取模法的死穴是模数一变,几乎所有数据都要搬。32 改成 33,1001 % 32 = 9 但 1001 % 33 = 11,绝大多数行都得迁。

所以行业惯例是双倍扩容:32 → 64。原来第 k 个分片的数据,只会落到新的第 k 个或第 k+32 个分片——每个老分片只需要搬一半出去,而且可以按分片灰度、搬完再切。

更彻底的做法是预分片:一开始就建 1024 个逻辑分片,物理库先少一点,扩容时只改逻辑分片到物理库的映射,不动数据。

常见答错

  • 上来就说「用 user_id 取模 32」,不说为什么选它、不说分片键的选择标准。
  • 不提「非分片键查询会广播」——这是分片落地后第一个爆的问题。
  • 说「分库分表解决性能问题」。它解决的是容量问题,跨分片查询反而让性能变差。

十四、不停服迁移:六步,每一步都能回滚

面试原题

「要把一个库迁到新实例 / 新版本 / 新分片,不能停机,怎么做?」

答题骨架

这题考的不是命令,是风险控制的顺序。核心原则只有一条:任何时刻都必须有一条退路。

阶段 写 读 同步
① 双写开启 老库 + 新库 老库 老库 → 新库
② 存量回填 老库 + 新库 老库 老库 → 新库(全量 + 增量)
③ 数据校验 老库 + 新库 老库 老库 → 新库
④ 读灰度 老库 + 新库 新库(按白名单) 老库 → 新库
⑤ 写切换 新库 新库 新库 → 老库(反向,为回滚)
⑥ 老库下线 新库 新库 停止

为什么顺序不能反

先双写,不能先切读。 不双写就切读,新库拿到的是旧数据——这时回滚容易,但数据已经错了。

切读在切写之前。 读是最容易回滚的一步(改个白名单比例就回去了),正好拿它来验证新库能不能扛住真实流量。

切写放最后。 写一旦切过去,老库就开始落后;这时回滚只能靠反向同步补差,成本最高。

三个决定成败的细节

一、双写必须能容忍失败。 新库写失败不能把业务请求打挂。做法是「主写老库,异步入新库 + 失败落队列补偿」。同步双写只在低峰、且已验证新库性能之后才考虑。

二、数据校验必须做到差异为 0。 全量 checksum 按表 / 按分片 / 按时间段比对,增量再按 binlog 追一段。没有校验就切读写,等于闭着眼睛跳。

三、灰度要按业务维度,不是按机器维度。 按 user_id 尾号或白名单切,1% → 10% → 50% → 100%。这样出问题时影响面可控,也能精准剥离。

最加分的一句

「切换前我们会先做一次回滚演练——真的把流量切回老库跑一遍,确认反向同步是通的、延迟在可接受范围。」

绝大多数人只讲怎么切过去,讲不出怎么切回来。这句话能直接区分「看过文章」和「做过」。

必被追问

「双写期间读到旧数据怎么办?」

切读之前只读老库,所以不会有这个问题。切读之后如果发现新库延迟,靠监控位点差报警 + 白名单回退。

「怎么保证不丢数据?」

三层:双写失败落补偿队列(不丢写)、存量回填后做全量校验(不丢存量)、切换前把同步延迟压到 0(不丢增量)。

「表结构变更怎么处理?」

迁移期间尽量避免 DDL。必须有的话用 gh-ost 或 pt-online-schema-change,两边都执行一遍并校验。

常见答错

  • 只说「用 DTS / 阿里云的迁移工具」。工具是手段,考的是流程设计。
  • 没有回滚方案。这是这道题的核心,缺了就等于没答。
  • 不提数据校验。

十五、CDC:不改业务代码,把变更实时送出去

面试原题

「怎么把 MySQL 的数据实时同步到 ES / 数仓 / 缓存?」

答题骨架

链路五段:

MySQL 主库 → binlog → Canal / Debezium → Kafka → 下游(ES / 数仓 / 缓存)

回答要覆盖:为什么不用业务双写、四个前置条件、两个必炸的坑。

为什么不用「业务里发消息」

业务双写(改代码发 MQ) CDC(订阅 binlog)
一致性 写库成功、发消息失败 → 不一致,要上本地消息表才勉强解决 binlog 就是提交结果,天然一致
侵入性 每张表每个写入点都要改 零侵入,业务代码不动
覆盖面 只覆盖你改过的地方 覆盖全部变更,包括手工修数、批量脚本
成本 每条消息一次网络往返 从库级别读取,异步、可批量

四个前置条件,缺一不可

① binlog_format 必须是 ROW。 STATEMENT 记的是 SQL 原文,NOW()、UUID() 这类函数写进去的值还原不出来,CDC 直接失效。这是第一个卡点,很多老库默认还是 STATEMENT。

② binlog_row_image 建议 FULL。 默认 MINIMAL 只记主键和被改的列——下游拿不到完整前后镜像,做不了「把整行写进 ES」。代价是 binlog 体积变大。

③ 开 GTID。 断点续传靠位点,file + position 在切主之后会失效,GTID 不会。

④ 下游必须幂等。 CDC 的语义是至少一次(at-least-once),不是恰好一次。重启、位点回退、Kafka 重投都会导致重复。落地一律按主键 upsert,别用 INSERT。

两个一定会在生产上炸的坑

大事务。 一条 UPDATE 改了 200 万行,会生成一个巨大的事务,CDC 端要在内存里一次性拼出完整事务(Debezium 尤其明显),直接 OOM。解法是在源头拆批。

顺序。 同一个主键的变更必须有序。Kafka 分区键要用主键——用表名会让所有表挤在一个分区,用随机键会让同一行乱序、下游最终值是错的。

必被追问

「延迟怎么监控?」

两个指标:位点差(当前已消费位点 vs 主库最新位点,换算成秒)和业务时间戳差(最新一条消息里的 update_time 与当前时间的差)。前者反映链路健康,后者反映业务真实延迟。

「DDL 怎么同步?」

binlog 里 DDL 是语句不是行变更,多数 CDC 工具默认跳过。加字段之后下游 ES 的 mapping 不会自动更新,要单独维护一条 DDL 变更通道,或用 schema registry。这是加分项,很多人想不到。

「能保证 exactly once 吗?」

不能。端到端精确一次需要分布式事务,代价极高。工程上的标准答案是至少一次 + 幂等消费,效果等价。

常见答错

  • 说「用触发器」——触发器在主库执行,性能极差,且拿不到变更前后的完整镜像。
  • 说「定时扫表比对」——那是批处理,延迟按分钟算,而且是全表扫。
  • 不提「ROW 格式」这个前提,直接讲 Canal 怎么装。

题库覆盖率清点:为什么不把 118 道全画成图

很多人会问这个,直接给出账:MySQL 118 道题,按「是否必须靠图」分成三类。

类型 题量 处理方式 状态
需要空间结构 / 时序 约 42 道 画图,就是上面这 15 个考点 已覆盖
对比题(A 和 B 有什么区别) 约 40 道 表格比图清楚,题库里已有答案骨架 不需要图
纯事实题(参数值、版本差异、工具用法) 约 36 道 清单或表格 不需要图

第二类和第三类不是「还没画」,是画了反而更差。

原因很直接:图画的价值在于表达「空间位置」和「时间顺序」这两种关系。而「varchar 和 char 有什么区别」「int(11) 的 11 表示什么」这类问题里,根本没有位置和顺序——强行画成图,读者要多做一层视觉解码,信息量却没有增加。对比表反而一眼看完。

判断标准可以背下来:问「是什么结构 / 怎么流动」就画图,问「有哪些区别 / 什么值」就用表。

答题顺序:被问到这类问题时怎么组织

面试里答这类题,按这个顺序开口,会显得你是「想过的」而不是「背过的」:

  1. 先说物理结构——它存的是什么(页、叶子节点、存不存整行)
  2. 再说数量级——1170 扇出、3 层 2190 万行,数字比形容词有说服力
  3. 再说代价——回表是随机 IO、索引要维护、间隙锁会阻塞插入
  4. 最后说取舍——覆盖索引 vs 索引体积、RR vs RC

没有取舍的方案是不存在的。能说出「什么时候不该这么做」,比把优点说满更像做过事的人。


十五张图分在三辑里,链接在开头的按钮。如果你正在按 8 周刷题排期 走,建议这样排:

  • 第 2~4 周:第一辑的 B+ 树、回表、最左前缀 + 第二辑的 EXPLAIN、ICP——和索引优化那几天的实验一起做
  • 第 3~5 周:第一辑的 MVCC、间隙锁 + 第二辑的三种异常 + 第三辑的死锁——和事务隔离级别的实验一起做
  • 第 6~7 周:第二辑的三种日志、Buffer Pool——这两块偏内核,动手做一次「kill -9 后看崩溃恢复」印象最深
  • 第 7~8 周:第二辑的备份恢复 + 第三辑的分库分表、不停服迁移、CDC——这三块是面试里的「架构题」,对着自己的项目讲一遍最有效

图能帮你理解结构,但真正记住的永远是自己动手踩过的那次:自己建表、自己 EXPLAIN、自己 SHOW ENGINE INNODB STATUS 看锁、自己停掉 mysqld 再起来看恢复日志。

第三辑里有三个考点,光看图不够,得自己造一次场景:

  • 死锁:开两个会话,照第十一节那四步敲一遍,你会亲眼看到第二个 UPDATE 直接报 1213,然后再去 SHOW ENGINE INNODB STATUS 里找那段现场
  • ICP:同一张表建好索引,SET optimizer_switch='index_condition_pushdown=off' 关掉,前后各跑一次 EXPLAIN ANALYZE,actual rows 会变
  • 分库分表:不用真搭中间件,用 user_id % 32 在纸上算几十个 ID 落到哪,再想想「按手机号查」会发生什么