子查询分页能绕过offset性能陷阱,是因为它用where范围过滤(如id > 100000)替代offset扫描丢弃,数据库直接索引定位并顺序读取20行,避免扫描前10万行。

子查询分页为什么能绕过 OFFSET 性能陷阱
因为 OFFSET 要求数据库真实扫描并丢弃前 N 行,而子查询分页(如用主键 ID 做范围过滤)让数据库直接定位起始位置,跳过物理扫描。MySQL 执行 OFFSET 100000 可能要读取 10 万 + 20 行再丢掉前面的,但 WHERE id > 100000 ORDER BY id LIMIT 20 只需索引定位、顺序读取 20 行。
手写子查询实现 COUNT 总数的正确写法
别用 db.Where(...).Limit(...).Offset(...).Count(&total)——它返回的是带分页限制后的数量,永远 ≤ 每页条数。必须把 COUNT 查询和分页查询彻底隔离:
- 简单场景:
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) - 如果业务允许不显示总页数,就查
size + 1条,用len(results) > size判断是否有下一页,省掉 COUNT
游标分页中子查询的典型误用点
游标分页本身不依赖子查询,但有人试图用子查询“模拟”页码跳转(比如查第 100 页 → 写子查询先算出第 991 条的 ID),这是错的:子查询嵌套深、无法利用索引、且并发写入下结果不可靠。
- 正确做法是前端透传上一页最后一条的
id或created_at+id组合,后端直接拼WHERE id > ? - 错误示例:
SELECT * FROM users WHERE id IN (SELECT id FROM users ORDER BY id LIMIT 990, 1)—— 这个子查询仍带LIMIT/OFFSET,没解决问题 - 排序字段必须有索引,否则
WHERE id > ?会全表扫描;高并发下避免单用created_at,加id DESC做二级排序兜底
什么时候该用子查询替代普通分页
不是所有分页都适合子查询优化。子查询真正起作用的场景有限:
- 当你要做「关联表条件分页」且主表数据量不大,但关联结果集极大时(例如查“每个用户最新一条订单”,用子查询先筛出 user_id + max(created_at) 再 JOIN)
- 当分页逻辑必须基于聚合或计算字段(如按月统计后分页),这时子查询是唯一可行方式
- 普通列表分页(如文章、用户)优先走游标分页,而不是绞尽脑汁写子查询——后者更难维护、更易出错
- GORM 的
SubQuery()方法只适合构造IN、EXISTS类条件,不适合替代OFFSET,硬套反而性能更差
子查询不是 OFFSET 分页的“加速补丁”,它是另一种查询建模思路。真正卡在超深分页的,问题往往不在 Go 层怎么写,而在是否用了游标、索引是否覆盖、以及前端是否还在传 page=1000 这种参数。











