mysql分组查询默认走快照读,基于事务启动时的readview过滤行,故聚合结果可能“看不见”新提交的行;需实时统计时应改用lock in share mode触发当前读。

分组查询默认走快照读,但聚合结果可能“看不见”新提交的行
MySQL 的 GROUP BY 查询在可重复读(RR)隔离级别下,本质仍是快照读——它不加锁,也不读最新版本,而是基于事务启动时生成的 Read View 判断每行是否可见。这意味着:即使其他事务在你执行 SELECT COUNT(*) FROM orders GROUP BY status 期间提交了新订单,只要这些行的 DB_TRX_ID 大于你的 Read View 的 up_limit_id,它们就不会参与聚合。
常见错误现象:
- 同一事务中两次执行相同
GROUP BY,结果行数或数值不变,误以为数据没更新 - 业务监控看到「今日新增订单数」始终为 0,实际已有提交,只因监控脚本开了长事务
关键点在于:不是 GROUP BY 特殊,而是它底层仍调用快照读逻辑。所有非加锁的 SELECT(含 DISTINCT、HAVING、子查询中的聚合)都受同一 Read View 约束。
需要实时聚合时,必须显式触发当前读
如果你的分组统计必须反映“此刻已提交的全部数据”,就得绕过快照机制,强制走当前读。唯一可靠方式是让查询带上锁语义——哪怕只是共享锁。
实操建议:
- 用
SELECT COUNT(*) FROM orders GROUP BY status LOCK IN SHARE MODE:触发当前读,读最新行并加 S 锁,避免被并发 UPDATE 阻塞,但能感知新插入/提交的行 - 避免用
SELECT ... FOR UPDATE做纯统计:它加 X 锁,会阻塞其他写操作,对只读聚合属于过度锁定 - 不要依赖
SELECT ... INTO @var+ 后续GROUP BY:变量赋值不改变读取时机,仍按首次SELECT时的快照执行
注意:LOCK IN SHARE MODE 在 RR 下仍可能遇到幻读(比如新插入的 status='shipped' 行),但至少保证聚合覆盖所有当前可见行,比快照读更贴近业务实时性需求。
聚合函数本身不改变 MVCC 行为,但 WHERE 条件会影响版本匹配
COUNT()、SUM()、AVG() 这些函数只是对输入行做计算,不参与可见性判断。真正决定“哪些行进得来”的,是 WHERE 子句和底层的 Read View 匹配逻辑。
举例说明:
-
SELECT COUNT(*) FROM t WHERE c > 100:先用Read View过滤出所有满足c > 100且可见的行,再计数 -
SELECT COUNT(*) FROM t WHERE c > (SELECT MAX(c) FROM t):子查询也走快照读,若外层与子查询不在同一事务,可能因快照时间点不同导致逻辑矛盾 - 带
JOIN的分组(如orders JOIN users):每张表各行独立判断可见性,不会因关联而“拉平”快照时间点
性能提示:如果 WHERE 条件无法走索引,InnoDB 仍需遍历版本链逐行检查 DB_TRX_ID,此时长事务导致的 undo 膨胀会让扫描明显变慢。
长事务是分组查询一致性的最大隐形杀手
一个未提交的事务,哪怕只执行了 BEGIN; SELECT * FROM config LIMIT 1;,也会让自己的 Read View 一直有效。这直接导致:
- 所有依赖该快照的
GROUP BY查询,永远看不到它之后提交的任何变更 - undo log 无法清理,
information_schema.INNODB_TRX中TRX_ROWS_LOCKED可能暴增 - VACUUM(PostgreSQL)或 purge thread(MySQL)被阻塞,磁盘空间持续上涨
最容易被忽略的地方:应用层 ORM(如 Django 的 transaction.atomic、Spring 的 @Transactional)可能隐式开启长事务;后台定时任务若包含调试用的 print() + 长暂停,同样危险。查活跃长事务用:SELECT * FROM information_schema.INNODB_TRX WHERE TIME_TO_SEC(TIMEDIFF(NOW(), TRX_STARTED)) > 60;










