根本原因是mysql需扫描大量匹配行并排序丢弃,解决方法是建联合索引index(created_at, id)并用游标分页where created_at > ? or (created_at = ? and id > ?)。

大范围日期筛选分页在 GORM 中慢,根本不是 Go 层写法问题,而是 MySQL 必须扫描所有匹配时间范围的行再排序、再跳 OFFSET —— 即使加了 created_at 索引,只要没覆盖查询字段,照样回表+排序+丢弃,一翻到几百页就卡死。
为什么 WHERE created_at BETWEEN ... ORDER BY id LIMIT 10 OFFSET 10000 还是慢
因为 BETWEEN 范围太大(比如查“近一年”),MySQL 先用索引快速定位出几十万条 created_at 匹配记录,但这些记录在物理磁盘上是离散的;接着要按 id 排序(如果没联合索引)、取前 N 条、再丢弃前 10000 行——每一步都在放大 I/O 和 CPU 开销。
- 单靠
created_at索引无法避免 filesort,除非你ORDER BY created_at且该字段在索引最左位 -
OFFSET 10000不是“跳过”,是真实读取并丢弃 10000 行,哪怕它们早被缓存命中 - 如果还带
JOIN或SELECT *,回表次数 × 扫描行数 = 性能雪崩点
必须建联合索引,且顺序不能错
日期筛选 + 分页的唯一解法是让数据库用索引直接定位“第 N 条”,不排序、不回表、不丢弃。这要求联合索引覆盖查询条件和排序字段。
- 最优索引:
INDEX (created_at, id)—— 适用于WHERE created_at >= ? ORDER BY created_at, id LIMIT ? - 若你要
ORDER BY id ASC,索引必须是INDEX (created_at, id),不能反过来;否则created_at范围扫描后仍需 filesort - 如果查询还带
status = 'active',索引应扩展为INDEX (status, created_at, id),且WHERE条件顺序必须严格匹配 - 别信“加了索引就快”,用
EXPLAIN看key_len和Extra:出现Using filesort或Using temporary就是索引失效
游标分页绕不开,但 WHERE created_at = ? AND id > ? 才是真稳
用 created_at 单字段做游标(如 WHERE created_at > ?)在高并发写入下会漏数据:同一毫秒插入多条,created_at 相同,顺序不可控。
- 正确游标条件:
WHERE created_at > ? OR (created_at = ? AND id > ?),前端必须传两个值:last_created_at和last_id - 首次请求不带游标,但必须固定排序:
db.Order("created_at ASC, id ASC").Limit(20).Find(&items) - 后续请求解析上一页最后一条的
item.CreatedAt和item.ID,拼进 WHERE —— 别用字符串格式化,用参数占位符 - 注意时区:数据库存的是 UTC,前端传的可能是本地时间,统一转成
time.Time再传给 GORM,别手动拼"2025-01-01"
Count 总数查询别和主查询共用 db 实例
你在主查询里写了 db.Where("created_at BETWEEN ? AND ?", start, end).Joins("JOIN profiles..."),然后顺手 .Count(&total) —— 这个 total 一定不准,因为 Count() 默认忽略 Joins 和 Preload。
- 安全写法:
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where(...).Joins(...).Count(&total) - 更可靠(尤其含 JOIN):
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE u.created_at BETWEEN ? AND ?) AS t", start, end).Scan(&total) - 大数据量建议放弃精确总数:查
Limit(21),如果返回 21 条,就设has_next = true,前端显示“加载更多”即可
真正卡住你的从来不是 GORM 的 Offset 写法,而是没想清楚:日期范围筛选本质是“找一个区间”,而分页是“在这个区间里按某种顺序切片”。这两个动作必须由同一个联合索引原子完成,否则数据库只能硬扫。游标不是可选项,是必选项;索引顺序不是细节,是前提。











