GORM的Limit+Offset分页在数据量超10万后性能断崖式下跌,根本原因是数据库需扫描并丢弃前N行;应改用游标分页或索引优化,避免Count(*)全表统计,慎用可变排序字段。

深度分页(OFFSET > 10万)在GORM中必然变慢,这不是代码写得不够“Go”,而是MySQL/PostgreSQL执行机制决定的——它真会扫描并丢弃前N行。想靠调优GORM参数解决,是方向性错误。
为什么 Offset 超过 10 万后查询会突然卡住
数据库执行 SELECT * FROM users LIMIT 20 OFFSET 100000 时,并不会跳指针,而是老老实实从索引头开始遍历、排序(若没覆盖索引)、逐行计数到第100001条才开始取结果。即使id有主键索引,B+树仍需从根节点逐层跳转,无法直接定位物理偏移位置。
- MySQL 在 OFFSET 超过 5000 行后,I/O 和 CPU 开销就明显上升;PostgreSQL 约在 10000 行起出现断崖
- 高并发下,同一
OFFSET可能因 MVCC 版本不一致返回不同结果,导致页面“抖动”或漏数据 -
Count(&total)若带条件,同样要全表或全索引扫描,和主查询一起用等于双倍压力 - 别信“加了索引就没事”——单字段
created_at索引 +WHERE status = ?,若没建联合索引,照样回表+排序
GORM 中 Count 查询总不准的三个典型场景
db.Where("status = ?", "active").Limit(20).Offset(40).Count(&total) 这类链式写法,Count 实际行为取决于 GORM 小版本和 session 复用逻辑,极易出错。
- 在 GORM v2.6+ 中,
Count默认复用前面的Limit和Offset,结果永远 ≤ 20,不是总数 - 用
Preload关联查询后调Count,可能因 JOIN 导致重复计数(如 1 用户有 3 订单 → COUNT 算成 3) - 事务内先
Count再Find,中间若有写入,总数和当前页数据对不上,但 GORM 不报错也不预警
正确做法:用独立会话或手写子查询。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 u.status = ?) AS t").Scan(&total)。
游标分页在 GORM 中落地的关键细节
游标分页不是“换种写法”,而是把“第 N 页”变成“从某条记录之后取”。它稳定、快、适合无限滚动,但对排序和索引要求苛刻。
- 首排序字段必须非空、高基数、有索引(推荐
id ASC或created_at DESC),不能是RAND()或updated_at(频繁更新导致游标漂移) - 多字段排序如
ORDER BY status, created_at DESC, id DESC,对应索引必须严格匹配:INDEX idx_status_created_id (status, created_at, id) - 前端传的游标值(如
cursor=MTIzNDU2)必须用base64.RawURLEncoding.DecodeString解码,避免 URL 中+或/被篡改 - 首次请求不带游标,查
db.Order("id ASC").Limit(pageSize + 1).Find(&users)(多查 1 条判断是否有下一页);下一页游标必须基于当前页最后一条记录生成,不是第一条
最容易被忽略的排序稳定性陷阱
没 Order 的分页,等于在赌数据库心情。同一 SQL 执行两次,可能漏掉记录、重复返回、甚至顺序完全颠倒——这不是 GORM Bug,是 SQL 标准允许的行为。
- 只写
Order("created_at DESC")风险极高:时间相同的数据,数据库返回顺序无保证,翻页时可能把同一批记录拆到两页 - 正确姿势是复合排序:
Order("created_at DESC").Order("id DESC")或Order("id ASC")(主键天然唯一) - 别为“省一个字段”去掉二级排序,代价是线上用户反复刷新看到不同内容
- 测试时务必用并发插入模拟写入压力,单机跑一次“正确”,不等于线上不出问题


















