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 做了一次锁定处理——结果三次读到的行数完全不同。

表结构:
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=100 → 10 行 |
|
| t3 | BEGIN; |
|
| t4 | INSERT INTO permissions VALUES(100,'new_analytics',1); |
|
| t5 | COMMIT; |
|
| t6 | SELECT * FROM permissions WHERE user_id=100 → 10 行 ✅ |
|
| t7 | SELECT * FROM permissions WHERE user_id=100 FOR UPDATE → 11 行 ❗ |
|
| t8 | SELECT * FROM permissions WHERE user_id=100 → 10 行 ❓ |
|
| 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 的反复横跳。


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
检测模式:同一方法内先后出现
SELECT和SELECT ... 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,不重新查
}

策略 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拆代码的曹操」