爬虫跑了一个多星期,眼看着 JSON 文件越堆越多,想查一条历史数据得靠搜索文件名,想统计每天的采集成功率只能手动写脚本数行数。这种状态我相信不少初学爬虫的朋友都经历过。数据入库这一步——用 MySQL 或 PostgreSQL 把爬下来的内容存成有结构、能查询、能去重的表——是爬虫从“能跑”走向“能用”的分水岭。
这一节我把重点放在三件事上:表设计、索引和Upsert。前两者决定你的数据存得稳不稳、查得快不快,后者解决爬虫场景里最头疼的“重复抓取”问题。不管你是刚开始用数据库,还是已经在用但总感觉哪里不对劲,这篇都值得你花十分钟读完。
1. 为什么爬虫非得用数据库,而不是继续堆文件
1.1 文件存储的三个致命问题
我在给入门朋友讲爬虫存储时,最常被问到的一句话是:“我存 CSV 不是也一样吗?Python 也能查啊。”能用,但仅限于数据量小、结构简单、你自己知道每个文件里是什么的早期阶段。
第一个问题是无约束。CSV 里没有主键、没有唯一键,同一篇文章被脚本抓了三次,文件里就会出现三行一模一样的记录。你可以在代码层面做判断,但每次跑之前都要读一遍整个文件,数据量大了之后脚本会越来越慢。
第二个问题是查询效率。你想知道“最近一周从某个新闻源采集了多少条”,用文件存储只能全量遍历,Python 这边读一遍、过滤一遍、统计一遍。数据上了十万条,这种遍历就开始卡顿;上了百万条,基本没法用。
第三个问题是并发写入。爬虫只要稍微上点规模,就会用多线程或者多进程。两个进程同时往同一个 CSV 里 append,轻则数据错位,重则把文件写坏。虽然可以用加锁的方式缓解,但加锁本身又会让采集速度大打折扣。
数据库解决的就是这些问题:约束保证数据质量,索引加速查询,事务保证并发安全,SQL 让你几秒钟就能统计出报表。
1.2 MySQL 和 PostgreSQL 怎么选
这是另一个高频问题。“新手到底学哪个?”我的回答很简单:如果你已经有一台装好的 MySQL 服务器,就用 MySQL;如果是从零开始,我推荐 PostgreSQL。这不是说 MySQL 不行,而是两个数据库的定位确实有差异。
MySQL 的优势是生态成熟、文档多、教程多。你在网上搜任何问题,几乎都能找到答案。公司里大概率也在用 MySQL,学习迁移成本低。PostgreSQL 则在功能上更“完整”——它的 JSONB 类型在存爬虫抓到的半结构化数据时非常舒服,窗口函数、并行查询、更严格的约束机制也都做得更深入。
爬虫这个场景有个特点:数据源本身是异构的。同一个字段,这个网站可能没值,那个网站可能塞了一长串 JSON。PostgreSQL 的 JSONB 能让你把不确定结构的内容直接存进去,后面还能用 SQL 检索 JSON 内的字段。MySQL 8.0 虽然也支持 JSON 类型,但好用程度还是差了那么一点。
如果硬要给一个选型标准:
- 团队或公司已有 MySQL 基础设施,直接融入,不要另起炉灶。
- 个人学习、自建项目、爬虫数据要经常做统计分析,选 PostgreSQL。
- 教程、面试考题、运维资料明显 MySQL 更多,走就业方向可以先 MySQL,原理是通用的。
这一节的后续内容,我会把两种数据库的建表、索引和 Upsert 写法都贴出来,你在自己环境里照着试就行。
2. 建表不是写字段列表,核心是把业务约束落进表结构
2.1 一张典型的爬虫文章表长什么样
下面这张表,是我在几个不同的采集项目里反复打磨过的一套结构。以抓取新闻文章为例,字段看起来不复杂,但每个字段的选型和约束都有讲究。
CREATE TABLE `news_article` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `url` varchar(500) NOT NULL, `title` varchar(255) DEFAULT NULL, `author` varchar(100) DEFAULT NULL, `source_name` varchar(100) DEFAULT NULL, `content_summary` text, `publish_time` datetime DEFAULT NULL, `extra` json DEFAULT NULL, `content_hash` char(32) NOT NULL, `crawl_status` tinyint NOT NULL DEFAULT '0', `crawl_count` int NOT NULL DEFAULT '0', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_url` (`url`), KEY `idx_hash` (`content_hash`), KEY `idx_status` (`crawl_status`), KEY `idx_source_time` (`source_name`, `publish_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;PostgreSQL 的等价写法:
CREATE TABLE news_article ( id BIGSERIAL PRIMARY KEY, url VARCHAR(1000) NOT NULL UNIQUE, title TEXT, author VARCHAR(100), source_name VARCHAR(100), content_summary TEXT, publish_time TIMESTAMPTZ, extra JSONB, content_hash CHAR(32) NOT NULL, crawl_status SMALLINT NOT NULL DEFAULT 0, crawl_count INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_hash ON news_article(content_hash); CREATE INDEX idx_status ON news_article(crawl_status); CREATE INDEX idx_source_time ON news_article(source_name, publish_time);2.2 每个关键字段的“为什么”
先说url。唯一键建在 URL 上是必须的。爬虫的天然去重逻辑就是 URL——同一个地址你就应该只存一条。别以为代码里判断过了就没事,多线程、多进程下极容易出竞态,数据库加唯一约束是最硬的一道防线。
再说content_hash。这是很多人忽略的字段。URL 唯一只能保证“同一个链接没重复”,但有些网站的内容会被多个链接指向(比如转载、短链接、带参数的入口)。这时候 URL 判断就不够用了,我用content_hash存正文内容的 MD5,建普通索引用于查找,发现 hash 相同但 URL 不同的数据,就可以在代码层面做合并。为什么不能给content_hash也加唯一索引?因为转载场景里你可能还是想保留两条不同来源的记录,只是标记出来,让业务自己决断。这是爬虫与一般业务表的一个差异:键的唯一性设计要跟着数据的实际含义走。
然后是crawl_status和crawl_count。爬虫一定会遇到网络超时、解析失败、反爬拦截,这两字段就是记录这些状态用的。crawl_status我建议只用 0、1、2 三个数字:0 表示待处理/采集失败待重试,1 表示成功,2 表示明确放弃(比如页面内容不符合预期)。crawl_count记录重试次数,超过上限就变成 2。有了这两个字段,你随时可以用一条 SQL 统计出采集成功率,而不是靠猜。
extra字段是万能口袋。不同网站的字段千奇百怪:阅读量、点赞数、封面图、作者主页地址……这些字段不值得都建列,统一塞进 JSON(extra) 里最省事。MySQL 里用json,PostgreSQL 用jsonb。存 JSON 不等于不能查,两边的数据库都支持通过 JSON 路径表达式做条件查询。
2.3 字符集、时区、ID 选型的细节
MySQL 之所以要把字符集明确写成utf8mb4,不是随便选的。MySQL 的utf8实际最多只支持 3 个字节,存不了 emoji 和生僻字,而爬虫页面里出现表情符号的概率比你想象的高得多,一存就报错或变成乱码。排序规则我习惯用utf8mb4_unicode_ci,它在按中文拼音排序、大小写处理上更符合业务直觉。
时区这个坑更是隐蔽。MySQL 里timestamp和datetime的区别,很多入门资料讲得云里雾里,实操结论是:建表列用timestamp,Python 写入时统一用datetime.now()这种带本地时区的对象,千万别在爬虫脚本里手拼字符串时间。PostgreSQL 那边直接用timestamptz类型,它会在内部帮你处理时区转换,查出来的是带时区的时间,最省心。
ID 选型我建议直接用自增主键,别用 UUID。自增 ID 在 InnoDB 里遵循聚簇索引规则,新数据永远追加到末尾,写入性能和空间利用率都是最优的。UUID 是无序的,会导致索引频繁分裂页,数据量大以后写入性能下降非常明显。UUID 作为业务标识可以,但主键不要。
3. 索引不是越多越好:爬虫场景下的取舍标准
3.1 先搞懂索引解决什么问题
索引本质上就是“书的目录”。没有目录的书,你只能从头翻到尾才能找到一个字——这叫全表扫描;有目录后,按下标作物探头快速定位——这叫索引查找。对爬虫场景来说,建的索引本质上是在回答三个问题:按什么条件去重、按什么条件筛选、按什么条件排序统计。
拿刚才那张表举例:
- 唯一索引
uk_url负责去重。这个索引最重要的特性不是“加快查询”,而是“确保约束”,宁可不用它查数据,也不能没有它。 - 普通索引
idx_hash负责查重。重试任务时,我需要快速知道这篇内容是否已经入库。 - 普通索引
idx_status负责筛选待重试队列,idx_source_time负责按来源和时间段做统计报表。
这里有一个非常重要的取舍原则:爬虫是典型的高频写、低频读应用。每个索引都会增加写入成本,因为你每插一条记录,数据库都要同步维护多个索引树。所以索引必须“按需建”,而不是把能想到的字段都加一遍。
3.2 三个容易犯错的索引设计
第一个错误是给低区分度字段单独建索引。比如crawl_status只有 0、1、2 三个值,我的idx_status其实在数据量大以后几乎不会被优化器使用。为什么?因为优化器发现查出来的行数占总行数比例太高,干脆直接全表扫描更快。这时真正有效的组合是(crawl_status, crawl_count)这种复合索引——先按状态过滤,再按重试次数排序。我实际项目里,idx_status已经删了,换成了(crawl_status, crawl_count)。
第二个错误是盲目做“覆盖索引”。很多文章会把(source_name, publish_time)这类组合索引说得神乎其神,但爬虫表不是报表系统。如果查询里只有一个统计需求,就不值得为它多建一个索引。我建idx_source_time的前提是,每半小时会跑一次按来源分组、按发布时间排序的统计 SQL,否则这个索引完全没必要。
第三个错误是查询时对索引列做函数运算。比如你建了publish_time索引,SQL 里却写WHERE DATE(publish_time) = '2024-01-01',这会导致索引失效,因为数据库必须先对每行做函数计算才能比较。正确写法是范围查询:WHERE publish_time >= '2024-01-01 00:00:00' AND publish_time < '2024-01-02 00:00:00'。
3.3 用 EXPLAIN 验证索引是否生效
建完索引,别急着高兴,用EXPLAIN看一眼执行计划,你能直接看到数据库到底走了哪个索引、扫了多少行。
MySQL 看:
EXPLAIN SELECT * FROM news_article WHERE source_name = '某新闻站' ORDER BY publish_time DESC LIMIT 10;PostgreSQL 看:
EXPLAIN SELECT * FROM news_article WHERE source_name = '某新闻站' ORDER BY publish_time DESC LIMIT 10;重点看 type 列不是ALL(全表扫描),key 列里有索引名,rows 估算的扫描行数远小于总行数。我见过新手建了一堆索引,结果 SQL 写得不匹配,索引一个都没用上。所以每次调整 SQL,都该养成先EXPLAIN再跑的习惯。
提示:索引能有效的前提是数据分布正常。如果表就几百行,优化器大概率连索引都懒得用,直接扫描更快,这是正常现象,不是索引没建好。
4. Upsert 才是爬虫入库的灵魂:两种数据库的同一种思想
4.1 没有 Upsert 之前我们怎么做
“有就更新,没有就插入”,这个操作在数据库术语里叫Upsert(Update + Insert 的合成词)。为什么爬虫场景必须用这个东西?
你把爬虫任务每天跑一次,同一个 URL 在第二次采集时,页面内容大概率已经有变化(发布时间没变,但阅读量变了、正文可能修订了)。你总不能先删掉旧记录再插一条新的吧?这样会导致 ID 变化,如果数据已经被别的表引用了,关联就断了。传统的先查再改是这么写的:
SELECT id FROM news_article WHERE url = ?- 没查到,执行
INSERT。 - 查到了,执行
UPDATE。
这段逻辑看起来没问题,但实际运行有两个麻烦:一是多一次网络往返,批量处理时性能差一倍;二是多线程环境下,两个进程同时执行到第 1 步都查不到,然后都执行 INSERT,其中一个就会撞唯一索引报错。Upsert 把这三步合并成一个原子操作,冲突交给数据库判断,性能和正确性同时解决。
4.2 MySQL 的写法与版本注意点
MySQL 里 Upsert 的标准语法是INSERT ... ON DUPLICATE KEY UPDATE。它会试着插入数据,如果遇到主键或唯一键冲突,就转去执行 UPDATE 子句。
INSERT INTO news_article (url, title, content_summary, author, source_name, publish_time, extra, content_hash, crawl_status, crawl_count) VALUES ('https://example.com/a.html', '标题', '摘要', '作者', '来源', '2025-01-01 10:00:00', '{}', 'abc123...', 1, 1) AS new ON DUPLICATE KEY UPDATE title = new.title, content_summary = new.content_summary, crawl_count = new.crawl_count, crawl_status = new.crawl_status, updated_at = CURRENT_TIMESTAMP;这里用了 MySQL 8.0.20 之后的推荐写法:AS new,然后 UPDATE 里直接引用new.字段拿到新插入的值。老版本用的是VALUES(title)这种函数写法,在新版本里已经标记为废弃了。我为什么专门提这个?因为我见过很多老教程和 AI 生成的代码还在用VALUES(),你在 MySQL 8.0.20+ 跑起来虽然不报错,但会刷弃用警告,而且官方已经在去掉这个功能了,先学新写法更稳。
需要注意ON DUPLICATE KEY UPDATE的“重复”判断规则:它检查的是所有唯一索引和主键,只要有一个冲突就算重复。所以如果你表上的content_hash也加了唯一索引,那 hash 撞了也会走 UPDATE,这一点在设计唯一约束时要想清楚。
4.3 PostgreSQL 的写法与冲突控制
PostgreSQL 用的是INSERT ... ON CONFLICT语法,逻辑上和 MySQL 一样,但表达更精确,它能明确指定到底在哪个键上做冲突检测。
INSERT INTO news_article (url, title, content_summary, author, source_name, publish_time, extra, content_hash, crawl_status, crawl_count) VALUES ('https://example.com/a.html', '标题', '摘要', '作者', '来源', '2025-01-01 10:00:00', '{}', 'abc123...', 1, 1) ON CONFLICT (url) DO UPDATE SET title = EXCLUDED.title, content_summary = EXCLUDED.content_summary, crawl_count = EXCLUDED.crawl_count, crawl_status = EXCLUDED.crawl_status, updated_at = NOW();ON CONFLICT (url)明确告诉数据库只在 url 冲突时执行更新。如果你只想“重复就跳过”,用ON CONFLICT (url) DO NOTHING,这在采集日志表里特别好用——记一下“今天抓了哪些 URL”,重复抓过就跳过不处理。
EXCLUDED是 PG 里的一个伪表,表示那些“本来想插入但因为冲突被拦下来”的新数据行。在 UPDATE SET 里引用EXCLUDED.字段,就等于把新值赋给旧记录。
4.4 两种语法的一页对照
| 对比项 | MySQL | PostgreSQL |
|---|---|---|
| 语法 | ON DUPLICATE KEY UPDATE | ON CONFLICT (...) DO UPDATE / DO NOTHING |
| 冲突范围 | 任何主键或唯一键冲突 | 可指定具体约束或列 |
| 引用新值 | 新版本用AS new,旧用法VALUES()已弃用 | 用EXCLUDED |
| 指定冲突目标 | 不能指定 | 可以指定 |
| 受影响行数 | 插入=1,更新=2,无变化=0 | 插入=1,更新=2,DO NOTHING 跳过=0 |
两组返回值需要重点理解:MySQL 的更新会返回2,PG 的更新也返回2,如果你在 Python 里通过cursor.rowcount统计成功条数,千万别把更新的行当成新插入的重复计算,否则你会觉得“怎么多了”。我在早期项目里就因为没注意这个,把采集完成率统计错了。
提示:PostgreSQL 15 起也开始支持标准的
MERGE语句,功能覆盖 Upsert 之外还能做更复杂的条件合并。但日常爬虫场景里,ON CONFLICT已经够用了,保持简单。
5. Python 实操:批量 Upsert 的完整代码
5.1 环境准备与连接串
先装驱动,MySQL 用pymysql,PostgreSQL 用psycopg2-binary(binary 版自带编译好的依赖,省去一堆麻烦):
pip install pymysql psycopg2-binary连接字符串的写法,两边都非常接近:
# MySQL conn = pymysql.connect( host="127.0.0.1", user="root", password="your_password", database="spider_db", charset="utf8mb4", autocommit=False, ) # PostgreSQL import psycopg2 conn = psycopg2.connect( host="127.0.0.1", user="postgres", password="your_password", dbname="spider_db", )如果你在连接时遇到mysql ssl connection error或can't connect to local mysql server through socket这种报错,99% 是服务没起来、密码不对、或者 MySQL 绑定在本地 socket 上而你用 TCP 去连了。这种问题排查顺序:先确认服务在跑(systemctl status mysql),再确认端口开着(netstat -an | grep 3306),最后确认用户权限。别一上来就卸载重装。
5.2 MySQL 版本的批量 Upsert 封装
爬虫是批量产数据的,一条一条插入效率太低。我用executemany配合批量 SQL,一次塞几百条。下面这个函数可以直接复制改字段名用:
import pymysql import hashlib import json def build_hash(text): return hashlib.md5(text.encode("utf-8")).hexdigest() def upsert_articles_mysql(conn, items): sql = """ INSERT INTO news_article (url, title, content_summary, author, source_name, publish_time, extra, content_hash, crawl_status, crawl_count) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) AS new ON DUPLICATE KEY UPDATE title = new.title, content_summary = new.content_summary, crawl_count = new.crawl_count, crawl_status = new.crawl_status, updated_at = CURRENT_TIMESTAMP """ rows = [] for item in items: summary = (item.get("content") or "")[:300] rows.append(( item["url"], item.get("title"), summary, item.get("author"), item.get("source_name"), item.get("publish_time"), json.dumps(item.get("extra", {}), ensure_ascii=False), build_hash(item.get("content") or ""), item.get("crawl_status", 1), item.get("crawl_count", 1), )) with conn.cursor() as cur: cur.executemany(sql, rows) conn.commit()有几个容易翻车的细节:
summary字段只存前 300 字,避免大文本拖慢写入。完整正文如果没有业务需求就不要入库,真要存就放content_summary之外的独立大字段表。extra字段必须json.dumps序列化。很多新手直接往里塞 dict,MySQL 驱动会报错。PostgreSQL 那边也一样,但 psycopg2 会自动把 dict 转成 json。publish_time传 Python 的datetime对象,不要传字符串。传字符串容易踩时区、格式错误,驱动转换时还会多一步。
5.3 PostgreSQL 版本的批量 Upsert 封装
PostgreSQL 里我推荐用psycopg2.extras.execute_values,它比executemany快得多,尤其批量大时差距非常明显:
import psycopg2 from psycopg2.extras import execute_values def upsert_articles_pg(conn, items): sql = """ INSERT INTO news_article (url, title, content_summary, author, source_name, publish_time, extra, content_hash, crawl_status, crawl_count) VALUES %s ON CONFLICT (url) DO UPDATE SET title = EXCLUDED.title, content_summary = EXCLUDED.content_summary, crawl_count = EXCLUDED.crawl_count, crawl_status = EXCLUDED.crawl_status, updated_at = NOW() """ rows = [] for item in items: summary = (item.get("content") or "")[:300] rows.append(( item["url"], item.get("title"), summary, item.get("author"), item.get("source_name"), item.get("publish_time"), json.dumps(item.get("extra", {}), ensure_ascii=False), build_hash(item.get("content") or ""), item.get("crawl_status", 1), item.get("crawl_count", 1), )) with conn.cursor() as cur: execute_values(cur, sql, rows, page_size=500) conn.commit()page_size=500表示每 500 条组成一个值块发送,既不会让单条 SQL 过大,又能显著减少网络往返。批量的大小不是越大越好,我试过一次塞 5000 条,MySQL 和 PG 的反应都是占用的内存和锁时间明显增加,最后稳定在 300 到 500 条比较舒服。
5.4 为什么批量时要把脏数据先“洗”一遍
在实际抓取中,你永远不能指望上游数据长成你想要的样子。同一个字段,A 站给的是None,B 站给的是空字符串,C 站给的是一个带 HTML 标签的字符串。在拼 SQL 之前,统一转成你的表结构要的类型,这是入库前最重要的一步。
我在上面封装函数里做的这几件事是固定项:
- 标题、来源、作者等字符串字段:strip() 去首尾空白,None 换成空字符串。
- 正文摘要:截断到固定长度,避免 text 字段里塞入几百万字符的大傻瓜数据。
- 内容 hash:基于处理过的正文计算,保证同样内容算出的 hash 一致。
- 时间字段:能解析的解析成 datetime,解析不了的给 None,让数据库默认值兜底。
别小看这几步,你入库的数据干净程度直接决定后期的统计能不能信。
6. 入库后必然会踩的坑:我把翻车现场翻出来给你看
6.1 时间字段的隐雷:TIMESTAMP 与 DATETIME / TIMESTAMPTZ
MySQL 的timestamp类型有个年份上限(2038 年),国内很多教程干脆推荐全表用datetime,但我的实践是:爬虫数据表用timestamp就够,配合CURRENT_TIMESTAMP 默认值和ON UPDATE CURRENT_TIMESTAMP,省心。PostgreSQL 就固定用timestamptz,没有任何 2038 问题,自动带时区。
真正需要小心的场景是你把时间当字符串传进去。MySQL 在宽松模式下会自动帮你把'2025-01-01 10:00:00'转成日期,但如果传进去'2025/01/01 10:00'或带中文的格式,就会直接报错或存成0000-00-00。你在代码里统一先datetime.strptime处理一遍,比在数据库那边调宽容模式靠谱。
6.2 唯一索引冲突导致整批失败的场景
executemany批量执行时,如果数据里有一条已经存在且按业务规则不该更新它,整个事务会抛异常回滚吗?答案是:会。默认事务里,一条冲突会让整批失败。
我踩过一次很深的坑:某次采集任务里,因为页面 URL 带有参数,导致一个真实文章的 URL 生成了几十个变体,批量插入时报了唯一键冲突,结果整批数据都没写进去,日志里全是 Duplicate entry。当时的解决办法是把冲突数据“吞掉”,全部转成更新逻辑。如果是“重复就跳过”的场景,最稳妥的方案是在代码里先分组:要插入的走 INSERT,要更新的走 UPDATE,避免一个 Upsert 语句里混入两种意图。
如果一定要在批量语句里忽略冲突,PostgreSQL 可以这样:
INSERT INTO ... VALUES %s ON CONFLICT DO NOTHINGMySQL 的INSERT IGNORE也能忽略重复,但它的副作用是:
- 其他所有错误(类型转换失败、字段超长)也会被静默忽略。
- 无法区分哪些被插入了、哪些被忽略了。
所以INSERT IGNORE我只在采集日志这类“丢了也无所谓”的表里用,业务数据坚决不用。
6.3 爬虫脚本与数据库之间别忘了连接池
早期我的爬虫脚本里,每个线程各开一个数据库连接,用完就关。数据量小的时候没感觉,线程一多,数据库频繁建连、断连,MySQL 报Too many connections,PG 报连接拒绝,整个采集任务直接卡死在等连接上。
后来我换成了连接池。MySQL 用dbutils的PooledDB,PostgreSQL 用psycopg2.pool.ThreadedConnectionPool,或者干脆用 SQLAlchemy 的engine统一管。核心思想就一句话:连接是稀缺资源,必须池化复用,不能每次用都新建。爬虫进程只要启动,连接池里保持 5 到 10 个连接,线程来取、用完归还,稳定多了。
# MySQL 连接池示例 from dbutils.pooled_db import PooledDB import pymysql pool = PooledDB( creator=pymysql, maxconnections=10, host="127.0.0.1", user="root", password="your_password", database="spider_db", charset="utf8mb4", ) conn = pool.connection() try: with conn.cursor() as cur: cur.execute("SELECT 1") cur.fetchone() finally: conn.close() # 归还连接而非真正关闭连接池还有一个附带好处:它天然帮你规避了“长连接断开后第一次查询报错”的问题。MySQL 的wait_timeout默认是 8 小时,爬虫跑久了,旧连接被服务端断开,下次查询会报MySQL server has gone away。连接池里对连接做保活和重建,基本不再遇到这个报错。
6.4 备份与迁移别等到出事才想起来
爬虫数据是自己一点点攒下来的,丢了等于白干。至少要养成两个习惯:
- 定期用
mysqldump或pg_dump备份表数据。哪怕一周一次,也比没有强。 - 表结构变更前,先备份旧表结构,用
CREATE TABLE news_article_bak LIKE news_article这种轻量方式做一次结构快照,然后在快照上做变更测试,跑通了再改真表。
PostgreSQL 迁移到 MySQL,或者反过来,最省事的是导入导出一致的数据格式(CSV/JSON),再在新库重建表。列类型对应关系要人工核对,常见坑包括:PG 的TEXT与 MySQL 的TEXT对应没问题,但 PG 的BOOLEAN到 MySQL 得转TINYINT(1),PG 的JSONB到 MySQL 得转JSON。
数据库备份和迁移属于“平时用不上、用上救一命”的活,入门爬虫时顺手学一下,成本很低,收益很高。
7. 批量环节的性能调优思路和我的实测数据
7.1 为什么 execute_values 比逐条插入快这么多
我给你一组我本地实测的参考数据(普通笔记本,MySQL 8.0 / PostgreSQL 16,10 万条新闻数据):
- 逐条 INSERT + 逐条 commit:MySQL 约 450 秒,PG 约 520 秒。
executemany+ 一次 commit:MySQL 约 20 秒。insertmany/execute_values批量写入 + 一次 commit:MySQL 约 13 秒,PG 约 11 秒。
差距的核心不是数据库快,而是减少了网络往返和事务提交次数。逐条 commit 时每次都要等待磁盘刷盘、日志落盘,批量把 500 条凑成一个大事务,整体只需要一次 commit,性能差一到两个数量级很正常。
7.2 批量 Upsert 的推荐实践顺序
在实际项目里,我是怎么设计的?简单说,入库任务分三阶段:
- 采集阶段:只负责抓页面、解析字段,产出干净的 item 列表,先打到内存队列,不用管数据库。
- 攒批阶段:攒够 200 到 500 条,或者超时 5 秒,触发一次批量 Upsert。
- 对账阶段:每次批量执行完,看
cursor.rowcount,更新采集统计,把失败的单独进重试队列。
这个过程的优雅之处在于:Upsert 的幂等性让重复跑任务变得安全。某个批次中途崩了,下次重跑同一个 URL,数据库自动更新而不是重复插,统计数字也不会虚高。
7.3 什么时候该上消息队列
如果你的爬虫已经大到需要多台机器协同,数据入库往往不再直接连库,而是往 Kafka / Redis 队列里丢,消费者再从队列里批量写库。我个人的经验是:单机多进程 + 数据库连接池 + 批量 Upsert 能撑到每天几百万条,这之前完全不需要引入消息队列。等真的到了那一步,你再回来学消息队列,认知会更清晰——知道数据库写入瓶颈在哪,就知道队列为什么要做削峰填谷。
8. 最后再聊几句我一路走来的体会
数据库这块内容,看起来是“存储”这个不起眼的小环节,但它实际上决定了爬虫项目的上限。文件存储的爬虫是玩具,数据库入库的爬虫才是工具,能长期跑、出报表、支撑业务分析的工具。
我见过太多初学朋友把精力全砸在反爬和解剖网页上,结果数据存得一团乱。能在爬虫项目一开始就认真设计表结构、建对索引、用对 Upsert 的人,后面做数据可视化、机器学习训练集改造、业务统计,都会顺利得多。
如果你想动手实操,建议从 PostgreSQL 版开始。把建表 SQL 敲进去,用ON CONFLICT (url) DO UPDATE反复插入同一条 URL 十几次,看看最终表里是不是只有一条记录,crawl_count是不是在涨。把crawl_status改成不同值跑几轮查询,顺便看看EXPLAIN的扫描行数变化。数据库这东西,只有亲手碰过一个细节,才能真正长进自己的肌肉记忆里。
这一节就到这里。下一部分我会继续讲爬虫数据存储进阶:分表、分区、数据归档,以及爬虫表和业务表之间的关联设计。如果你在实操中有哪一步卡住了,或者遇到了我没提到的报错,带着报错信息去查官方文档之外,也欢迎先把自己那套表结构和索引设计贴出来互相对比一下,很多问题都是在比较里看明白的。