不能直接用Limit+Offset做异步报表导出分页,因为OFFSET需扫描并丢弃前N行,导致深分页极慢;且数据增删改会引起记录偏移,造成漏行或重复,无法保证全量导出一致性。

为什么不能直接用 Limit + Offset 做异步报表导出分页
异步导出报表时,分页不是为了“翻页展示”,而是为了把全量数据切片、并发或串行拉取后拼成完整文件。这时候还套用 Offset((page-1)*pageSize) 会出大问题:数据在导出过程中可能被增删改,导致某条记录漏掉或重复;更关键的是,OFFSET 越大越慢,导出百万行时第 5000 页可能单次查询就卡住几秒,拖垮整个任务队列。
- MySQL/PostgreSQL 对
OFFSET 100000是真实扫描并丢弃前 10 万行,不是跳指针 - 导出中途若有人插入新记录,按页码算的
OFFSET会整体偏移,最后文件缺行 - 无法利用索引高效定位“下一页起点”,只能靠全表扫描式推进
用主键游标分页替代页码分页
游标分页不依赖页码,而是记住上一批最后一条记录的排序字段值(如 id 或 created_at),下一次查 WHERE id > ? ORDER BY id LIMIT 1000。这样每批都从确定位置开始,不跳行、不漏行、性能稳定。
- 必须选有索引的单调字段:首选自增
id,次选created_at+id组合(防时间重复) - 首次查询用
ORDER BY id ASC LIMIT 1000,拿到users[len(users)-1].ID作为下一轮游标 - 导出任务启动时先查总条数:
DB.Model(&User{}).Count(&total),但别复用同一个*gorm.DB实例——否则Count()可能继承前面的WHERE或LIMIT条件而返回错误值 - 不要在游标查询里混用
Preload:先查主表 ID 列表,再用IN批量加载关联数据,避免 N+1 和结果集膨胀
如何安全传递和校验游标参数
前端或任务队列传进来的游标不是页码,而是上一批末尾的 id 值(字符串形式,比如 "123456")。它必须被严格校验,否则可能触发全表扫描或 SQL 注入。
- 用
strconv.ParseUint(p.Cursor, 10, 64)解析,失败则返回错误,不 fallback 到 0 或 1 - 游标为 0 或空字符串时,应视为“从头开始”,而不是报错——但需明确记录日志
- 禁止把游标拼进 SQL 字符串,始终用 GORM 的占位符:
DB.Where("id > ?", cursorID).Order("id ASC").Limit(1000).Find(&users) - 每批最多取 1000 条(硬限制),防止单次查询过载;超出则截断,不放大恶意请求
异步任务中分页状态怎么存和续
导出是长任务,可能失败重试,所以不能只靠内存记游标。得把当前游标、已写入行数、文件路径等存到数据库或 Redis,方便中断后恢复。
- 建一张
export_tasks表,字段至少含:task_id、cursor_id(uint64)、processed_rows、status(running/done/failed) - 每次处理完一批,用
UPDATE ... SET cursor_id = ?, processed_rows = ? WHERE task_id = ? AND status = 'running'原子更新,避免并发冲突 - 重试时先查该任务最新
cursor_id,继续往后取,而不是从头再来 - 别用事务包住整批导出:游标分页本身无事务必要,长事务反而锁表;单次查询保持短事务即可
page=123 硬套进导出逻辑的做法,都会在数据写入频繁的系统里露出破绽。


















