第 10 讲 · SQLite 持久化:让爬虫「爬两遍不重复」
本讲目标 :把「会跑第二次」的爬虫,从文件升级到数据库。核心是一个词------幂等 。
拆开 SQLite 的三种写入写法,讲清哪种 upsert 才是安全的,以及索引和并发锁。
配套代码 :
spiders/s10_sqlite_store.py运行 :
python spiders/s10_sqlite_store.py本讲产物 :
data/books.db(表books,48 本书数据;本讲演示只入 20 条)
---下载地址:git clone https://gitee.com/wuyikui555/python-spider.git
一、先看裸 SQL:三种写法,差别很大
只要你的爬虫会跑第二次,就必须用数据库。原因只有一个词:
幂等 = 同一个操作执行一次和执行 N 次,结果一样。
用 CSV 追加,跑两遍就两倍数据;用数据库 + 主键,跑一百遍还是那 48 条。
不过在说「用哪个写法」之前,得先看清三种写法的区别。脚本用内存库把三者摆在一起:
python
conn = sqlite3.connect(":memory:") # 内存库:不产生文件、不碰磁盘
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, title TEXT, price REAL)")
conn.executemany("INSERT INTO t VALUES (?,?,?)",
[(1, "原版", 10.0), (2, "另一本", 20.0)])
# 写法一:普通 INSERT ------ 主键冲突直接报错
conn.execute("INSERT INTO t VALUES (?,?,?)", (1, "改过名的", 99.0))
# 写法二:INSERT OR REPLACE ------ 能过,但它是"删了重插"
conn.execute("INSERT OR REPLACE INTO t VALUES (?,?,?)", (1, "改过名的", 99.0))
# 写法三:ON CONFLICT DO UPDATE ------ 只更新我带来的字段
conn.execute("""INSERT INTO t (id, title, price) VALUES (?,?,?)
ON CONFLICT(id) DO UPDATE SET title=excluded.title""",
(1, "只改了标题", 99.0))
实测输出:
初始: [(1, '原版', 10.0), (2, '另一本', 20.0)]
写法一 INSERT → 报错: UNIQUE constraint failed: t.id
写法二 OR REPLACE → [(1, '改过名的', 99.0)]
写法三 UPSERT(只更新title) → [(1, '只改了标题', 99.0)]
★ 三者的区别,用一句话说清:
INSERT 遇到重复主键 = 报错
INSERT OR REPLACE 遇到重复主键 = 先 DELETE 再 INSERT
ON CONFLICT UPDATE 遇到重复主键 = 只更新列出来的字段
危险的是 REPLACE:如果这次爬取没拿到某个字段(比如详情页 403 了、
stock 是空),REPLACE 会把它整行换成新值,老数据里的 stock 直接丢了。
三种写法对照:
| 写法 | 主键冲突时 | 未提供的字段 | 危险点 |
|---|---|---|---|
INSERT |
抛 IntegrityError |
------ | 不能重复跑 |
INSERT OR REPLACE |
先 DELETE 再 INSERT | 被清空 | 静默丢字段 |
ON CONFLICT ... DO UPDATE |
只更新列出的列 | 保留原值 | 略长,但要写 SQL |
注意写法三里那个 excluded ------它是 SQLite 的关键字,指「本次 INSERT 想插入的那一行的值」。SET title=excluded.title 就是「用新值覆盖旧 title,其它列不动」。
为什么这很关键? 想象你的详情页偶发 403,那一轮 stock 没抓到、是 None。如果用 INSERT OR REPLACE,这一行会被整行删除再写回,老数据里好好的 stock 就变成空了------而且没有任何报错。你要等到某天统计库存时才发觉数据少了一半。
本工程的做法是把 ON CONFLICT DO UPDATE 封成 SqliteStore.upsert_many(utils/store.py):
python
updates = ", ".join(f'"{c}"=excluded."{c}"' for c in cols if c != self.pk)
sql = (f'INSERT INTO "{self.table}" ({quoted}) VALUES ({placeholders}) '
f'ON CONFLICT("{self.pk}") DO UPDATE SET {updates}')
二、幂等实测:同一批数据入库三次
理论说再多,不如真的入三遍。脚本对同一批 20 条数据连续 upsert_many 三次:
python
store = SqliteStore(DB_FILE, "books", COLS, pk="id")
store.clear()
rows = crawl(client, 20)
print(f" 第一次入库({len(rows)} 条)→ 新增 {store.upsert_many(rows)}")
print(f" 第二次入库({len(rows)} 条)→ 新增 {store.upsert_many(rows)}")
print(f" 第三次入库({len(rows)} 条)→ 新增 {store.upsert_many(rows)}")
print(f" 库里最终条数:{store.count()}")
实测输出:
第一次入库(20 条)→ 新增 (20, 0)
第二次入库(20 条)→ 新增 (0, 20)
第三次入库(20 条)→ 新增 (0, 20)
库里最终条数:20
★ 三次返回 (新增, 更新) 依次是 (20,0) (0,20) (0,20),总数始终 20。
这就是幂等:重复执行不会让数据膨胀。
返回的 (新增, 更新) 是怎么算出来的?看 upsert_many 的收尾:
python
before = conn.execute(f'SELECT COUNT(*) FROM "{self.table}"').fetchone()[0]
conn.executemany(sql, [...])
after = conn.execute(f'SELECT COUNT(*) FROM "{self.table}"').fetchone()[0]
inserted = after - before
return inserted, len(rows) - inserted
用「写前写后的行数差」推新增数 ------第一次 20 条全新,行数 +20,所以 (20, 0);后两次都是更新已有行,行数不变,于是 (0, 20)。条数永远停在 20。
这就是幂等带来的两个好处:
| 好处 | 说明 |
|---|---|
| 重复跑不膨胀 | 网络抖动导致重试、任务被重启,都不会产生重复行 |
| 增量更新 | 价格、库存变了,重跑一遍自动覆盖成最新值 |
一个更省事的技巧:先问再抓
既然库里已经有主键,那在发请求之前就能判断哪些不用抓了:
python
def existing_ids(self) -> set:
with self._conn() as conn:
return {r[0] for r in conn.execute(f'SELECT "{self.pk}" FROM "{self.table}"')}
拿到 existing_ids(),在循环里 if i in ids: continue------省下的是整个 HTTP 请求,不只是写库那点开销。
三、入库之后,才真正开始「用数据」
数据进了库,统计就不用拉回 Python 里算了,直接写 SQL:
python
for r in store.query(
"SELECT category, COUNT(*) n, ROUND(AVG(price),1) avg_price "
"FROM books GROUP BY category ORDER BY n DESC"):
print(f" {r['category']:6s} {r['n']:>3} 本 均价 ¥{r['avg_price']}")
实测输出:
按分类统计(SQL 里直接 group by):
历史 4 本 均价 ¥73.6
文学 4 本 均价 ¥69.4
科普 4 本 均价 ¥72.5
经济 4 本 均价 ¥100.7
计算机 4 本 均价 ¥110.8
Top 5 高分书:
9.7 漫谈乡土中国
9.6 极简操作系统
9.4 重构Python 爬虫
9.4 深入理解操作系统
9.3 实战微服务架构
按标签找书(LIKE 模糊查询):
带「畅销」标签的有 8 本:['极简操作系统', '重构Python 爬虫', '深入理解微服务架构']...
store.query() 只是开了个口子,让你能塞任意 SQL:
python
def query(self, sql: str, params: tuple = ()) -> list[dict]:
with self._conn() as conn:
return [dict(r) for r in conn.execute(sql, params)]
为什么统计要放数据库算,而不是拉回 Python 里 Counter?
| 维度 | 拉回 Python | 在 SQL 里算 |
|---|---|---|
| 内存 | 全部数据进内存 | 只回传结果 |
| 速度 | 自己要遍历一遍 | 引擎有索引,快几个数量级 |
| 协作 | 写库和统计写在一起 | 一个进程写、另一个随时读 |
顺带说一句:LIKE '%畅销%' 前面带通配符,用不上索引,会全表扫描。数据量小无所谓,上百万行时这种模糊查询要么改用全文检索(FTS5),要么单独建一张「标签 → 书 id」的关联表。
四、索引:一个容易被忽略的性能点
数据库扫一个表有两种方式:SCAN(从头到尾一行行看)和 SEARCH ... USING INDEX(走索引直接跳到目标)。
python
conn.execute("CREATE INDEX IF NOT EXISTS idx_books_category ON books(category)")
plan = conn.execute(
"EXPLAIN QUERY PLAN SELECT * FROM books WHERE category='计算机'").fetchall()
EXPLAIN QUERY PLAN 会告诉你引擎打算怎么执行这条查询。实测输出:
执行计划: SEARCH books USING INDEX idx_books_category (category=?)
★ 看到 SEARCH ... USING INDEX 就说明索引生效了;
如果是 SCAN(全表扫描),几十万行时会明显变慢。
★ 但索引不是越多越好:它会让写入变慢、占空间。
只给「经常用来查」的列建:主键、分类、时间。
索引的取舍,一句话:它用「写入变慢 + 占空间」换「查询变快」。 所以只给真正会用来查/排/分组的列建索引,常见的就是主键、分类、时间戳这几类。还有一个隐形成本别忘了------索引本身也要跟着 INSERT/UPDATE 一起维护,你每写一行,索引就要更新一次。
五、并发写锁与「内存库 / 文件库」的取舍
前面都跑得顺,但这两个问题在真实项目里几乎必然会撞上。
并发的写会撞锁
SQLite 默认是单写者 模型:同一时刻只允许一个连接写。你的多线程/多进程爬虫同时 upsert,就可能碰到 database is locked。
本工程的 _conn() 里写了 timeout=10,就是这个原因:
python
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path, timeout=10) # ← 等锁最多 10 秒
conn.row_factory = sqlite3.Row
return conn
timeout 的含义是「锁被占用时,最多等这么久再报错」。另外几个实践:
| 手段 | 作用 |
|---|---|
timeout=10 或更大 |
让后到的写等一等,而不是立刻报错 |
PRAGMA journal_mode=WAL |
读写可以并发,读不被写阻塞(推荐) |
| 写操作集中到一个线程 | 生产者抓数据,单一消费者写库,从根上避免争抢 |
WAL 模式(Write-Ahead Logging)是重点,一条语句就能开:
python
conn.execute("PRAGMA journal_mode=WAL")
开了之后,读和写不再互相挡路,多个进程读也能同时进行------多个进程写仍然要排队,这是 SQLite 的定义,绕不过去。
内存库还是文件库
本讲第①节的原理演示用的是 sqlite3.connect(":memory:"),而真正的存储用的是 DB_FILE(data/books.db)。两者别混:
:memory: |
文件库(books.db) |
|
|---|---|---|
| 生命周期 | 连接关了就没了 | 进程重启数据还在 |
| 用途 | 讲原理、跑测试、临时算 | 真正存数据 |
| 落盘 | 不落盘 | 落盘 |
| 副作用 | 无 | 需要管路径、并发锁 |
脚本注释里有一句很值得抄走的判断:
# 用 :memory: 开一个内存数据库:讲原理足够,且不产生任何文件、
# 不需要"先删掉上次的库"这种副作用(删文件这件事,能不做就不做)
演示和测试用内存库,生产用文件库------这是最省心的惯例。
换成真实站点时要注意
- 主键别想当然用 URL。 有些站的 URL 带时间戳/会话参数,每次都变。这时要么自己定业务主键(如 ISBN),要么用
utils/store.py里的fingerprint()(把若干字段拼起来算 MD5)做内容级去重。 - 字段类型要提前定。 SQLite 是弱类型,但列声明
REAL还是TEXT会影响你后面ROUND(AVG())的结果。价格、评分给REAL,标题、作者给TEXT。 - 大批量写要开事务。
executemany已经在一个事务里了,但如果你手动循环execute,记得with conn:包起来,否则每次都是一个隐式事务,慢得离谱。
本讲小结
| 能力 | 一句话要点 |
|---|---|
| 幂等 | 给数据定主键,跑 N 遍结果一样;这是「会跑第二次」的前提 |
| upsert 写法 | 用 ON CONFLICT DO UPDATE,别用 INSERT OR REPLACE(它会删行丢字段) |
excluded |
SQLite 关键字,代表「本次想插入的值」,只在 DO UPDATE 里用 |
| 增量爬取 | existing_ids() 在发请求前就跳过已有数据,省的是整个 HTTP 请求 |
| SQL 统计 | GROUP BY / AVG / ORDER BY 交给引擎,省内存又快 |
| 索引 | 用写入速度和空间换查询速度,只给常查的列建 |
| 并发 | timeout 等锁 + PRAGMA journal_mode=WAL + 写操作集中到一个线程 |
下一讲
到现在为止,我们的爬虫在理想情况下跑得很好:请求能重试、数据能去重。
但「跑 50 条」和「跑 5 万条」是两个世界。跑一整晚会暴露四个问题:
出错不知道死在哪一步、网络抖一下就丢数据、把对方网站打崩、跑到一半崩了得从头再来。
下一讲把这四件事一次补齐------日志、重试、限速、断点续爬 。
其中「限速」有个反直觉的坑:你设的间隔,可能根本不起作用 ,
因为单请求本身就比间隔还慢。