核心问题是group by未走索引导致全表扫描和内存暴增;应先建匹配顺序的复合索引,改用游标分页替代offset limit,采用流式查询而非select into outfile,并对高基数分组考虑近似计算或拆步处理。

分组后结果集过大,直接导出容易卡死、OOM 或超时,核心问题不在“导出动作”本身,而在于数据库执行 GROUP BY 后把全部聚合结果一次性加载进内存或临时表——尤其当分组键基数高(比如按 user_id 分组千万用户)、或聚合函数含 JSON_AGG/STRING_AGG 时,内存消耗会指数级上升。
GROUP BY 结果太大,先确认是不是索引没生效
别急着切分逻辑,先看执行计划是否真走索引。很多慢不是因为数据多,而是 GROUP BY 字段没索引,被迫全表扫描+临时文件排序。
- 用
EXPLAIN ANALYZE(PostgreSQL)或EXPLAIN FORMAT=TREE(MySQL 8.0+)查type/access_type是否为index或range,别只盯rows估算值 - 复合索引顺序必须严格匹配
GROUP BY字段顺序,CREATE INDEX idx_u_c ON orders(user_id, created_date)才能加速GROUP BY user_id, created_date - 如果带
WHERE条件(如WHERE status = 'paid'),索引应把过滤字段放前面:CREATE INDEX idx_s_u ON orders(status, user_id)
导出千万级分组结果,必须放弃 OFFSET LIMIT
OFFSET 1000000 LIMIT 1000 看似分页,实则让数据库重复扫描前 100 万行,CPU 和 I/O 压力陡增,且越往后越慢。这不是导出瓶颈,是分页模型本身不适用聚合结果。
- 改用游标分页:记录上一页最后一条的分组键值(如
MAX(user_id)),下一页查WHERE user_id > ? GROUP BY user_id - 游标字段必须有索引,且类型稳定——避免用
UUID、FLOAT或TEXT做游标,否则排序不可靠、范围查询失效 - 每次查询加
LIMIT 5000,太大易超时(尤其聚合含大对象),太小网络往返多;5k 是多数 OLTP 场景的平衡点
流式导出比 SELECT INTO OUTFILE 更可控
SELECT INTO OUTFILE 表面“直出”,但受限于数据库磁盘权限、单线程写入、NFS/云盘 I/O 延迟,实际吞吐常不如应用层边查边写 CSV 流。
- 应用层用数据库驱动的流式游标(如 PostgreSQL 的
DECLARE c CURSOR FOR ...+FETCH 5000;MySQL 的useCursor=true参数)逐批拉取,内存驻留始终可控 - 避免在 SQL 层做
DISTINCT或JSON_AGG后再导出,改用应用层聚合:先按主键或时间范围分片查原始明细,再在代码里分组汇总 - 导出 CSV 时,对可能含换行符或逗号的字段,必须加双引号包裹并转义,否则 Excel 打开错列——这点
SELECT INTO OUTFILE不处理,应用层可精确控制
近似计算或拆步处理,比硬扛更实际
当分组键基数极高(如按设备 ID 分组亿级设备)、且业务允许误差时,硬导全量既慢又危险,不如换思路。
- 用
APPROX_COUNT_DISTINCT(BigQuery/Spark)、HLL(PostgreSQL + hll extension)替代COUNT(DISTINCT),内存占用从 O(N) 降到 O(1) - 把“一次分组导出”拆成两步:先用
INSERT INTO summary_table SELECT ... GROUP BY ...落地中间表,再从该表分页导出——中间表可建索引、可压缩、可限速 - 如果目标只是报表展示,前端分页查聚合结果即可,根本不需要导出全部;导出动作本身应是低频、离线、有明确用途的操作
真正难的不是“怎么分页”,而是判断哪些分组维度值得导出、哪些聚合可以降精度、哪些字段其实根本没人看。导出前花 5 分钟看一眼 GROUP BY 后的行数和最大聚合值长度,比盲目调大 JVM 堆内存有用得多。










