Offset分页越翻越慢,是因为数据库执行OFFSET时需真实扫描并丢弃前N行,而非跳过;5000页≈扫描100万行,I/O与CPU开销线性上涨,是MySQL/PostgreSQL引擎硬限制。

为什么 Offset 分页在 GORM 里越翻越慢
因为数据库执行 OFFSET 100000 时,不是“跳过”前 10 万行,而是老老实实扫描、排序(如果没走对索引)、再丢弃——每翻一页,I/O 和 CPU 开销线性上涨。5000 页 ≈ 扫 100 万行,这不是 Go 层能优化的,是 MySQL/PostgreSQL 引擎的硬限制。
常见错误现象:db.Offset(99999).Limit(20) 在压测中触发数据库 CPU 飙升、连接超时;同一页刷新两次,返回记录顺序不一致甚至漏掉某条。
- 必须显式写
Order("id ASC")或Order("created_at DESC, id DESC"),否则无序分页结果不可重现 -
Offset()值不能由前端直接传入,要校验page >= 1且pageSize在 1–100 之间,避免恶意构造大偏移量 - 即使加了
id索引,OFFSET仍需在 B+ 树里逐节点跳转,无法直接定位
GORM 游标分页怎么写才不重复不漏数据
游标分页本质是“从某条记录之后开始取”,绕开物理扫描缺陷。但它极度依赖确定的排序字段和索引,写错一个细节就可能返回空结果或重复数据。
- 首选排序字段是带索引的自增
id;若用created_at,必须加id兜底,避免时间重复导致漏数据,例如ORDER BY created_at DESC, id DESC - 首次请求:用
db.Order("id ASC").Limit(21).Find(&users)(多查 1 条判断是否有下一页) - 后续请求:用
db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users),lastID必须来自当前页最后一条记录的id字段值 - 前端必须透传
last_id(不是页码),后端不做转换;若last_id非法(如负数、非数字),应返回 400 而非静默处理
Count 查询为什么总不准
db.Where("status = ?", "active").Limit(20).Offset(40).Count(&total) 这种链式调用,Count 会忽略 Limit 和 Offset,但**不一定忽略 Where**——实际行为取决于 GORM 版本和 session 复用逻辑,极易出错。
- 简单场景可用
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", "active").Count(&total)隔离条件 - 复杂关联查询(含
Joins或子查询)建议手写SELECT COUNT(*) FROM ( ... ) AS t,用db.Raw().Scan(&total)执行 - 如果 UI 不强制显示“共 XX 条”,就别查总数——省一次全表扫描,QPS 能翻倍
什么时候必须切游标分页
当接口面向用户端(如 App 无限滚动)、数据实时写入频繁、或页码可能远超 100 时,游标分页不是“可选优化”,而是必须项。传统分页只适用于数据变动少、页码靠前(page )、总记录数稳定(如后台管理类场景)。
最容易被忽略的一点是:游标分页要求前端完全放弃“页码”概念,改用 last_id 或 cursor 字符串;而这个字符串一旦生成,就必须严格基于当前页最后一条记录的排序字段值,不能取第一条、不能拼接、不能缓存中间状态。











