线上优惠券超发,日志里两个事务读到的已发数量都是 999,插入都成功了。更怪的是排查时看到一个事务里 UPDATE 返回 1 row affected,紧接着用同样的条件 SELECT 却是空结果。这两个现象根子上是同一件事:InnoDB 在 RR 隔离级别下,快照读和当前读看到的是两个版本的世界。
这篇文章用两个 mysql 终端就能复现的 SQL 讲清楚:幻读到底怎么发生、间隙锁为什么没能拦住「先查后插」、无索引更新怎么会把整张表锁住,以及业务代码里该用唯一索引还是原子扣减。
复现:UPDATE 影响 1 行,SELECT 返回空
准备一张发券记录表,coupon_id 上建普通索引,避免后面的 UPDATE 走全表扫描把复现搅乱。
CREATE TABLE `coupon_grant` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`coupon_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_coupon` (`coupon_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
开两个终端,都连到同一个库。终端 A:
-- 终端 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
-- 第一次快照读,此时表里没有 coupon_id=100 的记录
-- 这条语句创建了本事务的 Read View
SELECT * FROM coupon_grant WHERE coupon_id = 100;
-- Empty set
终端 B 插入一条并提交:
-- 终端 B
BEGIN;
INSERT INTO coupon_grant(user_id, coupon_id) VALUES (1, 100);
COMMIT;
回到终端 A,先做当前读,再做快照读:
-- 终端 A
-- UPDATE 是当前读,能看到 B 已提交的行,并把它改掉
UPDATE coupon_grant SET user_id = 2 WHERE coupon_id = 100;
-- Query OK, 1 row affected
-- 同一个事务里再查,还是空,因为复用第一次的 Read View
SELECT * FROM coupon_grant WHERE coupon_id = 100;
-- Empty set
COMMIT;
-- 事务结束后新开一条语句,新 Read View,才能看到
SELECT * FROM coupon_grant WHERE coupon_id = 100;
-- 1 row
这里没有出现「两次 SELECT 结果不同」,但问题更隐蔽:写操作看到了读操作看不到的行。如果代码逻辑是「查一下有没有,没有就更新」,就会走到一个事务里改了数据、校验逻辑却认为数据不存在。
把第二次查询换成 SELECT ... FOR UPDATE,同一事务里同样条件的 SELECT 就会返回 1 行,因为它是当前读:
BEGIN;
SELECT * FROM coupon_grant WHERE coupon_id = 100; -- 空
-- 另一个终端插入并提交
SELECT * FROM coupon_grant WHERE coupon_id = 100 FOR UPDATE; -- 1 行
SELECT * FROM coupon_grant WHERE coupon_id = 100; -- 空
COMMIT;
同一事务、同一条件、三条 SELECT,结果在「空」和「1 行」之间横跳。这就是幻读在 InnoDB 里的真实样子,它不是简单的「两次普通 SELECT 结果不同」——RR 下普通 SELECT 反而不变,变的是当前读。
快照读和当前读:两套视野
InnoDB 的 MVCC 给每个事务一个 Read View,里面记着当时活跃的事务 ID 列表、最小事务 ID、下一个事务 ID。普通 SELECT 拿着这个视图去 undo log 里找可见版本,所以看不到视图创建之后才提交的插入。
UPDATE、DELETE、INSERT ... SELECT、SELECT ... FOR UPDATE 是当前读:读最新已提交版本,并且加锁。它不认 Read View。
RC 和 RR 的差别只在 Read View 的创建时机:
- RC:每条普通 SELECT 都新建 Read View,所以第二次 SELECT 能看到别的事务刚提交的插入。
- RR:事务里第一条普通 SELECT 创建 Read View,之后一直复用,所以看不到。
用一张表对比:
| 语句 | 读类型 | 加锁 | RR 下能否看到其他事务新提交的插入 |
|---|---|---|---|
SELECT ... |
快照读 | 否 | 不能 |
SELECT ... FOR UPDATE |
当前读 | 是 | 能 |
UPDATE / DELETE |
当前读 | 是 | 能 |
INSERT ... SELECT |
当前读 | 是 | 能 |
所以「RR 解决了幻读」这个说法要加限定:它只让快照读在事务内保持一致,当前读照样能看到新行。业务里一旦混用两种读,就会出现上面那种「改得到、查不到」。
间隙锁拦不住「先查后插」
真实的超发代码通常长这样:
@Transactional
public void grant(Long couponId, Long userId) {
// 普通 SELECT,快照读,不加锁
int count = grantMapper.countByCouponId(couponId);
if (count >= 1000) {
throw new BizException("已抢完");
}
grantMapper.insert(userId, couponId);
}
两个事务同时进来,都读到 999,都通过判断,都插入,最终 1001 条。这是写偏斜(write skew),表现和幻读一样:基于同一组读结果做了写入决策。
有人会改成 SELECT count(*) ... FOR UPDATE。这条语句在 RR 下确实会加 next-key lock,但锁的是扫描到的索引记录和间隙。当 coupon_id=100 一行都没有时,锁住的是 idx_coupon 上的一段间隙,能阻止其他事务往这段间隙插入,看似管用。问题是:
- 锁范围依赖索引和查询条件,条件稍微一改锁范围就变,容易锁过头。
- 多个券 ID 并发时,不同事务锁不同间隙,再交叉插入,很容易死锁。
- 统计的是历史总量,锁住的是发券记录表,后续任何插入都可能被阻塞。
更稳的做法是把「判断」和「扣减」合并成一条原子语句。建一张库存表:
CREATE TABLE `coupon` (
`id` BIGINT UNSIGNED NOT NULL PRIMARY KEY,
`stock` INT NOT NULL,
`version` INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
INSERT INTO coupon(id, stock) VALUES (100, 1000);
发券时先扣库存:
-- 当前读 + 行锁,stock > 0 保证不会扣成负数
UPDATE coupon
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND stock > 0;
-- 影响行数为 1 才继续,为 0 直接返回「已抢完」
再插入发券记录,并用唯一索引兜底防重:
ALTER TABLE coupon_grant
ADD UNIQUE KEY uk_user_coupon (user_id, coupon_id);
-- 同一用户重复领取时,唯一索引冲突,影响行数 0
INSERT INTO coupon_grant(user_id, coupon_id) VALUES (1, 100)
ON DUPLICATE KEY UPDATE id = id;
ON DUPLICATE KEY UPDATE id = id 是个常用写法,但有两个代价要清楚:一是冲突时也会加锁,高并发下同一唯一键的插入会排队,甚至死锁;二是自增 ID 会跳号,因为 InnoDB 在冲突前已经分配了自增值。如果业务对 ID 连续性有要求,得换 INSERT IGNORE 或先查缓存,但 INSERT IGNORE 会吞掉其他错误(比如字段超长),要配合严格 SQL 模式。
无索引的 UPDATE 能把整张表锁住
RR 下最容易被忽略的性能陷阱:UPDATE 的 WHERE 条件没有索引。
-- phone 上没有索引
UPDATE orders SET status = 1 WHERE phone = '13800000000';
InnoDB 只能全表扫描,扫描过程中对每一条记录加 next-key lock,效果接近锁表。其他事务插入任何订单都会被阻塞,直到这个事务提交。线上表现是「一个后台跑批更新,把整个下单链路卡死」。
先看执行计划:
EXPLAIN UPDATE orders SET status = 1 WHERE phone = '13800000000';
-- type=ALL, key=NULL, rows=1000000
加索引:
ALTER TABLE orders ADD INDEX idx_phone (phone);
EXPLAIN UPDATE orders SET status = 1 WHERE phone = '13800000000';
-- type=ref, key=idx_phone, rows=1
加完索引后锁范围从全表缩小到命中记录和相邻间隙。注意即使走索引,如果是非唯一索引的等值查询,RR 下仍会锁记录和间隙;唯一索引等值命中且记录存在时,next-key lock 会退化为 record lock,只锁那一行。这是「唯一索引等值查」在锁粒度上的优势。
锁等待超时的默认值是 50 秒:
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 50
排查时别只看应用日志,直接查锁:
SELECT
ENGINE_TRANSACTION_ID,
OBJECT_NAME,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA
FROM performance_schema.data_locks;
LOCK_MODE 里出现 X,GAP、X,REC_NOT_GAP、X 这些组合,分别对应间隙锁、记录锁、next-key lock。再看谁在等谁:
SELECT * FROM performance_schema.data_lock_waits\G
死锁现场在 SHOW ENGINE INNODB STATUS\G 的 LATEST DETECTED DEADLOCK 段,里面会打印两个事务持有的锁和等待的锁。构造一个典型的间隙死锁:
-- 表 t(id PK, v INT, KEY idx_v(v)),数据 (1,1),(2,2),(3,3)
-- 终端 A
BEGIN;
SELECT * FROM t WHERE v = 2 FOR UPDATE; -- 锁住 v=2 及相邻间隙
-- 终端 B
BEGIN;
SELECT * FROM t WHERE v = 3 FOR UPDATE; -- 锁住 v=3 及相邻间隙
-- 终端 A
INSERT INTO t(id, v) VALUES (4, 3); -- 等 B 释放 v=3 的间隙
-- 终端 B
INSERT INTO t(id, v) VALUES (5, 2); -- 等 A 释放 v=2 的间隙
-- 死锁,InnoDB 回滚其中一个事务
两个事务都在自己的间隙锁里等对方让出插入位置。这类死锁在「先按范围 FOR UPDATE,再插入」的代码里很常见,靠重试能解决一部分,但根因是锁范围设计得太宽。
RC 还是 RR:按写入模式选
把两者的差异列成表:
| 维度 | RR | RC |
|---|---|---|
| 普通 SELECT 一致性 | 整个事务一个快照 | 每条语句一个新快照 |
| 幻读 | 快照读避免,当前读仍可见新行 | 更明显 |
| 间隙锁 | 有,范围更新易阻塞插入 | 基本没有(外键和唯一性检查除外) |
| 并发插入 | 容易被间隙锁阻塞 | 阻塞少 |
| 主从复制要求 | STATEMENT 格式下相对安全 | 必须 ROW 格式 |
| 适合场景 | 账务、余额等强一致读 | 订单、券、高并发写入 |
如果选 RC,配置至少两行:
[mysqld]
transaction-isolation = READ-COMMITTED
binlog-format = ROW
Spring Boot 里用连接池设置:
spring:
datasource:
hikari:
transaction-isolation: TRANSACTION_READ_COMMITTED
也可以在方法上指定:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void grant(Long couponId, Long userId) {
// ...
}
注意别在代码里手动 SET SESSION TRANSACTION ISOLATION LEVEL。连接池会复用连接,这个设置会跟着连接跑到下一个请求,排查时非常难定位。要改就改连接池或全局配置。
RC 下没有间隙锁,并发插入顺畅,代价是同一事务里两次普通 SELECT 可能看到不同结果。所以业务代码不能依赖「事务内读一致」做判断,必须用原子 UPDATE、唯一索引或显式行锁。换句话说,选 RC 不是放弃正确性,而是把正确性从隔离级别挪到约束和 SQL 写法上。
几条能直接落地的规则
- 涉及限量、计数、余额的「先查后写」,不要用普通 SELECT 的结果做决策,改成
UPDATE ... WHERE stock > 0看影响行数,或者锁住确定的单行(WHERE id = ? FOR UPDATE)。 - 唯一约束必须加,且要处理 duplicate key 异常;但唯一索引只能防重,防不了「总量超卖」,总量要靠库存行锁。
- 写任何范围 UPDATE 之前先 EXPLAIN,确认走索引;RR 下无索引更新的锁范围是全表。
- 高并发写入表优先 RC + ROW 复制,接受幻读,用唯一索引和原子操作兜底;账务类读多写少、要求事务内强一致的场景保留 RR。
- 排查锁先看
performance_schema.data_locks和data_lock_waits,再看SHOW ENGINE INNODB STATUS,别从应用日志猜。