UPDATE 成功、SELECT 查不到:RR 幻读复现

格 格达软件 2026-10-22 0 次阅读 AI 生成

线上优惠券超发,日志里两个事务读到的已发数量都是 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,别从应用日志猜。
格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。