前言
在 MySQL InnoDB 引擎中,高并发事务场景会遇到读写冲突、写写冲突、幻读等问题。很多人容易混淆 MVCC 和行锁(记录锁、间隙锁):MVCC 用于快照读,实现读写互不阻塞;记录锁、间隙锁、临键锁属于悲观锁,用于当前读,解决写写冲突与幻读问题。同时很多人存在一个经典误区:覆盖索引查询一定不需要回表。实际上二级索引本身不携带MVCC版本信息,在快照读场景下,覆盖索引有可能失效,触发回表。
一、LBCC:传统基于锁的并发控制(MVCC出现之前)
LBCC(Lock-Based Concurrency Control),基于锁的并发控制。 核心逻辑:读写互斥。当事务A修改某一行且未提交时,事务B如果要读取这条数据,必须阻塞等待,直到事务A提交或者回滚。
弊端:
- 高并发场景锁竞争激烈,大量读请求被阻塞,数据库吞吐大幅下降;
- 长事务会导致大量读请求长时间卡死,性能很差。
问题根源:修改数据直接覆盖原有记录,没有历史版本,读操作只能等待写事务结束。
二、MVCC 多版本并发控制(Multi-Version Concurrency Control)
1. 核心思想
写操作不直接覆盖原有数据,而是生成新的数据版本;读操作可以不加锁,读取历史快照版本,实现读写互不阻塞。
2. 底层实现:undo log + 版本链 + ReadView
InnoDB 每条聚簇索引记录自带两个隐藏列:
trx_id:最后修改这条记录的事务IDroll_pointer:指针,指向 undo log 里的旧版本记录
- undo log 事务修改数据时,不会直接覆盖旧数据,会把修改前的旧值保存到 undo log。多次修改,多个版本通过
roll_pointer串联,形成版本链。 - ReadView(读视图) 事务开启时生成ReadView,用来判断版本链里哪个版本对当前事务可见。普通
SELECT快照读,顺着版本链找到符合可见规则的版本返回。
✅ 快照读:普通
select * from table,走MVCC,不加锁,读写不阻塞。 ❗ 当前读:UPDATE / DELETE / SELECT ... FOR UPDATE / LOCK IN SHARE MODE,不走MVCC快照,读取最新版本,会加悲观锁。
3. MVCC的局限
MVCC只解决读写冲突,无法解决写写冲突 。 两个事务同时更新同一行数据,写写冲突依旧会触发悲观行锁,后执行的事务阻塞等待。 另外,MVCC 快照读可以规避幻读,但当前读场景下仅靠MVCC无法阻止幻读,需要间隙锁配合。
衍生问题:长事务不提交,它的ReadView长期有效,undo log历史版本不能被purge线程清理,undo log持续膨胀,占用大量磁盘空间。
三、InnoDB 行锁体系:记录锁、间隙锁、临键锁
这三类锁都属于悲观锁,仅在当前读生效,RR(可重复读)隔离级别下生效;RC(读已提交)没有间隙锁。
1. 记录锁(Record Lock)
锁定索引上真实存在的一条记录,只锁行,不锁索引之间的空隙。 触发场景:RR级别,主键等值查询,记录真实存在,临键锁降级为记录锁。 作用:防止其他事务修改这条已存在记录,解决行数据的更新冲突。
2. 间隙锁(Gap Lock)
锁定索引记录之间的空隙区间 ,不锁定真实存在的数据行。 触发场景:RR级别,等值查询,找不到匹配记录。 作用:防止幻读,锁住区间,禁止其他事务在间隙内插入新记录。
3. 临键锁(Next-key Lock)
InnoDB RR隔离级别默认的加锁算法 :临键锁 = 记录锁 + 间隙锁,左开右闭区间。
- 查询命中真实存在记录:临键锁降级为记录锁
- 查询没有匹配到记录:临键锁降级为间隙锁
幻读:同一个事务内,多次当前读,前后查询结果行数不一致,新增了别的事务插入的数据。 RR解决幻读的两套组合:
- 快照读:MVCC,读取快照,看不到新插入数据;
- 当前读:间隙锁,锁住索引间隙,阻止其他事务插入新行。
四、二级索引与MVCC:二级索引没有版本信息
聚簇索引 vs 二级索引 MVCC实现差异
聚簇索引(主键索引) 记录原地更新,每条记录自带trx_id、roll_pointer隐藏列。修改数据时,旧版本写入undo log,通过roll_pointer串联版本链,可以直接在聚簇索引上结合ReadView判断版本可见性。
二级索引(普通索引) 二级索引存储:索引列 + 主键值 ,没有trx_id、roll_pointer,无法维护undo版本链。
当二级索引字段发生更新:不会原地修改旧索引记录 。旧记录打上
delete-marked删除标记,再插入一条新的索引记录。标记删除的记录后续由purge线程清理。
👉 核心痛点:二级索引拿到数据后,无法判断这条记录对当前事务是否可见,必须回表到聚簇索引,依靠聚簇索引上的undo版本链做可见性校验。
经典踩坑:覆盖索引失效
哪怕查询字段全部包含在二级索引内(满足覆盖索引),只要需要MVCC版本判断,覆盖索引会失效,触发回表。
示例SQL
CREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(20),
age INT,
INDEX idx_name_age(name, age)
);
-- 事务A,更新未提交
UPDATE t SET age = 20 WHERE name = 'Tom';
-- 事务B,快照读,理论上覆盖索引
SELECT age FROM t WHERE name = 'Tom';
idx_name_age包含name、age,满足覆盖索引。 但是事务A修改了索引页,二级索引页的page_max_trx_id更新,事务B不能直接信任二级索引的数据,必须回表到聚簇索引,通过trx_id+undo log找到当前事务可见的版本。
优化:page_max_trx_id,减少不必要回表
InnoDB做了优化:二级索引的每个索引页的页头Page Header,保存page_max_trx_id。
page_max_trx_id:这个索引页中,最后修改记录的最大事务ID。一页只有一个,不是每行一条。
查询逻辑:
- 当前事务启动时,会得到最小活跃事务ID;
- 如果
page_max_trx_id < 当前事务最小活跃事务ID:代表这个页面所有记录在事务启动前就已经提交,页面内所有记录对当前事务全部可见,直接读取二级索引,不需要回表; - 如果
page_max_trx_id >= 当前事务最小活跃事务ID,或者记录带有delete-marked标记:必须回表,去聚簇索引判断可见性。
坑点:只要页内任意一条记录被新事务修改,整个页的page_max_trx_id就会变大。页面里其他没修改的记录,也会触发回表。
ICP索引条件下推,配合MVCC
即使需要回表,InnoDB会启用ICP(索引条件下推): 把WHERE里可以用索引字段判断的条件,下推到存储引擎层,先用二级索引过滤掉不满足条件的行,只把剩下符合索引条件的记录回表做版本可见性校验,减少回表次数。
限制:ICP只能过滤索引字段条件,不能替代版本可见性判断。过滤完剩下的记录,只要是delete-marked或者页面page_max_trx_id过大,依然要回表。
RR隔离级别下,二级索引如何保证可重复读?
二级索引本身没有版本链,可重复读能力完全依赖聚簇索引的MVCC。 流程:
- 二级索引扫描,拿到主键;
- 回表访问聚簇索引;
- 使用事务的ReadView + 聚簇索引记录的trx_id,判断版本可见;
- 如果当前版本不可见,顺着roll_pointer到undo log版本链向前查找,找到对当前事务可见版本。
五、长事务带来的连锁问题
- 长事务不提交,ReadView长期保留;
- 二级索引页
page_max_trx_id一直很大,页面上所有查询都无法走覆盖索引,每次都要回表,性能暴跌; - 旧版本undo log、delete-marked标记的二级索引记录,无法被purge线程清理;
- undo log持续膨胀,版本链变长,回表查找历史版本耗时越来越高。
生产规范:尽量避免长事务,控制事务执行时间。
六、purge线程清理delete-marked记录
二级索引被标记删除的记录不会立刻删除,由purge后台线程清理。 清理前提:全局最老活跃ReadView不再需要这条旧版本。 只要存在长事务占用旧ReadView,purge就卡住,旧索引记录堆积,表空间不会释放。
七、面试题汇总
Q1:MVCC 是不是万能的?
A:不是。MVCC只能解决读写冲突。写写冲突依然依赖行锁;select ... for update属于当前读,不走MVCC快照,直接加锁读取最新数据。
Q2:MyISAM 有MVCC吗?
A:MyISAM没有MVCC,只支持表锁。读的时候锁整张表,写操作要等待读完成,高并发性能差。InnoDB从MySQL5.1后成为默认引擎,核心优势就是MVCC带来高并发。
Q3:二级索引为什么没有MVCC快照?
A:二级索引只保存索引列+主键,缺少trx_id和roll_pointer隐藏列,无法维护undo版本链,不能独立判断记录版本可见性。
Q4:既然是覆盖索引,为什么还会回表?
A:覆盖索引只是不需要回表拿数据,但MVCC快照读需要做版本可见性判断,二级索引没有版本信息,page_max_trx_id过大时,就必须回表到聚簇索引校验版本。
Q5:page_max_trx_id保存在哪里?每条记录都有吗?
A:存在索引页的Page Header,一个索引页只有一个,不是每条记录都有。页内任意一条记录被新事务修改,整个页page_max_trx_id就会更新。
Q6:长事务对二级索引查询有什么影响?
A:长事务会让页面page_max_trx_id长期偏大,覆盖索引失效,大量回表;同时purge线程无法清理undo log和delete-marked记录,版本链越来越长,查询性能下降,磁盘占用上涨。