bigywater.tech
爬虫工程 · 63

sqlite 存抓取结果:WAL 与单写线程

用 sqlite 保存抓取结果很方便,但多个爬虫线程同时写会撞上 locked。WAL 模式、busy 等待、单写线程加队列分别解决什么,代价是什么?这篇用 sqlite 3.53.4 一项项测。

3347 对 36454 次/秒
单线程每行一个事务:默认日志模式(delete + NORMAL) 与 WAL + NORMAL 的中位数(波动很大,只看量级)
1505 对 0 次
4 个线程各自写、delete 模式下 busy 等待为 0 时 “database is locked” 的重试次数中位 与 等待 5 秒时的重试次数
41.9 MiB 的 WAL
有一个读事务一直不结束时,写 20000 行(每行约 2 KB)后 -wal 文件的大小(主库只有 8 KiB)

WAL 与 synchronous

实测 · 本机执行输出
  sqlite 版本 3.53.4,Python sqlite3 模块,数据库文件在本机磁盘的临时目录
  journal_mode=delete synchronous=FULL  (PRAGMA 读回 2):中位   3476 次提交/秒(1462–3645)
  journal_mode=delete synchronous=NORMAL(PRAGMA 读回 1):中位   3347 次提交/秒(2453–4087)
  journal_mode=wal    synchronous=FULL  (PRAGMA 读回 2):中位  21166 次提交/秒(5058–21767)
  journal_mode=wal    synchronous=NORMAL(PRAGMA 读回 1):中位  36454 次提交/秒(3172–46450)
  journal_mode=wal    synchronous=OFF   (PRAGMA 读回 0):中位  64743 次提交/秒(63177–65096)

每行一个事务的写入主要花在每次提交的磁盘同步上。同样 2000 行,WAL 模式的提交速度比默认的 delete 日志高一个数量级左右;synchronous 降低还能更快,但 OFF 在掉电时可能损坏数据,NORMAL 在 WAL 下的风险要看文档(这里没有测掉电)。单次测量波动很大,表里给的是 5 次中位数与范围。

多线程各自写

实测 · 本机执行输出
  journal_mode=delete busy 等待 timeout=0:中位   3000 行/秒(811–3587),遇到 "database is locked" 并重试的次数中位 1505(1063–5613),每次最后表里都是 2000 行
  journal_mode=delete busy 等待 timeout=0.05:中位   3535 行/秒(510–3682),遇到 "database is locked" 并重试的次数中位 13(12–50),每次最后表里都是 2000 行
  journal_mode=delete busy 等待 timeout=5:中位   2804 行/秒(1402–2855),遇到 "database is locked" 并重试的次数中位 0(0–0),每次最后表里都是 2000 行
  journal_mode=wal    busy 等待 timeout=0:中位   8292 行/秒(7292–20722),遇到 "database is locked" 并重试的次数中位 158(157–287),每次最后表里都是 2000 行
  journal_mode=wal    busy 等待 timeout=0.05:中位  13324 行/秒(7806–18322),遇到 "database is locked" 并重试的次数中位 1(1–2),每次最后表里都是 2000 行
  journal_mode=wal    busy 等待 timeout=5:中位   9584 行/秒(5086–12079),遇到 "database is locked" 并重试的次数中位 0(0–0),每次最后表里都是 2000 行

多个线程各开连接写同一个库,sqlite 同一时刻只允许一个写者。连接的 busy 等待为 0 时,抢不到锁就立刻报 “database is locked”(delete 模式中位重试 1505 次);设了等待(Python 里是 connect 的 timeout 参数,默认 5 秒)就由 sqlite 在内部重试,实验里重试次数为 0。WAL 下吞吐更高。

单写线程加队列

实测 · 本机执行输出
  4 个生产线程各放 500 行,批大小   1:中位   8310 行/秒(5360–11995),写线程提交 2000 次,表里 2000 行,没有 locked 错误
  4 个生产线程各放 500 行,批大小  10:中位  49717 行/秒(2952–52716),写线程提交 200 次,表里 2000 行,没有 locked 错误
  4 个生产线程各放 500 行,批大小 100:中位  90207 行/秒(78879–114349),写线程提交 20 次,表里 2000 行,没有 locked 错误
  4 个生产线程各放 500 行,批大小 500:中位 117097 行/秒(97004–122708),写线程提交 4 次,表里 2000 行,没有 locked 错误

爬虫线程把结果放进 queue.Queue,由一个写线程持有唯一的连接、攒一批再提交。实验里批大小 1 时 8310 行/秒,批大小 100 时 90207 行/秒;提交次数从 2000 降到 20。没有 locked 错误,因为只有一个写者。队列无上限时写线程跟不上会占用内存(队列满时让生产者等待,见第 22 篇)。

