offset越翻越慢是因为mysql必须真实扫描并丢弃前n行,而非跳过;1500万弹幕表中offset 10000平均耗时1.2s,p99达2.7s,索引无法规避b+树逐层遍历的定位开销。

为什么 OFFSET 在弹幕分页里会越来越慢
因为弹幕表数据量一旦过千万,OFFSET 会强制 MySQL 扫描跳过的所有行。比如拉第 1000 页(每页 20 条),MySQL 得先读 19980 行再扔掉,只留最后 20 条——这和你翻到《新华字典》第 1000 页才查“弹”字一样低效。
GORM 默认的 Limit().Offset() 就是走这条路。别指望加索引能救回来:即使有 (video_id, created_at) 联合索引,OFFSET 仍需定位到逻辑偏移位置,B+ 树得逐层遍历。
- 真实线上观测:1500 万弹幕表,
OFFSET 10000平均耗时 1.2s,P99 达 2.7s WHERE created_at 同样条件只要 8ms- GORM v1.25+ 支持游标式分页,但默认不启用,得手动构造
用 Where().Order().Limit() 实现游标分页
核心是放弃页码概念,改用上一页最后一条的 created_at 和 id 作为下一页起点。这对弹幕这种严格按时间递增、且允许微小重复(同一毫秒多条)的场景非常合适。
示例代码(GORM v1.25+):
// 拉取第一页(最新 20 条)
db.Where("video_id = ?", videoID).
Order("created_at DESC, id DESC").
Limit(20).
Find(&danmus)
// 拉取下一页:传入上一页最后一条的 created_at 和 id
db.Where("video_id = ? AND (created_at
- 必须用
created_at DESC, id DESC双字段排序,否则同一毫秒的弹幕顺序不一致 -
WHERE条件中用括号包裹OR,确保复合主键语义正确 - 避免用
time.Time直接比较——MySQL 的DATETIME(3)和 Go 的纳秒精度可能错位,建议统一转为秒级时间戳或使用created_at + <code>id 组合
Count() 对弹幕分页几乎没意义
用户根本不需要知道“总共有多少页”。前端显示“没有更多弹幕”比“共 124867 页”更合理,也省掉一次全表扫描计数。
- 执行
SELECT COUNT(*) FROM danmu WHERE video_id = ?在 1500 万行时稳定 300ms+ - 如果非要总数,可异步更新 Redis 缓存:
INCRBY danmu:count:{video_id} N,但注意删除弹幕时也要同步减 - GORM 的
Count()会忽略Order和Limit,但无法跳过WHERE条件重写,容易误用
别让 GORM 自动注入 ORDER BY id
某些 GORM 配置(如启用 gorm.Model 或设置了全局默认排序)会在无显式 Order() 时悄悄加上 ORDER BY id,导致游标失效或结果错乱。
- 检查日志输出的 SQL,确认没有隐式
ORDER BY - 在初始化 DB 时禁用默认排序:
db.Session(&gorm.Session{DryRun: false}).Order("")不起作用,正确做法是每次查询都显式写Order("created_at DESC, id DESC") - 如果用了软删除(
gorm.DeletedAt),记得加Unscoped()—— 弹幕一般不逻辑删除,但若业务后期支持“撤回”,这里容易漏











