MySQL 事务与 MVCC:隔离级别、锁与幻读的真相

举报
yd_236430038 发表于 2026/11/01 22:55:28 2026/11/01
【摘要】 MySQL 事务与 MVCC:隔离级别、锁与幻读的真相 前言"MySQL 默认隔离级别能防幻读吗"这个问题,面试和实战都能问出事故。本文从 MVCC 的底层结构讲起,说清快照读与当前读的分工,以及为什么加了索引还会死锁。 一、ACID 与四种隔离级别隔离级别脏读不可重复读幻读实现方式READ UNCOMMITTED可能可能可能直接读最新数据READ COMMITTED否可能可能每条语句新建...

MySQL 事务与 MVCC:隔离级别、锁与幻读的真相

前言

"MySQL 默认隔离级别能防幻读吗"这个问题,面试和实战都能问出事故。本文从 MVCC 的底层结构讲起,说清快照读与当前读的分工,以及为什么加了索引还会死锁。

一、ACID 与四种隔离级别

隔离级别 脏读 不可重复读 幻读 实现方式
READ UNCOMMITTED 可能 可能 可能 直接读最新数据
READ COMMITTED 否 可能 可能 每条语句新建 ReadView
REPEATABLE READ 否 否 InnoDB 下基本不会 事务首次快照读时建 ReadView
SERIALIZABLE 否 否 否 读也加共享锁

InnoDB 默认 RR。值得注意的是,不少互联网团队把线上改成 RC,因为 RC 没有间隙锁、死锁概率显著下降;保留 RR 的场景,主要图 binlog 在 STATEMENT 格式下的一致性。

二、MVCC 的三个零件

1. 隐藏列:每行都有 DB_TRX_ID(最后修改它的事务 ID)、DB_ROLL_PTR(指向 undo 的版本链指针)、DB_ROW_ID(无主键时才有)。

2. undo log 版本链:每次更新都把旧值写进 undo,通过 roll_ptr 串成链表,构成这一行的历史版本。

3. ReadView:快照读时生成,包含四个关键值:

  • m_ids:生成瞬间所有活跃(未提交)事务 ID 的列表;
  • min_trx_id:m_ids 中的最小值;
  • max_trx_id:下一个将分配的事务 ID;
  • creator_trx_id:当前事务 ID。

沿版本链从新到旧找第一个可见版本,判断规则:

trx_id == creator_trx_id           → 可见(自己的修改)
trx_id <  min_trx_id               → 可见(早已提交)
trx_id >= max_trx_id               → 不可见(在我之后才开启)
min_trx_id <= trx_id < max_trx_id  → 在 m_ids 中则不可见,否则可见

这就是"可重复读"的全部秘密:RR 只在第一次快照读时建一次 ReadView,之后所有读都对着同一个快照;RC 每条 SELECT 重建,所以能看到别人新提交的数据。

三、快照读 vs 当前读

这是理解锁的前置条件:

类型 语句 读什么
快照读 普通 SELECT ReadView 对应的历史版本,不加锁
当前读 SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT 最新已提交版本,加锁

推论很重要:UPDATE 看不到旧快照,它必须读最新数据。所以"先查再判断再写"的并发控制写法在 RR 下依然有竞态,必须用当前读或唯一约束兜底。

四、锁:Next-Key Lock 是主角

InnoDB 的行锁加在索引上,不走索引就退化成锁全部记录加全部间隙。

  • Record Lock:锁单条索引记录;
  • Gap Lock:锁索引记录之间的间隙,只阻止插入,不阻止读取;
  • Next-Key Lock:Record + Gap,左开右闭区间,RR 下的默认形态。
-- 表 t:id 为主键,uid 普通索引,已有值 1、5、10
BEGIN;
UPDATE t SET v = 1 WHERE uid = 5;                        -- uid=5 存在:加 next-key lock
SELECT * FROM t WHERE uid > 5 AND uid < 10 FOR UPDATE;   -- 锁住 (5,10] 这段区间

关键认知:锁的是索引区间,不是行。所以"表里明明没有这条记录,却插不进去"的怪事,正是间隙锁在起作用。

五、一个真实的死锁

-- 会话 A
BEGIN;
UPDATE t SET v = 1 WHERE uid = 5;        -- uid=5 不存在 → 在 (1,10) 上加间隙锁
-- 会话 B
BEGIN;
INSERT INTO t (uid, v) VALUES (5, 1);    -- 插入意向锁被 A 的间隙锁阻塞
-- 会话 A 继续
INSERT INTO t (uid, v) VALUES (5, 1);    -- 也需要插入意向锁,与 B 互相等待,形成环

SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK 段会完整打印双方的加锁与等待。InnoDB 发现环后会主动回滚代价较小的事务,应用层必须能识别 1213 错误并重试。

六、RR 到底解决幻读了吗

结论:快照读不幻读,当前读靠 Next-Key Lock 防幻读,但两者混用时会出现"逻辑上的幻读"。

BEGIN;
SELECT * FROM t WHERE uid = 5;    -- 快照读,结果为空
UPDATE t SET v = 2 WHERE uid = 5; -- 当前读,更新了别人刚提交插入的行
SELECT * FROM t WHERE uid = 5;    -- 能读到这行(因为自己改过)

所以 RR 的语义准确说是"本事务看到一致的快照 + 自己能读到自己的修改",而不是"数据库里不存在新行"。业务上不要依赖"读不到就等于不存在"这种假设,用唯一索引做最后一道防线。

七、生产环境的六条铁律

  1. 事务要短:事务内不要做 RPC、不要等用户输入,锁持有时间与并发吞吐成反比;
  2. 更新顺序固定:批量更新按主键排序执行,能消除大部分死锁;
  3. 所有写操作走索引:没索引的 UPDATE 是表级灾难;
  4. 大事务拆批:DELETE ... LIMIT 1000 循环执行,避免长事务拖住 undo 清理和主从延迟;
  5. 捕获 1213 / 1205:死锁与锁等待超时都要有重试策略,且必须带退避,别立刻重试;
  6. 监控长事务:information_schema.innodb_trx 中 trx_started 超过 60s 就该告警。

总结

一句话记住 MVCC:每行带版本链,快照读按 ReadView 挑版本,当前读加 Next-Key Lock 锁区间。理解这条分工线,“为什么 RR 不幻读”“为什么没查到的行会被锁”"为什么偶发死锁"这三个高频困惑就都能自洽解释了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。