MySQL事务隔离级别与幻读:从超卖到间隙锁

事务隔离级别是那种「平时完全不用管、出事时完全看不懂」的知识。绝大多数站长写代码时只会用到 BEGIN 和 COMMIT,从没想过中间发生了什么;直到某天发现「明明加了锁还是超卖」「两个请求同时进来把库存扣成了负数」「报表和实时数据对不上」,才开始回头翻 InnoDB 的默认隔离级别。

这篇文章按「现象 → 原理 → 复现 → 选择」的顺序写,重点放在自建站实际会遇到的问题上,而不是背四个隔离级别的定义。所有实验都可以在 MySQL 5.7 / 8.0 上直接跑。

一、四种异常,先认清名字

四个隔离级别(读未提交、读已提交、可重复读、串行化)之所以存在,是为了解决三类异常现象。这三类现象的名字很抽象,用一句话说清:

脏读:A 事务改了数据还没提交,B 事务读到了这个「半成品」。如果 A 最后回滚了,B 读到的就是一个从未存在过的值。

不可重复读:A 事务里读了两遍同一行,中间 B 事务把这行改了并提交,于是 A 两次读到不一样的值。注意是「同一行的内容变了」。

幻读:A 事务里按条件查了两次,中间 B 事务插入了一条符合条件的新行,于是 A 第二次查出来多了一行。注意是「多出了行」,这是它和不可重复读的区别。

InnoDB 的默认隔离级别是「可重复读」(REPEATABLE READ),它解决的问题是脏读和不可重复读;幻读在理论上仍然存在,但 InnoDB 用 Next-Key Lock(间隙锁)把它在实践中堵住了大半。这也正是很多人困惑的源头:教科书说可重复读不解决幻读,但实测又好像解决了。

二、两套并存的「读」:快照读与当前读

理解 InnoDB 事务,最关键的一件事是分清两种读:

快照读(普通 SELECT):读的是 MVCC 生成的版本链上的某个快照,不加锁。你在可重复读级别下,事务开始后第一次 SELECT 会建立一个 Read View,之后整个事务里所有普通 SELECT 都用这个 Read View,所以别人怎么改你都看不见。

当前读(SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT):读的是最新提交的版本,并且要加锁。

这一区分解释了大量「看起来矛盾」的现象。比如下面这段:

-- 会话 A
BEGIN;
SELECT stock FROM goods WHERE id = 1;   -- 快照读,返回 10
-- 会话 B 此时执行 UPDATE goods SET stock = 5 WHERE id = 1; COMMIT;
SELECT stock FROM goods WHERE id = 1;   -- 还是 10(快照没变)
SELECT stock FROM goods WHERE id = 1 FOR UPDATE;  -- 返回 5(当前读)

第一次读到 10,第二次还是 10,第三次突然变成 5。不知道快照读/当前读区别的人,会以为数据库出了 bug。

三、亲手复现:三种异常

准备一张最简单的表:

