Python 爬虫翻页为何重复、漏数据?SQLite 复现 OFFSET、复合游标与快照的 8 项检查

页码递增、每次都返回成功,并不能证明已经完整采集数据。列表在翻页期间新增、删除或改变排序,下一页里的"第几条"就可能指向不同记录。把最后结果转成 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 在本地实际运行核对。

相关推荐
夜雪一千1 小时前
如何使用Python实现音频转文本(ASR语音识别)
python
SunnyDays10111 小时前
使用 Python 将 PowerPoint 转换为视频(无需安装 Microsoft PowerPoint)
python·powerpoint·ppt 转 动画·ppt 转 mp4·ppt 转 wmv·幻灯片转动画·幻灯片转 mp4
飞Link1 小时前
零基础:离散数据积分与 Python 实现保姆级教程
python·算法
用户0332126663672 小时前
使用 Python 将 PDF 转换为 Word(转换单个与多个文件)
python
码农学院2 小时前
制造业B2B官网GEO实战:用 Python 向量相似度给老产品手册自动补语义内链,让 AI 爬虫抓得更深
运维·人工智能·爬虫·ai优化aio
troy1282 小时前
Visual Studio Code 详细教程:从入门到高效开发
开发语言·python
夜雪一千2 小时前
Python ASR音频转文本 完整代码示例
python
JarmanYuo2 小时前
YOLO 涨点研究(十二):具身 CV 进阶篇——Sim2Real 域随机化与真机部署
人工智能·pytorch·python·yolo·计算机视觉
小蒜学长2 小时前
基于Python的微博热搜话题可视化分析系统的设计与实现(代码+数据库+LW)
后端·python·django·数据可视化·情感分析·微博热搜