MySQL 同一个事务查了四次,数据反复横跳——快照读和当前读的边界

以下分析基于 MySQL 8.0.32场景:同一个事务内,同一张表,同一个 WHERE 条件——查了四次,数据量每次不一样 路径:四次查询 → ReadView 规则 → 快照读 vs 当前读 → 排查与重构

上篇讲了间隙锁死锁——两个并发 INSERT 互相堵,根因是 REPEATABLE READ 下的间隙锁机制。这次从"堵"到"跳",看一种更反直觉的现象:同一个事务里查了四次,WHERE 条件一字不差,结果每次不一样。

BEGIN;
SELECT * FROM permissions WHERE user_id = 100;              -- ① 10 行
-- 事务 2 插入了一行新模块并提交
SELECT * FROM permissions WHERE user_id = 100;              -- ② 10 行(嗯,RR 正常工作)
SELECT * FROM permissions WHERE user_id = 100 FOR UPDATE;   -- ③ 11 行(?!)
SELECT * FROM permissions WHERE user_id = 100;              -- ④ 10 行(又回去了?)
COMMIT;

——四次查询的数据分别是:10 行、10 行、11 行、10 行。

第一次和第二次一样,正常。第三次多了一行,还能解释。但第四次怎么又缩回去了?同一个事务,同一个快照——MySQL 的数据在"跳"?


现象 — 四次查询,四种心境

业务场景是权限管理系统。用户登录后查自己有权限的模块列表。在一个事务内,代码先查了一次做缓存预热,又查了一次做权限校验,中间用 FOR UPDATE 做了一次锁定处理——结果三次读到的行数完全不同。

MySQL 会话 — 四次查询结果不同,数据反复横跳

表结构:

CREATE TABLE permissions (
  id INT PRIMARY KEY AUTO_INCREMENT,
  user_id INT NOT NULL,
  module VARCHAR(50) NOT NULL,
  status TINYINT NOT NULL DEFAULT 0,  -- 0=待审核, 1=已通过
  INDEX idx_user_id (user_id)
) ENGINE=InnoDB;

事务完整序列:

时刻 事务 A 事务 B(并发)
t1 BEGIN;
t2 SELECT * FROM permissions WHERE user_id=10010 行
t3 BEGIN;
t4 INSERT INTO permissions VALUES(100,'new_analytics',1);
t5 COMMIT;
t6 SELECT * FROM permissions WHERE user_id=10010 行
t7 SELECT * FROM permissions WHERE user_id=100 FOR UPDATE11 行
t8 SELECT * FROM permissions WHERE user_id=10010 行
t9 COMMIT;

你看到了"幻读"——但它只发生在 FOR UPDATE 那一刻。前后两次普通的 SELECT 都坚定地返回 10 行。

这不是 MySQL 的 bug。这是 REPEATABLE READ 下快照读(consistent read)和当前读(locking read)两条路径的分岔


解析 — 三层追问

Layer 1:是 RC 还是 RR?

先确认隔离级别:

SHOW VARIABLES LIKE 'transaction_isolation';
-- → REPEATABLE-READ

如果是 READ COMMITTED,t6 就会看到 11 行(RC 下每个语句独立创建 ReadView)。但这里 t6 返回 10 行——说明是 RR,ReadView 复用了。

t7 的 FOR UPDATE 为什么会多一行?

Layer 2:RR 下存在两条读路径

MySQL InnoDB 在 RR 下有两种完全不同的读路径:

