分页翻着翻着数据重复或漏掉:从偏移分页缺陷排查到游标分页改造

2026-07-29

内容截至 2026-07。不同数据库对行值比较、索引方向、快照隔离的支持程度差异较大,文中写法请以你所用数据库的官方最新文档和实际执行计划为准。

这类问题九成不是前端渲染错了,也不是数据库返回了脏数据,而是你的分页方式默认了一个不成立的前提:两次查询之间,结果集的顺序和内容不变。 只要用 LIMIT/OFFSET 翻页,你就是在假设存在一份稳定的结果集、并按序号去切片;可事实上每一次请求都是一条独立的查询,各自看到的是各自那一刻的数据。线上数据一直在写,排序键还可能有并列,于是窗口会滑动——靠前的行被删掉,第二页就漏掉一条;靠前插入一行,第一页的末尾就会在第二页再出现一次。AI 写业务代码时默认给的就是偏移分页,测试数据又是静态的,本地怎么点都对,上线才炸。

先说清这篇的分工。如果你的症状是同一个接口的字段名、嵌套层级或空值语义变了导致解析崩溃,那是另一条线,去看 接口返回结构变了怎么排查;如果你想的是让工具在代码合并之前就把这种写法拦下来,那属于评审环节,看 AI 代码评审工具怎么用。本篇只管一件事:数据已经在翻页时出现重复或缺失,你怎么定位到成因并改掉。

一、先把「重复」和「漏掉」拆成四类成因

不要一上来就改 SQL。同样是「第 3 页出现了第 2 页的记录」,成因至少有四种,处置动作完全不同。

排序不稳定。 你写了 ORDER BY created_at DESC,而 created_at 有大量并列值(批量导入、同秒写入、精度只到秒)。关系型数据库不承诺并列行之间的相对顺序,同一条 SQL 两次执行、走不同的执行计划或不同的并行度,并列行的顺序就可能不同。这时候即使一行数据都没变,翻页照样重复和漏。这是最容易被排除掉、也最容易被误判成「数据库有 bug」的一类。

并发写入导致窗口滑动。 这是偏移分页的天然缺陷。你翻第 1 页到第 2 页之间隔了几秒,期间有新记录插到了排序靠前的位置,OFFSET 50 指向的行就整体后移一位。倒序时间列表 + 高频写入的场景,几乎必然出现。

读到了不同的数据视图。 读写分离下,第 1 页命中主库、第 2 页命中延迟副本,或者两个副本延迟不同;分库分表时 OFFSET 被下推到每个分片再归并,语义直接就是错的;再或者中间有一层按 URL 缓存的网关,某一页命中了旧缓存。

前端把同一页追加了两遍。 无限滚动的页码状态有竞态,滚动事件触发了两次相同参数的请求,返回后都往列表里 push,而列表项没有以主键做去重。这类的特征是:后端日志里同一组分页参数被请求了两次,且返回内容完全一致。

二、判别表:现象、成因、验证方法、处置

现象大概率成因怎么验证处置动作
数据没有任何写入时也能复现重复排序键不唯一,顺序不稳定直接查排序键有没有并列:SELECT created_at, count(*) FROM orders GROUP BY created_at HAVING count(*) > 1 LIMIT 10,有并列就具备了不稳定的条件;再在只读时段对同一组分页参数连查两次比对主键序列,序列不一致即坐实(相同也不能排除,只说明这次没抖)排序键补一个唯一的次序列(通常是主键),立刻缓解
只在业务高峰复现,低峰复现不了并发写入使窗口滑动在预发环境冻结写入后跑全量翻页脚本,重复与缺失归零即坐实改游标分页;导出类链路改主键区间遍历
重复的行集中在某几页,且内容像「旧版本」副本延迟或分页缓存强制整个翻页会话读主库(或粘连同一节点)后重跑同一脚本,重复消失即坐实;仍复现说明不是这一类同一分页会话绑定同一数据源;网关对分页路径关闭缓存
前端列表里出现连续成段的重复前端并发或状态竞态抓服务端访问日志看同一组分页参数是否被请求两次,再比对这两次的响应体是否完全一致;一致即坐实是前端追加了两遍,而不是后端翻错页前端以主键做 Map 合并,请求加取消控制,页码用单一状态机管理
每页返回条数忽多忽少,还提前显示「没有更多」应用层过滤(软删除、权限)在取数之后做打印每页 SQL 返回行数与过滤后行数的差值过滤条件下推到查询里;用多取一条的方式判断是否还有下一页
深翻页越往后越慢,超时后重试又拿到重复数据偏移分页要先扫过再丢弃前 N 行,代价随偏移量增长;慢又拉长了两次请求的间隔,窗口滑动的概率随之变大固定 limit,把 offset 从小到大取几档跑同一条查询,看执行计划的扫描行数和耗时是不是跟着偏移量一起涨换游标分页;这条同时也是性能问题,见 AI 改完代码性能退化怎么定位

