页码递增、每次都返回成功,并不能证明已经完整采集数据。列表在翻页期间新增、删除或改变排序,下一页里的"第几条"就可能指向不同记录。把最后结果转成 set,只能消除已经拿到的重复项,不能补回没有见过的数据。
这篇用 Python 标准库 SQLite 模拟一个分页数据源,逐次查询之间主动修改数据,复现问题。它不是某个网站的接口测试,也不涉及登录或反爬。适合正在排查采集完整性,或能够设计自己的列表、导出接口的开发者。
一、先定义这次采集要拿到哪一批数据
"完整"至少有两种含义:拿到采集开始时的那一批记录,或者持续追踪运行中出现的新记录。前者需要稳定的数据视图;后者需要增量规则、时间边界和重复处理。单独一个下一页游标,不会替你选择这些语义。
本例有六条虚构记录,按 score 降序、id 降序,每页两条。id 是唯一主键,score 非空;初始顺序为 2、1、3、4、5、6。score 故意存在同值,用于验证游标是否包含完整排序键。
SQLite 的 ORDER BY 与 LIMIT 文档说明,排序项全部相同的行没有确定的相对次序,OFFSET 则是在当前查询结果中跳过指定数量的行。这里加入 id,不是为了更快,而是先给结果一个无歧义的顺序。
| 数据变化 | 本例观察 | 不能由此推出 |
|---|---|---|
| 已读区域前插入记录 | OFFSET 重复读到 id=1 | 去重后就包含了新增记录 |
| 已读区域删除记录 | OFFSET 跳过未读的 id=3 | 状态码成功就代表没有漏抓 |
| 稳定排序键加复合游标 | 剩余初始记录正常接续 | 所有新记录都会进入本轮 |
| 排序字段跨过游标变化 | 游标分页仍可能漏读或重复 | cursor 等同于快照 |
| 复制整批行后分页 | 保持复制时成员与字段值 | 已实现跨进程数据库快照服务 |
二、完整本地实验
2026-09-14 实际环境为 Python 3.13.13、SQLite 3.51.2,只使用标准库。保存为 pagination_demo.py,用 python3 pagination_demo.py 运行。每个场景重新创建内存数据库,修改发生在相邻查询之间,不依赖网络时序。
python
"""Synthetic SQLite pagination fixture; no website or account access."""
import platform
import sqlite3
from contextlib import closing
BASE = [(1, 10), (2, 10), (3, 9), (4, 8), (5, 7), (6, 6)]
ORDER = [2, 1, 3, 4, 5, 6]
def database():
db = sqlite3.connect(':memory:')
db.execute('CREATE TABLE items(id INTEGER PRIMARY KEY, score INTEGER NOT NULL)')
db.executemany('INSERT INTO items VALUES (?, ?)', BASE)
return db
def page(db, offset=0, size=2):
return db.execute('SELECT id, score FROM items ORDER BY score DESC, id DESC '
'LIMIT ? OFFSET ?', (size, offset)).fetchall()
def after(db, cursor, size=2):
return db.execute('SELECT id, score FROM items WHERE (score, id) < (?, ?) '
'ORDER BY score DESC, id DESC LIMIT ?',
(cursor[1], cursor[0], size)).fetchall()
def ids(rows):
return [row[0] for row in rows]
def offset_rest(db, offset=2):
result = []
while rows := page(db, offset):
result.extend(rows)
offset += len(rows)
return result
def cursor_rest(db, cursor):
result = []
while rows := after(db, cursor):
result.extend(rows)
cursor = rows[-1]
return result
def main():
print(f'Python {platform.python_version()} / SQLite {sqlite3.sqlite_version}')
with closing(database()) as db:
got = ids(page(db) + offset_rest(db))
assert got == ORDER
print('PASS unchanged data:', got)
with closing(database()) as db:
first = page(db)
db.execute('INSERT INTO items VALUES (7, 11)')
got = ids(first + offset_rest(db))
assert got == [2, 1, 1, 3, 4, 5, 6]
assert 7 not in got and len(set(got)) == 6
print('PASS insert before OFFSET repeats id=1; dedup cannot recover id=7:', got)
with closing(database()) as db:
first = page(db)
db.execute('DELETE FROM items WHERE id=2')
got = ids(first + offset_rest(db))
assert got == [2, 1, 4, 5, 6] and 3 not in got
print('PASS delete before OFFSET skips unread id=3:', got)
with closing(database()) as db:
first = page(db)
db.execute('INSERT INTO items VALUES (7, 11)')
db.execute('DELETE FROM items WHERE id=2')
got = ids(first + cursor_rest(db, first[-1]))
assert got == ORDER and 7 not in got
print('PASS composite cursor preserves remaining stable keys, not new head:', got)
with closing(database()) as db:
first = page(db, size=1)
bad = db.execute('SELECT id, score FROM items WHERE score < ? '
'ORDER BY score DESC, id DESC LIMIT 1', (first[0][1],)).fetchall()
good = after(db, first[-1], size=1)
assert ids(bad) == [3] and ids(good) == [1]
print('PASS tied score needs id in cursor: score-only=[3], composite=[1]')
with closing(database()) as db:
first = page(db)
db.execute('UPDATE items SET score=12 WHERE id=3')
got = ids(first + cursor_rest(db, first[-1]))
assert got == [2, 1, 4, 5, 6]
print('PASS unread item crossing cursor can still be missed:', got)
with closing(database()) as db:
first = page(db)
db.execute('UPDATE items SET score=5 WHERE id=1')
got = ids(first + cursor_rest(db, first[-1]))
assert got == [2, 1, 3, 4, 5, 6, 1]
print('PASS previously read item crossing cursor can repeat:', got)
with closing(database()) as db:
db.execute('CREATE TEMP TABLE frozen AS SELECT id, score FROM items')
db.execute('DELETE FROM items WHERE id=2')
db.execute('UPDATE items SET score=12 WHERE id=3')
db.execute('INSERT INTO items VALUES (7, 11)')
frozen = []
for offset in range(0, 6, 2):
frozen.extend(db.execute('SELECT id, score FROM frozen '
'ORDER BY score DESC, id DESC LIMIT 2 OFFSET ?',
(offset,)).fetchall())
assert ids(frozen) == ORDER and dict(frozen) == dict(BASE)
print('PASS copied rows preserve original membership and values:', ids(frozen))
print('8 checks passed; no performance or live-site benchmark')
if __name__ == '__main__':
main()
本次实际输出:
text
Python 3.13.13 / SQLite 3.51.2
PASS unchanged data: [2, 1, 3, 4, 5, 6]
PASS insert before OFFSET repeats id=1; dedup cannot recover id=7: [2, 1, 1, 3, 4, 5, 6]
PASS delete before OFFSET skips unread id=3: [2, 1, 4, 5, 6]
PASS composite cursor preserves remaining stable keys, not new head: [2, 1, 3, 4, 5, 6]
PASS tied score needs id in cursor: score-only=[3], composite=[1]
PASS unread item crossing cursor can still be missed: [2, 1, 4, 5, 6]
PASS previously read item crossing cursor can repeat: [2, 1, 3, 4, 5, 6, 1]
PASS copied rows preserve original membership and values: [2, 1, 3, 4, 5, 6]
8 checks passed; no performance or live-site benchmark
三、复合游标需要和排序完全对应
本例按 score DESC、id DESC 排序,所以"接着最后一条往后读"对应 WHERE (score, id) < (?, ?)。参数顺序是最后一条的 score、id,不能和返回列 id、score 的顺序混淆。SQLite 的行值比较文档给出了这种多列比较与滚动窗口查询写法。
第五项只读一条时,第一条是 id=2、score=10。如果下一页仅写 score < 10,另一条同分记录 id=1 会被跳过;把唯一 id 一并放入游标,下一条才能正确读到它。这个实验验证的是正确性,没有测索引、执行计划或吞吐。
这里的降序同向比较只适用于这个排序约定。混合升降序、NULL、字符串排序规则以及可重复的业务键,需要重新推导条件。实际接口给出不透明 next_cursor 时,客户端应按接口约定传回,并保持筛选与排序条件一致,不应自行拼接内部游标。
四、游标也无法冻结会变化的排序键
第六项把尚未读取的 id=3 从 score=9 改到 12,它移动到游标之前,本轮后续查询便看不到它。第七项把已读 id=1 改到 score=5,它移动到后方,又被读到一次。
因此,复合游标解决的是接续位置问题。若排序字段会更新,还要明确版本、增量窗口或快照机制。去重可防重复入库,但不能证明没有遗漏;想发现遗漏,需要有可比较的目标集合或服务端提供的完整性证据。
第八项把 id 与 score 一起复制到临时表 frozen,再修改原表。分页读 frozen 能保持当时的成员和字段值。只存 ID 后重新查实时表,并不等价,因为字段可能变化、记录可能删除。
这是物化副本演示,不是长事务或 WAL 并发隔离实验。SQLite 的隔离文档讨论了数据库事务视图;生产导出还需要考虑存储、过期清理、权限和版本生命周期,不宜照搬临时表作为完整服务。
五、如何用在爬虫排障
先保存每页的请求参数、获取时间、记录主键与排序键、返回的下一页信息。比较相邻页面边界,可以区分"请求一直没变"和"请求变了,但底层列表也变了"。不要只记行数。
如果你只能使用对方提供的普通页码接口,无法在客户端凭空造出服务端快照。可以记录本轮时间范围与局限,使用稳定标识去重,在许可和合理速率内安排增量核对。只有接口明确支持时,才采用它的游标或导出版本。
八项检查说明了几种可复现的失败机制,没有对任何网站宣称全量抓取成功,也没有得出游标分页始终更快的性能结论。
本文由 AI 辅助组织与撰写;代码和输出于 2026-09-14 在本地实际运行核对。