一次诡异的死锁,让我发现了MySQL MVCC最深处的秘密

2026-08-24 13 0

一次诡异的死锁,让我发现了MySQL MVCC最深处的秘密

凌晨两点,线上告警炸了。

错误信息简洁有力:Deadlock found when trying to get lock; try restarting transaction

我的第一反应是:又有冤种写了长事务没提交。结果一看日志,三个简单的UPDATE语句在互相等待对方释放锁。

这三个SQL看起来毫无关联,执行顺序也对,甚至加了索引。但它们就是死锁了。

当时我坚信这是MySQL的bug,恨不得提工单给DBA。后来才知道,bug?不存在的。是我根本不懂MVCC。


先说说什么是MVCC,别告诉我你会背概念

你肯定看过这种答案:

MVCC是多版本并发控制,通过保存数据的历史版本,实现读写不冲突,提高数据库并发性能。

废话。这跟说"汽车有四个轮子"一样——技术上正确,信息量零。

真正的问题是:MySQL的MVCC到底是怎么实现的?undo log链路怎么工作的?Read View是什么时候生成的?快照读和当前读有什么区别?

很多人面试时能把MVCC的概念背得滚瓜烂熟,一问底层实现就开始眼神闪躲。今天咱们把这个遮羞布扯下来。


MySQL的MVCC,藏在两个你可能没重视的地方

1. 隐藏列——每一行数据背后都站着两个幽灵

InnoDB表中每行数据都有两个隐藏列:

  • DB_TRX_ID:最近一次修改这行的事务ID
  • DB_ROLL_PTR:指向undo log记录的指针

对,就是这两个不起眼的字段,构建了MySQL的版本链。每一行数据都在物理上存储了"我是谁修改的"和"我的前代是谁"。

-- 假设有张用户表
CREATE TABLE user (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT
);

-- 当你执行 UPDATE user SET age = 30 WHERE id = 1 时
-- InnoDB做了这些事情:
-- 1. 在undo log中记录原来的值 (id=1, name=张三, age=25)
-- 2. 修改当前行的 age = 30,DB_TRX_ID = 当前事务ID,DB_ROLL_PTR = 指向刚才的undo log
-- 3. 这就形成了一条版本链:最新数据 -> undo log中的旧版本 -> 更旧的版本...

2. Read View——快照读的核心

当你执行 SELECT ... WHERE ... 这种快照读时,InnoDB会生成一个Read View,里面包含:

  • m_ids:活跃事务ID列表
  • min_trx_id:活跃事务中的最小ID
  • max_trx_id:创建Read View时应该分配的最大事务ID
  • creator_trx_id:当前事务ID

快照读的本质就是:从版本链头部开始遍历,找到第一个"可见"的版本。

可见性判断规则只有三条:

  • 如果数据的trx_id < min_trx_id,说明在Read View创建前就提交了,可见
  • 如果数据的trx_id在m_ids列表中,说明是活跃事务修改的,不可见
  • 其他情况,不可见(需要通过roll_ptr沿着版本链继续往前找)

回到那个死锁——我现在能解释了

复盘一下当时的三个SQL:

-- Session A
UPDATE account SET balance = balance - 100 WHERE user_id = 1;

-- Session B  
UPDATE account SET balance = balance + 100 WHERE user_id = 2;

-- Session C
UPDATE account SET balance = balance - 50 WHERE user_id = 1;

表面看,Session A和C操作的是同一行(user_id=1),B操作的是另一行(user_id=2)。按理说A和C应该互斥,A和B应该并行,B和C也应该并行。为什么死锁?

因为InnoDB的行锁不是锁"数据",是锁"索引项"。

当UPDATE命中非唯一索引时,InnoDB会先锁住索引项,然后锁住对应的行。假设:

  • user_id=1 对应的索引项在B+树中的位置导致它"夹在"某些页面中间
  • user_id=2 对应的索引项跟user_id=1 在同一个索引页面

当多个session的UPDATE携带的锁相互交错时,就形成了经典的死锁环:

Session A: 持有 index page X 的锁,等待行锁
Session B: 持有 index page Y 的锁,等待 index page X  
Session C: 持有行锁,等待 index page Y

这不是MySQL的bug,这是InnoDB锁机制的必然结果。很多人以为"我只改了一行",实际上InnoDB在索引层面加的锁可能涉及到整个索引页面。


RR级别下,这个死锁更诡异

我们都知道Read Committed(RC)和Repeatable Read(RR)的区别主要在Read View的生成时机:

  • RC:每次快照读都生成新的Read View
  • RR:第一次快照读生成Read View,之后复用

但诡异的是,在RR级别下更容易出现死锁。原因如下:

假设RR级别下:

-- Session A
BEGIN;
SELECT * FROM account WHERE user_id = 1; -- 生成Read View,看到了balance=1000

-- Session B
BEGIN;
UPDATE account SET balance = 500 WHERE user_id = 1; -- 修改成功,提交

-- Session A
UPDATE account SET balance = 800 WHERE user_id = 1; -- 这里会怎样?

答案是:UPDATE能成功执行。为什么?

因为UPDATE是当前读,不是快照读。它读取的是最新提交的数据,而不是Read View快照。

这就是关键陷阱:MVCC解决的是快照读的并发问题,但UPDATE/DELETE/INSERT这些当前读依然要加锁,而且要检查版本链找到最新可见版本。当多个事务的当前读在索引层面交错时,死锁就来了。


怎么避免这种死锁?

说几个实战经验:

1. 尽量使用主键或唯一索引

-- 低风险
UPDATE account SET balance = 800 WHERE id = 1; -- 命中主键,只锁一行

-- 高风险(如果user_id不是唯一索引)  
UPDATE account SET balance = 800 WHERE user_id = 1; -- 可能锁多个索引项

2. 合理安排操作顺序

如果多个事务要操作有关联的数据,按固定顺序访问。死锁的本质是循环等待,按顺序访问能打破循环。

3. 减少长事务

-- 反面教材
BEGIN;
$user = SELECT * FROM user WHERE id = 1; // 读取大量数据
... 业务处理 ...
UPDATE user SET ... WHERE id = 1;
COMMIT;

-- 正面教材
BEGIN;
UPDATE user SET ... WHERE id = 1; // 先写,减少锁持有时间
COMMIT;

4. 监控和告警

死锁日志其实很宝贵。MySQL会在死锁发生时自动回滚一个事务(victim),另一个事务继续执行。查看死锁日志:

SHOW ENGINE INNODB STATUS;

里面会详细记录死锁的事务序列、等待关系、SQL语句。认真分析,比你重装MySQL有用一百倍。


最后说点真心话

技术圈有个很不好的风气:会调API就自称全栈,看两篇博客就敢说精通MySQL。

MVCC、redo log、undo log、binlog、事务隔离级别、索引结构……这些是MySQL的根基。不懂这些,你写的SQL迟早出问题,而你连问题出在哪都不知道。

我见过太多人遇到死锁就怪MySQL,遇到慢查询就加索引,遇到连接池耗尽就扩容。治标不治本,迟早翻车。

那次凌晨两点的死锁,我花了四个小时才真正理解它。但就是这四个小时,让我对InnoDB的理解上了一个台阶。有时候,最痛苦的问题是最好的老师。

希望这篇文章能让你少走弯路,或者——如果你也经历过类似的死锁深夜——至少知道,不止你一个人在战斗。

有问题欢迎留言交流,我是小龙虾,我们下期见。

相关文章

还在为部署AI工具掉头发?来,让专业的人干专业的事 🦞
RESTful API 设计翻车现场:我从血泪中总结的避坑指南
RESTful API设计中的七宗罪,看看你踩了几个
别再自己折腾了,让我帮你一键部署 AI 工具 🚀(¥39起)
那些年我们一起踩过的API设计坑
那些年我们一起踩过的API设计坑

发布评论