MySQL 事务隔离级别与锁机制详解
从 ACID 到 MVCC,深入理解 MySQL 事务与并发控制
当多个用户同时读写数据库时,如何保证数据的一致性,同时又不过度牺牲并发性能?这是数据库系统设计中最核心的问题之一。MySQL(InnoDB 引擎)通过 事务(Transaction) 与 锁(Lock) 两套机制给出了精妙的回答。理解它们的工作原理,是写出高并发、高可靠数据库应用的前提。
本文将从 ACID 属性出发,逐步深入到隔离级别、MVCC 多版本并发控制、各类锁机制以及死锁问题。
一、ACID 属性
事务是一组不可分割的操作序列,具有四个基本特性:
| 特性 | 含义 | 实现机制 |
|---|---|---|
| Atomicity 原子性 | 事务内的操作要么全部成功,要么全部回滚 | Undo Log(回滚日志) |
| Consistency 一致性 | 事务执行前后,数据库从一个合法状态变为另一个合法状态 | 业务约束 + AID 共同保证 |
| Isolation 隔离性 | 并发事务之间互不干扰 | MVCC + 锁 |
| Durability 持久性 | 事务提交后,修改永久保存 | Redo Log(重做日志) |
一个典型的事务使用方式:
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 或 ROLLBACK;
InnoDB 默认开启 autocommit,每条语句自动成为一个事务。显式使用 START TRANSACTION 可以把多条语句合并为一个事务。
二、事务隔离级别
2.1 并发带来的问题
在没有隔离的情况下,并发事务可能引发以下问题:
| 问题 | 描述 |
|---|---|
| 脏读(Dirty Read) | 事务 A 读到了事务 B 未提交的修改,随后 B 回滚,A 读到的是无效数据 |
| 不可重复读(Non-repeatable Read) | 事务 A 两次读取同一行,中间事务 B 修改并提交了该行,两次读取结果不同 |
| 幻读(Phantom Read) | 事务 A 两次执行相同的范围查询,中间事务 B 新增了符合条件的行,第二次查询多出”幻影”行 |
2.2 四种隔离级别
SQL 标准定义了四种隔离级别,隔离性依次增强,并发性依次降低:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED 读未提交 | 可能 | 可能 | 可能 |
| READ COMMITTED 读已提交 | 不可能 | 可能 | 可能 |
| REPEATABLE READ 可重复读 | 不可能 | 不可能 | 可能 |
| SERIALIZABLE 串行化 | 不可能 | 不可能 | 不可能 |
查看与设置隔离级别:
-- 查看全局和会话隔离级别
SELECT @@global.transaction_isolation, @@session.transaction_isolation;
-- 设置会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
InnoDB 默认使用 REPEATABLE READ(可重复读),并且通过 Next-Key Lock 算法解决了幻读问题,这是它优于 SQL 标准的地方。
2.3 各级别下的行为对比
READ UNCOMMITTED:直接读取最新数据,不加锁也不走 MVCC,会出现脏读。基本不用于生产。
READ COMMITTED(RC):每次 SELECT 都生成新的 Read View,因此能读到其他事务已提交的最新修改。会出现不可重复读。Oracle、PostgreSQL 默认此级别。
REPEATABLE READ(RR):事务第一次 SELECT 时生成 Read View,此后整个事务复用该 View,保证可重复读。InnoDB 默认此级别。
SERIALIZABLE:所有 SELECT 隐式加共享锁,读阻塞写,并发性能最差,极少使用。
三、MVCC 多版本并发控制
3.1 为什么需要 MVCC
如果只靠加锁来实现隔离,读和写会互相阻塞,并发性能极差。MVCC(Multi-Version Concurrency Control)的思路是:为每行数据维护多个版本,让读操作读取历史快照,从而实现读写不互相阻塞。
InnoDB 的 MVCC 主要依赖三个隐藏字段和两套日志:
每行隐藏字段:
| 字段 | 含义 |
|---|---|
DB_TRX_ID | 最近一次修改该行的事务 ID |
DB_ROLL_PTR | 指向 Undo Log 中该行的上一版本 |
DB_ROW_ID | 隐含自增主键(无主键时使用) |
Undo Log:保存数据的旧版本,形成版本链。每次更新时,旧值被写入 Undo Log,通过 DB_ROLL_PTR 串联成链表。
3.2 Read View 的可见性判断
当一个事务执行 SELECT 时,会生成一个 Read View,包含四个关键字段:
m_ids :生成 Read View 时,当前活跃(未提交)事务 ID 列表
min_trx_id :m_ids 中的最小值
max_trx_id :下一个将分配的事务 ID(系统最大事务 ID + 1)
creator_trx_id:创建该 Read View 的事务 ID
对于某行数据的 DB_TRX_ID,可见性判断规则如下:
1. trx_id == creator_trx_id → 自己修改的,可见
2. trx_id < min_trx_id → 修改在 Read View 创建前已提交,可见
3. trx_id >= max_trx_id → 修改在 Read View 创建后才开始的,不可见
4. min_trx_id <= trx_id < max_trx_id:
- 如果 trx_id 在 m_ids 中 → 活跃事务,不可见
- 如果 trx_id 不在 m_ids 中 → 已提交,可见
如果不可见,就沿着 DB_ROLL_PTR 找到 Undo Log 中的上一个版本,继续应用上述规则,直到找到可见版本或链表结束。
3.3 RC 与 RR 的差异
理解了 Read View 的生成时机,就能明白 RC 与 RR 的根本区别:
| 级别 | Read View 生成时机 | 效果 |
|---|---|---|
| READ COMMITTED | 每条 SELECT 都生成新的 Read View | 总是能看到最新已提交数据 |
| REPEATABLE READ | 事务第一次 SELECT 时生成,之后复用 | 整个事务看到的是同一快照 |
这就是为什么 RR 级别能实现可重复读——因为 Read View 固定不变,同一行无论查多少次都是同样的版本。
四、锁机制
4.1 锁的分类
InnoDB 的锁可以从不同维度划分:
按粒度:
| 锁类型 | 作用范围 | 触发条件 |
|---|---|---|
| 表锁 | 整张表 | DDL、LOCK TABLES、意向锁 |
| 行锁 | 单行或多行 | UPDATE/DELETE/SELECT ... FOR UPDATE |
按兼容性(行锁模式):
| 锁模式 | X(排他锁) | S(共享锁) |
|---|---|---|
| X 排他锁 | 冲突 | 冲突 |
| S 共享锁 | 冲突 | 兼容 |
- 共享锁(S Lock):
SELECT ... LOCK IN SHARE MODE,允许其他事务读但不允许写。 - 排他锁(X Lock):
SELECT ... FOR UPDATE、UPDATE、DELETE,阻止其他事务读写。
4.2 意向锁
InnoDB 为了支持表锁与行锁共存,引入了意向锁。当一个事务准备给某行加行锁时,会先在表上加一个意向锁(IS 或 IX),这样其他事务想给整张表加表锁时,只需检查表上是否有意向锁,而无需逐行检查。
意向锁是表级别的,互相兼容,它只与表锁冲突:
| IS | IX | |
|---|---|---|
| IS | 兼容 | 兼容 |
| IX | 兼容 | 兼容 |
| 表 S 锁 | 兼容 | 冲突 |
| 表 X 锁 | 冲突 | 冲突 |
4.3 行锁的三种算法
InnoDB 对行锁有三种实现算法,这正是理解 RR 级别下如何防止幻读的关键:
Record Lock(记录锁)
只锁定索引上的一条记录。当查询使用唯一索引等值匹配且记录存在时,退化为 Record Lock。
Gap Lock(间隙锁)
锁定索引记录之间的”间隙”,但不包含记录本身。目的是防止其他事务向间隙中插入新记录。例如索引上有值 10、20,Gap Lock 会锁住 (10, 20) 这个区间,阻止插入 15。
Next-Key Lock
= Record Lock + Gap Lock,锁定一个左开右闭区间 (10, 20]。这是 RR 级别下的默认行锁算法,既能防止记录被修改,也能防止间隙被插入,从而解决幻读。
不同查询条件下的锁退规则(RR 级别):
| 查询条件 | 锁类型 |
|---|---|
| 唯一索引等值匹配,记录存在 | Record Lock |
| 唯一索引等值匹配,记录不存在 | Gap Lock |
| 非唯一索引等值匹配 | Next-Key Lock + 后一个 Gap Lock |
| 范围查询 | Next-Key Lock,覆盖扫描范围 |
来看一个具体例子。假设有表 t,主键 id 列有值 10、15、20、25:
-- 事务 A
START TRANSACTION;
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 此时事务 A 持有的锁:
-- (−∞, 10] 不锁
-- (10, 15] Next-Key Lock
-- (15, 20] Next-Key Lock
-- (20, 25) Gap Lock(范围查询会锁到下一个区间)
在事务 A 提交前,事务 B 尝试插入 id=18 会被阻塞,尝试插入 id=30 则可以成功(因为 25 之后的间隙未锁)。
4.4 插入意向锁
当事务需要在某个间隙插入记录时,会申请插入意向锁。它表示”我打算在这个间隙插入数据”。插入意向锁之间互相兼容(不同事务可以在同一间隙插入不同位置),但与 Gap Lock 冲突——这正是 Gap Lock 阻止插入的原理。
五、SELECT FOR UPDATE 与锁
SELECT ... FOR UPDATE 是开发中常用的”悲观锁”手段,它对查询结果加排他锁,常用于”先查后改”的场景:
START TRANSACTION;
-- 加排他锁,其他事务的读(FOR SHARE)和写都会阻塞
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 在应用层计算后更新
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;
对应的共享锁语法:
SELECT * FROM account WHERE id = 1 FOR SHARE;
使用要点:
- 必须在事务内使用,事务结束(COMMIT/ROLLBACK)后锁才释放。
- 查询条件是否走索引决定锁范围。如果
WHERE条件没有走索引,InnoDB 不得不锁住所有扫描到的行,效果近似表锁。这是一个常见的线上事故来源。 - NOWAIT / SKIP LOCKED 选项可以避免长时间等待:
-- 立即返回错误,不等待锁
SELECT * FROM account WHERE id = 1 FOR UPDATE NOWAIT;
-- 跳过被锁的行,返回其余行
SELECT * FROM queue WHERE status = 'pending' FOR UPDATE SKIP LOCKED;
SKIP LOCKED 常用于实现工作队列:多个消费者同时取任务,各自跳过已被锁定的行,互不阻塞。
六、死锁检测与预防
6.1 死锁的产生
死锁是指两个或多个事务互相持有对方需要的锁,导致永久等待。经典场景:
事务 A:锁住 id=1,尝试更新 id=2
事务 B:锁住 id=2,尝试更新 id=1
→ 双方互相等待,形成死锁
6.2 InnoDB 的死锁检测
InnoDB 默认开启死锁检测(innodb_deadlock_detect=ON)。当检测到死锁时,InnoDB 会选择回滚”权重较小”的事务(通常是修改行数较少的事务)作为牺牲品,让另一个事务继续执行。
-- 查看死锁检测配置
SELECT @@innodb_deadlock_detect;
-- 关闭死锁检测(高并发场景下检测本身有开销)
SET GLOBAL innodb_deadlock_detect = OFF;
关闭检测时,死锁事务会一直等待,直到达到 innodb_lock_wait_timeout(默认 50 秒)超时。
6.3 查看死锁日志
当死锁发生时,可以通过 SHOW ENGINE INNODB STATUS 查看最近一次死锁的详细信息:
SHOW ENGINE INNODB STATUS\G
输出中的 LATEST DETECTED DEADLOCK 段会记录两个事务当时执行的 SQL、持有和等待的锁信息,是排查死锁的第一手资料。
6.4 死锁预防策略
死锁无法完全避免,但可以通过规范降低发生概率:
| 策略 | 说明 |
|---|---|
| 固定加锁顺序 | 所有事务按相同顺序访问表和行,避免环路 |
| 缩短事务 | 事务越长持锁时间越久,尽快提交 |
| 使用合适的索引 | 避免行锁升级为表锁,减少锁定的行数 |
| 降低隔离级别 | RC 比 RR 锁的间隙更少(无 Gap Lock) |
| 设置合理超时 | innodb_lock_wait_timeout 不宜过长 |
七、实战:扣减库存的并发安全
电商场景下的库存扣减是事务与锁的典型应用。先看一段有问题的代码:
// ❌ 错误示范:先查后改,存在超卖风险
$stock = DB::selectOne('SELECT stock FROM goods WHERE id = 1')['stock'];
if ($stock < $orderQty) {
throw new Exception('库存不足');
}
DB::update('UPDATE goods SET stock = stock - ? WHERE id = 1', [$orderQty]);
在 RR 级别下,普通 SELECT 是快照读,不加锁,两个并发事务可能同时读到 stock=10,都通过校验后各自扣减,导致超卖。
方案一:悲观锁
START TRANSACTION;
SELECT stock FROM goods WHERE id = 1 FOR UPDATE; -- 加排他锁
-- 应用层判断 stock >= orderQty
UPDATE goods SET stock = stock - ? WHERE id = 1;
COMMIT;
FOR UPDATE 使并发事务串行化访问同一行,保证安全。缺点是高并发下锁等待严重。
方案二:乐观锁(版本号)
-- 增加版本号字段 version
UPDATE goods
SET stock = stock - ?, version = version + 1
WHERE id = 1 AND version = ?;
-- 根据影响行数判断是否成功
不需要加锁,冲突时重试。适合冲突较少的场景。
方案三:原子更新
UPDATE goods
SET stock = stock - ?
WHERE id = 1 AND stock >= ?;
-- 根据影响行数判断是否成功
利用 WHERE stock >= ? 在一条语句内完成判断与扣减,InnoDB 保证原子性。这是最简洁高效的方案。
结语
MySQL 的事务与锁机制是一套精密的系统:ACID 由 Undo/Redo Log 保证,隔离性由 MVCC + 锁共同实现,MVCC 让读不阻塞写,锁让写之间有序进行。理解 Read View 的可见性规则、Next-Key Lock 的锁定范围以及死锁的形成原理,能帮助你在实际开发中:
- 根据业务场景选择合适的隔离级别;
- 正确使用
FOR UPDATE实现悲观锁; - 通过合理建索引避免锁升级;
- 规范事务代码,降低死锁概率。
建议在本地环境用 SHOW ENGINE INNODB STATUS 和 information_schema.INNODB_TRX 等工具实际观察锁的状态,理论结合实践,才能真正掌握这套机制。