MySQL 事务与 MVCC:隔离级别、锁与幻读的真相
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 的语义准确说是"本事务看到一致的快照 + 自己能读到自己的修改",而不是"数据库里不存在新行"。业务上不要依赖"读不到就等于不存在"这种假设,用唯一索引做最后一道防线。
七、生产环境的六条铁律
- 事务要短:事务内不要做 RPC、不要等用户输入,锁持有时间与并发吞吐成反比;
- 更新顺序固定:批量更新按主键排序执行,能消除大部分死锁;
- 所有写操作走索引:没索引的
UPDATE是表级灾难; - 大事务拆批:
DELETE ... LIMIT 1000循环执行,避免长事务拖住 undo 清理和主从延迟; - 捕获 1213 / 1205:死锁与锁等待超时都要有重试策略,且必须带退避,别立刻重试;
- 监控长事务:
information_schema.innodb_trx中trx_started超过 60s 就该告警。
总结
一句话记住 MVCC:每行带版本链,快照读按 ReadView 挑版本,当前读加 Next-Key Lock 锁区间。理解这条分工线,“为什么 RR 不幻读”“为什么没查到的行会被锁”"为什么偶发死锁"这三个高频困惑就都能自洽解释了。
- 点赞
- 收藏
- 关注作者
评论(0)