可行但需手动实现:放弃limit offset,改用where id > ? order by id asc limit ?,确保排序字段有索引、单调递增,并处理复合游标、边界校验及preload冲突。

直接用 GORM 做“无感滚动分页”(即基于游标/last_id 的分页)是可行的,但不是开箱即用——它不提供类似 Pageable 那样的封装,必须手动构造条件、控制边界、避免重复或跳漏。关键在于放弃 LIMIT OFFSET,改用 WHERE id > ? ORDER BY id ASC LIMIT ? 这类游标查询模式。
为什么不能直接用 Offset 分页做无感滚动
OFFSET 在数据高频写入或删除时极易导致重复/丢失,且随着偏移量增大性能断崖式下降。GORM 的 Limit().Offset() 或 Scopes(paginate) 扩展都走的是这条老路,和“无感滚动”目标冲突。
常见错误现象:
- 用户下拉加载第 3 页时,新插入的前几条记录挤占了原有位置,导致某条数据被跳过或重复出现
- 接口返回
total: 12345,但前端滚动到底后实际只拿到 12300 条,差值来自中间删改
根本原因:Offset 分页依赖“全局有序索引”,而真实业务中这个索引是动态漂移的。
GORM 游标分页的核心写法(以 ID 为主键升序为例)
核心逻辑是每次请求携带上一页最后一条记录的 id(即游标),查询 “比它大 + 限制数量” 的数据。必须确保排序字段有索引、非空、单调递增(如自增主键或时间戳+唯一ID组合)。
实操建议:
- 查询语句必须显式写
ORDER BY id ASC,不能依赖默认顺序 - 首次请求不带游标,用
WHERE id > 0或直接ORDER BY id ASC LIMIT ? - 后续请求传
cursor=12345,查WHERE id > 12345 ORDER BY id ASC LIMIT ? - 返回结果里取最后一条的
id作为下一次cursor,不要用len(results)推算 - 如果排序字段是
created_at,需加二级条件防重复:ORDER BY created_at ASC, id ASC
示例代码片段:
var posts []Post
err := db.Where("id > ?", cursor).
Order("id ASC").
Limit(20).
Find(&posts).Error
if err != nil {
// handle
}
nextCursor := uint(0)
if len(posts) > 0 {
nextCursor = posts[len(posts)-1].ID
}
复合游标场景:时间戳 + ID 防重排序
当主键不是严格单调(比如 UUID 或软删除后复用 ID),或业务要求按 created_at 时间倒序展示(最新在前),就必须用复合游标。此时不能只比对时间,因为同一秒可能有多条记录。
正确做法:
- 升序滚动(从旧到新):用
ORDER BY created_at ASC, id ASC,游标传created_at=2026-08-20 10:00:00,id=1001,条件写WHERE (created_at, id) > (?, ?) - 降序滚动(从新到旧):用
ORDER BY created_at DESC, id DESC,游标传created_at=2026-08-20 10:00:00,id=1001,条件仍是WHERE (created_at, id) - GORM 不原生支持元组比较,得手写 SQL 或用
Where("(created_at, id) > (?, ?)", lastTime, lastID)
注意:MySQL 8.0+ 和 PostgreSQL 支持行构造器语法,但 SQLite 不支持,跨数据库时需降级为 WHERE created_at > ? OR (created_at = ? AND id > ?)。
容易被忽略的边界与兼容性细节
游标分页不是把 Offset 换个参数就完事,几个硬伤点常被跳过:
- 前端必须严格按返回的
next_cursor发起下一次请求,不能自己累加页码或拼接 offset - 删除操作不影响已发出的游标,但新插入的数据若落在游标范围内,会导致“插入即可见”,这是预期行为,不是 bug
- GORM 的
Preload关联查询会破坏游标逻辑,因为JOIN后排序字段可能重复或错乱;应先查主表游标,再用 IDs 批量查关联数据 - 如果用
SELECT *但模型字段有gorm:"-"标签,游标字段(如id)必须显式出现在查询结果中,否则posts[len(posts)-1].ID为空
最麻烦的一点:没有总条数。这不是缺陷,而是无感滚动的设计前提——你本就不该告诉用户“还剩多少没刷完”。如果产品硬要显示“共 XX 条”,只能额外跑一次 COUNT(*),但这会让“无感”打折扣。











