gorm的offset分页实为“扫描丢弃”,数据超10万时性能断崖式下跌;应改用游标分页,确保排序字段有索引、前端透传last_id、count查询独立执行。

OFFSET 分页在 GORM 里根本不是“分页”,而是“扫描丢弃”——数据量一过十万,响应就从毫秒跳到秒级,这不是代码写得不够好,是 SQL 语义决定的硬伤。必须换思路。
为什么 Offset() 越翻越慢且结果不可靠
MySQL/PostgreSQL 执行 OFFSET 100000 LIMIT 20 时,真会读、比、丢弃前 10 万行,不是跳指针。CPU 和 IO 压力陡增,第 1000 页可能耗时超 1.8 秒。
更危险的是数据一致性:如果翻页过程中有新记录插入(比如按 created_at DESC 排序),同一页可能漏掉某条,或某条重复出现在两页里。
- 没加
Order("id ASC")或Order("created_at DESC, id DESC")的分页,数据库不保证顺序,结果不可重现 -
Offset()参数若由前端直接传入且未校验,恶意构造page=1000000可直接拖垮 DB - 排序字段没索引?查询计划会走全表扫描,
OFFSET只是雪上加霜
游标分页怎么写才不丢数据、不重复
游标分页本质是“基于最后一条已知记录往后取”,绕开物理扫描。但错一个细节,就返回空或重复。
- 首次请求:用
db.Order("id ASC").Limit(20).Find(&users),不带Where - 后续请求:用
db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users),lastID必须来自上一页users[len(users)-1].ID - 前端必须透传
last_id(不是page),后端不做转换,直接拼进Where - 若排序用
created_at DESC,条件得改成WHERE created_at ,并确保字段精度(推荐 <code>datetime(3)或bigint时间戳) -
last_id非法(负数、非数字、不存在)时,应明确返回400 Bad Request,别静默 fallback
Count() 总数查不准的三个典型陷阱
Count() 看似简单,但八成不准是因为复用了同一个 *gorm.DB 实例链。它会继承前面的 Limit、Offset、Joins,甚至忽略 Preload 条件。
- 错误写法:
db.Where("status = ?", "active").Limit(20).Offset(100).Count(&total)→ 返回永远 ≤ 20 - 简单场景:用
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", "active").Count(&total)隔离上下文 - 复杂关联(如 JOIN profiles):手写子查询更可靠,例如
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE p.active = ?) AS t", true).Scan(&total) - 如果业务允许(如无限滚动),可只查
size + 1条,用len(results) > size判断has_next,彻底省掉COUNT
什么时候该放弃“页码”思维
只要接口面向用户端(App 列表、Web 无限滚动)、数据高频写入、或页码可能远超 100,就不是“要不要切游标分页”的问题,而是“必须切”。OFFSET 在百万级表上不是性能问题,是架构缺陷。
- 别指望 GORM 自动优化
OFFSET—— 它只是拼 SQL 的工具,慢的根本在索引和查询模型 - 游标分页依赖确定性排序,首选带索引的自增
id;次选created_at DESC, id DESC(避免时间重复) - 别在游标分页里混用
Preload,关联数据应分两步:先查主表 ID 列表,再用IN批量加载 - 前端传参格式要改:从
/users?page=5&page_size=20变成/users?last_id=12345&limit=20
last_id、后端悄悄 fallback 到 OFFSET、排序字段没建索引、或者总数查询还挂在同一个 DB 实例上——这些点漏一个,线上就出事。











