游标分页是高并发写入或页码不可控场景下的唯一可行方案,因OFFSET需扫描丢弃前N行导致性能断崖和数据错乱;必须用WHERE id > ? + ORDER BY id ASC并确保索引覆盖,多字段需复合索引;推荐base64编码cursor防篡改,避免敏感字段;游标分页不应查total,用len(users)==pageSize判断has_next更准确。

游标分页不是“比Offset快一点的优化”,而是高并发写入或页码不可控场景下的唯一可行方案。用 OFFSET 翻到第 1000 页时性能断崖、数据错乱,这不是GORM的问题,是SQL执行模型决定的——数据库真得扫描并丢弃前 N 行。
为什么游标分页必须用 WHERE id > ? 而不是 WHERE created_at
主键 id 是单调递增、全局唯一、索引覆盖最稳的字段。用 created_at 做游标容易踩两个坑:
- 高并发下多条记录时间戳相同,
WHERE created_at 可能漏掉同秒插入的其他记录 - MySQL 的
DATETIME默认精度为秒,即使设成DATETIME(3),时区转换或NTP漂移也可能导致边界错位 - 如果排序是
ORDER BY created_at DESC, id DESC,那游标条件必须同时带上两个值:WHERE created_at ,逻辑复杂且易错
所以首次落地建议只用 id:前端传 last_id=12345,后端直接拼 WHERE id > 12345 ORDER BY id ASC LIMIT 20。等稳定后再考虑复合游标。
db.Where("id > ?", lastID).Order("id ASC").Limit(20) 为什么必须加索引
这个查询能否走索引,取决于你的 ORDER BY 和 WHERE 是否能被同一个索引覆盖。如果只在 id 上建了主键,那它天然满足;但如果你换成 created_at,就必须显式建索引:
ALTER TABLE users ADD INDEX idx_created_at_id (created_at DESC, id DESC);- 注意顺序:
ORDER BY created_at DESC, id DESC要求索引字段顺序和方向完全匹配,否则会退化为 filesort - 没索引时,
WHERE created_at > ?+ORDER BY created_at DESC仍可能全表扫描,游标就失去了意义
前端该传 last_id 还是 cursor 字符串
传原始 last_id 最简单,但有局限:
- 只能用于单字段游标(如纯
id),无法扩展到多字段或加密场景 - 如果某次请求返回空切片(比如最后一页刚被删光),你没法区分“到底有没有下一页”还是“last_id 传错了”
- 更健壮的做法是后端生成 base64 编码的 cursor,例如
base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", user.ID))),前端只透传不解析 - 进阶可把排序字段值+时间戳+签名打包进 cursor,防篡改、防重放,比如用
github.com/gofrs/uuid生成带时间戳的 token
别在 cursor 里塞敏感字段(如用户邮箱),也别用 json.Marshal 直接序列化结构体——空字段或浮点数精度会导致解码失败。
Count(&total) 在游标分页里要不要查
游标分页本质是“流式读取”,total 失去了业务意义。强行查会带来三个问题:
- 你得手写子查询才能保证和主查询条件一致,例如
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users WHERE id > ?) t", lastID).Scan(&total),但这个total是“剩余总数”,前端根本不会显示 - 大数据量下
COUNT(*)本身就很慢,尤其带 JOIN 时,可能比主查询还耗时 - 真实业务中,用户只关心“能不能往下翻”,而不是“总共多少页”。用
has_next = len(users) == pageSize判断更轻量、更准确
唯一需要 total 的场景是后台管理类接口,且数据变动极少——这时才值得单独跑一次 db.Model(&User{}).Where(...).Count(),但要确保条件和主查询完全一致,别复用带 Where("id > ?") 的链式 db 实例。


















