分页接口设计:offset/limit 与游标对比
一句话结论:offset/limit 适合“能跳页、要总数”的管理后台:SELECT ... ORDER BY id LIMIT n OFFSET m,实现简单,但深翻页要扫描并丢弃前面所有行(越翻越慢),且数据变动时会出现重复/漏行;游标分页(keyset)适合 feed/无限滚动:WHERE id > 上次位置 ORDER BY id LIMIT n,每页成本恒定、结果稳定,代价是不能跳页、多条件排序要用复合游标。选择口诀:后台表格用 offset,客户端时间线用 cursor,超大表禁止深 offset。
两种方式对比
| 维度 | offset/limit | cursor(keyset) |
|---|---|---|
| 实现 | LIMIT n OFFSET m | WHERE 排序列 > last 值 LIMIT n |
| 跳页 | 支持 | 不支持 |
| 深分页成本 | O(offset+n),越深越慢 | 每页成本相近 |
| 并发写稳定性 | 可能重复/漏行 | 稳定,新数据只影响首页 |
| 总数 | 可 COUNT(*)(大表也贵) | 一般不返回总数 |
| 适用 | 后台列表、报表 | 信息流、聊天记录、实时榜单 |
offset 参数处理
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page < 1 { page = 1 }
if size < 1 || size > 100 { size = 20 } // 上限防拖库
db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&list)
游标分页
// 响应里带 nextCursor=最后一条 id;请求 ?cursor=xxx
db.Where("id < ?", cursor).Order("id DESC").Limit(size).Find(&list)
if len(list) == size { nextCursor = list[len(list)-1].ID } // 还有下一页
- 复合排序(如按 updated_at 再按 id)时游标形如 base64(updated_at + "|" + id),WHERE (updated_at,id) < (?,?) 元组比较,保证唯一稳定序。
- 游标值对客户端是“不透明令牌”,服务端解析并校验,别让客户端拼 SQL 片段。
追问记忆点
- 追问:深 offset 为什么慢?——数据库要先把前 offset 行全找出来再丢掉,offset=100 万时扫描量巨大。
- 追问:cursor 分页还能翻回上一页吗?——原生不行,需要反向游标或另存位置快照;交互需要双向时往往回到 offset。
- 追问:列表总数 COUNT(*) 很慢怎么办?——后台可缓存计数或接受近似;cursor 场景根本不需要总数。
- 记忆点:跳页要总数→offset;feed 无限滚→cursor;排序字段唯一化(加 id)是稳定分页的前提。