GORM的Offset+Limit分页在并发写入下会丢数据或重复,因其依赖物理偏移而非逻辑位置:新插入记录可能挤入已跳过范围导致漏读,删除则引发重复;游标分页通过WHERE id > last_id+ORDER BY确保顺序稳定,避免扫描与竞态。

为什么 GORM 的分页查询在并发写入下会丢数据或重复
直接用 Offset + Limit 做分页,本质是“跳过前 N 条再取 M 条”,但数据库不保证两次查询之间数据顺序不变。比如用户翻到第 50 页(Offset(999))时,刚好有新记录插入到前 1000 条里,下一页就会漏掉这条;若中间有删除,同一条记录可能在两页里都出现。
这不是 GORM 的 bug,是所有基于偏移量的物理分页共有的行为。尤其在订单、日志、消息流这类高频写入场景,问题暴露得更快。
- 必须显式加
Order("id ASC")或Order("created_at DESC"),且排序字段要有索引,否则连基本顺序都无法保障 - 不要复用同一个
*gorm.DB实例做Count()和Find(),链式调用会污染条件,导致总数和列表不一致 - 前端传
page=1000时,Offset(19980)在百万级表上可能触发全表扫描,MySQL 甚至直接超时
如何用游标分页替代 Offset 实现并发安全消费
游标分页不依赖“第几页”,而是记住上一批最后一条的排序值(如 id 或 created_at),下次查询只取“大于该值”的记录。这样无论中间插入多少条,都不会跳过或重复。
示例:查完 id=12345 这条后,下一批用 WHERE id > 12345 ORDER BY id LIMIT 20。只要 id 是主键或有唯一索引,性能就稳定在 O(log n)。
- 必须确保排序字段单调递增(
id最稳妥;created_at要配合id防止时间重复,例如ORDER BY created_at DESC, id DESC) - 首次请求无游标时,用最小值兜底:
WHERE id > 0或WHERE created_at - GORM 中不能直接拼
WHERE,要用Where("id > ?", lastID),避免 SQL 注入 - 游标值要从结果集最后一条显式取出,别用
MAX(id)—— 并发插入可能导致它比实际最后一条还大
Scopes 封装分页时如何避免并发污染和 panic
用 Scopes 写分页逻辑很常见,但错误封装会让 Count() 和 Find() 共享同一个 *gorm.DB 实例,导致 WHERE 条件、GROUP BY 甚至 LIMIT 溢出到另一个查询里。
正确做法是:在 Scope 内部新建干净实例,或显式用 Model(&User{}) 重置上下文。
- 分页 Scope 必须放在所有其他 Scope(如权限过滤、软删除)之后,否则
Offset/Limit可能被提前截断 -
Current和Size字段必须为*int,否则 Gin 的ShouldBindQuery绑定失败时会设为 0,Offset(-20)直接 panic - 总数查询必须独立:用
db.Model(&User{}).Where(...).Count(&total),别用db.Where(...).Count() - 如果用了
Joins或Preload,Count()会因 LEFT JOIN 产生重复计数,此时应改用子查询或去掉关联
高吞吐消费者怎么批量拉取又不压垮数据库
单次拉 20 条太慢,拉 1000 条又容易锁表或超内存。折中方案是固定批次大小(如 100),配合游标 + 批量处理,并控制并发度。
关键不是“一次查多少”,而是“查完立刻推进游标”,避免同一段数据被多个 worker 重复消费。
- 游标值(如
last_id)必须作为消费进度持久化到 DB 或 Redis,不能只存在内存里 - 用
SELECT ... FOR UPDATE SKIP LOCKED(MySQL 8.0+ / PostgreSQL)防止多 worker 争抢同一批数据 - 批量更新状态时,别用循环
Save(),改用db.Table("users").Where("id IN ?", ids).Update("status", "done") - 如果业务允许,把“分页查询”和“消费动作”拆成两个服务:一个只负责按游标吐 ID 列表,另一个按 ID 批量查详情并处理


















