事务、隔离级别与锁

一、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_idm_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 当前读

语句读到什么是否加锁
快照读普通 SELECTRead View 决定的历史版本不加锁
当前读SELECT ... FOR UPDATESELECT ... 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(左开右闭)为基本单位,再按以下规则退化。

  1. 等值查询命中唯一索引(存在该行)→ 退化为记录锁
  2. 等值查询在唯一索引上未命中 → 退化为间隙锁(锁住该值所在的空隙,防止插入);
  3. 等值查询在非唯一索引上 → 锁住命中记录的 next-key lock,并向右延伸到第一个不满足条件的记录,该记录上的锁退化为间隙锁;
  4. 范围查询 → 一路加 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;

预防手段:

  1. 保证多个事务按相同顺序访问资源(如批量更新前先按主键排序)——最有效的一条;
  2. 缩短事务:把 RPC 调用、复杂计算移出事务,把最容易冲突的更新放在事务的最后一步
  3. 用 RC 隔离级别减少间隙锁;
  4. 为热点更新加索引,避免全表加锁;
  5. 极端热点(如秒杀扣库存)可以考虑在应用层排队,或用乐观锁(版本号 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;

下一步 👉 执行计划与调优

上次更新:
贡献者: Joe