bigywater.tech
爬虫工程 · 42

分页抓取:offset 与 cursor 在数据变动时漏页、重复

抓一个分页列表时,数据可能正在变化。用 offset 翻页,新数据一插入,下一页就读到上一页读过的条目;删了前面的条目,后面的又被跳过。这篇用一个会同时新增和删除数据的本机服务器,对比四种分页方式的重复和漏抓。

148 条
新增数据时偏移量分页(最新在前)的重复条数——游标和快照是 0
59 条
随机删除数据时偏移量分页(最新在前)漏掉的条数——游标和快照是 0
0 / 0
游标分页在新增、删除、两者并存时的 重复 / 漏掉

基准

实测 · 本机执行输出
  (初始 1000 条数据,每页 20 条,约 50 页;每页之间客户端间隔 10 ms。"漏掉"=初始就有、整个抓取期间都没被删除、却从没返回过的数据条数;"重复"=返回的总条数减去不同的条数)
  分页方式                        请求页数    返回条数    重复    漏掉   期间新增/删除
  偏移量分页,最新在前                51    1000     0     0   0 / 0
  偏移量分页,最旧在前                51    1000     0     0   0 / 0
  游标(键集)分页,最新在前             51    1000     0     0   0 / 0
  服务器快照分页                   51    1000     0     0   0 / 0

数据不变时四种方式没有区别,都是 51 次请求、1000 条。

新增数据 → 重复

实测 · 本机执行输出
  分页方式                        请求页数    返回条数    重复    漏掉   期间新增/删除
  偏移量分页,最新在前                59    1148   148     0   151 / 0   (3 次的 重复/漏掉:[(149, 0), (148, 0), (147, 0)])
  偏移量分页,最旧在前                59    1142     0     0   146 / 0   (3 次的 重复/漏掉:[(0, 0), (0, 0), (0, 0)])
  游标(键集)分页,最新在前             51    1000     0     0   131 / 0   (3 次的 重复/漏掉:[(0, 0), (0, 0), (0, 0)])
  服务器快照分页                   51    1000     0     0   135 / 0   (3 次的 重复/漏掉:[(0, 0), (0, 0), (0, 0)])

最新在前的偏移量分页:新数据插在最前面,把已读过的条目向后推到后面的页里,于是下一页又读一遍,重复 148 条。最旧在前时新数据追加在末尾,没有重复、也没有漏,抓取还读到了新增的数据。

删除数据 → 漏掉

实测 · 本机执行输出
  分页方式                        请求页数    返回条数    重复    漏掉   期间新增/删除
  偏移量分页,最新在前                46     884     0    59   0 / 120   (3 次的 重复/漏掉:[(0, 58), (0, 59), (0, 60)])
  偏移量分页,最旧在前                46     889     0    46   0 / 114   (3 次的 重复/漏掉:[(0, 54), (0, 46), (0, 52)])
  游标(键集)分页,最新在前             49     947     0     0   0 / 128   (3 次的 重复/漏掉:[(0, 0), (0, 0), (0, 0)])
  服务器快照分页                   51    1000     0     0   0 / 134   (3 次的 重复/漏掉:[(0, 0), (0, 0), (0, 0)])

偏移量分页不论排序,前面有条目被删除,后面的条目前移一格,下一页的偏移量就跳过它们:漏掉 46 到 59 条。游标分页以最后一条 id 继续,不受影响;快照分页返回冻结时的数据(包括之后被删除的条目)。

新增加删除

实测 · 本机执行输出
  分页方式                        请求页数    返回条数    重复    漏掉   期间新增/删除
  偏移量分页,最新在前                51    1000    60     0   138 / 138   (3 次的 重复/漏掉:[(53, 0), (60, 0), (58, 0)])
  偏移量分页,最旧在前                51    1000     0    45   137 / 137   (3 次的 重复/漏掉:[(0, 41), (0, 45), (0, 44)])
  游标(键集)分页,最新在前             49     950     0     0   122 / 122   (3 次的 重复/漏掉:[(0, 0), (0, 0), (0, 0)])
  服务器快照分页                   51    1000     0     0   134 / 134   (3 次的 重复/漏掉:[(0, 0), (0, 0), (0, 0)])

两种变化同时发生时,偏移量分页的结果更难预料(重复 60 或漏掉 45,取决于排序);游标和快照仍然既不重复也不漏掉。

怎么选

实测 · 本机执行输出
  读完第 2 页后,新增 3 条(id 101–103):
      offset_new 第 2 页 [95, 94, 93, 92, 91],变化后的第 3 页 [93, 92, 91, 90, 89]
      offset_old 第 2 页 [6, 7, 8, 9, 10],变化后的第 3 页 [11, 12, 13, 14, 15]
      cursor     第 2 页 [95, 94, 93, 92, 91],变化后的第 3 页 [90, 89, 88, 87, 86]
  读完第 2 页后,删除 3 条已读过的条目:
      offset_new 第 2 页 [95, 94, 93, 92, 91],变化后的第 3 页 [87, 86, 85, 84, 83]
      offset_old 第 2 页 [6, 7, 8, 9, 10],变化后的第 3 页 [11, 12, 13, 14, 15]
      cursor     第 2 页 [95, 94, 93, 92, 91],变化后的第 3 页 [90, 89, 88, 87, 86]

能用游标(按 id 或时间戳继续)就用游标;API 只给 offset 时,最旧在前加倍抓取校验(用抓到的 id 去重,并在结束后再扫一遍)可以减少风险。本实验没有测校验方案。

分步演示

分页抓取的漂移
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。本机服务器保存一个 id 列表,另一个线程每 4 ms 新增(追加递增的 id)或随机删除一条;四种接口:offset_new(按 id 倒序切片)、offset_old(按 id 升序切片)、cursor(id 小于 before 的 20 条)、snapshot(第一次请求时冻结整份数据);客户端每页间隔 10 ms;“漏掉”以初始 1000 条中整个抓取期间都没被删除的为准;每种情形跑 3 次,主要数字取第 2 次,3 次的结果写在括号里
    • 没有验证:真实 API 的分页(例如返回 next_cursor 的 API 里游标含义不同);数据变更频率更高或更低时的比例;用重复检查和二次扫描来弥补偏移量分页的做法;分页大小对结果的影响;更新(修改排序字段)而不是插入或删除的情形;并发多页同时抓取时的一致性
    京ICP备2026067502号-1京公网安备11010502063487号