后端接口拉列表几乎都要分页。最常见的是 current + pageSize:
{
"current": 1,
"pageSize": 20
}
简单直观,但数据量大 + 频繁翻页 + 数据在变化的场景下有致命问题。生产上大厂 API(GitHub、Slack、Stripe)都在推 Cursor 分页。
Offset 分页的两个坑
1. 深度分页越来越慢
SQL 长这样:
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;
数据库要扫过前 100 万行才能扔掉、再取 20 行。1000 万条数据翻到第 500 页,实测能到秒级。
原因:MySQL 不知道”跳过 100 万行”能不能走索引——即使能,也要真的扫过去。
2. 数据漂移
分页过程中数据在增删——用户看到重复或者漏掉。
例子:翻第 1 页看到 20 条 → 有人删了第 5 条 → 翻第 2 页时,原本第 21 条变成了第 20 条,被跳过。
对于列表实时更新的场景(订单、动态、评论流),Offset 分页几乎必然会有这种漏项。
Cursor 分页(Keyset)
思路:用”上一页最后一条的位置”作为下一页起点,不用 offset。
-- 第一页
SELECT * FROM products
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 第二页(用上一页最后一条的 created_at + id 作为起点)
SELECT * FROM products
WHERE (created_at, id) < ('2026-06-04 13:00:00', 998765)
ORDER BY created_at DESC, id DESC
LIMIT 20;
关键点:
- 有索引的排序字段(
created_at、id) - 元组比较处理并列时间
- 走索引 range scan,速度不随深度衰减
接口设计
Offset 版本:
// 请求
{ "current": 3, "pageSize": 20 }
// 响应
{
"list": [...],
"total": 12345,
"current": 3,
"pageSize": 20
}
Cursor 版本:
// 请求
{ "cursor": "eyJ0IjoxNzE3NDg...", "pageSize": 20 }
// 响应
{
"list": [...],
"nextCursor": "eyJ0IjoxNzE3NDg2...",
"hasMore": true
}
cursor 通常是 Base64 编码的 {time, id} 之类的组合,客户端不需要理解内容,直接透传。
Cursor 具体怎么生成
import base64, json
def encode_cursor(last_row):
payload = {"t": last_row.created_at.isoformat(), "id": last_row.id}
return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
def decode_cursor(cursor):
return json.loads(base64.urlsafe_b64decode(cursor))
# API 侧
if cursor:
c = decode_cursor(cursor)
rows = db.execute("""
SELECT * FROM products
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT ?
""", [c["t"], c["id"], page_size])
else:
rows = db.execute("""
SELECT * FROM products
ORDER BY created_at DESC, id DESC
LIMIT ?
""", [page_size])
next_cursor = encode_cursor(rows[-1]) if len(rows) == page_size else None
两者对比
| 维度 | Offset | Cursor |
|---|---|---|
| 简单度 | ✅ 直观 | 需要理解元组比较 |
| 深度分页性能 | ❌ 越来越慢 | ✅ 常数时间 |
| 支持随机跳页 | ✅ 可以直接第 N 页 | ❌ 只能顺序翻 |
| 数据变化时稳定 | ❌ 漏项 / 重复 | ✅ 稳定 |
| 需要 total 计数 | ✅ 好算 | ❌ 通常不返回 |
| 支持排序 | 任意 | 排序字段必须唯一 + 有索引 |
什么时候用哪个
用 Offset:
- 后台管理系统的表格(数据不常变、用户会跳页)
- 数据总量不大(几千到几万条)
- 客户端要显示”第 5 / 100 页”这种明确导航
用 Cursor:
- 时间流列表(订单、聊天、动态、日志)
- 数据量大(十万+)
- 客户端做”上拉加载更多”(不需要跳页)
- 数据实时增删
两个都做:GitHub API 大多支持 ?page=N(Offset)和 ?before=cursor(Cursor),场景多的时候都提供。
顺带:total 慢查询
Offset 分页返回 total 时,SELECT COUNT(*) 在千万级表上也会慢。优化:
- 缓存 total(Redis,几分钟一次异步刷新)
- 估算 total(PostgreSQL
pg_class.reltuples、MySQLINFORMATION_SCHEMA.TABLES) - 只算前几页的精确 total,之后返回
total: null或hasMore: true就够
一句话总结
大数据 + 时间流用 Cursor(速度不衰减、结果稳定)、后台管理小表格用 Offset(简单、支持跳页)。别把 Offset 用在千万级实时数据的 API 上。
