GORM的Limit+Offset分页在数据量超10万后性能断崖式下跌,因数据库必须扫描并丢弃前N行;游标分页需严格按索引顺序排序、用最后ID作为游标、避免Count统计以提升性能。

直接说结论:GORM 的 Limit + Offset 分页在数据量超 10 万后,性能会断崖式下跌,这不是写法问题,是数据库执行机制决定的——它必须真实扫描并丢弃前 N 行,无法跳过。
为什么 Offset 越大查询越慢?
MySQL/PostgreSQL 执行 SELECT * FROM users LIMIT 20 OFFSET 100000 时,并不会“定位到第 100001 行”,而是老老实实从头开始扫描、排序(若没走对索引)、比较、丢弃前 10 万行。每翻一页,I/O 和 CPU 开销线性上涨。
- 即使有
id主键索引,OFFSET仍需在 B+ 树中逐节点跳转,不能直接寻址 - 高并发下,同一
OFFSET查询可能因 MVCC 版本不同返回不一致结果 -
Count(*)全表统计在大数据量下同样耗时,且多数前端根本不需要“总页数”
游标分页怎么写才不翻车?
游标分页本质是把“第 N 页”换成“从某条记录之后开始取”,绕开 OFFSET 的物理扫描缺陷。但写错一个细节,就可能返回空结果或重复数据。
- 必须用
ORDER BY,且首排序字段要有高基数、非空、有索引(如id或created_at) - 多字段排序(如
ORDER BY status, created_at DESC, id DESC)时,索引必须严格匹配顺序:INDEX idx_status_created_id (status, created_at, id) - 首次请求不带游标,查
db.Order("id ASC").Limit(21).Find(&users)(多查 1 条判断是否有下一页) - 下一页游标必须基于当前页最后一条记录生成,不是第一条;前端传
last_id,后端不做转换,直接拼进WHERE id > ? - 避免直接拼字符串传游标值,用
base64.RawURLEncoding.DecodeString解码,防止 URL 中+或/被误解析
GORM 的 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 能翻倍
- 缓存总数量只适用于低频变更的数据(如后台配置列表),用户动态数据缓存反而引入一致性风险
最容易被忽略的一点:游标分页的稳定性完全依赖排序字段的唯一性和索引覆盖。哪怕你用了 created_at DESC,只要存在毫秒级相同时间戳的多条记录,又没加 id 作为第二排序字段,就可能漏数据或重复——这和 GORM 无关,是 SQL 排序语义本身决定的。



















