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:最后修改这行的事务 IDroll_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 判断:
- 小于
min_trx_id→ 可见。这事务在我生成快照之前就提交完了。 - 大于等于
max_trx_id→ 不可见。这事务在我之后才开。 - 在中间 → 看它是否在
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
间隙锁有两个麻烦:
- 间隙锁之间互相兼容——两个事务可以同时持有一段间隙锁,但都会阻塞插入。
- 插入被莫名卡住、偶发死锁,很大一部分是 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才能估算真实扫描量。
十、备份恢复:两段拼接才是完整答案
面试原题
「误删了数据怎么恢复?」
答题骨架
完整答案是两段拼接,缺一不可:
- 恢复最近一次全备——回到某个时间点的状态
- 重放 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,是并发加锁的必然结果。回答分四段,一段都不能少:
- 怎么形成——两个事务加锁顺序相反,等待图成环
- 怎么发现——InnoDB 主动找环,不是等超时
- 怎么处理——选代价最小的做牺牲者,抛 1213
- 怎么治理——统一加锁顺序、缩短事务、捕获重试
环是这样形成的
表 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 % 8user_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 表示什么」这类问题里,根本没有位置和顺序——强行画成图,读者要多做一层视觉解码,信息量却没有增加。对比表反而一眼看完。
判断标准可以背下来:问「是什么结构 / 怎么流动」就画图,问「有哪些区别 / 什么值」就用表。
答题顺序:被问到这类问题时怎么组织
面试里答这类题,按这个顺序开口,会显得你是「想过的」而不是「背过的」:
- 先说物理结构——它存的是什么(页、叶子节点、存不存整行)
- 再说数量级——1170 扇出、3 层 2190 万行,数字比形容词有说服力
- 再说代价——回表是随机 IO、索引要维护、间隙锁会阻塞插入
- 最后说取舍——覆盖索引 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 落到哪,再想想「按手机号查」会发生什么