分页查询漏数据或重复的根本原因是未加ORDER BY导致数据库不保证返回顺序,同一SQL多次执行结果可能不同;并发写入下MVCC快照差异叠加无序扫描,使OFFSET基于行号的机制失效,必须用主键或时间戳+主键组合排序确保稳定性。

分页查询在并发写入下为什么漏数据或重复
因为没加 ORDER BY 的分页,数据库不保证返回顺序——哪怕同一 SQL 执行两次,结果都可能不同。MySQL/PostgreSQL 的 LIMIT + OFFSET 是基于当前快照扫描物理行,而 MVCC 下不同事务看到的数据版本不同,加上无序,就等于每次都在抽签。
常见现象:db.Offset(100).Limit(20).Find(&users) 刷新页面后某条记录突然消失,或在第 49 页和第 51 页都出现。这不是 GORM 的 bug,是 SQL 语义本身决定的。
- 必须显式写
Order("id ASC")或Order("created_at DESC, id DESC"),主键或带时间戳+主键组合最稳 - 避免单用
Order("status ASC")或Order("created_at DESC"):字段值重复时排序不可控 - 高并发插入场景下,
created_at毫秒级重复很常见,补id DESC是低成本兜底
GORM 分页是否受事务隔离级别影响
是,但影响方式常被误读。事务隔离级别(如 ReadCommitted、RepeatableRead)控制的是“能看到哪些已提交/未提交数据”,不解决分页本身的逻辑缺陷。
比如你在 RepeatableRead 事务里执行两次相同 OFFSET 查询,结果可能一致;但只要其他事务在中间插入了一条满足 WHERE 条件的新记录,且它排在当前页范围内,你就必然漏掉它——因为 OFFSET 是按“当前快照里的行号”算的,不是按业务逻辑位置。
-
ReadCommitted下,分页页内可能跳过新插入的记录(因它还没提交) -
RepeatableRead下,分页页内可能固定住旧快照,导致“看不见”刚插入的有效数据 - 无论哪种隔离级别,都不改变
OFFSET必须扫描前 N 行的本质,也不修复无序导致的重复/丢失
Count 总数查询与分页查询不在同一事务里安全吗
不安全,但问题不在事务,而在数据一致性窗口。即使把 Count 和 Find 放进同一个事务,只要中间有其他事务插入/删除,总数和实际返回列表就可能对不上。
典型陷阱:db.Where("status = ?", "active").Count(&total) 和 db.Where("status = ?", "active").Offset().Limit().Find(&users) 即使共用一个事务,只要别的连接在它们之间 insert 了一条 active 记录,total 就比当前页实际能取到的总条数少。
- 别指望事务让分页“绝对一致”——这是业务权衡点,不是技术可解题
- 如果 UI 必须显示“共 XX 条”,建议缓存总数(仅适用于低频变更数据,如配置表)
- 更务实的做法:查
Limit(size + 1),用最后一项是否存在来判断has_next,彻底绕开COUNT
游标分页为什么天然更抗并发
因为它不依赖“第几页”这种全局位置描述,而是基于上一页最后一条记录的确定性字段值做条件过滤,比如 WHERE id > 12345 ORDER BY id LIMIT 20。数据库只需从索引中定位 id = 12345 后的第一个节点,往后扫 20 行即可。
这个过程不扫描、不丢弃、不依赖快照行号,所以不管有没有并发写入,只要索引字段单调、非空、有索引,结果就稳定可预测。
- 首请求用
db.Order("id ASC").Limit(21).Find(&users),多查 1 条用于判断是否有下一页 - 下一页游标必须取自当前页最后一条的
id(不是第一条),且该字段要有索引 - 前端必须透传游标值(如
?cursor=12345),后端不做任何转换,直接拼进WHERE - 若排序用
created_at DESC,则条件应为WHERE created_at ,并确保 <code>created_at字段精度足够(推荐datetime(3)或 bigint 时间戳)


















