最近让ChatGPT、Codex优化老项目时,我发现有一类改动特别容易产生误判:
SQL性能确实提升了,但业务结果不一定还是原来的结果。
比如一个订单列表,数据库里已经有几百万条记录。
原来的接口使用OFFSET分页。
第一页很快。
第100页还能接受。
但翻到第10000页,SQL执行时间明显增加。
Codex分析执行计划以后,建议把传统OFFSET分页改成Cursor Pagination,也就是游标分页。
改造完成后,效果非常明显。
原来深分页查询可能需要几百毫秒,现在通过索引定位,几十毫秒就能返回。
数据库扫描量下降。
接口P99改善。
测试全部通过。
看起来是一次成功的性能优化。
但真正上线以后,用户开始反馈两个问题:
有些订单连续出现两次。
有些订单翻了很多页,始终看不到。
更奇怪的是,单独查询这些订单,它们确实存在。
数据库没有丢数据。
接口也没有报错。
这时候真正需要怀疑的,不再是SQL性能,而是:
Agent把分页实现换掉以后,是否同时改变了原来的数据遍历语义。
这也是分页重构中非常容易被忽略的问题:
Query Latency下降,不代表Pagination Correctness仍然成立。
一、先给核心判断:分页不是简单地每次查询20条
很多人理解分页时,会把它看成一种SQL性能问题。
每次返回20条。
第一页20条。
第二页再返回20条。
直到没有数据。
但真正的分页不只是限制返回数量。
它还要回答三个问题:
第一,数据按什么规则排序?
第二,下一页应该从哪里开始?
第三,数据在翻页过程中发生变化时,结果应该如何处理?
OFFSET和Cursor虽然都能完成分页,但回答这些问题的方式不同。
OFFSET主要依赖位置。
Cursor主要依赖排序键。
所以把OFFSET改成Cursor,并不是简单地把:
LIMIT 20 OFFSET 10000
替换成:
WHERE id < 10000 LIMIT 20
真正改变的是:
下一页如何确定自己的起点。
如果起点规则不稳定,查询再快,也可能产生重复和漏数据。
二、OFFSET为什么在深分页场景容易变慢?
假设订单表已经有500万条记录。
接口按照创建时间倒序排列,每页20条。
传统分页可能使用:
ORDER BY created_at DESC LIMIT 20 OFFSET 200000
数据库要取得靠后的20条记录,通常仍然需要处理前面大量符合条件的排序记录。
即使查询能够利用索引,大OFFSET也可能带来明显的扫描和跳过成本。
因此随着页码增加,查询成本往往持续上升。
Cursor Pagination则换了一种思路。
如果上一页最后一条记录的排序位置已经确定,下一页可以直接从这个位置继续查询。
在排序字段和索引设计合理的情况下,就不需要反复跳过前面大量数据。
所以Cursor对连续向后翻页尤其有价值。
但要注意:
Cursor并不天然比OFFSET快。
如果过滤条件没有合适索引,排序字段无法有效利用索引,或者查询本身存在复杂关联,Cursor同样可能很慢。
性能收益来自合适的查询方式和索引,而不是因为SQL里出现了Cursor这个概念。
三、第一个高频错误:只用created_at作为游标
这是Agent改造分页时,我特别建议检查的地方。
假设订单按创建时间倒序排列。
Codex把下一页条件写成:
WHERE created_at < :lastCreatedAt
看起来完全合理。
上一页最后一条记录是什么时间,下一页就继续查询更早的数据。
但这里隐藏了一个前提:
created_at必须能够唯一确定一条记录的位置。
现实中,这个前提往往不成立。
例如三条订单:
订单A:10:00:00,ID为103。
订单B:10:00:00,ID为102。
订单C:10:00:00,ID为101。
如果每页只返回两条,第一页可能取到A和B。
最后一条记录的创建时间是10:00:00。
下一页继续执行:
created_at < '10:00:00'
那么订单C怎么办?
它的创建时间同样是10:00:00。
不满足小于条件。
于是直接被跳过。
这就是为什么:
SQL没有报错,Cursor也正常传递,但分页仍然会漏数据。
问题出在排序键无法唯一确定位置。
四、为什么把小于改成小于等于也不行?
有人发现同一时间的记录被跳过以后,会很自然地把条件改成:
created_at <= :lastCreatedAt
这样确实能继续查到同一时间的数据。
但新的问题马上出现:
上一页已经返回过的A和B,也符合这个条件。
于是下一页可能再次返回它们。
结果就变成:
修复漏数据以后,又开始重复数据。
所以问题根本不是:
应该使用 < 还是 <=。
真正缺少的是:
一个能够确定唯一顺序的排序规则。
这也是为什么游标分页必须把排序语义设计完整,而不能只随便选择一个时间字段。
五、真正稳定的做法:使用复合排序键
如果业务要求按照创建时间倒序展示订单,可以增加唯一ID作为第二排序条件。
例如:
SELECT id, created_at, amount, status
FROM orders
WHERE status = 'PAID'
AND (
created_at < :cursorTime
OR (
created_at = :cursorTime
AND id < :cursorId
)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这里假设:
id唯一。
created_at非空。
排序方向固定为倒序。
第一页不需要游标条件,后续页面才使用上一页最后一条记录的时间和ID。
这样即使多条订单拥有完全相同的创建时间,也能通过ID继续确定先后顺序。
核心思路就是:
created_at负责业务排序,id负责消除排序歧义。
注意,游标条件必须与ORDER BY的方向对应。
如果排序改成升序,比较方向也需要调整。
如果业务排序包含更多字段,游标通常也需要包含足以确定位置的对应排序键。
所以Cursor不是简单保存:
"最后一个ID"。
而是保存:
上一页最后一条记录在完整排序规则中的位置。
六、第二个高频问题:排序字段本身会变化
即使已经使用:
created_at + id
作为复合游标,也不能认为所有问题都解决了。
如果业务实际按照:
updated_at DESC
排序,就需要考虑记录更新以后发生什么。
假设用户已经翻过订单A。
随后订单A状态发生变化,更新时间被修改。
它可能移动到整个列表更靠前的位置。
反过来,一条尚未浏览的记录,如果排序位置发生移动,也可能跨越当前游标边界。
这就可能造成:
重复出现。
跳过记录。
或者用户翻页期间列表顺序发生变化。
因此分页设计里还有一个非常重要的问题:
排序字段是不是稳定的?
对于需要完整遍历的数据,如果排序键会在分页期间频繁修改,就不能只依赖普通Cursor保证全程无重复、无遗漏。
这不是SQL语法错误。
而是动态数据集本身带来的语义问题。
七、第三个问题:翻页期间不断有新数据插入
假设用户正在查看订单列表。
第一页查询完成。
这时系统又新增了几十条订单。
用户继续请求第二页。
结果应该怎样?
不同业务可能有不同答案。
对于普通信息流,通常可以接受:
新内容先不进入当前连续翻页过程,用户刷新后再看。
但如果是财务导出、审计记录或者批量处理,就可能要求:
这一次分页任务必须覆盖同一批确定的数据。
这两个场景不能使用完全相同的正确性标准。
尤其需要注意,Cursor Pagination并不自动提供数据库快照。
即使使用稳定的复合排序键,也只能改善位置定位和排序稳定性。
它不能保证多次HTTP请求期间,数据库内容始终不发生变化。
所以真正需要先明确的是:
业务要求实时列表,还是固定数据集遍历?
这个问题必须在改造之前回答。
八、什么时候需要Snapshot语义?
如果只是用户浏览商品或订单列表,允许看到一个不断变化的数据集合,那么可以明确采用动态分页语义。
但如果业务要求:
一次性导出全部订单。
执行全量账单核对。
批量更新符合条件的数据。
遍历某个时点的全部记录。
就不能简单依赖一个Cursor完成严格一致性保证。
一种思路是在任务开始时确定数据边界,例如最大ID或业务截止时间。
但这种方式也有局限。
固定时间上界,只能约束部分新增数据,并不能自动冻结已有数据的修改和删除。
如果确实要求严格的快照一致性,就需要评估数据库快照、版本化记录或者预先固化结果集等机制。
跨多次HTTP请求长期保持数据库事务快照,通常也不是适合随意采用的方案。
所以Cursor解决的是:
如何高效继续遍历。
Snapshot解决的是:
整个遍历过程面对的是不是同一份确定的数据。
这两者不能混为一谈。
九、第四个容易忽略的问题:下一页过滤条件变了
例如第一页查询条件是:
已支付订单。
按照创建时间倒序排列。
用户翻到第二页时,前端却重新提交了不同的状态条件。
或者某个默认筛选参数发生变化。
此时继续使用上一页Cursor,结果就可能出现异常。
因为:
Cursor是针对特定查询条件和排序规则生成的位置。
查询条件发生变化以后,原来的游标不一定还有正确意义。
所以真正成熟的设计应该明确:
同一次连续分页过程中,过滤条件必须保持一致。
如果用户切换:
订单状态。
时间范围。
排序方向。
搜索关键词。
就应该重新开始分页,而不是继续沿用旧Cursor。
对于跨服务API,还可以把必要的查询上下文编码进不透明游标,并校验其完整性。
但要记住:
游标不能代替权限验证。
服务端仍然必须独立确认当前用户能够访问哪些数据。
十、索引也必须跟着分页规则一起调整
这是性能优化不能忽略的一层。
如果SQL按照:
status、created_at、id
进行过滤和排序,就需要评估对应的复合索引。
例如某些场景可以考虑:
(status, created_at DESC, id DESC)
如果系统是多租户结构,还可能需要把租户过滤列纳入索引设计。
但索引并不是越多越好。
是否真正有效,需要结合查询条件、数据分布和实际执行计划判断。
我更倾向让ChatGPT、Codex同时做两件事:
一是证明分页结果正确。
二是通过EXPLAIN和真实数据量验证查询效率。
否则很容易出现:
SQL逻辑终于修正确了。
但新条件无法高效利用索引。
最终深分页性能又退回原点。
真正成功的改造必须同时满足:
Correctness + Performance。
十一、Agent为什么特别容易漏掉这些边界?
因为从局部代码看,分页改造非常容易完成。
找到LIMIT和OFFSET。
选择一个排序字段。
增加WHERE条件。
保存最后一条记录的游标。
修改接口参数。
然后跑测试。
如果测试数据只有几十条,并且每条记录的创建时间都不一样,那么几乎不会暴露问题。
尤其很多Agent生成的测试只验证:
第一页返回20条。
第二页也返回20条。
接口状态码为200。
但没有验证:
两页之间是否存在重复记录,是否漏掉本应返回的记录。
所以分页测试的重点不能只是:
每页能不能正常返回。
而应该是:
把所有页面拼起来以后,最终是不是仍然得到业务预期的数据集合。
十二、我更推荐先做完整遍历验证,再上线
如果让Codex完成这次分页重构,我会要求它至少准备几类测试场景。
第一,同一时间戳下存在大量记录,超过一页容量。
第二,订单数量不是分页大小的整数倍。
第三,连续翻到最后一页,检查完整结果集。
第四,分页过程中插入、删除或更新记录,验证是否符合约定的动态数据语义。
第五,修改筛选条件和排序方向,确认旧Cursor不会被错误复用。
对于稳定测试数据,可以先通过一条基准查询得到预期结果集。
然后调用分页接口,从第一页开始持续请求,直到没有下一页。
最后将所有页面拼接起来。
逐条比较:
数量是否一致。
ID是否重复。
是否存在遗漏。
顺序是否正确。
这里最关键的不是跑了多少个测试,而是:
有没有真正验证跨页结果之间的关系。
十三、这篇我建议只看一个指标:Pagination Consistency Rate
可以定义一个工程自测指标:
Pagination Consistency Rate------分页结果一致率。
计算方式:
满足预期完整性规则的分页测试场景数 ÷ 全部分页验证场景数。
例如设计20组测试。
其中包含:
同时间戳。
边界页。
不同过滤条件。
稳定数据全集遍历。
并发插入与更新。
最终18组符合各自约定的分页结果。
那么分页结果一致率就是90%。
需要强调的是,这属于团队自定义的验证指标,并不是某种通用数据库标准。
而且必须先为不同场景规定正确性要求。
稳定数据集可以要求:
不重复、不遗漏、顺序正确。
动态数据集则应该根据业务规则判断,不能直接拿最终数据库全集作为唯一答案。
这个指标最大的价值,是让团队从:
"单次分页查询成功率"
转向:
"连续分页是否保持约定的数据语义"。
十四、Cursor Pagination不是所有场景都应该使用
如果业务就是连续下拉:
订单列表。
消息列表。
日志浏览。
时间流。
Cursor Pagination通常值得优先考虑。
但如果业务要求:
直接跳到第500页。
展示精确总页数。
支持任意页码定位。
那么传统OFFSET分页在交互上可能更加直接。
游标分页天然更适合:
上一页 → 下一页
这种连续遍历方式。
它并不擅长在没有额外索引或游标定位机制的情况下直接跳到任意页面。
所以让Agent进行分页重构之前,还应该先确认:
业务交互是否允许从页码分页变成游标分页。
否则SQL变快了,前端却发现原来的跳页功能无法继续实现。
这也是工程改造中很容易被忽略的兼容性问题。
十五、ChatGPT、Codex在这里真正应该怎么用?
我不太建议直接给Codex一句:
把OFFSET分页优化成Cursor Pagination。
更合理的做法,是先让它分析:
当前SQL完整排序规则是什么。
哪些字段可以确定唯一顺序。
排序字段会不会更新。
哪些过滤条件必须保持一致。
是否允许翻页期间数据变化。
前端是否依赖总页数和任意跳页。
现有索引是否支持新的查询方式。
然后再生成SQL和接口代码。
改完以后,继续让它完成:
固定数据集遍历验证 → 重复/遗漏检测 → 动态数据测试 → 执行计划分析 → 性能对比。
这样Agent的任务就不只是:
让SQL更快。
而是:
在保持业务语义的前提下,让SQL更快。
这才是工程重构真正需要的结果。
十六、Plus和Pro怎么判断?
如果你平时只是偶尔让ChatGPT、Codex:
分析一条慢SQL。
优化一个分页接口。
检查索引。
补少量分页测试。
这类任务范围相对集中,Plus通常已经够用。
如果你的日常已经变成:
大型数据库持续优化。
多个列表接口需要改造。
同时分析SQL、索引、Java Repository、API协议、前端Cursor和完整回归测试。
还要让Codex在大数据量下反复修改、验证和比较执行计划。
这种多模块、长时间、高频的工程任务已经成为常态,Pro会更适合。
真正的判断标准不是订单表有多少万行,而是:
ChatGPT、Codex是不是已经持续参与完整的数据库性能和数据正确性改造流程。
最后
Agent把OFFSET分页改成游标分页,为什么查询更快了,数据反而可能重复和丢失?
因为:
分页性能和分页正确性,是两个必须分别验证的问题。
OFFSET主要按照位置分页。
Cursor主要按照排序位置继续遍历。
如果排序键不唯一,可能漏数据。
如果游标边界设计错误,可能重复数据。
如果排序字段发生变化,结果可能跨页移动。
如果查询条件变化,旧Cursor可能失效。
如果业务要求固定全集,普通Cursor也无法自动提供Snapshot保证。
所以真正成熟的改造顺序应该是:
定义分页语义 → 确定稳定排序 → 设计复合Cursor → 对齐过滤条件 → 验证动态数据边界 → 优化索引 → 完整遍历测试。
当ChatGPT、Codex越来越容易帮我们完成SQL优化以后,真正需要守住的,也不只是:
查询从500ms降到了50ms。
而是:
用户连续翻完所有页面以后,看到的仍然是业务真正希望他看到的数据。