GORM的Limit+Offset在百万数据下会卡死,是因为数据库必须真实扫描并丢弃前N行,而非跳过;例如OFFSET 100000时,MySQL/PostgreSQL逐行读取、排序(若无索引)、丢弃前10万行,导致I/O和CPU开销线性上涨。

为什么 GORM 的 Limit + Offset 在百万数据下会卡死
不是 Go 代码写得慢,是数据库执行逻辑决定了它必须扫描并丢弃前 N 行。比如 SELECT * FROM orders LIMIT 20 OFFSET 100000,MySQL/PostgreSQL 不会“跳过”,而是真实读取、排序(若没索引)、丢弃前 10 万行——I/O 和 CPU 开销线性上涨。压测中常见现象:第 5000 页响应超 1.8 秒、DB CPU 拉满、连接池耗尽。
容易踩的坑:
-
Offset值由前端直接传入且未校验,恶意构造Offset=9999999可直接拖垮数据库 - 没加
Order("id ASC")或排序字段无索引,导致每次查询结果顺序不一致,甚至漏数据 - 在高并发场景下,MVCC 版本差异会让同个
Offset返回不同记录集
游标分页怎么写才不重复、不漏、不崩
游标分页本质是把“第 N 页”换成“从某条记录之后开始取”,绕开物理扫描缺陷。但一个细节写错,就可能返回空或重复。
实操要点:
- 首排序字段必须有索引、非空、高基数——
id最稳;用created_at必须加id兜底,如ORDER BY created_at DESC, id DESC - 首次请求:查
db.Order("id ASC").Limit(21).Find(&users)(多查 1 条判断是否有下一页) - 后续请求:用
db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users),lastID必须来自上一页最后一条记录的id字段值 - 前端必须透传
last_id(不是页码),后端不做转换;若last_id是负数或非数字,应直接返回 400
Count(*) 总数查询为什么不准还拖慢接口
db.Where("status = ?", "active").Limit(20).Offset(40).Count(&total) 这种链式调用,Count 会忽略 Limit 和 Offset,但是否继承 Where 取决于 GORM 版本和 session 复用逻辑——极易出错。
更隐蔽的问题是关联查询: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("status = ?", "active").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) - UI 不强制显示“共 XX 条”时,直接放弃
Count,改用has_next布尔值(靠多查 1 条判断)
索引没建对,游标分页也白搭
即使用了游标分页,如果 WHERE id > ? 对应的 id 字段没索引,或者复合索引顺序与查询不匹配,照样全表扫描。
验证和修复方法:
- 用
EXPLAIN SELECT * FROM orders WHERE id > 123456 ORDER BY id ASC LIMIT 20查看key列是否命中索引、Extra是否含Using index - 多条件排序如
ORDER BY status, created_at DESC, id DESC,索引必须严格按此顺序建:INDEX idx_status_created_id (status, created_at, id) - WHERE 条件字段类型必须和数据库一致:Go 里传
"123"(字符串)给 INT 字段,MySQL 会隐式转换导致索引失效;应传int64(123)或绑定 struct 字段
游标分页本身不难,难的是排序字段的索引完整性、last_id 的来源可靠性、以及前端透传机制的一致性。这三个点只要漏一个,性能优势就归零。


















