并发分页必须用游标替代Offset,因Offset非原子且受数据变更影响导致重复/遗漏;需游标驱动、状态隔离、批次锁控三层设计,禁用Count和Preload。

并发安全队列 + 分批分页消费者在 GORM 场景下不能靠 sync.Mutex 或简单 channel 就完事——核心矛盾是:分页查询本身不具备原子性,多 goroutine 并发执行 Limit/Offset 会相互干扰,且数据库行序随写入实时漂移。必须拆解为「游标驱动 + 状态隔离 + 批次锁控」三层设计。
为什么并发调用 Offset 分页必然出错
多个 goroutine 同时执行 db.Offset(1000).Limit(20),不是“各自跳过 1000 行”,而是每条查询都从当前快照中扫描并丢弃前 1000 行物理记录。若期间有新数据插入或旧数据删除,不同 goroutine 看到的“第 1001 行”可能完全不同;更糟的是,MySQL/PostgreSQL 在无索引排序字段下连快照一致性都不保证。
- 常见错误现象:
goroutine A查到 ID=1005~1024,goroutine B同时查到 ID=1003~1022,中间重叠且漏掉 ID=1004 - 根本原因:Offset 不是逻辑页码,是数据库引擎的线性扫描偏移量,无法并发复用
- 性能断崖:单次
Offset(50000)查询耗时 800ms,10 个 goroutine 并发就是 8s+ 的无效扫描
用游标替代 Offset 构建并发安全队列
游标分页天然适合并发消费——每个 worker 拿到不同的起始点(如上一页末尾的 id),彼此查询范围不重叠、不依赖全局偏移量。关键是要让游标值可被安全分配且不重复。
- 队列初始化时,用
db.Order("id ASC").Limit(1).Select("id").Find(&firstID)获取首个游标,而非Offset(0) - worker 启动后,用
WHERE id > ? ORDER BY id ASC LIMIT 20查询,查完立刻取users[len(users)-1].ID作为下一轮游标 - 避免时间戳游标(如
created_at)单独使用:高并发下毫秒级重复极多,必须补二级排序,例如ORDER BY created_at ASC, id ASC - 游标值必须通过 channel 或原子变量分发,禁止多个 goroutine 共享同一变量并自行递增——这会跳过记录
分批消费者如何避免重复处理与漏处理
分批(batch)不等于并发(concurrent)。一批 100 条数据可以由一个 goroutine 串行处理,但多个 batch 必须保证互斥推进。重点不在“锁数据”,而在“锁游标进度”。
- 每次成功消费完一批,必须原子更新全局游标状态,例如用
atomic.StoreInt64(&nextCursor, lastID),而不是写 DB 或 redis 记录——减少 IO 延迟放大 - 若处理失败,不要回退游标;应记录失败批次的
[start_id, end_id]范围,后续单独重试,否则会陷入死循环 - 别在 consumer 内部用
Preload加载关联数据:GORM 的Preload会在主查询后发起 N+1 次子查询,破坏批次边界;正确做法是先查出所有id列表,再用WHERE id IN (?)批量加载 - 每个 consumer goroutine 应持有独立的
*gorm.DB实例(通过db.Session(&gorm.Session{NewDB: true})创建),防止事务/条件污染
分页总数统计在并发场景下的取舍
并发消费者场景下,Count 几乎没有实用价值——总数在你查完的瞬间就已过期。强行统计不仅拖慢启动,还引入额外锁竞争。
- 放弃
Count(&total):首次启动只查首批数据,返回has_next = len(results) == batchSize即可 - 如业务强依赖总数(如后台管理页码跳转),必须用带时间戳的快照方式:
SELECT COUNT(*) FROM table WHERE created_at ,把“总数”锚定在某个确定时刻 - 切忌在 consumer 循环里反复调用
Count:它会成为最慢的一环,且结果对任何 worker 都无意义
真正难的不是写 goroutine,而是让每个 worker 对数据库的“视线”彼此隔离、互不干扰。游标是唯一能绕过 Offset 天然缺陷的路径,而它的稳定性完全取决于排序字段是否带索引、是否足够唯一——别省那条 CREATE INDEX idx_user_created_at_id ON users(created_at, id)。


















