bigywater.tech
爬虫工程 · 64

批量写入:批大小对吞吐的影响

爬虫存结果该多少行提交一次?批太小慢,批太大占内存、进程被杀时丢得多,一行出错整批回滚。这篇在 sqlite 上把批大小、触发条件、被杀和出错时的行为逐项测一遍。

24167 对 480833 行/秒
每行 200 字节、50000 行,批大小 1 与批大小 1000 的写入速度(中位数)
2629 对 207 ms
生产速度忽快忽慢时,只按条数(500 行)攒批 与 “500 行或 0.2 秒”的最大等待
813 行
批大小 1000 的进程被 kill -9 时,库里最多少了这么多行(批大小 1 少 0 行)

批大小的收益

实测 · 本机执行输出
  批大小     1:中位    24167 行/秒(23000–24741);提交次数 50000;单个批事务(含组装参数)耗时 中位 0.04 ms,最大 50.6 ms
  批大小    10:中位   130194 行/秒(127739–142395);提交次数  5000;单个批事务(含组装参数)耗时 中位 0.06 ms,最大 8.4 ms
  批大小   100:中位   324420 行/秒(316855–326310);提交次数   500;单个批事务(含组装参数)耗时 中位 0.20 ms,最大 9.5 ms
  批大小  1000:中位   480833 行/秒(438218–508628);提交次数    50;单个批事务(含组装参数)耗时 中位 1.51 ms,最大 12.6 ms
  批大小 10000:中位   426959 行/秒(397520–439595);提交次数     5;单个批事务(含组装参数)耗时 中位 21.67 ms,最大 46.8 ms
  批大小 50000:中位   452612 行/秒(445481–460314);提交次数     1;单个批事务(含组装参数)耗时 中位 110.47 ms,最大 112.2 ms

每次提交都要付出固定的开销,批越大分摊得越多。实验里 200 字节的小行从批大小 1 到 100 快了十几倍,到 1000 以后持平,10000、50000 并不更快。单个批事务耗时随批大小增加(1000 行 1.51 ms,50000 行 110 ms),批太大还会让别的连接等更久。

行大了

实测 · 本机执行输出
  批大小    1:中位   9027 行/秒(7084–9099),相当于 172 MiB/秒;一批在内存里的正文共 0.0 MiB
  批大小   10:中位  12327 行/秒(12192–14148),相当于 235 MiB/秒;一批在内存里的正文共 0.2 MiB
  批大小  100:中位  13295 行/秒(12595–15397),相当于 254 MiB/秒;一批在内存里的正文共 1.9 MiB
  批大小 1000:中位  12307 行/秒(11252–13568),相当于 235 MiB/秒;一批在内存里的正文共 19.1 MiB

每行 20 KB 时,提交次数的影响变小(批大小 1 到 100 只快了约 1.5 倍),一批在内存里要占 19.1 MiB(批大小 1000)。行越大,批可以越小。

写法

实测 · 本机执行输出
  executemany                  中位   507275 行/秒(292117–536995)
  循环 execute(同一个事务里)           中位   445479 行/秒(422051–447144)
  多行 VALUES(每条 300 行)          中位   536813 行/秒(476374–589346)

executemany、循环 execute、多行 VALUES 差别在 20% 以内,批大小的影响比这大得多。

按条数还是按时间

实测 · 本机执行输出
  只按条数,每 500 行提交:生产 1629 行,提交 4 次;行从产生到落盘的等待:中位 325 ms、p99 2609 ms、最大 2629 ms(生产速度:1 秒 1000 行/s → 2 秒 20 行/s → 1 秒 1000 行/s;时间由 sleep 控制,实际速度略低)
  每 500 行或每 0.2 秒,先到者:生产 1623 行,提交 20 次;行从产生到落盘的等待:中位 98 ms、p99 202 ms、最大 207 ms(生产速度:1 秒 1000 行/s → 2 秒 20 行/s → 1 秒 1000 行/s;时间由 sleep 控制,实际速度略低)
  只按条数,每 50 行提交:生产 1638 行,提交 33 次;行从产生到落盘的等待:中位 32 ms、p99 1341 ms、最大 2034 ms(生产速度:1 秒 1000 行/s → 2 秒 20 行/s → 1 秒 1000 行/s;时间由 sleep 控制,实际速度略低)

只按条数攒批,在生产变慢时一批要等很久才能凑齐(最大等待 2629 ms);加一个超时就能保证最长等待(约 0.2 秒),代价是慢的时候提交次数更多。实际的触发条件常见的是“条数或时间先到者”。

进程被杀

实测 · 本机执行输出
  批大小    1          :生产 [1593, 1611, 1645] 行,kill 之后库里少了 [0, 0, 0] 行;integrity_check:ok
  批大小  100          :生产 [1796, 1791, 1801] 行,kill 之后库里少了 [96, 91, 1] 行;integrity_check:ok
  批大小 1000          :生产 [1808, 1803, 1813] 行,kill 之后库里少了 [808, 803, 813] 行;integrity_check:ok
  批大小 1000(或 0.2 秒):生产 [1806, 1803, 1803] 行,kill 之后库里少了 [36, 36, 31] 行;integrity_check:ok

没提交的那批在进程里,kill -9 就丢了:批大小 1000 时丢了约 800 行,批大小 1 时 0 行;库本身没有损坏(integrity_check 为 ok)。能不能接受丢这一批,取决于这些数据能不能重新抓(第 34 篇讲了断点续抓)。

一行出错

实测 · 本机执行输出
  整批 executemany(500 行,第 251 行重复):IntegrityError: UNIQUE constraint failed: r.url;回滚之后库里 1 行(只有开始前插入的那 1 行,另外 499 行也一起没了)
  整批失败后改成同一个事务里逐行 SAVEPOINT:成功 499 行、跳过 1 行,库里 500 行(重复行带来的开销:每行多两条语句)
  用 INSERT OR IGNORE 一次插入 500 个新行加 1 个重复行:库里 1000 行(重复的被静默跳过,不报错;连接的 total_changes 增加了 500,比 501 少 1,靠它能算出跳过了几行)

一批里有一行违反约束,整批回滚。逐行 SAVEPOINT 能保住其余的行,代价是每行多两条语句;INSERT OR IGNORE 会静默跳过,可用连接的 total_changes 算出跳过了几行。

分步演示

批大小
本步的对象

    本文用到的工具与边界

    • 本机 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,数字每次运行会有小幅抖动。sqlite 3.53.4,WAL + synchronous=NORMAL,临时目录里的本地文件;第一、二、三节各重复 3 次或 5 次,给出中位数和最小–最大;第四节每种设置一次(生产者用 sleep 控制速度,实际速度略低于标称);第五节子进程每组 3 次,SIGKILL;第六节一次
    • 没有验证:synchronous=FULL 或 OFF 下的批大小曲线;多个写连接并发;批大小对读延迟的影响;网络数据库(PostgreSQL、MySQL)的批量写入;批大小为 50000 时偶尔明显变慢的原因(第一次运行里它是 270650 行/秒,最终这次是 452612,我没有查原因);SIGKILL 以外的断电场景
    京ICP备2026067502号-1京公网安备11010502063487号