做爬虫的人,迟早会遇到一个问题:数据明明抓下来了,但入库越来越慢。刚开始只有几千条的时候,怎么都好说,程序随便写一下就行;等爬到几万、几十万条,控制台里的光标就像卡住了一样,数据库服务端日志里全是 INSERT 语句,一条一条排队执行,看了一会儿你可能会忍不住直接 Ctrl+C。这篇文章围绕“爬虫结果存入 MySQL:批量插入优化”这个主题,把我在实际项目里踩过的坑、用过的方案、最终稳定下来的写法都梳理出来。内容包括逐条插入为什么慢、批量插入的几种实现方式、批次大小怎么选、常见报错怎么排查。适合用 Python 写爬虫、把数据落地到 MySQL 的开发者参考,也适合刚开始学爬虫、还没意识到入库效率问题的新手。
1. 爬虫数据入库,瓶颈到底在哪?
1.1 逐条 INSERT 为什么慢
要理解批量插入为什么有用,得先搞清楚逐条插入慢在哪里。我用 requests 抓页面,再用 BeautifulSoup 解析,得到一条一条的结构化数据,如果按最直接的方式写:
python复制conn = pymysql.connect(...)
cursor = conn.cursor()
for item in items:
cursor.execute("INSERT INTO article(...) VALUES (%s, %s, ...)", item)
conn.commit()
这段代码在数据量小的时候没什么问题,但一旦数据量上来,性能会断崖式下跌。原因有几个:第一,每条 INSERT 都是一次独立的客户端到服务端往返,网络延迟哪怕只有 0.5ms,一万条数据就有 5 秒浪费在往返上;如果数据库和爬虫不在同一台机器,这个延迟会放大到几十毫秒甚至上百毫秒。第二,每条 INSERT 在执行时要经历 SQL 解析、权限检查、生成执行计划、索引更新等一系列环节,虽然单条很快,但累积起来就很可观。第三,如果开启了 autocommit,每条 INSERT 都会触一次事务提交,InnoDB 要刷 redo log、维护 undo 信息,这些磁盘操作远比想象中昂贵。换句话说,真正花在我们这条业务数据上的时间很少,大部分时间都耗在“建立连接、来回传 SQL、等待提交确认”这些外围动作上。把成千上万条数据重复做这些外围动作,自然就慢了。
也可以用生活类比来理解:就像去快递驿站寄一百个包裹,如果你每个包裹都单独去一趟驿站,光是路上往返就够耗一天。批量插入相当于把所有包裹打包放在一辆车上,一起送到驿站一次性处理,效率是肉眼可见的差距。
1.2 批量插入为什么能解决
批量插入的核心思路,就是把原来 N 次的数据库交互合并成一次或少数几次。这种合并带来的收益是全方位的。
一是网络往返次数从 N 次降到 1 次。对大表、大文本字段来说,SQL 体积本身也大,但如果合并成一条多值 INSERT,MySQL 只需要接收一次请求、返回一次结果,网络开销能减少一个量级。二是事务提交次数大大降低。原本每条记录一个事务,现在一批记录一个事务,InnoDB 只需要做一次日志刷盘、一次 binlog 写入,整体耗时自然就降下来了。三是 MySQL 服务端可以做更多的优化。比如一条多值 INSERT 在执行计划里共享一次解析结果,行数据也可以更紧凑地写入 InnoDB 的页中;即使逐条执行也能完成相同的数据落盘,但合并后的执行路径更高效。
但批量插入不是没有代价。单次 SQL 过大会消耗更多内存,事务过大可能带来更长的行锁持有时间,如果某个批次中途失败,需要回滚的数据范围也更大。所以“批量”不是无脑堆量,而是要在吞吐和安全之间找一个平衡点,这也是后面要重点展开的部分。
1.3 优化目标与适用场景
这个项目里,我的优化目标很明确:在不改动爬虫采集逻辑的前提下,把一万条数据从“插入近 30 秒”降到“1 秒以内”。也就是说,抓取、解析、清洗那些逻辑保持原样,只改数据入库这一段。
这种优化最适合的场景有三个:抓取的列表页和详情页数量大、单条数据字段多且包含长文本(比如文章正文、商品描述)、数据库部署在远程服务器上。如果你只是一天抓几百条数据,那确实没必要折腾,老老实实逐条插入也不会慢到哪去。但如果你做的是全网采集、历史数据回填、或者需要持续跑 7×24 的采集任务,批量插入就是必须跨过的一道坎。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种主流批量插入方案怎么选
2.1 方案一:executemany 批量执行
最省心的方法是用 Python 数据库驱动自带的 cursor.executemany(),它接收一条带占位符的 SQL 和一组参数列表,底层会把多条数据合并成一次执行流程。我用 pymysql 的示例是这样的:
python复制import pymysql
def batch_insert_executemany(items, batch_size=200):
conn = pymysql.connect(
host="127.0.0.1",
port=3306,
user="root",
password="your_password",
database="spider_db",
charset="utf8mb4",
autocommit=False
)
cursor = conn.cursor()
sql = """
INSERT INTO article (source_url, title, content, author, published_at)
VALUES (%s, %s, %s, %s, %s)
"""
try:
for i in range(0, len(items), batch_size):
batch = items[i:i + batch_size]
cursor.executemany(sql, batch)
conn.commit()
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
conn.close()
executemany 的好处是代码改动最小,SQL 还是原来那条,只是外层从 execute 换成了 executemany,参数从单条元组变成一批元组。PyMySQL 在处理 INSERT/REPLACE 语句时,会尝试把同一段 SQL 的多条参数合并成多值形式,所以网络交互和单条拼接 SQL 差不多。这个方案比较适合“存量代码已经是 pymysql 逐条插入、想最快速度优化”的情况。
要注意的是,executemany 并不保证所有驱动都会合并 SQL,有些驱动其实就是循环执行,所谓优化只体现在 API 层面。如果发现性能提升不明显,就要考虑换多值拼接方案。
2.2 方案二:拼接多值 SQL
多值拼接是性能上限最高的一种方式,也是很多高吞吐写入脚本采用的做法。核心是构造一条类似下面这种 SQL:
sql复制INSERT INTO article (source_url, title, content, author, published_at)
VALUES
('url1', 'title1', 'content1', 'author1', '2024-01-01 10:00:00'),
('url2', 'title2', 'content2', 'author2', '2024-01-01 10:00:01'),
...
('urln', 'titlen', 'contentn', 'authorn', '2024-01-01 10:00:0n');
在 Python 里,我习惯用占位符拼 SQL,再把参数铺平成一位数组传进去,这样既能享受一条 SQL 的高效,也不会因为字符串拼接引入 SQL 注入风险。核心代码是这样的:
python复制def batch_insert_multi_values(conn, items, batch_size=200):
sql_prefix = """
INSERT INTO article (source_url, title, content, author, published_at)
VALUES
"""
values_tpl = "(%s, %s, %s, %s, %s)"
cursor = conn.cursor()
try:
for i in range(0, len(items), batch_size):
batch = items[i:i + batch_size]
placeholders = ", ".join([values_tpl] * len(batch))
sql = sql_prefix + placeholders
flat_args = [value for row in batch for value in row]
cursor.execute(sql, flat_args)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cursor.close()
这个方案在数据量稳定、字段结构固定的场景下非常稳,一万条数据往往只需要几批就能完成。但要注意几个问题:SQL 长度会随着批次变大而增长,默认情况下 MySQL 服务端的 max_allowed_packet 是 4MB 或 16MB,超过这个限制会直接报错或者说连接断开;批次也不能无限大,否则客户端生成 SQL 和参数列表占用的内存也会同步上涨。实际使用中,我一般控制在每批 200 到 1000 条之间,具体取决于单条数据的长度。
2.3 方案三:分批事务 + 本地暂存
如果爬虫是持续运行的,数据不是一次性全部就绪,那我更推荐“先暂存、再分批入库”的模式。也就是爬虫解析完一条数据后,不要立刻写库,而是先追加到一个内存队列或者本地文件里;攒够一定数量,或者每隔一个固定时间,再把这一批数据提交到 MySQL。
这样做有几个实际好处:首先,它能把“抓取”和“入库”两个环节解耦。爬虫网络请求的延时波动很大,如果每抓一条就等着写库,整体吞吐会被数据库拖累;反过来,如果数据库暂时不可用,本地暂存的数据也不会丢失。其次,内存队列天然适合做批次控制,比如每攒满 500 条就刷一次库,不会出现“一条条提交流程每次都启动事务”的开销。最后,本地暂存文件还可以作为备份,万一库表结构后期要改,我们可以重新回放数据。
我常用的一个简化版结构是:爬虫解析结果 append 到一个 list,当 len(list) >= 500 就触发批量写入,写完清空 list。如果爬虫结束还有剩余数据,也再 flush 一次。配合 finally 确保连接关闭,整体逻辑并不复杂。
2.4 三种方案对比
| 方案 | 性能 | 代码改动量 | 安全性/稳定性 | 适用场景 |
|---|---|---|---|---|
| executemany | 中高 | 最小 | 较安全,依赖驱动实现 | 快速改造现有代码 |
| 多值拼接 SQL | 最高 | 中等 | 注意 SQL 长度和注入风险,使用占位符规避 | 大量历史数据回填、高吞吐写入 |
| 分批事务 + 本地暂存 | 高 | 较大 | 稳定,适合持续采集 | 7×24 抓取任务、断点续跑 |
选择建议很简单:如果你只是想把手头的爬虫脚本改成不卡库,先用 executemany;如果你追求极致的入库速度,并且能控制好字段长度,就上多值拼接;如果你的场景是长期运行的采集任务,务必加上本地暂存和分批提交,这是最不容易翻车的组合。
3. 手把手把爬虫入库改成批量插入
3.1 表结构设计:从源头避坑
任何入库优化都离不开表结构设计。很多爬虫新手建表非常随意,字段全部用 TEXT 或者 VARCHAR(255),索引也乱加,结果写入当然慢。我的建议是:
第一,字段类型尽量贴近真实数据。比如 URL 用 VARCHAR(500) 就够了,不要给成 TEXT;时间字段用 DATETIME 还是 TIMESTAMP 要想清楚;正文这种长文本才用 TEXT 或 MEDIUMTEXT。字段越窄,单条记录越小,同样一批 SQL 能携带的数据就越多,写入速度自然越快。第二,只建必要的索引。主键和业务唯一键是必须的,其他字段如果不是高频查询条件,不要急着加索引,因为每插入一行数据,所有二级索引都要同步更新,索引越多写入越慢。第三,如果有去重需求,一定要用唯一索引来约束,而不是靠业务代码里的 if 判断。
我这边文章表的简化建表语句如下,其中 source_url 加了唯一索引,方便做幂等去重:
sql复制CREATE TABLE article (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
source_url VARCHAR(500) NOT NULL,
title VARCHAR(255) NOT NULL,
content MEDIUMTEXT,
author VARCHAR(100) DEFAULT NULL,
published_at DATETIME DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_source_url (source_url)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.2 数据准备:先暂存再入库
在真实爬虫里,抓取、解析、入库通常是三个板块。不建议把入库逻辑直接嵌在解析函数里,否则后续改批次、改去重逻辑都很痛苦。我一般会在解析函数里把数据组装成一个元组,return 给上层调用方,由调用方统一 append 到一个列表里。列表攒够一批后,再调用统一入库函数。
假设每个被抓对象的清洗结果是这样一条元组:
python复制item = (
"https://example.com/news/12345",
"这里是一条新闻标题",
"这里是正文内容……",
"作者名",
"2024-01-01 10:00:00"
)
批量入库前,还可以做一个前置去重:把当前批次的 source_url 集合拿出来,跟数据库里已有的 URL 做一次 IN 查询,过滤掉已经存在的记录,减少无效写入。小批量时这个查询开销可以接受,大批量时不如直接依赖唯一索引,让 MySQL 在插入时做判断。
这种“先暂存再入库”的写法不仅让代码结构更清晰,还意味着你可以在写入前做统一的清洗、过滤、排序,甚至把原始数据先存一份 JSON 留作日志。真正出问题时,回放这些暂存数据就能定位是采集环节还是入库环节出了问题。
3.3 核心代码:分批提交版本
接下来给出一个完整的、带分批提交的入库函数。它接收一个 items 列表,内部按 batch_size 切块,使用多值拼接 SQL,并且处理了重复键和连接异常的情况。
python复制import pymysql
BATCH_SIZE = 500
def flush_to_mysql(items):
if not items:
return 0
conn = pymysql.connect(
host="127.0.0.1",
port=3306,
user="root",
password="your_password",
database="spider_db",
charset="utf8mb4",
autocommit=False
)
cursor = conn.cursor()
sql_prefix = """
INSERT IGNORE INTO article
(source_url, title, content, author, published_at)
VALUES
"""
values_tpl = "(%s, %s, %s, %s, %s)"
inserted = 0
try:
for i in range(0, len(items), BATCH_SIZE):
batch = items[i:i + BATCH_SIZE]
placeholders = ", ".join([values_tpl] * len(batch))
sql = sql_prefix + placeholders
flat_args = [value for row in batch for value in row]
affected = cursor.execute(sql, flat_args)
inserted += affected
conn.commit()
except pymysql.err.IntegrityError as e:
conn.rollback()
print("批量写入遇到唯一键冲突,已回滚:", e)
raise
except Exception as e:
conn.rollback()
print("批量写入失败:", e)
raise
finally:
cursor.close()
conn.close()
return inserted
这里我用了 INSERT IGNORE 而不是普通 INSERT,因为爬虫场景里 URL 重复是非常常见的事情,使用 INSERT IGNORE 能在遇到重复主键或唯一键时自动跳过,而不是让整个批次失败。如果你希望重复访问时更新正文,可以改成 INSERT ... ON DUPLICATE KEY UPDATE title=VALUES(title), content=VALUES(content),效果类似但语义不同。需要注意的是,INSERT IGNORE 会把字段超长、非法日期这类错误也一起忽略,所以在数据质量要求很高的项目里要谨慎使用,最好在入库前就做好清洗和长度校验。
核心是 autocommit=False,一批数据只 commit 一次,避免每个批次内部反复开启事务。这样既保证了事务边界清晰,又把提交次数控制在批次数量级别。
3.4 批量大小怎么看?我给一个参考
批量大小并没有标准答案,但我提供了一个经验判断依据:单条数据越长,批次越小;网络延迟越大,批次可以适当加大;线上数据库并发高,批次也要缩小。
如果单条记录平均在 1KB 左右,500
