使用 Codex 编写列表接口时,分页几乎是最常见的需求之一。
刚开始数据只有几百条时,下面这种写法通常没有明显问题:
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
第二页:
LIMIT 20 OFFSET 20;
第三页:
LIMIT 20 OFFSET 40;
但当订单表增长到几十万甚至几百万条以后,问题会逐渐出现:
-
第一页很快,翻到第1000页明显变慢;
-
用户连续翻页时出现重复数据;
-
刚插入一条新记录,下一页突然少了一条;
-
同一条记录在两个页面重复出现;
-
ORDER BY created_at后分页结果偶尔变化; -
Codex为了修复重复数据,开始增加复杂的前端去重逻辑。
这类问题很多时候不是前端分页组件出了问题,而是底层分页方式本身不适合持续变化的大数据表。
一、OFFSET分页为什么越往后越慢?
假设要读取第10000页,每页20条:
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 199980;
数据库并不是直接"跳到第199980条"。
很多情况下,它仍然需要找到前面的数据,然后将大量记录跳过,最终只返回20条。
可以简单理解成:
找到前200000条
↓
丢掉前199980条
↓
返回最后20条
所以随着 OFFSET 越来越大,需要扫描和丢弃的数据也越来越多。
第一页:
OFFSET 0
通常很快。
第10000页:
OFFSET 199980
成本就完全不同了。
二、OFFSET为什么会导致重复数据?
假设当前数据按创建时间倒序:
A
B
C
D
E
F
每页3条。
第一页:
A
B
C
用户查看第一页期间,又产生了一条新记录:
X
A
B
C
D
E
F
然后用户请求第二页:
LIMIT 3 OFFSET 3
结果可能变成:
C
D
E
C 已经在第一页出现过一次。
于是用户看到:
第一页:A B C
第二页:C D E
问题不是数据库返回错了,而是两次分页请求之间,数据集合发生了变化。
三、数据删除也可能导致漏数据
还是原来的列表:
A
B
C
D
E
F
第一页:
A
B
C
此时A被删除:
B
C
D
E
F
用户请求:
OFFSET 3
数据库会从新的第4条开始:
E
F
于是:
D
直接被跳过去了。
这就是典型的 OFFSET 分页数据漂移。
四、Cursor Pagination是什么?
Cursor Pagination,也叫游标分页。
它不再告诉数据库:
跳过前面多少条。
而是告诉数据库:
从上一页最后一条数据之后继续。
例如第一页:
SELECT *
FROM orders
ORDER BY id DESC
LIMIT 20;
假设最后一条:
id = 9850
下一页直接查询:
SELECT *
FROM orders
WHERE id < 9850
ORDER BY id DESC
LIMIT 20;
再假设第二页最后一条:
id = 9830
下一页:
WHERE id < 9830
这样不需要扫描并跳过大量历史数据。
五、Cursor为什么更适合不断增长的数据?
假设第一页读取:
100
99
98
游标:
98
此时又插入:
101
列表变成:
101
100
99
98
97
96
95
用户下一页仍然执行:
WHERE id < 98
ORDER BY id DESC
LIMIT 3;
得到:
97
96
95
新插入的101不会影响已经确定的分页边界。
这就是 Cursor Pagination 比 OFFSET 更稳定的原因之一。
六、不要只用created_at作为游标
很多Codex生成的代码会这样写:
WHERE created_at < ?
ORDER BY created_at DESC
LIMIT 20;
这看起来合理,但存在一个问题:
多条数据可能拥有相同的 created_at。
例如:
id=100 created_at=10:00:00
id=99 created_at=10:00:00
id=98 created_at=10:00:00
如果上一页最后一条是:
id=99
created_at=10:00:00
下一页只使用:
created_at < 10:00:00
那么:
id=98
可能直接被漏掉。
七、使用"时间 + ID"作为复合游标
更稳定的方式是:
created_at
+
id
排序:
ORDER BY created_at DESC, id DESC
下一页查询:
WHERE
created_at < :createdAt
OR (
created_at = :createdAt
AND id < :id
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这样即使多条数据创建时间完全相同,也可以通过 id 保证稳定顺序。
分页系统中有一个非常重要的原则:
排序必须稳定。
如果两次查询对相同数据的顺序可能变化,分页结果就很容易出现重复或遗漏。
八、Cursor不要直接暴露复杂数据库字段
接口可以返回:
{
"items": [],
"nextCursor": "..."
}
而不是让前端自己组合:
createdAt + id
服务端可以将:
{
"createdAt": "2026-08-11T08:00:00Z",
"id": 9850
}
编码成 Cursor。
例如经过 Base64 或其他可逆编码后:
eyJjcmVhdGVkQXQiOi...
下一次请求:
GET /api/orders?cursor=eyJjcmV...
服务端负责解析。
这样未来即使分页字段发生变化,前端也不需要知道数据库具体结构。
九、Cursor必须进行校验
不能直接信任客户端传来的 Cursor。
需要检查:
-
是否可以正确解析;
-
是否包含必要字段;
-
时间格式是否合法;
-
ID是否为正确类型;
-
Cursor是否属于当前查询条件;
-
是否已经过期。
错误 Cursor 应返回明确的参数错误,而不是直接拼进 SQL。
如果使用字符串拼接:
const sql = `
WHERE id < ${cursor}
`;
还可能引入新的安全风险。
应该继续使用数据库参数绑定。
十、筛选条件改变后必须重置Cursor
例如用户当前查看:
status=paid
第一页返回:
cursor=A
随后用户把筛选条件改为:
status=cancelled
此时不能继续使用原来的:
cursor=A
因为这个 Cursor 是基于旧查询结果生成的。
更合理的规则是:
筛选条件变化
↓
排序变化
↓
搜索关键词变化
↓
Cursor全部重置
否则很容易出现:
-
空页;
-
数据跳跃;
-
部分结果缺失。
十一、Cursor分页不适合所有场景
Cursor 并不是 OFFSET 的完全替代品。
如果产品明确需要:
直接跳到第68页
Cursor Pagination 就不太方便。
因为游标天然擅长:
上一页
下一页
继续加载
无限滚动
而不是随机定位某个页码。
因此可以根据业务选择。
OFFSET适合
后台管理
数据量不大
必须跳页
数据变化不频繁
Cursor适合
信息流
订单列表
动态内容
聊天记录
大数据表
无限滚动
实时变化列表
分页方式应该根据实际使用场景决定,而不是看到 Cursor 更先进就全部替换。
十二、分页性能仍然需要索引
把 OFFSET 改成 Cursor 后,如果查询字段没有索引,性能仍然可能很差。
例如:
WHERE
created_at < ?
ORDER BY created_at DESC, id DESC
可以评估对应的复合索引:
CREATE INDEX idx_orders_created_id
ON orders(created_at DESC, id DESC);
如果还带有:
WHERE status = 'paid'
可能需要结合真实查询模式重新设计索引。
不要直接让 Codex:
给所有字段都加索引。
索引越多并不一定越好,因为它还会增加:
-
INSERT成本;
-
UPDATE成本;
-
磁盘占用;
-
维护成本。
应该通过执行计划确认瓶颈。
十三、不要用前端Set去掩盖重复数据
遇到重复分页时,一个很常见的"修复"是:
const uniqueItems = Array.from(
new Map(
items.map(item => [item.id, item])
).values()
);
这确实可以让页面不显示重复项。
但如果根本原因是服务端分页边界错误,那么问题仍然存在。
更严重的是:
前端去重以后,原本应该显示20条:
收到20条
↓
删除3条重复
↓
页面只有17条
开发者还可能误以为是数据库少返回了数据。
因此:
前端去重可以作为保护措施,但不能替代正确的分页设计。
十四、测试分页必须模拟数据变化
普通测试可能只是:
插入100条
→ 请求第一页
→ 请求第二页
这种测试无法发现真实问题。
应该增加:
场景1:翻页过程中插入数据
请求第一页
→ 插入新记录
→ 请求第二页
→ 不允许重复
场景2:翻页过程中删除数据
请求第一页
→ 删除前面记录
→ 请求第二页
→ 不允许漏掉原有后续记录
场景3:相同created_at
插入多条相同时间的数据,确认:
无重复
无遗漏
场景4:筛选条件变化
paid第一页
→ 切换cancelled
→ Cursor必须重置
这些测试比单纯验证 LIMIT 20 更有价值。
十五、让Codex先分析当前分页方式
遇到分页问题时,可以先这样要求:
请先不要修改代码。
检查当前分页实现并输出:
1. 使用OFFSET还是Cursor;
2. 排序字段是什么;
3. 排序是否稳定;
4. 是否存在相同排序值;
5. 数据变化后是否会重复或遗漏;
6. 当前查询是否命中索引;
7. 是否适合切换Cursor Pagination。
先确认问题,再决定是否重构分页接口。
不要看到"分页慢"就直接大范围修改数据库和前端。
十六、把分页规则写进AGENTS.md
长期项目可以加入:
# 分页规则
- 大数据动态列表优先评估Cursor Pagination
- Cursor排序必须稳定
- 时间排序建议增加唯一ID作为第二排序字段
- 筛选或排序变化必须重置Cursor
- Cursor必须由服务端解析和校验
- 禁止直接拼接Cursor到SQL
- 分页重复问题不能只通过前端去重解决
- 索引必须根据真实查询和执行计划设计
- 修改分页后必须测试插入、删除和相同时间数据
这样 Codex 在新增列表接口时,就会更主动考虑真实数据变化。
十七、Plus还是Pro?
如果主要使用 Codex 处理:
-
单个分页接口;
-
普通 SQL;
-
中小型数据表;
-
简单索引分析;
-
单模块测试;
Plus 通常已经能够覆盖多数需求。
如果长期维护:
-
大型数据库;
-
多个列表接口;
-
高访问量系统;
-
大量 SQL 与执行计划分析;
-
前后端分页联动;
-
多轮性能测试;
则可以根据实际使用强度评估 Pro。
对于这种工程任务,Pro 的价值更多是让数据库分析、代码修改和多轮验证保持连续。
不过无论使用哪个方案,分页性能最终仍然取决于正确的数据模型、排序规则和索引设计。
总结
Codex 写出的分页接口越翻越慢、出现重复或漏数据,很多时候并不是分页组件本身的问题,而是 OFFSET 在动态大数据集上的天然限制。
通过 Cursor Pagination、稳定排序、复合游标和合理索引,可以让分页从:
第几页?
转变为:
从上一条数据之后继续读取。
对于不断增长和变化的列表,这是更加稳定的思路。
真正可靠的分页设计,不只是第一页加载得快,而是在用户不断翻页、数据持续新增和删除的情况下,仍然能够做到:
不重复、不遗漏,而且性能不会随着页码增长快速下降。
CSDN文章描述
本文介绍 Codex 编写分页接口时常见的 OFFSET 性能和数据漂移问题,并通过 Cursor Pagination、稳定排序、复合游标和数据库索引解决分页重复、漏数据与深分页性能下降。