事务、隔离级别与锁
一、ACID 与它们的实现
| 特性 | 含义 | InnoDB 靠什么实现 |
|---|---|---|
| Atomicity 原子性 | 要么全做,要么全不做 | undo log(回滚时按版本链逆向执行补偿操作) |
| Consistency 一致性 | 从一个合法状态到另一个合法状态 | 由 A、I、D 加上约束共同保证,是目的而非手段 |
| Isolation 隔离性 | 并发事务互不干扰 | 锁 + MVCC |
| Durability 持久性 | 提交后不丢 | redo log(WAL:先写日志后写数据页)+ 双写缓冲 |
redo 与 undo 的分工
- redo log:物理日志,记录"某个数据页做了什么修改"。崩溃恢复时前滚已提交但未落盘的修改。环形文件,写满则触发 checkpoint 刷脏页。
- undo log:逻辑日志,记录"如何撤销这次修改"。既用于回滚,也是 MVCC 版本链的载体。
- binlog:Server 层日志,用于主从复制和时间点恢复,与 InnoDB 的 redo 通过两阶段提交(prepare → 写 binlog → commit)保证一致。
二、并发带来的四类问题
| 问题 | 描述 |
|---|---|
| 脏读 Dirty Read | 读到了另一个事务尚未提交的修改 |
| 不可重复读 Non-repeatable Read | 同一事务内两次读同一行,结果不同(其他事务 UPDATE/DELETE 并提交) |
| 幻读 Phantom Read | 同一事务内两次执行同一范围查询,第二次多出了"幻影行"(其他事务 INSERT 并提交) |
| 丢失更新 Lost Update | 两个事务先后更新同一行,后者覆盖了前者 |
不可重复读针对已有行的修改,幻读针对新增的行——这是两者的本质区别。
三、四种隔离级别
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | ✅ 可能 | ✅ | ✅ |
| READ COMMITTED (RC) | ❌ | ✅ | ✅ |
| REPEATABLE READ (RR) | ❌ | ❌ | ✅ 标准中允许,InnoDB 基本避免 |
| SERIALIZABLE | ❌ | ❌ | ❌ |
SELECT @@transaction_isolation; -- 查看当前级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 会话级设置
默认值差异
- MySQL/InnoDB 默认 RR(历史原因:早期基于 statement 的 binlog 在 RC 下会导致主从不一致);
- PostgreSQL、Oracle、SQL Server 默认 RC。
互联网业务大多把 MySQL 改成 RC:间隙锁更少 → 并发更高、死锁更少;配合 ROW 格式 binlog 不会有一致性问题。选 RR 还是 RC 是一个明确的取舍,不是"越高越好"。
四、MVCC ⭐⭐⭐
MVCC(多版本并发控制)让读不加锁,实现"读写不阻塞"。
三个组成部分
1. 行的隐藏字段
每行除了用户数据,还有:
DB_TRX_ID(6 字节):最后修改这行的事务 ID;DB_ROLL_PTR(7 字节):指向 undo log 中上一个版本,串成版本链;DB_ROW_ID(6 字节):无主键时自动生成的隐藏主键。
2. undo 版本链
每次修改都会把旧值写入 undo log,通过 DB_ROLL_PTR 连成链表,链上是这一行的历史版本。
3. Read View(一致性视图)
生成快照时记录四个值:
m_ids:生成时刻**活跃(未提交)**的事务 ID 集合;min_trx_id:m_ids中的最小值;max_trx_id:下一个将被分配的事务 ID;creator_trx_id:当前事务 ID。
可见性判断规则(沿版本链从新到旧遍历,找到第一个可见版本):
设该版本的 trx_id 为 T:
T == creator_trx_id → 可见(自己改的)
T < min_trx_id → 可见(生成视图前就已提交)
T >= max_trx_id → 不可见(生成视图后才开启的事务)
min_trx_id <= T < max_trx_id:
T ∈ m_ids → 不可见(当时还没提交)
T ∉ m_ids → 可见(当时已提交)
RC 与 RR 的唯一区别
RR 在事务中第一次快照读时创建 Read View,整个事务复用它;RC 在每一次快照读时都重新创建 Read View。
这一句话解释了全部行为差异:RR 中反复读到的都是同一个快照,所以不可重复读消失;RC 每次都是最新快照,所以能读到别的事务新提交的数据。
快照读 vs 当前读
| 语句 | 读到什么 | 是否加锁 | |
|---|---|---|---|
| 快照读 | 普通 SELECT | Read View 决定的历史版本 | 不加锁 |
| 当前读 | SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE(8.0 写作 FOR SHARE)、INSERT / UPDATE / DELETE | 最新已提交版本 | 加锁 |
RR 下的幻读到底解决了没有
- 快照读:靠 MVCC,同一事务内范围查询结果不变 → 没有幻读;
- 当前读:靠 next-key lock(临键锁) 锁住范围,阻止其他事务在区间内插入 → 也没有幻读。
所以 InnoDB 的 RR 在绝大多数场景下避免了幻读,这是它相对标准 RR 的增强。但仍有一个经典反例:
-- 事务 A(RR)
SELECT * FROM t WHERE id = 5; -- 快照读,无此行
-- 事务 B 插入 id = 5 并提交
UPDATE t SET c = 1 WHERE id = 5; -- 当前读,居然更新成功了!
SELECT * FROM t WHERE id = 5; -- 现在能看到这行了(因为是自己改的)
"先快照读、后当前读"混用时,仍可能观察到幻影行。要彻底杜绝,只能用 SERIALIZABLE,或在读取时就用 FOR UPDATE 加锁。
五、InnoDB 的锁
锁的粒度与类型
表级:
- 意向锁 IS / IX:表级标记,表示"表内有行被加了 S / X 锁"。作用是让"要加表锁"的事务不必逐行检查。意向锁之间互不冲突。
- MDL(元数据锁):访问表时自动加 MDL 读锁,
ALTER TABLE加 MDL 写锁。长事务 + DDL 会造成整表阻塞,是线上事故的常见根因。 - AUTO-INC 锁:自增值分配用,由
innodb_autoinc_lock_mode控制(8.0 默认为 2,交叉模式,性能最好但要求ROW格式 binlog)。
行级(都加在索引记录上):
| 锁 | 范围 | 说明 |
|---|---|---|
| 记录锁 Record Lock | 单条索引记录 | 等值命中唯一索引时使用 |
| 间隙锁 Gap Lock | 两条记录之间的开区间 | 只阻止插入,间隙锁之间不冲突 |
| 临键锁 Next-Key Lock | 记录 + 前面的间隙,左开右闭 (a, b] | RR 下的默认行级锁 |
| 插入意向锁 Insert Intention | 间隙中的插入意图 | 与间隙锁冲突,是死锁的常见参与者 |
行锁加在索引上,不是加在行上
如果 WHERE 条件走不到索引,InnoDB 会扫描并锁住所有扫过的记录,实际效果等同于锁表。
-- name 无索引:全表扫描,全表行锁
UPDATE employee SET salary = salary + 1 WHERE name = 'Tom';
这是"为什么我的 UPDATE 把整张表锁住了"的标准答案。
加锁规则(RR 隔离级别)
原则:以 next-key lock(左开右闭)为基本单位,再按以下规则退化。
- 等值查询命中唯一索引(存在该行)→ 退化为记录锁;
- 等值查询在唯一索引上未命中 → 退化为间隙锁(锁住该值所在的空隙,防止插入);
- 等值查询在非唯一索引上 → 锁住命中记录的 next-key lock,并向右延伸到第一个不满足条件的记录,该记录上的锁退化为间隙锁;
- 范围查询 → 一路加 next-key lock 直到第一个不满足条件的记录为止(唯一索引上的范围查询在 8.0.x 中对最后一个不满足条件的记录也会退化为间隙锁)。
RC 隔离级别下没有间隙锁(除了外键检查和唯一性检查),只加记录锁,且不满足条件的行会立即释放锁——这是 RC 并发更高的直接原因。
六、死锁
死锁 = 两个事务互相持有对方需要的锁并循环等待。
-- 事务 A -- 事务 B
UPDATE t SET x=1 WHERE id=1; UPDATE t SET x=1 WHERE id=2;
UPDATE t SET x=1 WHERE id=2; UPDATE t SET x=1 WHERE id=1;
-- A 等 B 持有的 id=2 -- B 等 A 持有的 id=1 → 死锁
InnoDB 默认开启死锁检测(innodb_deadlock_detect = ON),检测到后主动回滚代价较小(修改行数少)的那个事务,报 ERROR 1213。另有 innodb_lock_wait_timeout(默认 50 秒)兜底普通的锁等待超时(ERROR 1205)。
排查:
SHOW ENGINE INNODB STATUS; -- LATEST DETECTED DEADLOCK 段落
-- 8.0 起可查看实时锁信息
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
预防手段:
- 保证多个事务按相同顺序访问资源(如批量更新前先按主键排序)——最有效的一条;
- 缩短事务:把 RPC 调用、复杂计算移出事务,把最容易冲突的更新放在事务的最后一步;
- 用 RC 隔离级别减少间隙锁;
- 为热点更新加索引,避免全表加锁;
- 极端热点(如秒杀扣库存)可以考虑在应用层排队,或用乐观锁(版本号 CAS)代替行锁。
七、长事务的危害
-- 找出运行超过 60 秒的事务
SELECT * FROM information_schema.innodb_trx
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;
一个长事务会:
- 撑爆 undo 表空间:它的 Read View 还需要旧版本,purge 线程无法清理 undo log,
ibdata持续膨胀; - 长期占用行锁,阻塞其他事务;
- 持有 MDL 读锁,让后面所有 DDL 以及排在 DDL 之后的普通查询全部阻塞。
务必关掉不必要的自动提交关闭(autocommit = 0 会让一条 SELECT 就开启事务并一直不提交),并在框架层设置事务超时。
八、乐观锁与悲观锁
悲观锁:假设一定会冲突,先加锁再操作。
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- 加 X 锁,其他事务阻塞
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;
乐观锁:假设不会冲突,提交时用版本号/条件判断。
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 10; -- 影响行数为 0 说明被别人改过,应用层重试
对库存这类场景,还有一种更简单的做法——把判断直接写进 WHERE,靠行锁的原子性保证正确:
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;
下一步 👉 执行计划与调优