读不被写挡住

实测 · 本机执行输出
  journal_mode=delete 未提交的写事务里有   10 行(约 0.0 MiB):读连接 读到 0 行,用时 0.0 ms;写提交之后再读:10 行
  journal_mode=delete 未提交的写事务里有 1000 行(约 2.0 MiB):读连接 错误 "database is locked",用时 0.1 ms;写提交之后再读:1000 行
  journal_mode=wal    未提交的写事务里有   10 行(约 0.0 MiB):读连接 读到 0 行,用时 0.1 ms;写提交之后再读:10 行
  journal_mode=wal    未提交的写事务里有 1000 行(约 2.0 MiB):读连接 读到 0 行,用时 0.2 ms;写提交之后再读:1000 行

WAL 下读连接读到的是写事务提交前的状态,不会被正在进行的写事务挡住;delete 模式下实验里 1000 行(约 2 MiB)的未提交写事务让读连接报 locked,而 10 行的没有。我只是观察到这个差别,没有去查原因。

WAL 文件会长大

实测 · 本机执行输出
  默认自动 checkpoint(1000 页):过程中 -wal 最大 4.1 MiB;写完时文件大小(KiB){'db': 38296, '-wal': 4217};此时手动 checkpoint(TRUNCATE) 返回 (0, 0, 0)(busy, 日志页数, 已移回页数)
     读事务结束后再 checkpoint(TRUNCATE):(0, 0, 0),-wal 现在 0 KiB
  关掉自动 checkpoint:过程中 -wal 最大 41.9 MiB;写完时文件大小(KiB){'db': 8, '-wal': 42910};此时手动 checkpoint(TRUNCATE) 返回 (0, 0, 0)(busy, 日志页数, 已移回页数)
     读事务结束后再 checkpoint(TRUNCATE):(0, 0, 0),-wal 现在 0 KiB
  默认自动 checkpoint,但有一个读连接一直开着读事务:过程中 -wal 最大 41.9 MiB;写完时文件大小(KiB){'db': 8, '-wal': 42910};此时手动 checkpoint(TRUNCATE) 返回 (1, 10665, 0)(busy, 日志页数, 已移回页数)
     读事务结束后再 checkpoint(TRUNCATE):(0, 0, 0),-wal 现在 0 KiB

默认每累积 1000 页自动做 checkpoint,-wal 最大 4.1 MiB。关掉自动 checkpoint,或者有一个读事务一直不结束,checkpoint 就无法把数据移回主库,-wal 长到 41.9 MiB(主库只有 8 KiB),手动 checkpoint(TRUNCATE) 返回 busy=1。读事务结束后再 checkpoint,-wal 回到 0 KiB。长时间开着的读事务(比如迭代一个很大的结果集边遍历边处理)是个隐患。

Python 的隐式事务

实测 · 本机执行输出
  默认 isolation_level='';in_transaction(连接刚建好)=False;执行一条 INSERT 后 in_transaction=True(Python 自动开了事务,没 commit 前别的连接看不到)
  另一个连接此时 SELECT count(*) = 0;commit 后 = 1
  连接默认 check_same_thread=True,在另一个线程里用同一个连接:ProgrammingError: SQLite objects created in a thread can only be used in that same threa

Python sqlite3 默认在第一条 INSERT 时隐式开事务,忘了 commit 别的连接就看不到数据;连接默认不能跨线程使用。写线程模式天然符合这个限制:连接只在写线程里创建和使用。

分步演示

sqlite 单写线程
本步的对象

    本文用到的工具与边界

    • 本机 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(Python 自带的 sqlite3 模块),数据库文件放在本机磁盘的临时目录;每行约 2 KB 的 BLOB;第一到三节各重复 5 次,给出中位数和最小–最大(波动很大,例如 wal + NORMAL 是 3172–46450 次/秒);第四到六节各一次
    • 没有验证:掉电或崩溃后的数据完整性(synchronous 各级别的真实差别);macOS 上 PRAGMA fullfsync 的影响;更多线程、更大的行、多个数据库文件;写线程的队列有上限时的背压效果;WAL 模式在网络文件系统上的限制;读事务期间 checkpoint 的 PASSIVE/RESTART 等其它模式;busy_timeout 之外的重试策略;checkpoint 返回的三个数字的确切含义(只观察了值,(0, 0, 0) 我没有去解释)
    京ICP备2026067502号-1京公网安备11010502063487号