并发下Offset分页丢数据是因为OFFSET是数据库扫描跳过的物理行数而非逻辑页号,新记录插入会挤压或跳过原有记录,导致同一条数据出现在不同页或某页为空;必须显式Order by加唯一排序字段并建对应索引,游标分页(如WHERE id > ?)才能保证并发安全与结果确定性。

并发下 Offset 分页为什么丢数据
因为 OFFSET 是数据库扫描跳过的物理行数,不是逻辑页号。当分页过程中有新记录插入(尤其在排序字段前),后续页的 OFFSET 会把本该属于上一页的记录“挤”进下一页,或直接跳过——同一记录可能出现在第 49 页和第 51 页,第 50 页却为空。
这不是 GORM 的 bug,是 SQL 层面的固有缺陷。高并发写入场景(如订单创建、日志写入)下,page=50&size=20 这种请求越往后翻,结果越不可靠。
- 必须显式
Order("id ASC")或Order("created_at DESC, id DESC"),否则数据库返回顺序无定义,并发读写时连“重复”都难复现 - 复合排序字段(如
created_at, id)需对应建立联合索引,否则ORDER BY本身就会触发 filesort - 别信“加了锁就安全”:即使整个查询加
SELECT ... FOR UPDATE,也只锁住当前查出的行,拦不住新插入的行落在已查区间内
游标分页才是并发安全的解法
游标分页不依赖页码,而是用上一页最后一条记录的排序字段值(如 id 或 created_at)作为下一次查询的起点,天然规避 OFFSET 的扫描跳跃问题。
关键不在“GORM 怎么写”,而在“SQL 怎么构造”:每次查询只扫增量数据,性能稳定,且结果确定性高。
- 首次请求:
db.Order("id ASC").Limit(20).Find(&users) - 后续请求:
db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users)(lastID来自上一页最后一个user.ID) - 若排序用时间戳(
created_at DESC),条件要改成WHERE created_at ,避免同秒多条导致漏数据 - 前端必须透传
cursor(如 base64 编码的id),不能把它转成page再算OFFSET,否则又绕回老路
GORM 中 Count 查询如何不被并发干扰
Count() 本身是快照语义,但它的结果是否“匹配分页列表”,取决于查询条件是否完全一致。并发写入不影响 COUNT 的准确性,但会影响它和分页数据的逻辑一致性——比如你查完总数是 1000,但分页查第 50 页时,因中间插入了 50 条,实际第 50 页已变成第 51 页的内容。
- 总数查询必须用独立的
*gorm.DB实例,例如:db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", "active").Count(&total) - 不要复用带
Limit()或Offset()的链式调用,GORM v2 里这些方法不会影响Count(),但容易让人误以为条件共享 - 复杂关联场景(如
Joins("profiles")),Count()不会自动继承Joins,必须手写子查询或用Scopes封装完整条件 - 如果业务允许,干脆不返回总页数——无限滚动场景下,前端只关心“有没有下一页”,靠游标是否为空判断即可
Preload 关联查询 + 分页的并发陷阱
在分页中用 Preload("Orders") 看似方便,实则危险:GORM 先查出 20 个用户,再发一条 SELECT * FROM orders WHERE user_id IN (1,2,...,20)。并发写入时,这 20 个 ID 对应的订单数可能瞬间翻倍,内存暴涨;更糟的是,如果某用户刚下了第 21 单,它不会出现在本次结果里,但下一页可能又出现——违反分页的连续性假设。
- 绝对避免
Preload+Limit/Offset组合,这是 GORM 官方明确警告的反模式 - 正确做法:先分页查主表(如
User),拿到 ID 列表后,用IN子查询或二次查库限制关联数据量,例如:db.Where("user_id IN ? AND status = ?", userIDList, "paid").Order("created_at DESC").Limit(3).Find(&orders) - 如果必须一对多聚合,改用子查询或窗口函数(PostgreSQL 支持
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)),GORM 可通过Raw()执行
游标分页不是“更高级的分页”,而是并发写入场景下的事实标准。最容易被忽略的点是:前端必须放弃页码思维,后端必须拒绝把 cursor 解码后转成 OFFSET——任何中间转换都会让并发安全性归零。


















