联合主键分页不能直接用limit+offset,因其无全局单调序,数据动态变更时易跳行或重复;必须改用游标分页,基于上一页最后记录的联合主键值(如where (a,b) > (?,?))并确保排序与联合索引顺序严格一致。

联合主键分页为什么不能直接用 Limit + Offset
因为联合主键没有全局单调递增的顺序,OFFSET 在数据动态变更(尤其高并发写入)时极易跳行或重复。GORM 的 Limit/Offset 本质是 SQL LIMIT/OFFSET,在复合排序下稳定性差——比如按 (user_id, created_at) 分页,若中间插入一条 user_id 相同但 created_at 更早的记录,后续页就会错位。
真正可行的是「游标分页(cursor-based pagination)」:用上一页最后一条记录的联合主键值作为下一页起点。
- 必须确保查询有确定性排序,且排序字段覆盖全部联合主键列(顺序一致)
- WHERE 条件需用「大于」逻辑(如
WHERE (a, b) > (?, ?)),不是单字段比较 - GORM 原生不支持元组比较语法,需手写
Where或用Scopes封装
GORM 中实现 (a, b) > (?, ?) 元组比较的两种写法
PostgreSQL 和 MySQL 8.0+ 支持行构造器语法 (a, b) > (?, ?),但 GORM 默认生成的 SQL 不会自动合并多字段为元组。必须显式构造:
方式一:用 Where 拼接字符串(简单直接,注意 SQL 注入风险仅来自变量,非结构)
db.Where("(user_id, created_at) > (?, ?)", lastUserID, lastCreatedAt).
Order("user_id ASC, created_at ASC").
Limit(20).
Find(&results)
方式二:用 Scopes 封装游标逻辑,提升复用性
func WithCursor(cursorUser int, cursorTime time.Time) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("(user_id, created_at) > (?, ?)", cursorUser, cursorTime).
Order("user_id ASC, created_at ASC")
}
}
// 使用
db.Scopes(WithCursor(lastUserID, lastCreatedAt)).Limit(20).Find(&results)
- MySQL 5.7 及更早版本不支持元组比较,需降级为「先比第一字段,相等再比第二字段」:
WHERE user_id > ? OR (user_id = ? AND created_at > ?) - SQLite 同样不支持元组语法,必须用嵌套 OR
- 排序字段顺序必须和 WHERE 中元组字段顺序严格一致,否则索引可能失效
如何安全获取上一页最后一条记录的联合主键值
不能只查业务字段再拼 user_id 和 created_at,而要确保拿到的是分页查询实际返回的最后一条的原始值——尤其是当查询带了 SELECT 投影、JOIN 或 WHERE 过滤时,结果集可能被裁剪。
- 务必对同一批查询做两次:一次取数据,一次只取游标字段(用
Pluck或Raw) - 推荐用子查询一次性拿到游标值,避免两次网络往返
- 示例(获取第一页后,提取最后一条用于下一页):
// 第一页查 20 条
var results []Order
db.Order("user_id ASC, created_at ASC").Limit(20).Find(&results)
if len(results) > 0 {
last := results[len(results)-1]
nextCursorUser := last.UserID
nextCursorTime := last.CreatedAt
// 传给前端或存入分页上下文
}
- 如果用了
Preload关联加载,results仍是主表实体,游标字段仍可直接取 - 切忌从响应 JSON 中反向解析游标值——浮点时间精度丢失、整数溢出、时区转换都可能导致游标失效
MySQL 5.7 兼容写法与性能陷阱
在不支持元组比较的老版本 MySQL 上,必须展开为逻辑或表达式,但写法稍有不慎就会导致全表扫描:
// ❌ 错误:OR 导致索引失效(即使 user_id 有索引) WHERE user_id > ? OR created_at > ? // ✅ 正确:利用联合索引最左前缀 WHERE user_id > ? OR (user_id = ? AND created_at > ?)
- 该 WHERE 条件要求数据库上有联合索引
INDEX(user_id, created_at),且顺序必须匹配排序字段 - GORM 的
Find不会自动创建索引,上线前必须人工确认并建索引 - 如果联合主键是
(a, b, c),WHERE 必须写成:a > ? OR (a = ? AND b > ?) OR (a = ? AND b = ? AND c > ?),不可省略中间项
游标分页的健壮性不取决于框架封装多漂亮,而取决于是否严格对齐数据库的索引结构和排序语义。漏掉一个字段顺序,或少建一个联合索引,线上就可能出现分页丢失或重复。











