MySQL执行OFFSET 99999时必须真实扫描并丢弃前99999行,每行都需走主键索引、回表及条件判断,导致I/O和CPU开销随偏移量线性增长;若排序仅依赖created_at而无id兜底,高并发下时间重复会导致翻页跳行或卡死。

千万级数据用 Offset 分页基本不可行,必须用带索引保障的游标分页,且排序字段不能只靠 created_at。
为什么 db.Offset(99999).Limit(20) 在千万表上会超时
MySQL 执行 OFFSET 99999 时不是“跳过”,而是真实读取并丢弃前 99999 行——每行都要走主键索引、回表、判断是否满足 WHERE 条件。数据越往后,扫描成本越高,第 5000 页常超 3 秒,连接池迅速耗尽。
常见错误现象:db.Offset(499999).Limit(20) 在压测中触发数据库超时;同一页刷新两次,返回顺序不一致,甚至漏掉某条记录。
- 必须显式加
Order("id ASC")或Order("created_at DESC, id DESC"),否则无序结果不可重现 -
Offset值不能由前端直接传入,要校验page >= 1且pageSize在 1–100 之间 - 别在
Count()查询里复用带Limit/Offset的 DB 实例,它会忽略这些条件,导致总数不准
游标分页怎么写才不漏数据、不重复
游标分页本质是“基于最后一条已知记录继续往后取”,绕开 OFFSET 的物理扫描缺陷。但写错一个细节,就可能返回空结果或重复数据。
-
last_id必须来自上一页最后一条记录的排序字段值,且该字段要有索引、单调递增(id最稳;created_at在高并发下需加id兜底) - 首次请求:
db.Order("id ASC").Limit(20).Find(&users),不带Where - 后续请求:
db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users) - 前端必须传
last_id(不是页码),后端不做转换,直接拼进Where;若last_id非法(如负数、非数字),应返回 400 而非静默处理 - 如果排序用
created_at DESC,则条件要改成created_at < ?,且注意时区和精度(建议用datetime(3)或bigint时间戳)
FindInBatches 不是分页,是安全批量扫描
FindInBatches 常被误当成“分页替代方案”,但它其实是为后台任务设计的批量扫描工具,不适用于用户端分页场景。
- 它内部自动按主键区间切片,每次执行类似
WHERE id BETWEEN ? AND ?,但不暴露游标,也不保证全局顺序稳定 - 适合导出、统计、异步清洗等后台任务,不适合前端“上一页/下一页”交互
- 调用时必须传入函数闭包处理每批数据,不能直接拿到
[]User后再做分页逻辑 - 若强行用于分页,容易因批次边界与业务排序逻辑冲突,导致某页数据突然消失或重复
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().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) - 覆盖索引优化:避免
SELECT *,尤其在深分页时,MySQL 要对每条做回表;查总数时用SELECT COUNT(id)更轻量
最易被忽略的一点:游标分页的排序字段必须是**有索引 + 单调 + 可比较**的组合。单独用 created_at 在毫秒级并发插入时大概率撞时间,必须补 id 作为第二排序键,否则翻页会跳行或卡死。


















