联合主键分页必须用游标分页,不可用OFFSET。核心是WHERE (a,b,c) > (?,?,?)配合完全匹配的联合索引与ORDER BY顺序,否则必然错乱、漏重或全表扫描。

联合主键分页必须放弃 OFFSET,否则数据必然错乱
联合主键(如 (category_id, id) 或 (tenant_id, created_at, id))本身不提供全局唯一递增序号,OFFSET 无法稳定定位“第 N 条”。一旦并发插入或删除发生,同一 OFFSET 值在两次请求中可能指向不同物理行——这不是 GORM 的 bug,而是 SQL 分页模型在非单调排序下的固有缺陷。
常见错误现象:page=10&size=20 返回空切片,或某条记录在 page=9 和 page=11 都出现;db.Offset(180).Limit(20) 在联合主键表上执行缓慢且结果不可复现。
- 不要试图用
ORDER BY category_id, id+OFFSET模拟“页码跳转”,即使加了索引也无法规避漏/重 - 联合主键的排序字段组合必须可构成唯一、确定性序列(例如
ORDER BY tenant_id ASC, created_at DESC, id DESC),且所有字段都需有联合索引支持 - 如果业务允许跳过总页数展示(如 App 无限滚动),直接放弃
COUNT(*)查询,省掉性能黑洞和逻辑耦合
游标分页必须用 WHERE + 最后一条记录的完整联合键值
游标分页是联合主键场景下唯一靠谱的选择。核心不是“跳过多少条”,而是“从上一页最后一条之后继续查”。假设你按 tenant_id ASC, created_at DESC, id DESC 排序,那么下一页查询条件必须是:
WHERE (tenant_id, created_at, id) > (?, ?, ?) ORDER BY tenant_id ASC, created_at DESC, id DESC LIMIT 20
注意:括号内字段顺序、方向(ASC/DESC)必须与 ORDER BY 完全一致,否则索引失效或语义错误。
- 前端必须传回上一页最后一条记录的全部三个字段值(
cursor_tenant_id,cursor_created_at,cursor_id),不能只传id - GORM 不支持原生元组比较,得手写
Where(" (tenant_id, created_at, id) > (?, ?, ?)", last.TenantID, last.CreatedAt, last.ID) - 首次请求无游标时,用
WHERE tenant_id = ?或其他业务约束兜底,避免全表扫描
复合索引设计决定游标分页能否走索引
上面那个 WHERE (tenant_id, created_at, id) > (?, ?, ?) 查询,只有当数据库存在匹配的联合索引时才能高效执行。MySQL/PostgreSQL 对元组比较的索引利用非常敏感。
- 必须建联合索引:
INDEX idx_tenant_time_id (tenant_id, created_at, id)—— 字段顺序、升降序必须与查询中完全一致 - 如果排序是
tenant_id ASC, created_at DESC, id DESC,索引也得声明为(tenant_id ASC, created_at DESC, id DESC)(PostgreSQL 支持,MySQL 8.0+ 支持,老版本 MySQL 只能全 ASC) - 别指望
INDEX(tenant_id)+INDEX(created_at)能优化元组查询——它不会合并使用,大概率触发 filesort - 用
EXPLAIN验证是否命中索引:type 应为range,key 显示你的联合索引名,rows 扫描数应接近LIMIT值而非百万级
GORM 封装游标分页要隔离参数、避免链式污染
别把游标参数塞进同一个 *gorm.DB 实例反复调用。GORM 的链式操作是 mutable 的,Where、Order、Limit 会累积,容易导致条件残留或排序错乱。
- 每次分页查询都从干净的
db.Session(&gorm.Session{NewDB: true})开始,或显式用db.Unscoped().Where(...)隔离 - 不要在分页函数里复用传入的
db *gorm.DB,而是复制一份:tx := db.Session(&gorm.Session{NewDB: true}) - 游标值必须校验非空且类型匹配(比如
created_at是time.Time,不能传字符串或零值),否则生成的 SQL 可能语法错误或逻辑错误 - 返回结构体里别硬塞
Total字段——联合主键场景下COUNT(*)几乎不可能高效,用HasNext(查 size+1 条)更实际


















