feed流分页必须用游标分页而非offset+limit,因高频写入下offset会导致数据重复或丢失、性能断崖式下降,且count总数无业务意义;应基于created_at和id组合索引,用where条件配合order by实现高效分页。

Feed流分页不能用 Offset + Limit,哪怕加了 ORDER BY 也扛不住高频刷新——这不是 GORM 的锅,是 MySQL/PostgreSQL 对 OFFSET 的语义限制决定的。
为什么 Offset 分页在 Feed 流里必然崩
Feed 流典型特征:新数据高频写入(如每秒几十条)、用户反复下拉刷新、排序字段(如 created_at)大量重复、要求“不漏不重”。OFFSET 在这种场景下会直接失效:
- 同一毫秒插入多条,
ORDER BY created_at DESC, id DESC虽能稳序,但OFFSET 10000仍需扫描并丢弃前 1 万行,CPU 和 I/O 持续飙升 - 用户 A 刷到第 5 页时,后台插入 3 条新 feed,A 再刷第 6 页,
OFFSET计算的位置已偏移,轻则跳过 3 条,重则某条重复出现两次 - GORM 的
Count查总数在高并发写入下毫无意义——刚查完 total=10000,下一秒就变成 10003,前端页码控件瞬间失准
必须改用游标分页(Cursor-based Pagination)
核心思路:不记“第几页”,只记“从哪条继续往后取”。后端返回上一页最后一条的排序字段值(cursor),前端下次请求带上它。
实操要点:
- 排序字段必须有覆盖索引,例如
created_at DESC, id DESC,且该组合在表上建联合索引:CREATE INDEX idx_feed_cursor ON feeds (created_at DESC, id DESC) - 查询 SQL 必须用
WHERE (created_at, id) 形式(PostgreSQL 支持元组比较;MySQL 8.0+ 支持,低版本需拆成 <code>created_at ) - GORM 中不能依赖
Preload做关联加载——游标分页结果集是“切片”,不是完整集合,Preload("User")会触发 N+1 或错乱的 IN 查询。应改用Joins+Select一次性拼出所需字段 - cursor 值必须由服务端生成并校验,禁止前端传 raw id 或时间戳——防止篡改或越权读取。推荐用 base64 编码
created_at.UnixMilli()和id拼接后签名
如何用 GORM 写安全的游标查询
别封装成通用 Paginate 函数,容易掩盖 cursor 解析逻辑。每类 Feed 单独写,显式可控:
// 示例:获取最新 feed(降序),cursor 是上一页最后一条的 (created_at, id)
var feeds []Feed
cursorAt := time.UnixMilli(1724240520000) // 前端传来的 base64 解码后时间
cursorID := uint64(9999)
db.Where("(created_at, id)
<p>注意:</p>
- MySQL 5.7 不支持元组比较,得手写:
db.Where("created_at -
Find前必须加Order,且顺序和 WHERE 中一致,否则索引无法生效 - 第一次请求没 cursor?用
db.Order("created_at DESC, id DESC").Limit(20).Find(&feeds),然后把feeds[0].CreatedAt和feeds[0].ID组合成下一次的 cursor - 别在事务里做游标查询——并发写入时,事务隔离级别可能导致 cursor 值“看不见”刚插入的数据,造成断层
总数和“是否有下一页”怎么处理
Feed 流根本不需要总页数。用户只关心“还能不能往下拉”:
- 查
Limit(21)条,如果拿到 21 条,说明还有下一页,只返回前 20 条 +has_next: true;否则has_next: false - 完全放弃
Count——它在游标模型下既慢又无业务价值。GORM 的RowsAffected返回的是本次查到的条数(≤21),不能当总数用 - 如果产品硬要“共 XXX 条”,用 Redis HyperLogLog 做去重估算,或异步任务每天统计一次快照,别实时算
真正卡住 Feed 性能的,从来不是 GORM 写法,而是把关系型数据库当时间序列存储用。游标分页不是“优化技巧”,是 Feed 场景下的唯一合理模型——索引对了,SQL 简单了,GORM 反而退成透明胶水。











