Spider-第10讲-SQLite持久化与去重

第 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 万条」是两个世界。跑一整晚会暴露四个问题:

出错不知道死在哪一步、网络抖一下就丢数据、把对方网站打崩、跑到一半崩了得从头再来。

下一讲把这四件事一次补齐------日志、重试、限速、断点续爬 。

其中「限速」有个反直觉的坑:你设的间隔,可能根本不起作用 ,

因为单请求本身就比间隔还慢。

相关推荐
乌暮2 小时前
Spring AOP 与 Spring 事务:用大白话彻底理解原理与实践
java·数据库·spring boot·后端·sql·学习·spring
小此方2 小时前
Re:Mysql数据库基础篇(十二):什么是事务、事务解决了什么问题、事务的四大属性、事务的操作方式
数据库·mysql
flash俊杰4 小时前
v0.5-01 让桌面 AI 助手记得住:预算组装、递归摘要、长期记忆与评估基线
数据库·安全·架构
2501_933923254 小时前
redis:string类型常见命令
数据库·redis·缓存
byte轻骑兵5 小时前
时序数据库选型指南:Apache IoTDB 社区版实践与踩坑
数据库·人工智能·apache·时序数据库·iotdb
黄连木笛6 小时前
Redis Insight 汉化教程:Windows / macOS / Linux 设置中文界面(附繁體、教程汉化、关闭自动更新)
数据库·redis·缓存
nice_lcj5206 小时前
MySQL 存储引擎入门:InnoDB 和 MyISAM 到底怎么选
java·数据库·mysql·面试·innodb·存储引擎·myisam
wuyk5557 小时前
Spider-第09讲-三格式落盘CSV-Excel-JSON
爬虫·python·json·excel
古典和浪漫7 小时前
MVCC机制
数据库