GORM的Limit+Offset分页在任务分发中必然失败,因其依赖物理行偏移,新插入/状态更新/并发MVCC导致漏任务、重复派发、超时;必须改用status+id游标分页,建联合索引,禁用Count和Preload。

大规模任务分发场景下,GORM 的 Limit + Offset 分页不可用——它会漏任务、重复派发、查询超时,且无法横向扩展。
为什么 Offset 分页在任务分发中必然失败
任务分发要求“每条任务只被取一次、不跳过、不重发”,而 OFFSET 依赖物理行偏移,与数据变更强耦合:
- 新任务持续插入时,
OFFSET 10000下次执行可能指向不同记录(MySQL/PostgreSQL 不保证无序扫描稳定性) - 任务状态更新(如
status = 'processing')后,同一OFFSET可能跳过刚更新的行,导致任务丢失 - 并发 Worker 同时执行
OFFSET 5000 LIMIT 100,因 MVCC 快照差异,可能各自取到重叠或空缺的任务 -
OFFSET 100000在百万级任务表上常超 2s,Worker 超时重试进一步加剧重复消费
必须用游标分页:基于 status + id 的双条件 WHERE
任务表通常有 status(pending/processing/done)和自增 id,这是天然游标组合:
- 首次查询:
db.Where("status = ?", "pending").Order("id ASC").Limit(101).Find(&tasks)(多查 1 条判断是否有下一页) - 下一页游标:取
tasks[len(tasks)-1].ID,下次查db.Where("status = ? AND id > ?", "pending", lastID).Order("id ASC").Limit(101).Find(&tasks) - 务必建联合索引:
INDEX idx_status_id (status, id),否则WHERE status = ? AND id > ?无法高效定位 - 避免用
created_at当主游标字段——高并发写入下时间精度相同,id是唯一稳定锚点
Count 总数查询在任务分发中是伪需求
任务分发接口根本不需要返回“剩余多少 pending 任务”,查 COUNT(*) 会拖慢整个调度循环:
-
SELECT COUNT(*) FROM tasks WHERE status = 'pending'在千万级表上可能秒级响应,且结果在返回前就已过期 - Worker 只关心“有没有下一批”,用
len(tasks) == 101判断即可,省掉一次全表扫描 - 若需监控大盘,走异步统计表或 Prometheus 指标,而非同步 API 返回
- 真要总数?用
db.Raw("SELECT APPROX_COUNT_DISTINCT(id) FROM tasks WHERE status = ?", "pending").Scan(&approx)(MySQL 8.0+ / PG)
Preload 关联任务数据必须拆开查
任务常关联用户、模板、配置等,但 Preload 在游标分页中会破坏原子性:
- 错误写法:
db.Preload("User").Where(...).Find(&tasks)→ 先查 100 个 task,再发 100 次SELECT * FROM users WHERE id IN (?),延迟爆炸 - 正确做法:先查
task.ID列表,再用db.Where("id IN ?", taskIDs).Find(&users)批量加载,最后 in-memory 关联 - 更优:任务表冗余关键字段(如
user_name,template_name),彻底避免 JOIN - 禁止在游标分页中用
Joins——WHERE条件无法下推,索引失效
游标分页不是“优化选项”,是任务分发系统的数据一致性底线。一旦 OFFSET 出现在生产调度代码里,就要立刻替换——哪怕只改一行 SQL,也得改。


















