Offset越大越慢是SQL本身问题,因数据库需扫描丢弃前N行;游标分页才是高偏移场景的强制方案,需基于有索引的确定性字段(如id)并校验游标有效性。

Offset 越大越慢,不是 GORM 的锅,是 SQL 本身的问题
执行 db.Offset(100000).Limit(20).Find(&users) 时变慢,根本原因不是 GORM 拼 SQL 不够快,而是数据库必须物理扫描并丢弃前 10 万行——MySQL 和 PostgreSQL 都如此。即使加了索引,只要排序字段和查询条件没覆盖全,仍可能回表或全索引扫描。
- MySQL 中 OFFSET > 5000 就可能明显延迟;PostgreSQL 在 OFFSET > 10000 时响应常突破 1s
- 复合排序如
Order("created_at DESC, id DESC")必须匹配联合索引(INDEX(created_at, id)),否则 ORDER BY 无法利用索引下推 - 别指望 GORM 自动加索引提示或重写查询,它只负责生成 SQL,优化得靠你建对索引 + 写对 WHERE + 显式 ORDER
Count 查询总条数,千万别复用同一个 *gorm.DB 实例
db.Where("status = ?", "active").Limit(20).Offset(40).Count(&total) 返回的 total 永远 ≤ 20,因为 Count() 会继承前面链上的 Limit 和 Offset —— 这是 GORM v2 的默认行为,也是最常踩的坑。
- 正确做法:用
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", "active").Count(&total)隔离上下文 - 复杂 JOIN 或 Preload 场景下,
Count()可能因笛卡尔积虚高,此时直接手写子查询更可靠:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u LEFT JOIN profiles p ON u.id = p.user_id WHERE u.status = ?) t", "active").Scan(&total) - 如果业务允许(如 Feed 流),可跳过总数,改查
size + 1条,仅返回has_next,省掉一次 COUNT
Preload 分页时关联数据爆炸,本质是 N+1 的变种
写 db.Preload("Orders").Offset(100).Limit(20).Find(&users),GORM 先查出 20 个用户,再发一条 SELECT * FROM orders WHERE user_id IN (1,2,...,20) —— 看似只有 2 次查询,但若每个用户有 50 个订单,结果就是加载 1000 条订单记录,远超分页本意。
- Preload 无法控制“每个用户只取最新 3 个订单”,它只做批量 IN 查询
- 真正需要关联聚合时,用
Joins+Select手写 JOIN,或改用子查询(如 LATERAL)在数据库层裁剪 - 高频分页列表接口,应避免 Preload,把关联逻辑拆到前端多次请求,或用缓存预热(如 Redis Hash 存用户基础信息 + 单独接口查订单摘要)
游标分页不是“高级选项”,而是高偏移场景的强制要求
当页码可能超过 50、数据每分钟都在写入、或接口用于 App 无限滚动时,Offset 分页已不可靠——新插入记录会让后续页整体偏移,导致重复或丢失。
- 游标必须基于**确定性、单调、有索引**的字段,首选主键
id(自增或 UUID 排序友好),次选created_at+id复合(防时间重复) - 示例:上一页最后一条是
id = 12345,下一页查db.Where("id > ?", 12345).Order("id ASC").Limit(20).Find(&users) - 前端需透传游标值(如 URL 参数
?cursor=12345),不能暴露“第几页”语义;后端绝不解析page=50再算 offset - GORM 不提供游标分页封装,必须手写 WHERE + ORDER + LIMIT,且确保该字段有索引,否则性能不比 Offset 好
实际项目里,最容易被忽略的是:游标值本身要校验有效性(比如用户传 cursor=abc 或负数),以及排序字段缺失索引时,游标查询照样慢。这两点不处理,换游标只是把问题从“翻页错乱”变成“翻页超时”。


















