Limit+Offset在内容发布系统中翻页卡顿,是因为OFFSET需逐行扫描并丢弃前N行,数据量大时触发filesort和全索引扫描;而游标分页通过上一页最后id(如WHERE id
为什么 Limit + Offset 在内容发布系统里翻页就卡
因为内容发布系统数据写入频繁、排序字段(比如
created_at)重复率高,而OFFSET要求数据库真实扫描并丢弃前 N 行——不是跳过,是逐行读、排序、比较、丢弃。10 万条内容时,第 500 页(OFFSET 9980)可能就要扫近万行;到 50 万条,OFFSET 100000常常触发 MySQL 的Using filesort和全索引扫描,响应从 50ms 拉到 2s+。
- 现象:前端翻到 page=50 就超时,或同一页刷新两次返回不同文章
- 根本原因不是 GORM 慢,是 SQL 执行机制本身不支持“跳页”语义
ORDER BY created_at DESC单独用,高并发下毫秒级重复会导致排序不稳定;必须补上id DESC做唯一兜底- 没建索引的
ORDER BY字段会让分页直接退化为全表排序,EXPLAIN里key列为空、Extra含Using filesort游标分页怎么写才不丢内容也不重复
游标分页本质是“从上一页最后一条往后取”,绕开
OFFSET的物理扫描缺陷。但在内容系统里,它容易因字段选择、索引顺序、值透传出错而漏掉新发布的文章,或把刚删除的内容又刷出来。
- 排序字段必须单调、唯一、有索引:首选
id DESC;若要用时间戳,字段类型必须是BIGINT(毫秒时间戳)或DATETIME(3),且索引要包含id作为第二列,例如INDEX idx_created_id (created_at, id)- 首次请求不带游标:
db.Order("id DESC").Limit(21).Find(&posts)(多查 1 条判断是否有下一页)- 后续请求严格用上一页最后一条的
id:db.Where("id ,注意是不是- 前端必须透传
last_id(不是页码),后端不做任何转换;非法值(如负数、非数字)直接返回400,不 fallback- 别拼字符串构造条件:
Where("id 是 SQL 注入温床,永远用参数绑定GORM 中 Count 总数查询为什么总不准
内容系统前端常要显示“共 XX 篇”,但
Count()很容易和主查询条件脱节——尤其在带Joins、Preload或复杂Where时,GORM 的链式调用会残留或清空条件,导致总数虚高或为 0。
db.Where("status = ?", "published").Limit(20).Offset(40).Count(&total)实际执行的是SELECT COUNT(*) FROM posts WHERE status = 'published' LIMIT 20 OFFSET 40,结果永远 ≤20- 正确做法是拆成两个独立
*gorm.DB实例:query := db.Where("status = ?", "published"),再分别调用query.Count(&total)和query.Order("id DESC").Limit(20).Find(&posts)- 如果用了
Joins("LEFT JOIN tags ..."),Count()默认不走 JOIN,得手写子查询:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM posts p LEFT JOIN tags t ON p.id = t.post_id WHERE p.status = ?) AS t", "published").Scan(&total)- 更现实的做法:UI 不强制显示总数,就别查——省一次全表扫描,QPS 能翻倍;或者缓存总数(仅适用于后台审核流等低频变更场景)
排序字段索引失效的三个隐蔽坑
内容系统里建了索引却没生效,是最难排查的性能问题。常见不是没建,而是建错了顺序、类型不匹配,或查询条件破坏了最左前缀原则。
- 复合索引字段顺序必须和查询严格对齐:要支持
WHERE status = ? AND created_at > ? ORDER BY created_at DESC, id DESC,索引就得是(status, created_at, id);只建(created_at, id),WHERE status = ?一加,索引就废了- Go 代码里传参类型和 DB 字段类型必须一致:DB 字段是
INT,就不能传"123"字符串,否则 MySQL 隐式转成CAST(id AS CHAR),索引失效;应传int64(123)或绑定 struct 字段WHERE created_at > ?中的?如果是 Go 的time.Time,注意时区:MySQL 默认用系统时区解析,而 Gotime.Now()可能带本地时区偏移。统一用 UTC 时间戳(time.Now().UnixMilli())+BIGINT字段最稳游标分页看似只改两行 SQL,但排序字段是否单调、索引是否覆盖、游标值是否被前端篡改、时间精度是否够用——每个点都可能让第 1000 条内容悄无声息地消失。












