Transactions, Locks and MVCC Explained
ACID implementation, isolation levels and phenomena, MVCC snapshots, row locks, gap locks and deadlock handling in InnoDB.
How ACID is Implemented
ACID is not magic: Atomicity comes from the undo log (rollback on failure or abort), Durability from the redo log with write-ahead logging (a page is durable once its redo is flushed), Isolation from locks plus MVCC, and Consistency is the outcome of the other three plus application constraints.
修改一行数据的内部流程:
1. 在缓冲池找到页(未命中则从磁盘读)
2. 生成 undo log(记录旧值)→ 可回滚
3. 更新页数据(脏页)
4. 生成 redo log 并顺序写入 log buffer
5. redo log 刷盘(innodb_flush_log_at_trx_commit=1)
6. 提交成功;脏页由后台线程择机写回磁盘
→ 日志先行(WAL):保证崩溃后 redo 能重放出已提交数据
Isolation Levels and the Phenomena
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED (RC) | 不可能 | 可能 | 可能 |
| REPEATABLE READ (RR,InnoDB 默认) | 不可能 | 不可能 | InnoDB 基本不可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
- Dirty read: reading another transaction's uncommitted modification, which can roll back.
- Non-repeatable read: the same query twice in one transaction sees different committed values.
- Phantom read: the same range query returns extra rows inserted by others.
- Rc reads the latest committed version each time — for gap prevention and statement-consistent reports pick RR.
MVCC: Read Without Locks
每行额外字段:trx_id(最后修改它的事务 id)、roll_pointer(指向 undo 链)
一致性视图(Read View):事务开启时记录「活跃事务列表」
判断版本可见性:
trx_id < min(活跃) 该版本在快照前已提交 → 可见
trx_id > max(已分配 id) 快照后才改的 → 不可见
在活跃列表里 未提交 → 不可见
不在列表且 <= max 已提交 → 可见
RC:每条 SELECT 都建新快照 → 总是读到最新已提交
RR:复用第一条 SELECT 的快照 → 整事务读结果一致
不可见时沿 roll_pointer 走 undo 链,找最近可见版本
Row Locks, Gap Locks and Next-Key
-- RR 下等值命中唯一索引:退化为行锁
SELECT * FROM users WHERE id = 10 FOR UPDATE; -- 锁 id=10 这一行
-- 等值未命中(如 id=11 不存在):间隙锁 (10, 20)
-- 防止别人在间隙里插入 → 防幻读
-- 范围查询:Next-Key Lock(记录锁 + 前面间隙)
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁 (5,10] 与 (10,20] 及其间的插入
-- 都在「主键等值唯一」场景尽量用:锁最少
-- 非唯一索引等值:锁多条记录 + 间隙
-- 显式加锁
SELECT ... FOR UPDATE; -- 排他锁(写前)
SELECT ... LOCK IN SHARE MODE;-- 共享锁(少用)
-- 查看锁
SELECT * FROM performance_schema.data_locks;
-- 乐观锁:版本号 / 时间戳,适合低竞争
UPDATE goods SET stock = stock - 1, version = version + 1
WHERE id = 7 AND version = 5; -- 受影响行数 = 0 则重试
-- 悲观锁:竞争激烈、重试成本高时
BEGIN;
SELECT stock FROM goods WHERE id = 7 FOR UPDATE;
-- 判断库存足够
UPDATE goods SET stock = stock - 1 WHERE id = 7;
COMMIT;
Deadlocks: Detect and Cure
死锁本质:两个事务互相等待对方释放锁,形成环
事务 A:锁行 1 → 想锁行 2
事务 B:锁行 2 → 想锁行 1
InnoDB 主动检测:等待图出现环即回滚「代价小」的事务
(innodb_deadlock_detect 默认开启,热点行上自查成本高)
- Fix the classic cause: tables locked in different order — enforce a global update order (sort by primary key).
- Shrink transactions: fewer statements, no RPC inside, shorter locks.
- Lower isolation (RR→RC) reduces gap locks; use unique indexes so equal-value locks degrade to row locks.
- High-concurrency hot rows: optimistic retry batching or queue serialization beats row locking.
- Watch deadlocks: SHOW ENGINE INNODB STATUS, or log ERROR 1213 and retry programmatically.
ACID 是如何实现的
ACID 不是玄学:原子性靠 undo 日志(失败或回滚时撤销),持久性靠 redo 日志的 WAL(日志刷盘成功即安全),隔离性靠锁加 MVCC,一致性是前三者加业务约束共同保证的结果。
修改一行数据的内部流程:
1. 在缓冲池找到页(未命中则从磁盘读)
2. 生成 undo log(记录旧值)→ 可回滚
3. 更新页数据(脏页)
4. 生成 redo log 并顺序写入 log buffer
5. redo log 刷盘(innodb_flush_log_at_trx_commit=1)
6. 提交成功;脏页由后台线程择机写回磁盘
→ 日志先行(WAL):保证崩溃后 redo 能重放出已提交数据
隔离级别与三种异常
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED (RC) | 不可能 | 可能 | 可能 |
| REPEATABLE READ (RR,InnoDB 默认) | 不可能 | 不可能 | InnoDB 基本不可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
- 脏读:读到别的事务未提交的修改,而它可能回滚。
- 不可重复读:同一事务里两次相同查询,值不一样(别的事务提交了更新)。
- 幻读:范围查询第二次出现别的事务新插入的行。
- RC 每条语句读最新已提交版本;要防幻读、要语句一致性快照,选 RR。
MVCC:无锁快照读
每行额外字段:trx_id(最后修改它的事务 id)、roll_pointer(指向 undo 链)
一致性视图(Read View):事务开启时记录「活跃事务列表」
判断版本可见性:
trx_id < min(活跃) 该版本在快照前已提交 → 可见
trx_id > max(已分配 id) 快照后才改的 → 不可见
在活跃列表里 未提交 → 不可见
不在列表且 <= max 已提交 → 可见
RC:每条 SELECT 都建新快照 → 总是读到最新已提交
RR:复用第一条 SELECT 的快照 → 整事务读结果一致
不可见时沿 roll_pointer 走 undo 链,找最近可见版本
行锁、间隙锁与临键锁
-- RR 下等值命中唯一索引:退化为行锁
SELECT * FROM users WHERE id = 10 FOR UPDATE; -- 锁 id=10 这一行
-- 等值未命中(如 id=11 不存在):间隙锁 (10, 20)
-- 防止别人在间隙里插入 → 防幻读
-- 范围查询:Next-Key Lock(记录锁 + 前面间隙)
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁 (5,10] 与 (10,20] 及其间的插入
-- 都在「主键等值唯一」场景尽量用:锁最少
-- 非唯一索引等值:锁多条记录 + 间隙
-- 显式加锁
SELECT ... FOR UPDATE; -- 排他锁(写前)
SELECT ... LOCK IN SHARE MODE;-- 共享锁(少用)
-- 查看锁
SELECT * FROM performance_schema.data_locks;
-- 乐观锁:版本号 / 时间戳,适合低竞争
UPDATE goods SET stock = stock - 1, version = version + 1
WHERE id = 7 AND version = 5; -- 受影响行数 = 0 则重试
-- 悲观锁:竞争激烈、重试成本高时
BEGIN;
SELECT stock FROM goods WHERE id = 7 FOR UPDATE;
-- 判断库存足够
UPDATE goods SET stock = stock - 1 WHERE id = 7;
COMMIT;
死锁:检测与处理
死锁本质:两个事务互相等待对方释放锁,形成环
事务 A:锁行 1 → 想锁行 2
事务 B:锁行 2 → 想锁行 1
InnoDB 主动检测:等待图出现环即回滚「代价小」的事务
(innodb_deadlock_detect 默认开启,热点行上自查成本高)
- 头号原因是「多表/多行更新顺序不一致」——用固定排序更新(如按主键升序)消除环。
- 缩短事务:语句少、事务内不做 RPC、持锁时间短。
- 降低隔离(RR→RC)能减少间隙锁;等值查询走唯一索引,锁退化为行锁。
- 高并发热点行:乐观重试/批量/排队串行化优于行锁。
- 监控 SHOW ENGINE INNODB STATUS 与 ERROR 1213,代码层捕获重试。