1. 先想清楚一个问题:索引和事务为什么总被放在一起考
最近团队做技术复盘,候选人几乎人手一份 MySQL 面试题,翻来覆去绕不开两块:索引和事务。我一开始觉得这只是面试官的偷懒题库,后来帮新人排查线上慢查询,又处理了一单库存扣减对不上账的问题,才意识到这两块知识根本就是同一个问题链上的两环——索引负责让 SQL 更快,事务负责让数据更稳,而它们底层都落在 InnoDB 的存储引擎上。
今天我不打算把教科书里的定义搬一遍,而是想从实际开发中“如果我真的在设计一个订单系统,我会关心什么”的角度,把索引和事务的核心知识点重新梳理一遍。文章面向的是用过 MySQL 但没系统学过原理的开发者,也可能对刚准备面试的人有帮助。你不需要提前掌握多少底层知识,但如果你写过几条 join 查询、加过几个普通索引、用过 @Transactional 注解,那么这篇文章里的很多问题你大概率都踩过。
先说清楚为什么这两个知识点值得一起复习。建索引和开事务,表面上互不相干,一个管读,一个管写,但 InnoDB 的行锁、MVCC、redo log 全都同时建立在索引和事务之上。事务要保证数据隔离,必须依赖索引去定位行;索引要保证高并发写入不乱,必须依赖事务日志去回滚和恢复。你如果只懂索引不认识事务,遇到“为什么这个 select 会锁住整个表”根本说不清;只懂事务不懂索引,遇到“为什么死锁检测报了错”又无从下手。所以这篇文章我会把两者之间互相咬合的部分也讲透,而不是单纯分成两个章节硬拼在一起。
最后说下阅读建议:如果你赶时间,可以直接跳到第三章的索引失效清单和第六章的事务失效场景,这两个小节像速查手册,能帮你先确认自己有没有踩过常见坑;如果你想把原理串起来,建议从第二、第五章开始按顺序读,我尽量用大白话把 B+ 树和 undo log 讲明白,不堆名词。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引篇:从 B+ 树到一条 SQL 是怎么走的
2.1 先回答一个最底层的问题:为什么 InnoDB 非要用 B+ 树
我在给新人讲索引时,习惯先问一个问题:如果你要在千万行数据里做等值查询和范围查询,你会选什么数据结构?哈希表,单行等值查确实快,但一遇到 id > 100 这种范围条件就直接跪了;平衡二叉树,查找稳定在 O(log n),但树高随数据量增长,而且每个节点只存一个数据,磁盘 IO 次数会多到让你怀疑人生;B 树解决了节点存储的问题,但非叶子节点同时还存着数据,同样的磁盘空间能装下的索引键数量变少,树自然比 B+ 树更高更胖。
B+ 树的解法是:把所有真正的数据行只放在叶子节点,并且叶子节点之间通过指针串成有序链表,非叶子节点只存索引键和子节点指针。这样整个结构可以获得两个红利。第一,树更矮更宽,三层 B+ 树基本就能承载千万级数据,磁盘 IO 次数被压到三次以内;第二,叶子节点有序,配合链表指针做范围扫描、排序、分组都极其顺滑,where id between 100 and 200 这种查询只要定位到起始位置,然后顺着链表往下读就行,不需要像 B 树那样频繁回溯父节点。
但这里有个新手特别容易忽略的点:B+ 树的“有序”不是天然免费的,它依赖主键顺序去维护排列。如果你用了 UUID 做主键,新插入的行主键是随机的大字符串,B+ 树为了维持有序,会频繁触发节点分裂和页重组,写入性能被拖得很厉害。这也是 InnoDB 为什么强烈建议使用自增主键的原因——有序追加写入的成本远低于随机插入。并不是 MySQL 不让你用 UUID,而是你要清楚这笔隐藏的账,备选方案可以做成“UUID 业务主键 + 自增 id 隐藏主键”的组合,牺牲一点存储换写入稳定。
2.2 聚簇索引和非聚簇索引:一次回表到底回的是什么
我把这个点单独拎出来,是因为很多人背过“聚簇索引叶子节点存整行数据,非聚簇索引叶子节点存主键”,但完全不理解这会造成什么实际差异。
InnoDB 的表本质上就是一个以主键为排序顺序的聚簇索引结构。你建的表如果没有显式主键,它会找第一个非空的唯一键当主键;都没有,就生成一个隐藏的 rowid 做聚簇索引。任何二级索引,也就是我们平时用 create index 建的普通索引、联合索引,它的叶子节点存的不是整行数据,而是对应的主键值。这意味着你走二级索引查询时,通常要分两步:先在二级索引的 B+ 树里找到主键,再拿主键去聚簇索引里定位整行数据。第二步,就被叫做回表。
举个具体例子,假设你有一个用户表,主键是 id,name 上有普通索引。执行 select * from user where name = '张三',MySQL 会先在 name 索引树里找到主键 id,再拿 id 到主键索引树里回表取整行。但如果执行的是 select id, name from user where name = '张三',由于查询需要的字段 id 和 name 都已经存在于 name 索引树中,MySQL 发现不需要回表,这种状态就叫覆盖索引,查询效率会明显高出一档。
索引设计里威力很大的一条优化思路就是“尽量把查询需要用到的字段塞进索引树里,避免回表”。所以你在联合索引里不要把字段顺序拍脑袋随便定,而是要考虑哪些字段用于过滤,哪些字段只是用来覆盖查询的。关于这条怎么落地,在下文索引规划里我详细说。
3. 写 SQL 前需要想明白的索引规划:联合索引、失效场景、下推机制
3.1 联合索引的顺序,真的不是你想怎么排就怎么排
联合索引的字段顺序决定索引树的第一排序关键字、第二排序关键字……它直接影响一条 SQL 能否命中最左前缀。我自己见过太多同事踩这个坑:他们建了 (a, b, c) 联合索引,以为 where b = 1 和 where c = 2 也能用的飞起,结果一查还是全表扫描。
先解释最左前缀原则的真正含义。联合索引相当于建立了一棵按多个字段逐级排序的树:先按 a 排,a 相同的再按 b 排,b 相同的再按 c 排。这意味着你可以利用的查询必须从最左边连续匹配。能用到这个索引的查询组合包括 (a)、(a, b)、(a, b, c),以及 a 字段先确定值、b 字段和 c 字段在各自范围内匹配的情况。而 where b = 1 and c = 2 这种把 a 跳过的查询走进索引树后会发现无从定位,因为你跳过了第一层排序条件,只能全树扫描,优化器也会告诉你不如不走这个索引。
这里有一个比较隐蔽但稍微绕一下就能用上索引的情况:where a = 1 and c = 2。优化器可以用 a = 1 定位到一批数据,但 c 不是连续的匹配条件,不能直接在索引树上继续下钻,只能在 a 的子范围内对 c 做过滤。这种查询并非全表扫,但它只吃到了联合索引一半的福利,因此建索引时字段顺序特别重要。核心原则是:区分度高的字段放前面、经常等值查询的字段放前面、范围查询的字段往后放,尽量把范围条件放最后,因为一旦某一步变成了范围,后续字段的顺序优势就断了。
3.2 索引失效的几类高频场景
索引失效是个老少皆宜翻车现场,面试爱问,线上慢查询更是天天见。我给几类最常见的失效场景做个速查清单,方便你对着排查。
第一类,对索引列做了计算或函数操作。比如 where date(create_time) = '2024-01-01',或者 where id + 1 = 100。你一旦把索引列包进函数或表达式里,MySQL 就无法直接用 B+ 树的有序性进行查找了。这不是它笨,是因为索引里存的是原始值,不是计算后的结果。想让这类查询走索引,应该改成 where create_time >= '2024-01-01' and create_time < '2024-01-02',或者干脆把等号两边调换成 where id = 99。
第二类,隐式类型转换。最常见的是字段类型是 varchar,但条件传的是整型;或者反过来,字段是 int,条件传的是字符串。MySQL 在比较时如果发现类型不一致,会选择把字段转换为另一种类型后再比较,这相当于在字段上套了一层转换函数,索引自然就失效了。我记得有一次排查线上慢查询,SQL 写的 where phone = 13800138000,phone 是 varchar,结果全表扫了四百多万行。改成 where phone = '13800138000' 之后瞬间毫秒级,这种低级问题其实很常见,建议写接口的时候严格把参数类型对齐字段类型。
第三类,前导模糊匹配。like '%关键字' 这种查询,因为不确定匹配开头,B+ 树有序性无法帮助你定位起点,只能把所有叶子节点扫一遍。但 like '关键字%' 是可以走索引的,所以模糊搜索场景建议用前缀匹配,如果实在需要任意位置匹配,正经方案是引入搜索引擎或者全文索引,而不是硬在 MySQL 上做 %xx%。
第四类,联合索引使用不正确导致优化器放弃。刚才说过,跳过最左字段会让索引失效;同时,你如果在一个联合索引中让范围条件字段占了中间位置,后续字段同样用不上排序优势。这个时候你需要从业务角度去权衡:是把范围字段挪到最后,还是拆成多个单独的索引来支持不同查询。
3.3 索引下推:MySQL 5.6 之后一个低调但见效快的优化
索引下推这个词听着门槛高,实际讲明白就一句话:旧版本里,二级索引查到主键后,先回表找到整行,再在服务层用其它条件过滤;5.6 之后,MySQL 直接在下层存储引擎层就把索引里能过滤的条件先过滤掉,减少回表次数。
举个例子,假设联合索引是 (age, city),表里有两千万行。执行 select * from user where age > 18 and city = '杭州'。在 5.6 之前,优化器会在二级索引里先把所有 age > 18 的主键拿出来,然后逐一回表,取回完整行后再在服务端过滤 city = '杭州'。5.6 之后,存储引擎在遍历二级索引的过程中,发现 city 这个列也在索引里,而且当前索引行的 city 不等于杭州,那这一行根本不需要回表取整行数据了。
你别小看这一步,回表是随机 IO,一次回表代表一次主键树的磁盘寻址,在高并发大查询场景下是致命的。我负责过的一个报表查询,原本执行时间 1.8 秒,靠索引下推优化到 400 毫秒,整个过程只需要让索引包含了过滤字段,零成本。而且 MySQL 5.6 之后的版本默认开启下推,并不需要额外配置,所以你可以把“设计联合索引时把 where 过滤条件尽量放进去”作为铁律执行,即使这个字段最终不参与排序,它也可能触发下推省掉回表。
4. 实战:索引优化的完整流程与性能排查实操
4.1 建索引之前,先逼自己回答几个问题
很多人的习惯是:查出慢 SQL 后,随手给 where 条件里的字段建个单列索引,完事。这样粗暴的做法偶尔有效,但更多时候建出来的索引利用率极低,甚至可能白白增加写入压力和存储成本。我在团队里反复强调:建立索引之前,必须拿到这条 SQL 的完整执行计划,并且问清下面四件事。
第一,这条 SQL 的执行频率怎么样?如果一天跑一次,每次 3 秒其实可以忍;如果每秒执行上百次,那 50 毫秒都可能是灾难。第二,驱动表是谁?如果这条 SQL 连了多张表,你要先通过执行计划确认哪张是外表、哪张是内表,驱动表的关联字段要有索引,被驱动表的关联字段也必须有索引,否则会产生非常恐怖的嵌套循环全表扫描。第三,查询真正需要哪些字段?能不能用覆盖索引避免回表?如果你只需要订单表的订单号、状态和创建时间三个字段,造一个 (status, create_time) 联合索引,查询就能全程在索引树里完成,不必回表取整行。第四,过滤字段的区分度够不够?假如一个字段只有男女两个取值,就算它走上了索引,也会因为需要回表扫出近一半行而让优化器直接放弃索引。区分度不够时,与其建索引不如考虑增加过滤条件。
这几个问题想清楚再建索引,基本能从根上避免“建了索引还是慢”的问题。实践上我还会把 explain 出来的 rows 和 filtered 两个字段对比着看:rows 代表预估扫描行数,filtered 代表经过条件过滤后的百分比,如果 rows 接近全表行数,说明索引的使用价值已经很弱了。
4.2 explain 执行计划到底怎么读
explain select ... 是排查索引问题的入口,你不需要把每个字段都背下来,但有几个关键字段必须在第一时间看懂。
首先是 possible_keys 和 key。possible_keys 表示这次查询理论上能用的索引,key 表示优化器最终选择哪个。如果你的条件字段明明建了索引,key 却是 null,这一步就说明有索引没生效,结合上一节的失效场景去排查函数操作、类型转换和联合索引顺序问题。其次是 type,这是衡量查询效率最直观的字段。出现 all 说明走了全表扫描,基本是性能警报;出现 index 表示只遍历索引树,虽然不扫全表,但如果扫描的索引很大也不理想;range 表示范围扫描,ref、eq_ref 分别代表命中普通索引和唯一索引的等值查找,都是健康状态;const 是最理想的,通过主键或者唯一索引定位到唯一一行,一般就一次 IO。
还有一个不能忽略的字段是 Extra。如果里面出现 Using filesort,说明查询需要额外的排序过程,数据量大时极耗资源;出现 Using temporary,说明用了临时表,一般关联很多或 group by 选择不当才会出现;出现 Using index 反而是好事,代表覆盖索引已经生效,不需要回表。一个真正健康的查询计划,理想状态基本是 type 在 range 以上、Extra 里没有 filesort 和 temporary。
我建议你每次优化完索引,都重新跑一遍 explain,观察 key、type、rows、Extra 四个字段有没有发生理想变化。优化不是猜测游戏,执行计划会给你最直接的反馈。记住,线上变更索引之前一定要在测试库用同样的数据量模拟一遍,因为索引的取舍和数据分布、表体量有很强关联,小表上怎么都快,不代表大表也快。
4.3 深分页、排序和 group by 里的索引陷阱
除了简单的 where 条件,别忽略 order by 和 limit 对索引的依赖。最常见的问题场景是深分页:order by id limit 1000000, 20,程序要先把前一百万行读出来,丢弃,然后返回最后 20 行。即使走主键索引,这个翻页越深,扫描的成本也会越大,因为你每次都要把前面一百万条过一遍。
常见解法是延迟关联或基于游标的分页。延迟关联的写法是:先通过索引快速查出来一页的主键 id,然后再 join 回原表取完整行。例如:
sql复制select a.* from orders a
inner join (select id from orders order by id limit 1000000, 20) b
on a.id = b.id;
这种写法的思路是让最耗时的翻页操作只在索引树上完成,索引树叶子节点空间小、扫描成本低,拿到 20 个 id 后再回表取数据,整条 SQL 的耗时往往能降一个量级。如果是基于游标的分页,那么在订单号单调递增的场景下,你完全可以用 where id > 1000000 order by id limit 20 代替偏移量翻页,彻底避开“还要读前面一百万个 id”的问题。
order by 和 group by 同样依赖索引的有序性。如果你的排序字段与 where 条件里的字段能组成同一个联合索引,并且排序方向和索引键顺序一致,MySQL 就能直接利用索引顺序输出结果,Extra 字段里不会出现 Using filesort。常见的坑是:where status = 1 order by create_time desc,如果只给 status 建了单列索引,排序就会重新进行,这时真正需要的是 (status, create_time) 联合索引,status 负责等值过滤,create_time 负责有序输出,两者配合才能从根本上消掉 filesort。
5. 事务篇:ACID 和隔离级别的底层机制,不只是背概念
5.1 ACID 不是口号,InnoDB 落实它靠三样东西
讲事务之前,我先铺垫一个实际场景。你在电商系统里成功扣了用户余额,但订单还没创建成功,此时进程突然崩溃。重启后如果余额扣减生效、订单却不存在,用户一定会来骂人。事务解决的就是这类“多个操作必须同时成功或同时失败”的问题,体系化的表述是 ACID——原子性、一致性、隔离性、持久性。
InnoDB 不是靠什么神秘魔法实现 ACID 的,它靠的是一组非常朴素的底层组件。持久性依赖 redo log:在数据真正刷入磁盘的 ibd 文件之前,事务提交时就会先把修改以物理日志形式写到 redo log 里,即使系统断电,重启后也能根据 redo log 重放事务,让已经提交的修改不至于丢失。你可以把 redo log 理解成快递员手里的签收底单,即使包裹在路上出了问题,凭底单也能证明这笔快递确实送达过。很多团队担心事务提交慢,其实 InnoDB 默认就开启了 group commit,把多个事务的 redo log 合并刷盘,极大降低了 fsync 次数。
原子性则依赖 undo log。事务执行过程中如果发生回滚,InnoDB 需要知道这行数据修改之前长什么样,undo log 里存的就是这个“旧值快照”,回滚时直接拿旧值覆盖回来即可。同时 undo log 还是实现多版本并发控制 MVCC 的核心原料,它记录了一个数据行的历史版本链,读事务在特定隔离级别下可以沿着这条链找到自己应该看到的旧版本,从而不被其他正在写的事务干扰。
隔离性则主要依赖行锁和 MVCC 的组合。这里有个高频误解:很多人以为 InnoDB 默认隔离级别“可重复读”意味着读写互不干扰,实则不同事务并发修改同一行时,必须靠行锁排队互斥;并发读才通过 MVCC 实现快照读。所以单纯靠加锁或单纯靠版本链都实现不了完整的隔离,二者是配套使用的。
5.2 四种隔离级别各自的边界
MySQL 标准定义了四种事务隔离级别,从宽松到严格排序依次是:读未提交、读已提交、可重复读、串行化。它们解决的核心问题对应三种并发异常:脏读、不可重复读、幻读。
脏读指的是一个事务读到了另一个未提交事务的修改。比如事务 A 把百元余额改成零元,但还没提交,事务 B 读到了零元并开始发优惠券,此时 A 回滚,B 的计算就完全错了。读未提交级别下存在脏读风险,所以除非做纯监控类业务,极少有正经系统用它。
读已提交解决脏读,却仍可能有不可重复读:事务内两次相同的查询,第一次查到的是别的已提交事务修改前的值,第二次却查到了修改后的值。想象在同一个事务里小明先查自己的余额,发现 100 元,然后队友给小明转入一笔钱并提交,小明再查同一条件余额变成了 150 元,这个结果可能让整体报表逻辑出错。从读已提交开始,InnoDB 的普通 select 变成了快照读,但读已提交实现的是“语句级快照”,每一条 SQL 执行时都会重新生成一个快照,所以两次查询仍可能看到不同的数据。
可重复读则是 InnoDB 默认级别,这里实现的是“事务级快照”:事务第一次执行快照读的瞬间,就生成一个一致的 ReadView,整个事务期间都基于这个快照读取数据,因此普通查询在同一个事务里永远看到同一个版本。但这里需要重点强调,快照读解决不了幻读,幻读指的是事务里同一条件查询两次,第二次多出几行之前不存在的记录。为什么快照读下还会幻读?因为如果事务 A 先快照读得到两条记录,之后事务 B 插入了一条符合条件的新行并提交,事务 A 如果再执行 select ... for update 这种当前读,就会直接读取最新版本的已提交数据,发现多出来一行。可重复读级别想彻底防幻,需要通过间隙锁和 next-key lock 锁定范围,防止其他事务在缝隙里插入数据。
这里我给一张对照表,方便你整理记忆:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 默认快照策略 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 无快照,直接读最新版本 |
| 读已提交 | 不可能 | 可能 | 可能 | 语句级快照,每次 select 重新建立 ReadView |
| 可重复读 | 不可能 | 不可能 | 潜在(需间隙锁防当前读) | 事务级快照,首次读建立 ReadView |
| 串行化 | 不可能 | 不可能 | 不可能 | 读写都加锁,并发极低 |
串行化是最严格的级别,读写完全互斥,代价是并发能力几乎归零。日常业务线上,绝大部分 MySQL 系统都使用默认的可重复读,因为 InnoDB 对这个级别的支持配套了 MVCC 和 next-key lock,既能保证高并发,也能在确实需要时防住幻读。
6. 事务传播行为与注解事务失效,这些坑我全踩过
6.1 事务传播行为到底在解决什么问题
传播行为这个词是从 Spring 事务管理里流传开的,Java 开发者应该都很熟悉,但它背后的语义并不限于 Spring。本质上,它描述的是一个事务方法调用另一个事务方法时,两者如何共享或隔离事务。最常见的配置是 REQUIRED,意思是如果当前没有事务就新建一个,当前已有事务就直接加入,调用方和被调用方变成了一个事务。绝大多数业务场景用 REQUIRED 就够了,因为同一请求内的多个写操作理应同生共死。
但还有几种传播行为你必须有概念。REQUIRES_NEW 会挂起当前事务并新建一个独立事务,适合类似“记录操作日志”的场景——主业务的失败不应该回滚掉已经记录好的日志,日志要单独落库。NESTED 是嵌套事务的概念,它依赖数据库的 savepoint 实现,内部子事务的回滚不会让外部事务整体回滚,适合部分逻辑允许单独回滚的复杂编排。NEVER、NOT_SUPPORTED 则属于特殊情况,比如外部服务回调要求无事务环境下执行。
实际开发中最容易犯的错,是把传播行为当成实现“部分回滚”的银弹。我见过一个支付系统在同一个 Service 里调用了 A 方法保存订单再调用 B 方法扣减库存,开发同学不想库存不足时撤销订单,就给 B 方法加了 REQUIRES_NEW。结果数据库库存是在子事务里成功扣了,但订单主事务回滚后发现两边数据对不上,最终只能人工补偿。这里的根本问题不是传播行为本身,而是业务上“订单和库存必须原子更新”的需求根本不该用独立子事务解决。给“能容忍独立事务”的操作才用 REQUIRES_NEW,对核心资金链路里的数据一致性不要自作聪明。
6.2 注解事务失效的三类真实场景
第一次接触声明式事务 @Transactional 时,很容易以为只要在方法上标个注解就万事大吉。实际上有大量场景会让这个注解静默失效,我根据自己排查过的线上事故,把最常见的三类原因整理出来。
第一类是方法自调用。Spring 的 @Transactional 基于 AOP 代理实现,只有在外层调用被 Spring 代理过的对象时,事务拦截器才会生效。如果你在同一个类的另一个方法里直接 this 调用这个带事务的 public 方法,实际上走的是对象内部方法调用,代理层没有参与,事务注解自然不会被解析。典型触发点是:Service A 的 public 方法里调用自己的另一个 public 方法,后者加了事务注解,但真正执行时根本没有事务包装。解决办法是拆分到独立的 Service Bean,或者用 self-injection 拿到代理对象再调用。
第二类是异常被吞掉。@Transactional 默认只在 RuntimeException 和 Error 时回滚,受检异常如 IOException 默认不会触发回滚。更坑的是catch 里把异常 log 之后吞掉,事务连异常都见不到,所有的写操作照样提交。一个很经典的线上转账 bug:数据库主键冲突被 catch 住并打印日志后继续往下走,结果余额扣款已经生效,订单状态也更新了,最终两边数据错位。事务方法内部的异常处理一定要谨慎,除非你真的清楚后续逻辑,不然应该让异常往外抛,或者用 rollbackFor 明确指定哪些异常需要回滚。
第三类是数据库引擎、连接配置或事务边界不合适导致回滚不彻底。比如表如果是 MyISAM 引擎,它压根不支持事务,你在上面的一切事务配置都是心理安慰,必须改成 InnoDB。此外事务要尽量短,不要在事务里调用远程接口、做耗时的外部 IO,因为长事务会持锁时间过久,导致并发请求大量堆积在锁等待上,甚至把 redo log 撑爆。我的习惯是:远程调用尽量安排在事务提交之后,事务内部只保留必要的数据库写操作。
7. 分布式事务:从两阶段提交到最终一致性
7.1 为什么直接套用单机事务行不通
一旦服务拆成微服务,原本在一个库里的订单表和库存表分散在两个独立的 MySQL 实例中,本地事务的 ACID 无法跨越数据库连接,这时就进入分布式事务的领域。
有些人第一反应是想用“全局锁”或者“XA 两阶段提交”来维护强一致,先不讨论实现难度,单看可用性风险就够喝一壶。XA 中参与事务的每个分库在执行 prepare 成功后,都要等待全局协调者发出 commit 或 rollback;如果协调者在 prepare 之后崩溃,所有参与者的资源会被长时间锁住,整个事务链路的连接、线程、数据库锁都会悬空。在秒杀、下单这类高并发场景,这种阻塞会让核心服务直接雪崩。
因此,真正工业界的大多数方案都放弃了强一致的思路,转而追求最终一致性,核心设计变成:把一个跨服务的复杂事务拆成一系列本地事务,通过可靠的消息或其他机制保证动作最终对所有参与方都完成。对业务最常见的两类选择是 2PC 和本地消息表,它们的取舍值得仔细分析。
7.2 2PC、TCC、本地消息表到底各适合什么场景
两阶段提交(2PC)强调参与者在阶段一先投票,阶段二根据投票结果统一提交或回滚,因为有明确的两个阶段,所以在数据一致性上比随意补偿可靠,但会带来参与资源的同步阻塞,协调者故障时恢复流程也较为复杂,适合对一致性要求极高、参与方较少的内部系统。
TCC 则是补偿模式的典型代表,把每个操作拆成 Try、Confirm、Cancel 三个阶段。Try 阶段做资源预留,比如下单预占库存的数量;Confirm 阶段真正扣减确认;Cancel 阶段把预占数量释放掉。它比 2PC 更灵活,也不需要长时间持有数据库锁,真正锁定的时间窗口被压缩到极短。但它的实现成本相当高,要求业务方必须为每个操作实现反向 Cancel 逻辑,而且需要考虑空回滚和幂等控制,没有足够的中间件经验,裸写很容易翻车。
本地消息表是工程落地最广泛的一种最终一致性方案。核心思路是:在自己所在的业务库里建一张消息表,业务操作与写消息表放在同一个本地事务里;事务提交后,通过后台任务定期扫描消息表并把消息发送到消息队列;下游服务消费到消息后执行自己的本地事务,执行成功则回调确认,否则消息生产者会重试。由于生产者的消息状态记录和业务数据修改共享同一个事务,不会出现本地数据已写而消息丢失的问题;下游只要保证消费幂等,就能做到最终一致。这个方案没有强一致的实时性,但工程上可控、易于排查和理解,很多订单中心和库存服务的异步对账用的都是这个套路。
值得一提的是,还有人会引入事务消息中间件。事务消息的原理是:生产者先发送一条 half 消息,消息中间件收到后并不马上对消费者可见;生产者执行本地事务,成功后提交消息,消息中间件才真正把消息投递给消费者;如果本地事务执行超时或失败,中间件会主动回查生产者的本地事务状态来决定提交或丢弃消息。这个机制从实现效果上相当于把本地消息表搬进了消息队列里,省掉你自己建表扫描的开销,适合已经重度使用消息队列的团队。
8. 高频面试题与易错点速查
8.1 值得反复练习的题目清单
我把这几年面试和带人过程中反复被问的索引与事务题目整理了一下,每个问题背后其实都能串出原理和实战两条线。
- 为什么 InnoDB 用 B+ 树,不用 LSM 或哈希索引,MySQL 中哪些场景又可以用哈希索引?这个问题的背后是存储引擎适配负载的取舍。
- 聚簇索引和非聚簇索引的本质区别是什么?什么时候会发生回表,怎么避免回表?
- 联合索引的最左前缀原则具体怎么理解,字段顺序怎么设计?
- 哪些场景会导致索引失效?函数的转换、隐式类型转换、like 前导模糊,还有哪些?
- 什么是索引下推,它解决了什么问题?
- 可重复读隔离级别下,MySQL 怎么解决幻读?间隙锁和 next-key lock 的原理是什么?
- MVCC 的底层实现到底是怎样的,undo log 版本链和 ReadView 如何配合?
- Spring 的 @Transactional 在什么情况下会失效?同一类内部方法调用、异常被吞、传播行为设置不当,都值得口述一遍。
- 分布式场景下单机事务解决不了什么问题,最终一致性的主流方案都有哪些?
这些问题先不用背答案,你可以尝试把自己代入真实的系统,去推理每种方案的取舍。面试官最喜欢的候选人不一定是背得全的,而是能说出“什么场景选什么、为什么、不选另一个是因为它的代价是什么”的人。
8.2 一道现场实战问题的复盘
最后分享一个我在技术评审时让候选人模拟过的真实问题,这个问题综合了索引、事务和分布式设计,非常适合用来检验你是否真正理解了今天讲的内容。
场景是:用户下单时调用订单服务创建订单,再调用库存服务扣减库存,两个服务分别使用不同的 MySQL 数据库。订单数据要插入订单表,库存要减掉对应商品数量,同时还要记录一条操作流水供后续审计。你的任务是为下单接口设计一套可落地的方案,保证两个数据库之间的数据不会出现大面积的错账,同时兼顾性能。候选人经常给出的答案分两种:第一种是先写订单库再写库存库,如果库存扣减失败就抛异常让订单服务本地回滚,但这个方案在库存成功而订单回滚后会导致库存被无端扣减;第二种是引入分布式事务框架做全局强一致,这个对大多数互联网团队来说属于杀鸡用牛刀,还会引入大量部署和运维成本。
比较合理的思路是:把订单服务和库存服务视为两个独立的参与者,利用本地消息表来解耦。具体操作是:在订单库中,创建订单和往消息流水表插入一条“扣减库存”消息处于同一个本地事务,保证两者同时成功或同时失败。事务提交后,由订单服务后台定时扫描消息流水表并发送到消息队列,库存服务消费消息后执行本地扣减。库存扣减成功则消费确认,如果扣减失败则根据重试策略持续重试。库存服务要做好幂等,可以在库存流水表上加唯一订单号约束,确保同一条扣减消息不会把库存扣两次。这个方案避免了两阶段提交带来的长时间资源锁定,在订单和库存异步处理时还能分摊高峰期流量。事后如果对账发现异常,人工或定时任务可以根据流水精确修复。
这道题的完整答案其实不止一条路径,但它很能检验你是否理解事务边界、幂等控制和异步补偿的核心逻辑。如果你能顺着上面的思路讲清楚每一步的原因,相信你对索引和事务的综合应用已经基本过关了。
根据我的经验,真正掌握 MySQL 索引与事务的方式是在故障中复盘、在慢查询日志里挑刺、在压测场景里观察锁等待状态,而不是在面试前突击八股。你今天看到的所有避坑点,背后都有真实系统在运行,希望你能在理解原理之后,动手去 explain 一条自己项目里的慢 SQL,再看看事务隔离级别和锁等待状态,把文章里的知识点变成自己手里的排查工具。