验证时别靠肉眼翻页面。写个脚本把所有页拉下来落盘,然后统计:

import collections, json

pages = json.load(open("pages.json", encoding="utf-8"))  # 每页返回的数组组成的列表
ids = [row["id"] for page in pages for row in page]
counter = collections.Counter(ids)
dup = [k for k, v in counter.items() if v > 1]
print("总条数", len(ids), "去重后", len(set(ids)), "重复主键", dup[:10])

把这个数字和数据库里的 count(*) 对一下,重复和缺失就都暴露了。要验证顺序是否稳定,就对同一组参数连着请求两次再比较主键序列:

curl -s "https://api.example.com/orders?limit=50&offset=100" > a.json
sleep 5
curl -s "https://api.example.com/orders?limit=50&offset=100" > b.json

两份文件的主键序列不一致,而这期间没有写入,那就是排序不稳定,不用再往并发方向查了。

三、先止血:把排序变成全序

在动分页协议之前,有一个改动成本极低、风险也低的动作:给排序加一个唯一的次序键。

-- 改前:created_at 有并列,顺序不确定
SELECT id, created_at FROM orders ORDER BY created_at DESC LIMIT 50 OFFSET 100;

-- 改后:(created_at, id) 组合唯一,全序确定
SELECT id, created_at FROM orders ORDER BY created_at DESC, id DESC LIMIT 50 OFFSET 100;

这一步只消除「顺序不稳定」这一类成因,消除不了并发写入导致的窗口滑动。但它有两个价值:一是很多列表页的重复问题到这里就没了;二是它是游标分页的前置条件——游标分页要求排序是全序,否则游标定位会有歧义。

同时在前端做一层主键去重。列表数据用主键作为 key 合并进一个 Map 再渲染,而不是无脑追加。这层去重是防御性的,不要指望它解决问题,但它能把「用户看到同一条记录两次」这种最刺眼的表现挡掉。

四、改游标分页:怎么写才是真的游标

游标分页(也叫 keyset 或 seek 分页)的思路是:不再说「跳过前 100 条」,而是说「从这一行之后继续取」。位置由数据本身的排序键锚定,前面插了多少行删了多少行都不影响。

-- 第一页
SELECT id, created_at, title
FROM orders
WHERE tenant_id = ?
ORDER BY created_at DESC, id DESC
LIMIT 51;

-- 后续页:把上一页最后一行的 (created_at, id) 传回来
SELECT id, created_at, title
FROM orders
WHERE tenant_id = ?
  AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 51;

几个落地要点。

索引要对齐排序。 需要一个 (tenant_id, created_at, id) 的复合索引,列的先后顺序和排序表达式一致。这里有个常见误解:并不是「排序写了 DESC 就必须建降序索引」。只要所有排序键方向一致(本例都是 DESC),普通升序索引反向扫描就能用上;只有排序方向混着来(比如 created_at DESC, id ASC)时,才需要在索引定义里逐列写明方向。真正的风险是根本没有覆盖这几列的索引,那时行值比较只能靠边扫边过滤,深翻页会退化得比偏移分页还难看。另外,(a, b) < (?, ?) 这种行值比较写法并非所有数据库都吃:有的能解析但走不好索引,有的干脆不支持这个语法。改完必须看执行计划确认走了索引,别凭感觉。支持不好或不支持时,退而求其次写成 created_at < ? OR (created_at = ? AND id < ?),语义等价,只是参数从两个变成三个,且更依赖优化器把它识别成一段索引范围扫描——同样以执行计划为准。

limit + 1 判断有没有下一页。 多取一条,如果返回了 51 条就说明还有下一页,把第 51 条丢掉、用第 50 条生成游标。用 返回条数 == limit 来判断会在总数恰好整除时多出一次空请求,在应用层还有过滤时更会误判。

游标必须是不透明的编码值,且要编码排序键。 常见做法是把 {"ts": ..., "id": ..., "dir": "next", "f": "过滤条件指纹"} 序列化后 base64。这里要说明白:base64 只是一种「请不要解析我」的约定,谁都能解回来,它不是加密。所以游标里放排序键和条件指纹可以,别顺手把内部表名、租户以外的标识、原始筛选值明文塞进去;对篡改敏感的接口再加一段服务端密钥签名,校验不过就当游标失效。有一种假游标要避免:把 offset 数字 base64 一下当游标返回——接口长得像游标分页,缺陷一个没少。过滤条件指纹的作用是,用户改了筛选条件却带着旧游标请求时,服务端能直接判定游标失效并从头开始,而不是返回一堆错位数据。

时间戳精度要守住。 游标里的时间值经过 JSON 序列化、时区转换、毫秒截断之后回传,可能已经和数据库里的值不完全相等,比较就会错位——表现是每页边界固定丢一条或多一条。稳妥的做法是游标里存整数形式的时间(epoch 毫秒或微秒,与列精度一致),或者干脆只用单调递增的主键当排序键。

