并发下offset分页会丢数据、重复返回、拖垮数据库,必须换游标分页;因其需全扫描前n行并受mvcc和数据变更影响,而游标分页通过where+order by+limit实现索引范围查询,避免计数开销,但要求排序字段组合全局唯一、索引匹配、游标传递准确。

大并发下用 Limit + Offset 分页,不是慢一点的问题,是会丢数据、重复返回、拖垮数据库——必须换游标分页,且排序字段、索引、游标传递方式一个都不能错。
为什么并发一高,Offset 分页就出事
数据库执行 SELECT * FROM users LIMIT 20 OFFSET 10000 时,并不是“跳到第 10001 行开始读”,而是老老实实扫描前 10000 行、排序(如果没走对索引)、再丢弃。高并发下:
- MVCC 多版本快照导致同一
OFFSET返回不同行集,用户刷两次页面看到的记录可能不一致 - 插入/删除操作让物理行位置漂移,同一条记录可能出现在第 49 页和第 51 页
- MySQL 在
OFFSET > 5000后扫描开销线性上涨,PostgreSQL 在OFFSET > 10000容易触发全索引扫描 - 连接池里一堆慢查询堆积,新请求排队超时,错误日志刷屏
context deadline exceeded
Cursor-based Pagination 的硬性落地条件
游标分页不是加个 WHERE id > ? 就完事,它依赖确定性排序和索引保障。漏掉任一条件,结果就不可靠:
- 首排序字段必须单调、非空、有索引:
id最稳;created_at必须配datetime(3)或bigint时间戳,且要加id作为第二排序字段兜底(防并发写入时间相同) - 复合排序必须严格匹配索引顺序:若用
Order("status ASC, created_at DESC, id DESC"),索引必须是INDEX idx_status_created_id (status, created_at, id) - 前端传的
cursor值必须是上一页最后一条记录的排序字段值,不能是第一条,也不能自己拼接或计算 - 首次请求不带游标,查
db.Order("id ASC").Limit(21).Find(&users)(多取 1 条判断是否有下一页) - 后端不解析、不转换游标,直接
Where("id > ?", cursor),非法值(如负数、非数字)直接返回400 Bad Request
Count(*) 查询在高并发分页里多数是伪需求
用户滚动加载时根本不需要“共 238912 条”,显示总页数反而暴露数据规模、增加攻击面、拖慢接口。真要总数,也得避开常见陷阱:
- 别在同一个
db实例上链式调用Where().Limit().Offset().Count()——Count()会忽略Limit和Offset,但Where是否生效取决于 GORM 版本和 session 复用逻辑,极易不准 - 简单场景用隔离会话:
db.Session(&gorm.Session{NewDB: true}).Model(&User{}).Where("status = ?", "active").Count(&total) - 带
Joins或子查询的复杂条件,手写子查询更可靠:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE p.active = ?) AS t", true).Scan(&total) - 缓存总数只适用于低频变更数据(如后台配置),用户表、订单表这类实时写入的,缓存过期策略比查总数还难维护
容易被忽略的稳定性细节
游标分页能跑通只是第一步,生产环境真正卡住人的,往往是这些不起眼的点:
-
cursor值从 URL 传入,必须用base64.RawURLEncoding.DecodeString解码,否则 URL 中的+或/会被误解析成空格或路径分隔符 - 排序字段类型要和数据库列类型严格一致:Go 里传
int64,数据库是BIGINT;传string,数据库是VARCHAR,类型不匹配会导致索引失效 - 所有分页接口必须显式设置
Order(),哪怕只排一个字段——没有ORDER BY的分页结果,数据库不保证顺序,高并发下必然乱 - 游标值不能来自客户端任意构造,要校验是否落在合理范围内(比如
id不能为负、不能超过当前最大id),否则可能绕过权限过滤逻辑











