游标分页能避免OOM是因为它用WHERE+ORDER BY跳过已读数据,而非扫描丢弃前N行。OFFSET会强制数据库加载并丢弃大量数据,导致内存飙升;游标则通过索引快速定位,内存占用恒定。

游标分页为什么能避免OOM
因为 OFFSET 不是跳过,而是强制数据库扫描并丢弃前 N 行——哪怕你只想要 20 条,OFFSET 100000 会让 MySQL 或 PostgreSQL 真实读取、比较、丢弃 10 万行。这不仅慢,还会把大量数据加载进内存缓冲区,高并发时直接触发数据库 OOM 或连接超时。
游标分页绕开了这个机制:它用 WHERE id > ?(或 created_at > ?)加 ORDER BY,让数据库直接定位到“下一个位置”,只扫描增量数据。只要排序字段有索引,B+ 树就能快速定位,内存占用稳定在常数级。
- 常见错误现象:
db.Offset(99999).Limit(20)在压测中导致 MySQL 内存使用飙升、连接池耗尽 - 关键前提:游标值必须来自上一页最后一条记录的排序字段,且该字段需有索引(
id最稳,created_at必须配id防重复) - 前端不能传
page=50,必须传last_id=12345(或cursor=MTIzNDU6MjAyNi0wNy0yMQ==编码后)
怎么写一个不翻车的游标查询
手写游标分页不是拼个 WHERE 就完事,错一个细节就可能漏数据或重复。核心逻辑分两步:首次请求不带游标,后续请求用上一页末尾值继续查。
- 首次请求:
db.Order("id ASC").Limit(20).Find(&users)—— 注意必须显式Order,否则结果不可重现 - 后续请求:
db.Where("id > ?", lastID).Order("id ASC").Limit(20).Find(&users)——lastID必须是users[len(users)-1].ID,不能从中间或随机取 - 如果排序用
created_at DESC,条件要改成created_at < ?,且建议用datetime(3)或bigint时间戳避免精度问题 - 禁止在游标分页里用
Preload:关联数据应先查出 ID 列表,再用IN批量加载,否则JOIN会破坏游标语义
游标值校验和边界处理不能省
前端传来的 last_id 是不受信任的输入,不校验就会变成注入入口或静默失败点。
-
last_id为负数、零、非数字字符串时,应直接返回400 Bad Request,而不是 fallback 到第一页——后者会让用户误以为数据“从头开始”,实际可能漏掉中间段 - 当查询结果为空(
len(users) == 0),说明已到底部,但不能简单返回空数组;应明确告知"has_next": false,避免前端无限重试 - 如果业务允许,游标建议用复合值编码(如
id:created_at拼接后 base64),防止客户端篡改单个字段破坏排序一致性 - 别在 URL 中裸露
last_id=12345,尤其涉及敏感数据时;可用加密 token 或服务端映射表做一层隔离
Count 总数在游标分页里要不要查
游标分页天然不支持“总页数”,COUNT(*) 会抵消掉游标带来的性能优势。是否查总数,取决于业务真实需求。
- 绝大多数面向用户的场景(App 无限滚动、Feed 流)根本不需要总数——只需
has_next布尔值,查Limit(21)然后判断第 21 条是否存在即可 - 如果真要总数,必须手写子查询,且确保和主查询条件完全一致:
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users WHERE status = ? AND id > ?) AS t", status, lastID).Scan(&total) - 大数据量下,
COUNT(*)查询本身可能比主查询还慢;此时更推荐用近似值(如 MySQL 的TABLE_ROWS)或直接放弃总数字段 - 切记:游标分页里绝不能复用同一个
*gorm.DB实例去Count(),它会继承前面的Where("id > ?"),导致总数永远偏小
last_id 转成 page、或在前端缓存游标后长期复用的行为,都会在数据高频变动时悄悄引入偏差。


