读路径 触发方式 读什么数据 ReadView 策略
快照读 (Consistent Read) 普通 SELECT(无 FOR UPDATE/SHARE MVCC 版本链回溯,只看到 ReadView 中可见的版本 复用在第一个 SELECT 时创建的 ReadView
当前读 (Locking Read) SELECT ... FOR UPDATE / SELECT ... FOR SHARE / UPDATE / DELETE 索引上的最新已提交版本 不使用 ReadView

这就是关键区别:

  • 快照读:第一个 SELECT 时创建 ReadView,记录了当时的活跃事务列表(m_ids),后续所有快照读复用这个 ReadView。事务 B 的 trx_id 不在这个列表中 → 不可见。所以 t2 和 t6 都返回 10 行。
  • 当前读SELECT ... FOR UPDATE 不读 MVCC 版本链。它直接访问索引页上的最新记录行,并加上锁。事务 B 已提交的行,在索引上就是最新可见的版本 → 所以 t7 返回 11 行。

"In a REPEATABLE READ transaction, consistent reads use the snapshot established by the first read. But locking reads and DML statements always read the latest committed version." — MySQL 官方文档

当前读之后,再切回快照读——数据又缩回去了。

t8 的普通 SELECT 重新进入快照读路径,使用最初创建的 ReadView。事务 B 的 trx_id 依然不可见。所以 t8 回到 10 行。

这不是"第一次 10 行,第二次 11 行"的增长——这是 10 → 10 → 11 → 10 的反复横跳。

同一事务内四次查询的数据变化 — 快照读 vs 当前读

四次查询的完整流程 — 数据如何从 10 行变成 11 行再回到 10 行

Layer 3:你的代码为什么会出现这种横跳?

三种常见的"无意识混合"场景:

① 巡检脚本:先 SELECT 再 FOR UPDATE 做锁定
   SELECT * FROM permissions WHERE status=0;        -- 快照读,3 行
   -- 管理员审批通过了一个新模块(另一个事务提交)
   SELECT * FROM permissions WHERE status=0 FOR UPDATE; -- 当前读,2 行!
   -- "怎么少了一行?刚才还有 3 条待审核!"

② 先查后改:SELECT 检查 → UPDATE 修改
   SELECT * FROM inventory WHERE id=100;              -- 快照读,当前库存
   -- 另一个事务扣减了库存并提交
   UPDATE inventory SET stock=stock-1 WHERE id=100;   -- 当前读!
   -- UPDATE 读到的 stock 是最新值,但 SELECT 读到的不是

③ ORM 隐式当前读:Spring Data JPA 的 saveAndFlush
   Optional<User> user = userRepo.findById(100);       -- 快照读
   user.getPermissions().size();                        -- 懒加载,快照读
   permissionRepo.saveAndFlush(newPermission);         -- INSERT → 当前读
   user.getPermissions().size();                        -- 快照读
   -- 如果另一个事务同时改了权限,ORM 层面会出现"不一致"

MySQL 官方文档对此有一段直白的警告:

"It is not recommended to mix locking statements with non-locking SELECT statements in a single REPEATABLE READ transaction, because these two different table states are inconsistent with each other and difficult to parse."

——翻译成人话:别在同一个 RR 事务里混用快照读和当前读,这两个世界的数据不一致,让人很难搞懂。


路径 — 🔍 排查事务内数据横跳

遇到"同一个事务查出来的数据不一样":

排查步骤 — 定位事务内混合读模式

第一步:确认是否真的是 RR

SHOW VARIABLES LIKE 'transaction_isolation';
-- READ-COMMITTED → 幻读是预期行为(每语句新 ReadView)
-- REPEATABLE-READ → 继续排查

第二步:查活跃事务

SELECT trx_id, trx_start_time, trx_isolation_level,
       TIMESTAMPDIFF(SECOND, trx_start_time, NOW()) AS trx_duration
FROM information_schema.INNODB_TRX;
-- 看是否有多个并发 RR 事务在运行
-- 重点关注 trx_start_time 和 trx_duration

第三步:在代码中找"混合模式"

在 Spring 项目里找到 @Transactional 注解的方法,检查方法体内是否有两种读路径同时出现:

@Transactional + 普通 SELECT + SELECT FOR UPDATE 或 UPDATE

检测模式:同一方法内先后出现 SELECTSELECT ... FOR UPDATE / UPDATE / DELETE 如果中间有其他事务提交 → 两个读路径看到的数据不一致


重构 — 三种应对策略

策略 A:统一用快照读(推荐)

如果业务逻辑不需要加锁,全程只用普通 SELECT。不要在一个事务里混入 FOR UPDATE 或 UPDATE:

 @Transactional
 public void process() {
-    List<Permission> list = repo.findByUserId(100);      // 快照读
-    // ... 中间有 FOR UPDATE 或 UPDATE → 当前读
-    List<Permission> list2 = repo.findByUserId(100);     // 又在看快照
+    List<Permission> list = repo.findByUserId(100);      // 一次性读好
+    // ... 所有业务判断基于 list,不重新查
 }

策略 A before/after — 统一快照读,消除横跳

策略 B:统一用当前读(FOR UPDATE)

如果业务依赖最新的数据(库存扣减、余额操作),干脆全部用 FOR UPDATE,不走 ReadView:

 @Transactional
 public void processWithLock(Long userId) {
-    List<Permission> list = repo.findByUserId(userId);  // 快照读
+    List<Permission> list = repo.findByUserIdWithLock(userId);  // FOR UPDATE
     // ... 所有后续操作基于同一个"最新"版本
 }
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Permission p WHERE p.userId = :userId")
List<Permission> findByUserIdWithLock(@Param("userId") Long userId);

策略 C:拆事务——读和写分家

如果方法天然"先读后写",拆成两个独立事务:

- @Transactional
- public void process(Long userId) {
-     List<Permission> list = permissionRepo.findByUserId(userId);
-     // 业务判断
-     permissionRepo.save(updated);
- }
+ @Transactional(readOnly = true)
+ public List<Permission> readPermissions(Long userId) { ... }
+ 
+ @Transactional
+ public void savePermission(Permission p) { ... }

三种策略选择:

策略 适用场景 不适用场景
A:统一快照读 配置/权限等变化少的读操作 库存/余额等强一致性要求
B:统一当前读 需要最新数据,接受锁开销 高频写入热点行
C:拆事务 天然读写分离的逻辑 需要单事务原子性

标记 — 🗝 在项目中搜索这类事务模式

模式 1:同一方法内混合 SELECT 和 FOR UPDATE

# 在 MyBatis XML 中搜索同一 mapper 方法内 SELECT + FOR UPDATE
grep -rn 'SELECT.*FROM' src/main/resources/ | grep -B 2 'FOR UPDATE'

模式 2:@Transactional 内多次请求

# 搜索 @Transactional 方法,人工审查是否有至少两次数据库查询
# 这是启发式搜索,需要人工排查
grep -n -A 20 '@Transactional' src/main/java/ | grep -E '(SELECT|findBy|findAll|query)'

模式 3:长事务内混合读写

-- 找运行超过 3 秒的 RR 事务
SELECT trx_id, trx_start_time,
       TIMESTAMPDIFF(SECOND, trx_start_time, NOW()) AS trx_duration
FROM information_schema.INNODB_TRX
WHERE trx_isolation_level = 2  -- 2 = REPEATABLE READ
  AND TIMESTAMPDIFF(SECOND, trx_start_time, NOW()) > 3;

总结

核心洞察:两条读路径,一个事务两个世界

REPEATABLE READ 不是一个统一的一致性保证——它是两条路径的组合:

  • 快照读:用 ReadView 构建事务开始时的数据镜像,一致性由 MVCC 保证
  • 当前读:直接读最新已提交数据,一致性由锁保证

这两条路径看到的数据库状态可以不同。当你混用时,数据就会在快照世界和真实世界之间横跳。

"同一个事务里,有两个并行的数据库——一个是 ReadView 中的历史快照,一个是索引上的最新现实。"

遇到事务内数据不一致,先确认:你切换了读模式吗?

下篇我们聊长事务导致的 UNDO 膨胀——ReadView 本身不可怕,可怕的是它不释放时 UNDO 日志会一直堆积。一个 SELECT 跑 30 分钟,UNDO 表空间涨 10 个 G。

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」