报表分页必须用游标分页+异步任务,禁用limit+offset:因mysql/pg需真实扫描丢弃前n行,join/group by使索引失效,高偏移下性能骤降、数据漂移;游标需order by created_at desc, id desc, order_no asc联合索引防断裂。

直接用 GORM 的 Limit+Offset 做报表分页,在数据量超过 10 万行后基本不可用——这不是配置问题,是 MySQL/PostgreSQL 底层扫描机制决定的。真要支撑高并发、大数据量报表导出或前端“无限滚动”,必须放弃传统分页,改用游标分页 + 异步任务拆解。
为什么报表场景下 Offset 分页会崩
报表系统常需查全量或近全量数据(比如“近半年订单汇总”),此时 OFFSET 往往达数万甚至数十万。MySQL 实际执行时,并不是跳指针,而是真实扫描并丢弃前 N 行;PostgreSQL 同理,且在无覆盖索引时还会触发 seq scan。更糟的是:报表查询常带 JOIN、GROUP BY、HAVING,这些会让优化器彻底放弃使用索引做偏移跳转。
-
db.Offset(50000).Limit(100)在 200 万行订单表上,平均响应超 2.3s(实测 MySQL 8.0 + 复合索引) - 并发 50+ 请求时,数据库 CPU 瞬间拉满,连接池耗尽
- 同一份报表多次导出,因中间有新订单写入,
OFFSET定位漂移,导致漏行或重复统计
游标分页必须配合确定性排序和索引
报表分页不能只靠 created_at,得用组合游标:主键 + 时间戳 + 业务唯一字段。否则高并发插入下,毫秒级时间重复会导致游标断裂(即某条记录永远查不到)。
- 推荐游标字段顺序:
ORDER BY created_at DESC, id DESC, order_no ASC(三者都建联合索引) - 首次请求不传游标,查
ORDER BY ... LIMIT 100;返回结果末尾的created_at/id/order_no拼成 base64 字符串作为next_cursor - 后续请求带
cursor=xxx,解码后生成 WHERE 条件:WHERE (created_at, id, order_no) - 千万别在游标分页里用
Preload—— 关联数据必须拆成两步:先查主表 ID 列表(游标分页),再用IN批量加载关联项
异步任务接管分页生成与缓存
报表不是实时交互操作,用户点击“导出 Excel”或“加载下一页”时,你不需要立刻返回数据,而应立即返回任务 ID,后台异步跑完再通知前端拉取结果。这能彻底解耦 DB 压力与用户等待。
- 用
goroutine+channel或轻量队列(如github.com/hibiken/asynq)接收分页任务 - 每个任务生成唯一
report_id,状态存 Redis:report:{id}:status(pending/running/done/failed) - 分页逻辑改写为“游标迭代”:每次查 1000 条 → 写入临时表或 S3 分片文件 → 更新游标 → 继续下一轮,直到无数据
- 最终结果聚合后存入
report:{id}:data(JSON 或 Parquet),前端轮询或 WebSocket 推送完成事件
Count 查询必须降级或绕过
报表总数动辄百万,SELECT COUNT(*) FROM ... JOIN ... WHERE ... 在复杂条件下面临严重性能瓶颈,且多数报表用户根本不在意“总共有多少页”,只关心“能不能继续翻”。硬算总数是典型过早优化。
- 优先返回
has_next: true/false:查LIMIT 101,如果拿到 101 条,就说明还有下一页,只返回前 100 条 - 总数走采样估算:
SELECT ROUND(TABLE_ROWS / 10) * 10 FROM information_schema.TABLES(仅适用于变化不频繁的报表底表) - 若必须精确总数,放到异步任务里单独跑,结果存 Redis 并设置 5 分钟 TTL,避免重复计算
- 绝对不要在同一个
*gorm.DB实例上调Count()后再Limit().Offset()——Count会继承前面链上的Limit,返回值永远 ≤ 当前页大小
真正难的不是写游标 SQL,而是让整个流程对前端透明、对运维可监控、对异常可追溯。游标值怎么序列化、分片文件如何清理、任务超时后怎么重试、失败日志是否包含完整 SQL 和参数——这些细节没处理好,异步分页照样在线上崩。











