直接用limit和offset分页在生产环境危险,因数据增删会导致记录跳过或重复;应改用游标分页,基于主键或组合唯一字段+确定性排序,如where id > ? order by id asc,并支持升序、降序及复合排序(如created_at + id)的游标构造与比较。

为什么直接用 Limit 和 Offset 做分页在生产环境很危险
因为数据实时增删时,Offset 会跳过或重复返回记录——尤其当上一页末尾的记录被删除、新记录插入到前几页位置时,用户下拉会看到“丢失”或“重复”的条目。这不是 GORM 的 bug,而是 SQL 分页模型本身的缺陷。
健壮分页必须依赖**游标(cursor)+ 确定性排序**,即用上一页最后一条记录的主键(或组合唯一字段)作为下一页起点,绕过 OFFSET。
- 只对带索引的排序字段生效(如
id、created_at+id) - 禁止对无索引字段(如
name LIKE '%x%')做游标分页 - 前端传入的
cursor必须是上一页响应中明确返回的next_cursor字符串,不能自行拼接或解码
如何用 GORM 实现基于主键的游标分页
核心思路:把 WHERE id > ? ORDER BY id ASC LIMIT ? 封装成可复用逻辑,同时兼容升序/降序和复合排序。
关键点不是写 SQL,而是让 GORM 的 Where 和 Order 能接收动态条件:
- 升序分页:查
id > last_id,按id ASC排,取limit + 1条(多查 1 条用于判断是否有下一页) - 降序分页:查
id ,按 <code>id DESC排,同样取limit + 1 - 必须显式指定排序字段顺序,否则
ORDER BY id默认是 ASC,但业务可能需要 DESC
示例片段(不依赖第三方库):
// 假设结构体有 ID uint64
var items []User
var total int64
// 解析 cursor:base64 解码后 JSON 反序列化为 map[string]interface{}
cursorMap := parseCursor(cursorStr) // 如 {"id": 123, "created_at": "2024-01-01T00:00:00Z"}
db := db.Order("id ASC")
for k, v := range cursorMap {
if k == "id" {
db = db.Where("id > ?", v)
}
}
db.Limit(limit + 1).Find(&items)
// 判断是否还有下一页:len(items) > limit
if len(items) > limit {
nextCursor := base64Encode(map[string]interface{}{"id": items[limit-1].ID})
items = items[:limit]
}
复合排序(如 created_at + id)下 cursor 怎么构造和比较
当排序字段不止一个,比如 ORDER BY created_at DESC, id DESC,就不能只记 id —— 因为同一秒可能有多条记录,必须同时携带 created_at 和 id 才能精确定位下一页起点。
此时 cursor 是一个结构体,且 WHERE 条件需分层构造:
- 先比
created_at:若当前记录created_at小于上一页最后一条,则直接满足;若相等,再比id - GORM 不支持原生的 “tuple comparison”,得手写
Where表达式:WHERE (created_at, id) - PostgreSQL 支持该语法,MySQL 8.0+ 也支持,但 SQLite 不支持——需降级为嵌套
OR条件
安全写法(兼容 MySQL):
db.Where("(created_at
<h3>分页组件要不要封装成 GORM Plugin 或独立函数</h3>
<p>不要封装成全局 Plugin。GORM 的 <code>Callback</code> 链难以调试,且游标分页强依赖业务排序逻辑和字段索引,硬塞进通用钩子里反而增加隐式耦合。</p>
<p>推荐做法是:每个需要分页的 Repository 方法内,用小函数封装游标逻辑,例如:</p>
- 定义
func buildCursorWhere(db *gorm.DB, cursor string, sortFields []string) *gorm.DB - 调用方明确传入
[]string{"created_at", "id"}和方向[]string{"DESC", "DESC"} - cursor 解析失败时,返回明确错误(如
ErrInvalidCursor),而不是静默 fallback 到 offset 分页
真正容易被忽略的是时间字段精度问题:数据库存的是 datetime(6),但 Go 的 time.Time 在 JSON 序列化时默认丢掉纳秒,导致游标比较失败。务必统一用 time.RFC3339Nano 格式序列化时间字段。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











