聚合查询本身不加锁,但嵌套于UPDATE或DELETE子查询时会因扫描大量物理行并逐个加临键锁而导致严重锁争用;MySQL只对访问的索引记录加Next-Key Lock,而非分组结果。

聚合查询本身不加锁,但一旦它被嵌套进写操作(比如 UPDATE 或 DELETE 的子查询),锁争用就立刻变得严重——根本原因不是“聚合”这个动作,而是执行计划被迫扫描大量物理行并逐个加临键锁。
GROUP BY 出现在 UPDATE 子查询里为什么等于锁全表
MySQL 不会对“分组结果”加锁,只对执行过程中访问到的每一条索引记录加 Next-Key Lock。哪怕最终只有 3 行满足 HAVING COUNT(*) > 5,只要执行计划需要遍历 10 万行来算出这 3 行,InnoDB 就会一路持锁过去。
-
EXPLAIN显示type: ALL或type: index:说明没走有效索引,锁范围覆盖整个索引树 - 即使有
user_id索引,HAVING条件也无法下推到扫描层,引擎必须累积计数,期间持续持有已扫描行的锁 - 若子查询未显式关联主表(如漏掉
WHERE orders.user_id = users.id),优化器可能退化为物化临时表 + 全量 join,导致对users表全表加行锁
窗口函数为什么比 GROUP BY 更安全
ROW_NUMBER()、RANK() 这类窗口函数是纯读操作,只在读取某一行时短暂持共享锁(S 锁),且 S 锁之间兼容。只要 OVER 子句中的 ORDER BY 字段有索引,引擎可流式计算,不落临时表、不缓存全部分区数据。
- 对比写法:
SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn FROM orders) t WHERE rn = 1—— MySQL 5.7 可能物化派生表并锁全表;而等价 CTE 写法WITH ranked AS (SELECT ..., ROW_NUMBER() ... FROM orders WHERE status = 1) SELECT * FROM ranked WHERE rn = 1能把status = 1下推,索引生效,锁范围大幅缩小 - 非确定性函数(如
NEWID()、GETDATE())会让优化器放弃流式路径,强制排序溢出到tempdb,间接加剧锁等待
真正危险的是“实时聚合 + 实时更新”的组合
这类语句是并发事故高发区,因为优化器无法提前知道分组后有多少目标行,只能保守地扩大扫描与加锁范围。锁持有时间远长于单行更新,且容易触发间隙锁(gap lock)蔓延。
- 触发器里写
SELECT COUNT(*) FROM logs WHERE user_id = NEW.user_id GROUP BY user_id:表面查一行,实际在REPEATABLE READ下锁住整个user_id索引范围 -
UPDATE中嵌套GROUP BY且WHERE条件未命中索引(如category_name LIKE '%abc'):直接升级为全索引扫描,等效锁表 - 替代方案不是“怎么优化这个 SQL”,而是拆开:先用带索引条件的
SELECT查出目标 ID 列表,再用IN批量更新——锁粒度从“扫描过程”收缩到“明确主键集合”
最常被忽略的一点:锁争用是否发生,和 SQL 写得“看起来多简洁”毫无关系,只取决于执行计划真实触达了哪些物理索引节点。哪怕你把 GROUP BY 包了十层 CTE,只要 EXPLAIN 显示 key: NULL,它就在锁全表。

















