MySQL的LIMIT OFFSET深分页性能差,是因为必须扫描并丢弃前OFFSET行,无法跳过,导致I/O和CPU开销随偏移量线性增长;游标分页通过WHERE排序字段>上一页末尾值实现高效分页。

千万级单表用 Limit + Offset 滚动分页,性能必然崩,不是 GORM 写得不对,是 MySQL/PostgreSQL 执行机制决定的——它真会逐行扫描并丢弃前 N 行,不是“跳过”。必须切游标分页,否则第 5000 页就超时。
为什么 Offset 越大越慢,且结果可能错
执行 SELECT * FROM users LIMIT 20 OFFSET 100000 时,数据库不会定位到第 100001 行再读,而是从头开始扫描、排序(若没走对索引)、比较、丢弃前 10 万行。B+ 树索引也无法跳过中间节点,只能顺序遍历。
- 常见错误现象:
db.Offset(499999).Limit(20)在压测中触发连接超时、CPU 飙升;同一页刷新两次,返回顺序不一致,甚至漏掉某条记录 - 根本原因:OFFSET 是物理偏移量,不是逻辑页码;数据插入/删除后,同一 offset 对应的“第 N 条”已变
- 即使加了
id索引,OFFSET仍需在索引树里逐节点跳转,无法直接寻址 - 高并发下,MVCC 版本差异会导致同一 offset 查询返回不同快照,结果不可重现
游标分页怎么写才不丢数据、不重复
游标分页本质是“从上一页最后一条往后取”,绕开 OFFSET 的扫描缺陷。但写错一个点,就可能返回空或重复。
- 首次请求必须不带
Where,只靠Order("id ASC")+Limit(21)(多查 1 条判断是否有下一页) - 后续请求用
db.Where("id > ?", lastID).Order("id ASC").Limit(20)——lastID必须来自当前页最后一条的id字段值,不能是第一条,也不能前端传错类型(如字符串) - 排序字段必须有索引,且单调、非空、高基数:
id最稳;若用created_at DESC,必须加id DESC兜底(防时间相同),索引要建为INDEX idx_created_id (created_at, id) - 前端必须透传
last_id(不是page),后端不做转换;若last_id非数字或负数,直接返回400,不静默 fallback - 避免拼接字符串传游标值,用
base64.RawURLEncoding.DecodeString解码,防止 URL 中+或/被误解析
Count 总数查询怎么又准又快
db.Model(&User{}).Count(&total) 看似简单,但一加 Where 或关联就容易出错——它不继承主查询的 Joins、Preload 或 Group,纯按模型表硬扫。
- 典型陷阱:你写了
db.Joins("JOIN profiles ON users.id = profiles.user_id").Where("profiles.active", true)查用户,却用db.Model(&User{}).Where("profiles.active", true).Count()算总数,结果漏 JOIN 条件,总数虚高 - 简单场景:用
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Count(&total)隔离会话,避免条件污染 - 复杂关联:手写子查询,例如
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) - 大数据量慎返
total:UI 不强制显示“共 XX 条”时,改用查Limit(21)后判断len(results) == 21返回has_next: true,省一次全表扫描
排序字段没索引或选错,游标照样翻车
游标分页不是加了 WHERE id > ? 就万事大吉。如果排序字段没索引,或索引不匹配查询模式,MySQL 仍会回表、全扫描,性能不比 Offset 好多少。
- 单字段排序(如
ORDER BY id ASC):确保id是主键或有单独索引 - 多字段排序(如
ORDER BY status, created_at DESC, id DESC):索引必须严格按顺序覆盖,建为INDEX idx_status_created_id (status, created_at, id),缺一不可 - 避免
ORDER BY created_at DESC单独使用:高并发下大量记录时间戳相同,排序不稳定,必须加id DESC作为第二排序字段 - 时间字段建议用
datetime(3)或bigint时间戳,避免 MySQL 默认秒级精度导致重复 - 用
EXPLAIN确认查询是否走了索引:重点看type是否为range或index,key是否命中你建的索引,rows是否接近 limit 值(而非百万级)
最易被忽略的一点:游标值必须来自上一页最后一条记录的排序字段原值,且该字段在数据库里必须是可比较、无歧义的——比如 id 可以,但 JSON 字段里的某个嵌套值 不行;前端传过来的游标必须做类型校验和范围检查,不能直接拼进 SQL。


