CREATE TABLE t_stock (
  id INT PRIMARY KEY,
  stock INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO t_stock VALUES (1, 10);

开两个 mysql 客户端(会话 A、B),逐个验:

脏读(只有「读未提交」能看到):

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;   -- 会话 A
BEGIN;
SELECT stock FROM t_stock WHERE id = 1;          -- 10

-- 会话 B(默认级别)
BEGIN;
UPDATE t_stock SET stock = 99 WHERE id = 1;      -- 不提交

-- 回会话 A
SELECT stock FROM t_stock WHERE id = 1;          -- 99 ← 脏读
-- 会话 B 执行 ROLLBACK 后,这个 99 从未真正存在过

不可重复读(读已提交下可见):

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT stock FROM t_stock WHERE id = 1;          -- 10
-- 会话 B:UPDATE ... SET stock = 99; COMMIT;
SELECT stock FROM t_stock WHERE id = 1;          -- 99 ← 内容变了

幻读(读已提交下可见,可重复读下靠间隙锁挡住):

BEGIN;
SELECT COUNT(*) FROM t_stock WHERE stock < 50;   -- 假设 3 条
-- 会话 B:INSERT INTO t_stock VALUES (2, 20); COMMIT;
SELECT COUNT(*) FROM t_stock WHERE stock < 50;   -- 4 条 ← 多了行

跑完这三个实验,你对四个隔离级别的理解就从「背定义」变成「见过」了。以后线上遇到「同一事务里两次查询结果不一致」,第一反应应该是「这里是不是当前读」。

四、自建站最常见的坑:超卖与重复扣款

可重复读能挡住脏读,但挡不住并发写冲突。最典型的例子是库存扣减,很多人写成这样:

BEGIN;
SELECT stock FROM goods WHERE id = 1;      -- 读到 10
-- PHP 里判断 if ($stock < 1) { 报错 }
UPDATE goods SET stock = stock - 1 WHERE id = 1;
COMMIT;

两个请求同时进来,都读到 10,都判断「够」,都执行了减一,最后库存变成 8,但卖出去了 2 件——正确应该是 8,可如果库存是 1 呢?两个请求都读到 1,都判断够,都减一,库存变成 -1,超卖了。

这段代码的问题不在于隔离级别,而在于「先查后改」中间有窗口。三种修法,从差到好:

修法一:把条件写进 UPDATE 的 WHERE。

UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock >= 1;
-- 检查 affected_rows,等于 0 就表示没扣到,返回「已售罄」

这条语句是原子的,InnoDB 会在行上加排他锁,两个并发请求只能一个一个来。第二个请求执行时 stock 已经不够,WHERE 不匹配,affected_rows = 0,业务层据此拒单。这是最简单也最推荐的写法,不需要显式事务,也不需要先查。

修法二:SELECT ... FOR UPDATE 把行锁住。

BEGIN;
SELECT stock FROM goods WHERE id = 1 FOR UPDATE;   -- 加排他锁
-- 判断、计算
UPDATE goods SET stock = stock - 1 WHERE id = 1;
COMMIT;

这条路径正确,但代价是锁持有时间变长。如果中间还有 HTTP 请求、文件读写,事务会开很久,锁等待会拖垮整个库。用它的时候必须保证「拿到锁之后不做任何耗时操作」,否则就是给自己制造死锁和超时。

修法三:乐观锁(版本号)。

UPDATE goods SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 10;

检查 affected_rows,为 0 说明被别人改过了,重试或报错。适合写少读多、冲突概率低的场景。冲突高的时候重试次数会很多,反而不如悲观锁。

三种修法的选择判据非常简单:冲突少用乐观锁,冲突多用条件 UPDATE,只有「必须读到最新值再计算」的逻辑才用 FOR UPDATE。绝大多数扣库存场景属于第二类,偏偏很多人一上来就用第三类(FOR UPDATE),把并发性能白白锁没了。

五、间隙锁:可重复读下最容易踩的「意外锁」

在可重复读级别下,InnoDB 对索引记录加的是 Next-Key Lock,也就是「记录锁 + 前面的间隙」。这个机制本来是为了挡幻读,但副作用是:它锁的范围可能比你想象的大得多。

经典场景:

-- 会话 A
BEGIN;
SELECT * FROM t_stock WHERE id = 5 FOR UPDATE;   -- 假设 id=5 不存在
-- 此时 A 锁住的是 (上一个存在的 id, 下一个存在的 id) 这个间隙

-- 会话 B
INSERT INTO t_stock VALUES (6, 10);              -- 可能被阻塞

注意:A 查的是一个不存在的 id,按理说锁不到任何行,但它仍然可能阻塞 B 的插入。这就是「查一个不存在的记录却把别人锁住了」的经典困惑。原因就是间隙锁。

两个实用结论:

第一,锁定查询一定要走索引。如果 WHERE 里的列没有索引,InnoDB 只能全表扫描并锁住扫描到的所有行,等于锁表。这在自建站上是性能灾难的常见来源,比死锁更隐蔽——因为它不报错,只是变慢。

第二,如果业务能接受,把隔离级别降到「读已提交」(READ COMMITTED)可以大幅减少间隙锁。MySQL 官方文档明确说过,RC 级别下间隙锁基本不生效(只有外键检查和唯一性检查时才有)。很多互联网业务选择 RC 而不是默认的 RR,原因就在这里。代价是会出现幻读,需要业务代码自己防。

-- 查看当前级别
SELECT @@transaction_isolation;      -- 8.0
SELECT @@tx_isolation;               -- 5.7

-- 单次会话切换
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

切换前务必确认业务里有没有依赖可重复读的地方。最常见的依赖是「事务里先 SELECT 判断,再 INSERT」,这种写法在 RC 下并发时会插入两条,需要靠唯一索引兜底。

六、死锁:不是错误,是检测机制在工作

看到 Deadlock found when trying to get lock; try restarting transaction 时,很多人的第一反应是「数据库坏了」。其实死锁是 InnoDB 的正常行为:它检测到两个事务互相等待,于是主动回滚代价较小的那个,让另一个继续,避免整体卡死。

要做的不是「消灭死锁」,而是「让它变少、并且让业务能正确重试」。三件事:

第一,统一加锁顺序。 死锁最常见的成因是两个事务以不同顺序锁同样的两行。比如 A 先锁 user 再锁 order,B 先锁 order 再锁 user,必然形成环路。约定「所有事务按主键升序加锁」,能消掉一大半。

第二,缩短事务。 事务里不要放 HTTP 请求、不要做文件读写、不要等用户输入。事务越长,持有锁的时间越长,撞上的概率越大。

第三,用唯一索引代替「先查后插」。 两个并发事务同时「查不存在 → 插入」,在 RR 下靠间隙锁互相阻塞,在 RC 下就插进去两条。正确做法是给业务列建唯一索引,用 INSERT ... ON DUPLICATE KEY UPDATE 或捕获 1062 错误。

查看最近的死锁详情:

SHOW ENGINE INNODB STATUS\G

输出里 LATEST DETECTED DEADLOCK 段落会写明两个事务分别执行了什么语句、等待什么锁、最终回滚了谁。这一段是排查死锁唯一可靠的信息源,比看应用日志有用得多。如果死锁频繁,可以打开 innodb_print_all_deadlocks = ON,把所有死锁都写进错误日志。

七、业务层必须做的两件事

不管隔离级别配得多合理,业务代码都要做两件事,否则前面所有的努力都会被抵消:

一是捕获死锁并重试。 MySQL 返回的 1213(死锁)和 1205(锁等待超时)都是「可以重试」的错误,不是业务错误。重试 2~3 次,每次间隔随机几十毫秒(避免同时重试又撞上),绝大多数情况下第二次就成功了。没有重试逻辑的代码,在并发高的时候会随机报错,用户看到的就是「偶尔下单失败」。

二是检查 affected_rows。 上面所有「条件 UPDATE」的写法,正确性完全依赖「有没有检查影响行数」。PHP PDO 里是 $stmt->rowCount(),mysqli 里是 $mysqli->affected_rows。很多代码写完 UPDATE 就直接往下走,不管有没有真的改到行,于是超卖、重复扣款全都从这里发生。

八、给自建站的实际建议

最后落到具体配置上。一个典型的自建站(Typecho、WordPress、自研 PHP 站),我的建议是:

保持默认可重复读,除非你明确知道自己在做什么。RR 更「保守」,出现问题的概率更低,代价是间隙锁会让某些并发写变慢。对小站来说,慢一点远比数据错好。

把「先查后改」的代码全部改成条件 UPDATE。这是收益最高的一件事,改完之后超卖类问题基本绝迹,而且不需要动任何数据库配置。

关键业务表加唯一索引。订单号、用户名、邮箱、外部单号,这些「业务上必须唯一」的列一定要有唯一索引。它是最便宜的一致性保证,比任何应用层判断都可靠。

监控锁等待。 打开 innodb_print_all_deadlocks,并且定期看一眼 SHOW ENGINE INNODB STATUS 的 TRANSACTIONS 段。当出现大量 LOCK WAIT 时,说明某处事务开得太久了。

不要用 LOCK TABLES。MyISAM 时代的做法,在 InnoDB 上除了慢没有别的效果,还会让并发彻底崩掉。

事务这块知识的特点是:不理解的时候,代码看起来都是对的,跑测试也都过;理解之后回头看,会发现到处都是「先查后改」的窗口。所以最好的排查方式不是等出事,而是主动 grep 一遍代码里的 SELECT 后面紧跟 UPDATE 的模式——每一处都值得改。

Last modification:November 1st, 2026 at 12:25 pm

Leave a Comment