本质是分组键与聚合值缓存在内存哈希表,分组数超千万或单组数据极大(如user_id对应500万行)即触发OOM;PostgreSQL关键控制点为work_mem,但需避免高并发争抢,推荐主键分片+临时表拆分聚合。

分组后数据量过大导致内存溢出,本质不是结果集太大,而是数据库在执行 GROUP BY 时把所有分组键和聚合值缓存在内存哈希表里——一旦分组数超千万或某组数据极多(比如一个 user_id 对应 500 万行),就直接撑爆 work_mem 或 JVM 堆。
PostgreSQL 中 GROUP BY OOM 的关键控制点是 work_mem,但不能乱调
它决定单个查询能用多少内存做哈希聚合。设太小会 fallback 到 SORTAGG(仍需内存排序,不自动落盘);设太大则高并发下多个查询争抢,反而集体触发 OOM。
- 先查当前值:
SHOW work_mem,默认通常 4MB,对千万级分组远远不够 - 临时调高只对当前会话有效:
SET LOCAL work_mem = '256MB',别改全局配置 - 确认是否真卡在哈希阶段:
EXPLAIN (ANALYZE, BUFFERS)输出里看有没有Hash Agg和大量Shared Hit/Read - 如果已 fallback 到
Sort且Buffers显示磁盘读,说明work_mem不足,但加到 1GB 也未必救得回来——该拆了
MySQL 没有 work_mem,靠 sort_buffer_size 和索引分组硬扛,效果有限
sort_buffer_size 主管排序,不直接控 GROUP BY 内存;真正起作用的是能否走索引分组(Using index for group-by)。一旦执行计划出现 Using temporary; Using filesort,说明 MySQL 放弃索引,全量加载进内存再聚合。
- 检查
GROUP BY字段顺序是否严格匹配联合索引顺序,例如索引是(status, user_id),那GROUP BY status, user_id可走索引,GROUP BY user_id, status就不行 - 避免在
GROUP BY里混用未被索引的字段,比如GROUP BY user_id, SUBSTRING(name, 1, 10)—— 函数会导致索引失效 - 用
SELECT COUNT(*) FROM (SELECT 1 FROM t GROUP BY a,b,c) AS _快速验证分组数是否爆炸(比直接SELECT *安全)
流式读取只缓解客户端 OOM,不解决数据库端聚合压力
JDBC 的 setFetchSize(Integer.MIN_VALUE) 或 MyBatis 的 fetchSize="-2147483648",只是让客户端不把全部聚合结果一次性 load 进 JVM 堆。但数据库服务端仍要算完全部分组、生成完整结果集,再逐批发给客户端——服务端内存照样可能爆。
- MySQL 连接串必须带
?useCursorFetch=true&defaultFetchSize=500,否则setFetchSize无效 - PostgreSQL 需显式声明游标:
DECLARE agg_cursor CURSOR FOR SELECT user_id, COUNT(*) FROM log GROUP BY user_id,再FETCH 1000 FROM agg_cursor - 注意:游标必须在事务内使用(
BEGIN; ...; COMMIT;),否则可能被自动关闭;长时间不FETCH会占用服务端资源
真正可控的解法:用主键分片 + 临时表替代全量 GROUP BY
不依赖数据库自动优化,而是把大聚合拆成小任务。前提:左表有单调主键(如 id 或 create_time),右表关联字段有索引。
- 建临时表存分段 ID:
CREATE TEMPORARY TABLE tmp_log_ids (id BIGINT PRIMARY KEY) - 每次插入 5 万行 ID:
INSERT INTO tmp_log_ids VALUES (1000001), ..., (1050000) - 聚合只扫这一批:
SELECT user_id, COUNT(*) FROM log JOIN tmp_log_ids USING (id) GROUP BY user_id - 每批内存峰值稳定在 50–200MB,且可并行跑多批次(只要不共享同一临时表)
容易被忽略的是:临时表必须显式加主键或索引,否则 JOIN 会退化成全表扫描;另外,MySQL 的 CREATE TEMPORARY TABLE ... SELECT 不支持建索引,得拆成 CREATE + INSERT 两步。











