MySQL InnoDB:MVCC、记录锁、间隙锁、二级索引MVCC底层原理

前言

在 MySQL InnoDB 引擎中,高并发事务场景会遇到读写冲突、写写冲突、幻读等问题。很多人容易混淆 MVCC 和行锁(记录锁、间隙锁):MVCC 用于快照读,实现读写互不阻塞;记录锁、间隙锁、临键锁属于悲观锁,用于当前读,解决写写冲突与幻读问题。同时很多人存在一个经典误区:覆盖索引查询一定不需要回表。实际上二级索引本身不携带MVCC版本信息,在快照读场景下,覆盖索引有可能失效,触发回表。

一、LBCC:传统基于锁的并发控制(MVCC出现之前)

LBCC(Lock-Based Concurrency Control),基于锁的并发控制。 核心逻辑:读写互斥。当事务A修改某一行且未提交时,事务B如果要读取这条数据,必须阻塞等待,直到事务A提交或者回滚。

弊端:

  1. 高并发场景锁竞争激烈,大量读请求被阻塞,数据库吞吐大幅下降;
  2. 长事务会导致大量读请求长时间卡死,性能很差。

问题根源:修改数据直接覆盖原有记录,没有历史版本,读操作只能等待写事务结束。

二、MVCC 多版本并发控制(Multi-Version Concurrency Control)

1. 核心思想

写操作不直接覆盖原有数据,而是生成新的数据版本;读操作可以不加锁,读取历史快照版本,实现读写互不阻塞。

2. 底层实现:undo log + 版本链 + ReadView

InnoDB 每条聚簇索引记录自带两个隐藏列:

  • trx_id:最后修改这条记录的事务ID
  • roll_pointer:指针,指向 undo log 里的旧版本记录
  1. undo log 事务修改数据时,不会直接覆盖旧数据,会把修改前的旧值保存到 undo log。多次修改,多个版本通过 roll_pointer 串联,形成版本链。
  2. 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解决幻读的两套组合:

  1. 快照读:MVCC,读取快照,看不到新插入数据;
  2. 当前读:间隙锁,锁住索引间隙,阻止其他事务插入新行。

四、二级索引与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。一页只有一个,不是每行一条。

查询逻辑:

  1. 当前事务启动时,会得到最小活跃事务ID;
  2. 如果page_max_trx_id < 当前事务最小活跃事务ID:代表这个页面所有记录在事务启动前就已经提交,页面内所有记录对当前事务全部可见,直接读取二级索引,不需要回表;
  3. 如果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。 流程:

  1. 二级索引扫描,拿到主键;
  2. 回表访问聚簇索引;
  3. 使用事务的ReadView + 聚簇索引记录的trx_id,判断版本可见;
  4. 如果当前版本不可见,顺着roll_pointer到undo log版本链向前查找,找到对当前事务可见版本。

五、长事务带来的连锁问题

  1. 长事务不提交,ReadView长期保留;
  2. 二级索引页page_max_trx_id一直很大,页面上所有查询都无法走覆盖索引,每次都要回表,性能暴跌;
  3. 旧版本undo log、delete-marked标记的二级索引记录,无法被purge线程清理;
  4. 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记录,版本链越来越长,查询性能下降,磁盘占用上涨。

相关推荐
.YM.Z1 小时前
C++——【红黑树】原理详解:定义、性质、插入变色与旋转实现
开发语言·c++
不停喝水1 小时前
【前端转全栈java速通课】 项目实战④7-11节 操作数据库-登录-注册-修改密码-注销-mubatis-plus简化crud-
java·前端·数据库
y1su2 小时前
Leetcode 二分模板
java·数据结构·算法·leetcode·排序算法
leisoo80972 小时前
融资融券数据怎么查两融指标含义与杠杆观察方法 IG50免费开源股票数据API接口
开发语言·jvm·数据库·python·json
霸道流氓气质2 小时前
多Agent通信机制与协议设计完全指南:从FIPA-ACL到A2A/MCP的Java生产级实战
java·开发语言
007张三丰2 小时前
C/C++ 内存管理详解:从内存分布到 new/delete 底层原理
java·c语言·c++·内存管理
weixin_419658312 小时前
CANoe 使用指南:从输出窗口到数据回放的完整实战教程
开发语言·功能测试·车载系统·自动化·汽车
当青春邂逅吉米多维奇2 小时前
C#图解教程(第5版) 同步方法
开发语言·c#