返回博客
技术 2025年3月11日 12 分钟阅读 · 3311 字

MySQL 事务隔离级别与锁机制详解

从 ACID 到 MVCC,深入理解 MySQL 事务与并发控制

#MySQL #事务 #锁机制 #MVCC
本文由 AI 辅助生成,经人工审核发布

当多个用户同时读写数据库时,如何保证数据的一致性,同时又不过度牺牲并发性能?这是数据库系统设计中最核心的问题之一。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 UPDATEUPDATEDELETE,阻止其他事务读写。

4.2 意向锁

InnoDB 为了支持表锁与行锁共存,引入了意向锁。当一个事务准备给某行加行锁时,会先在表上加一个意向锁(IS 或 IX),这样其他事务想给整张表加表锁时,只需检查表上是否有意向锁,而无需逐行检查。

意向锁是表级别的,互相兼容,它只与表锁冲突:

ISIX
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;

使用要点:

  1. 必须在事务内使用,事务结束(COMMIT/ROLLBACK)后锁才释放。
  2. 查询条件是否走索引决定锁范围。如果 WHERE 条件没有走索引,InnoDB 不得不锁住所有扫描到的行,效果近似表锁。这是一个常见的线上事故来源。
  3. 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 STATUSinformation_schema.INNODB_TRX 等工具实际观察锁的状态,理论结合实践,才能真正掌握这套机制。