游标分页不丢数据不重复的关键是用排序字段的值代替offset,如用id>lastid替代offset;主键最稳,时间排序需用created_at, id组合防重复。

为什么 db.Offset(100000).Limit(20) 越来越慢
不是 GORM 写得不好,是 MySQL/PostgreSQL 执行 OFFSET 100000 时必须从头扫描、排序、丢弃前 10 万行——哪怕你只想要 20 条。每翻一页,I/O 和 CPU 开销线性上涨;OFFSET 500000 时,EXPLAIN 显示 rows 值常超 50 万,Handler_read_next 暴涨,慢查询日志里 query_time 从几毫秒跳到 2 秒以上。
即使 id 有索引,B+ 树也无法直接跳转到第 N 行,仍需逐节点计数跳转;高并发下还可能因 MVCC 版本不一致,同一页刷新两次返回不同结果或漏数据。
游标分页怎么写才不丢数据也不重复
核心是“用值代替偏移”,靠排序字段的单调性和唯一性锚定位置。最稳的是主键 id,其次是 created_at DESC, id DESC 组合(防时间重复)。
- 第一请求:
db.Order("id ASC").Limit(20).Find(&users),拿到最后一条的users[len(users)-1].ID - 后续请求:
db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users),lastID必须由前端原样传入,后端不做任何转换 - 若用时间排序,条件要写成复合判断:
db.Where("(created_at -
lastID必须校验:非数字、负数、空字符串都应直接返回400 Bad Request,不能静默 fallback 到第一页
后台管理还要跳页怎么办:延迟关联怎么配索引
游标分页无法跳转任意页码,但后台系统常需输入页码。这时用延迟关联(Deferred Join)是最务实的 SQL 层补救方案。
原查询:SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 100000, 20 —— 慢在回表太多。
优化后:SELECT o.* FROM orders o INNER JOIN (SELECT id FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 100000, 20) t ON o.id = t.id
关键点:
- 必须建联合索引:
CREATE INDEX idx_status_created ON orders(status, created_at DESC),子查询才能走覆盖索引 - 子查询里只查
id,避免回表;外层 JOIN 只做一次批量主键匹配 - 如果
SELECT *字段多且大,该方案可将 I/O 降低 60% 以上,但仍是权宜之计,OFFSET 超过 10 万后仍可能超时
GORM 中 Count(*) 总数统计常踩的三个坑
总数不准,往往不是 SQL 写错,而是链式调用残留条件或忽略执行时机。
- 别对带
Offset/Limit的 DB 实例直接调Count(&total),GORM 会忽略这些方法,但 WHERE 条件可能残留,导致总数和分页结果不匹配 - 正确做法:另起一个干净的
db.Model(&Order{}),复用相同Where条件,再调Count;例如:db.Model(&Order{}).Where("status = ?", "paid").Count(&total) - 如果业务允许,干脆不返回总页数——很多列表接口根本不需要
total,前端只显示“下一页”按钮,后端用游标分页 + 空结果判断即可
游标分页看似只改了 WHERE 条件,但背后依赖排序字段的索引质量、值分布、是否单调。最容易被忽略的是:时间字段做游标时没加主键兜底,或前端传参没校验类型,一整页数据就悄无声息地重复或跳过了。











