OFFSET大偏移量性能差因数据库需扫描丢弃前N行,I/O和CPU线性增长;应改用覆盖索引子查询或游标分页,如WHERE id > lastID配合ORDER BY和LIMIT。

大偏移量下 OFFSET 为什么必须换思路
因为 MySQL/PostgreSQL 执行 OFFSET 100000 时,不是“跳过前 10 万行”,而是真实扫描、排序(若未走索引)、逐行丢弃——I/O 和 CPU 开销线性增长。第 5000 页可能耗时超 3 秒,连接池打满,结果还可能因 MVCC 版本不一致而漏数据或重复。这不是 GORM 的锅,是 SQL 引擎的硬限制。
GORM 中用子查询绕过 OFFSET 的写法
子查询本身不能“优化 OFFSET”,但能帮你把分页逻辑从主表扫描中剥离出来,尤其适合带复杂 JOIN 或 WHERE 条件的场景。核心是:先查出目标 ID 列表,再用 IN 回填主表数据。
- 先用子查询获取分页后的 ID:
db.Table("users").Select("id").Where("status = ?", "active").Order("id ASC").Limit(20).Offset(100000).Rows()(注意:这步仍慢,仅作过渡) - 更稳的做法是改用覆盖索引子查询:
db.Raw("SELECT u.* FROM users u INNER JOIN (SELECT id FROM users WHERE status = ? ORDER BY id ASC LIMIT 20 OFFSET ?) t ON u.id = t.id", "active", 100000).Scan(&users) - 该写法依赖
users(status, id)联合索引,让内层子查询只走索引,不回表;外层 JOIN 再按 ID 精准捞数据 - 别在子查询里用
SELECT *,否则失去覆盖索引优势;也别漏掉ORDER BY,否则子查询结果无序,LIMIT/OFFSET失效
子查询 + 游标才是真解法
纯子查询治标不治本。真正扛得住千万级数据的,是把子查询和游标分页结合:用子查询生成确定性游标,再靠 WHERE 推进。
- 首次请求不走子查询,直接:
db.Order("id ASC").Limit(21).Find(&users)(多查 1 条判断是否有下一页) - 拿到最后一条的
id后,后续请求改用:db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users) - 如果排序字段是
created_at DESC,且存在时间重复风险,必须加二级排序:Order("created_at DESC, id DESC"),对应游标条件也要变成Where("created_at - 前端传来的
last_id必须校验类型和范围(比如非负整数),非法值直接返回400 Bad Request,不静默 fallback
Count 总数怎么查才不拖垮数据库
在大偏移量场景下,Count(*) 本身就成了性能瓶颈。它不会复用子查询的索引路径,往往触发全表扫描。
- 简单 WHERE 条件:用隔离会话:
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", "active").Count(&total) - 含 JOIN 的复杂查询:必须手写子查询统计,例如:
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) - 如果 UI 不强制显示“共 XX 条”(比如无限滚动列表),最有效的优化就是压根不查总数——只查
pageSize + 1条,有第pageSize + 1条就置has_next = true,省掉一次全表 COUNT - 缓存总数只适用于低频变更数据(如后台配置),用户动态数据缓存反而引入一致性风险
游标分页不是“高级技巧”,而是大偏移量下的事实标准。最容易被忽略的是:游标值必须来自当前页最后一条记录,且排序字段组合必须与索引定义严格一致——差一个字段顺序,就可能漏数据或重复。


