接受能力边界。 游标分页不支持跳到第 N 页,也不天然给总数。这是协议本身的取舍,不是实现没做好。如果产品坚持要页码器和总条数,那属于产品决策,得单独谈,不要用「再加个 offset 参数」把两套语义混在一个接口里。

五、什么情况下别再折腾分页

有几个信号出现时,继续在分页逻辑上打补丁是浪费时间。

这条链路是导出、对账或计费。 这类场景对完整性的要求是「一条都不能多、一条都不能少」,而任何分页协议在长时间遍历中都要面对数据变动。正确的做法是换路:按主键区间切片遍历(WHERE id > ? ORDER BY id LIMIT n),或者在一个可重复读的事务/快照里一次性游标遍历,再或者先把结果集物化到一张带批次号的临时表,再对临时表分页。别在业务列表接口上凑合。

排查超过两三轮还稳定复现不出来。 这说明你缺的是证据不是猜想。停手去补可观测:接口返回里回显本次使用的游标和排序键值,日志里记下每页的首尾主键和返回条数。有了这些,下一次线上复现你十分钟就能定位,而不是再猜三天。

改动已经蔓延到数据库索引、后端协议、前端状态机三层。 这时先划回滚点:把工作拆成两个可独立回滚的提交,第一个只做「排序补唯一键 + 前端去重」,第二个才做游标协议。前者出问题可以单独回退,后者涉及接口契约,需要客户端配合灰度。让 AI 一次性帮你改完三层是最容易失控的做法,范围控制的思路见 怎么控制 AI 的改动范围

业务真正想要的是全量拉取。 有些调用方分页只是为了把数据全捞回去。那就别让他们翻页,提供批量导出或流式接口,一次给完。分页协议本来就不是为全量同步设计的。

六、避坑清单

created_at 单列当游标。 会踩是因为开发时的测试数据每条时间都不同,看不出并列。批量导入或高并发写入一来,同一时刻几十条,游标定位到哪一条都合法,于是漏一批或重一批。避法:游标一律用「排序列 + 主键」的组合,把排序做成全序。

用可变字段排序。updated_at、点赞数、评分排序时,一行数据可能在你翻页的过程中被更新而改变位置,两个方向都会出问题:它从你还没翻到的位置跳到了已经翻过的前面,这一行你就永远看不到了;反过来它从已经翻过的前面掉到后面,你就会在后续某一页再遇到它一次。这不是实现 bug,是「排序键会变」这件事本身的结果,换游标分页也治不了。避法:列表排序尽量用不可变字段;产品必须按热度排就明确告知这是近似结果,并在前端做主键去重。

应用层过滤在取数之后做。 从库里取 50 条,代码里过滤掉软删除和无权限的,剩 30 条返回。会踩是因为过滤逻辑往往是后加的,加的人没意识到它破坏了分页语义。表现是每页条数不齐,甚至因为过滤后为空而误判「没有更多」。避法:过滤条件下推到 SQL;实在下推不了,就在服务端循环补足到目标条数,并且用独立的标志位而不是条数来表达是否还有下一页。

忘了给游标绑定筛选条件。 用户在第 3 页改了筛选条件,前端把旧游标带上去了。会踩是因为游标看起来只是「位置」,很容易忽略它其实依赖于整个查询上下文。避法:游标里编码筛选条件的指纹,服务端校验不一致就返回明确的失效信号,让客户端重头开始。

在读写分离环境里不做会话粘连。 会踩是因为本地和预发通常只有一个库,副本延迟在这些环境里根本不存在。避法:一次分页遍历内绑定同一数据源,或对一致性要求高的列表直接读主库。

只用静态种子数据写测试。 这是这类 bug 能上线的根本原因:测试里数据不动,偏移分页永远正确。避法:写一个「翻页过程中并发插入和删除」的集成测试,断言全量遍历的结果既不重复也不缺失。测试全绿却在线上出事的模式,测试全绿线上还是崩了 里讲得更细。

OFFSET 用在分库分表上。 会踩是因为单库时它是对的,拆分之后同一段代码语义悄悄变了,没有报错。避法:分片场景一律用 keyset 加归并;如果必须支持深偏移,在归并层实现而不是下推给分片。

收束

这类问题的排查顺序其实很固定:先确认写入是否静止、排序是否全序,再看数据源是否一致,最后才看前端追加逻辑。定位到成因之前不要改协议,否则你会在游标分页上重新踩一遍排序不唯一的坑。

上线前对着这份自检清单过一遍:

  • 排序表达式是否构成全序(末尾是否带主键);
  • 是否用 limit + 1 而不是条数相等来判断还有没有下一页;
  • 游标里是否编码了排序键的原始精度值和筛选条件指纹;
  • 过滤条件是否全部下推到了查询层;
  • 是否有一个「并发写入下全量遍历」的测试,断言无重复无缺失;
  • 一次分页遍历是否绑定同一数据源;
  • 执行计划是否确认走了与排序方向匹配的索引;
  • 导出、对账这类要求完整性的链路,是否已经换成主键区间遍历或快照遍历。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。