1. 为什么爬完的数据不能直接扔进Excel
刚接触爬虫那会儿,我特别兴奋——requests拿到HTML,BeautifulSoup一顿解析,print出来满屏的数据,感觉整个世界都是我的。但很快问题就来了:爬了三千条商品信息,想按价格排序看看分布,发现数据全在内存里,关掉程序就没了;想筛选某个品牌的全部记录,得重新跑一遍爬虫;更崩溃的是,有一次爬了六个小时的数据,程序中途崩了,所有结果灰飞烟灭。
这就是典型的“数据在手,却用不起来”的状态。数据结构化存储要解决的核心问题就一个:把爬虫抓到的零散、临时、易失的数据,变成持久、可查询、可复用的结构化资源。说白了,就是给你的数据找个靠谱的“家”,而不是让它们一直漂在内存里。
这个章节适合谁看?如果你已经能用Python写一个能跑通的爬虫,能拿到网页上的文本、链接、图片地址,但每次处理数据都靠print和手动复制,那这一章就是为你准备的。如果你还在纠结requests怎么发请求、CSS选择器怎么写,建议先把前面的基础打牢再回来——因为存储这件事,一旦数据量上来,选错了方案会让你痛不欲生。
我个人的经验是,爬虫项目里存储方案的选择,重要性不亚于爬取逻辑本身。见过太多人花80%的时间调反爬、写解析规则,最后用最粗暴的方式存数据,结果后期做分析时又花200%的时间清洗和整理。存储设计做在前面,后面省下的时间是指数级的。
2. 存储方案选型:别拿锤子找钉子
2.1 三种主流存储方式的本质区别
在动手写代码之前,得先想清楚一件事:你爬下来的数据,最终要拿来干什么?这个问题的答案直接决定了你该用什么存储方案。
我习惯把结构化存储分成三大类,每类对应不同的使用场景:
| 存储类型 | 代表工具 | 数据模型 | 最适合的场景 | 典型痛点 |
|---|---|---|---|---|
| 文件存储 | CSV、JSON、Excel | 二维表/文档 | 一次性分析、数据交换、小批量 | 查询困难、并发写入易冲突 |
| 关系型数据库 | SQLite、MySQL、PostgreSQL | 表+行+列 | 需要复杂查询、多表关联、事务 | 需要建表设计、写入速度中等 |
| 非关系型数据库 | MongoDB、Redis | 文档/键值 | 字段不固定、海量写入、快速读取 | 查询语法学习成本、内存占用 |
这个表看起来简单,但实际选型时很多人会犯一个错误:用自己最熟悉的工具,而不是最适合的工具。比如明明只是爬几百条新闻标题做词频统计,非要搭一个MySQL,光环境配置就耗掉半天;或者明明需要频繁按多个条件筛选几十万条数据,却硬用CSV,每次查询都要全量加载到内存。
我的建议是遵循一个简单的决策树:数据量在1万条以下、只需要简单排序筛选,CSV或JSON足够;数据量在1万到100万之间、需要多条件查询和更新,SQLite或MySQL更合适;数据字段经常变化、或者需要存嵌套结构(比如一条商品包含多个SKU),MongoDB这类文档数据库更省心。
2.2 为什么我推荐从SQLite开始练手
对于刚入门结构化存储的开发者,我通常建议从SQLite入手。原因有三:
第一,零配置。Python标准库自带sqlite3模块,不需要安装任何服务端程序,不需要配置用户名密码端口,一个文件就是一个完整的数据库。你可以在任何一台电脑上跑通代码,不用担心环境问题。
第二,SQL语法通用。你在SQLite里学的CREATE TABLE、INSERT、SELECT、WHERE、JOIN,换到MySQL或PostgreSQL上几乎不用改。这是一笔投资回报率极高的学习成本。
第三,足够应对大多数爬虫场景。SQLite支持事务、索引、全文检索,单表存几百万条记录毫无压力。很多个人项目和中小型数据采集,用SQLite从头到尾都不需要换。
当然,SQLite也有局限:不支持高并发写入(同一时间只能有一个写操作),不适合多台机器同时访问。但这些问题在你数据量真正上来之前,基本不会遇到。过早优化是万恶之源,先用最简单的方案跑通,遇到瓶颈再换。
2.3 一个容易被忽略的选型维度:写入模式
除了数据量和查询需求,还有一个维度经常被忽略:你的爬虫是一次性批量写入还是持续增量写入?
一次性批量写入(比如爬完一个网站的所有页面再统一存)对存储的压力集中在最后阶段,这时候用事务批量提交能极大提升效率。而持续增量写入(比如监控某个网站的价格变化,每隔十分钟爬一次)则需要考虑写入频率、去重策略、以及是否要保留历史版本。
我踩过的一个坑:早期做一个价格监控爬虫,每次爬取都直接INSERT新记录,结果一周下来同一个商品存了几百条重复数据,查询时还得用GROUP BY去重。后来改成先根据商品ID查询是否存在,存在则UPDATE,不存在则INSERT,数据量立刻降了一个数量级。这个逻辑在SQL里叫UPSERT(更新或插入),SQLite和MySQL都有对应的语法支持。
3. 从零搭建一个SQLite存储管道
3.1 建表之前先想清楚字段设计
很多教程讲存储,上来就是CREATE TABLE,但字段设计才是真正体现经验的地方。我见过太多人建完表之后发现字段不够用,又ALTER TABLE加列,或者字段类型选错导致数据截断。
以爬取一个电商商品列表为例,假设每条记录包含:商品标题、价格、销量、店铺名称、商品链接、爬取时间。我会这样设计:
CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, price REAL, sales INTEGER, shop_name TEXT, url TEXT UNIQUE, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这里有几个设计决策值得展开说:
url字段加UNIQUE约束。这是最实用的去重手段。爬虫最怕重复抓取,给url加唯一索引后,插入重复数据时会直接报错,你可以选择忽略或更新。比在Python里手动维护一个已爬URL集合靠谱得多——程序重启后集合就没了,但数据库里的约束是持久的。
price用REAL而不是TEXT。价格看起来像字符串(“¥99.00”),但存储时应该去掉货币符号转成浮点数。这样后面做价格区间筛选、排序、计算均价时才能直接用SQL完成,不用在Python里再转换一遍。
crawl_time用TIMESTAMP DEFAULT CURRENT_TIMESTAMP。让数据库自动记录写入时间,比在Python里生成时间字符串再插入更可靠,也省去了一列手动赋值的代码。
id用INTEGER PRIMARY KEY AUTOINCREMENT。SQLite里这个组合会自动创建一个隐式索引,查询速度很快。虽然SQLite的rowid本身就能唯一标识行,但显式声明主键让表结构更清晰,迁移到其他数据库时也不用改。
注意:字段类型在SQLite里是“亲和类型”,你写TEXT但存了数字,SQLite也能存进去。但这种灵活性是双刃剑——前期方便,后期数据混乱。建议从一开始就严格按类型存值,该是数字就存数字,该是字符串就存字符串。
3.2 批量插入的性能优化:事务的威力
新手最容易犯的性能错误,就是每爬一条数据就执行一次INSERT。我做过实测:插入10000条记录,逐条提交耗时约12秒,而用事务批量提交只需要0.3秒左右。差距是40倍。
原因在于,每次INSERT如果不显式开启事务,SQLite会自动为每条语句创建一个事务,这意味着每次都要把数据刷到磁盘。而批量事务只在最后提交时刷一次盘。
Python里的写法很简单:
import sqlite3 conn = sqlite3.connect('products.db') cursor = conn.cursor() data_list = [ ('商品A', 99.0, 1000, '店铺甲', 'http://example.com/1'), ('商品B', 199.0, 500, '店铺乙', 'http://example.com/2'), # ... 更多数据 ] try: cursor.executemany( 'INSERT OR IGNORE INTO products (title, price, sales, shop_name, url) VALUES (?, ?, ?, ?, ?)', data_list ) conn.commit() except Exception as e: conn.rollback() print(f'批量插入失败: {e}') finally: conn.close()这里用了INSERT OR IGNORE,配合url字段的UNIQUE约束,重复的URL会被自动跳过,不会报错中断。executemany比循环调用execute效率高得多,因为它减少了Python和SQLite之间的通信次数。
还有一个细节:conn.commit()放在所有插入之后,而不是每条之后。这就是事务的核心——要么全部成功,要么全部回滚。如果中途某条数据格式有问题导致异常,rollback()会撤销本次事务的所有操作,保证数据一致性。
3.3 用上下文管理器让代码更健壮
上面的代码用了try/except/finally,能跑但不够优雅。Python的with语句可以自动管理连接和游标的关闭,代码更简洁:
import sqlite3 from contextlib import closing with closing(sqlite3.connect('products.db')) as conn: with conn: # 自动处理事务提交和回滚 conn.executemany( 'INSERT OR IGNORE INTO products (title, price, sales, shop_name, url) VALUES (?, ?, ?, ?, ?)', data_list )with conn:这个用法很多人不知道——它会在代码块正常结束时自动commit,出现异常时自动rollback。配合closing()确保连接最终关闭,整个存储逻辑就非常干净了。
我现在的习惯是,把数据库操作封装成一个类,初始化时建表,提供save_batch()和query()方法,爬虫主逻辑只管调用,不关心底层是SQLite还是MySQL。这样后期换存储方案时,只需要改这一个类,爬虫代码一行不动。
4. 数据清洗与入库的衔接技巧
4.1 爬虫端的数据预处理清单
数据从网页解析出来到写入数据库,中间有一个容易被忽视的环节:清洗。如果直接把原始文本塞进数据库,后期查询时会遇到各种脏数据问题。
我整理了一份入库前的检查清单,每次写爬虫都会对照过一遍:
- 去空白:标题、店铺名等文本字段,用
.strip()去掉首尾空格和换行符。网页里的文本经常带\n和\t,不处理的话数据库里看起来正常,查询时匹配不上。 - 类型转换:价格字段去掉“¥”“$”等符号,用
float()转成数字;销量字段去掉“人付款”“笔”等后缀,用int()转成整数。转换失败时给默认值0,不要让程序崩溃。 - 空值处理:某个字段没解析到,是存NULL还是空字符串?我的原则是:数值字段存NULL(方便后续用
IS NULL筛选),文本字段存空字符串(避免拼接时出现“None”)。 - 长度截断:TEXT类型虽然理论上能存很长的字符串,但过长的文本会影响查询性能。标题超过200字符的,截断并加省略号;描述超过5000字符的,考虑单独存一张表。
- 编码统一:确保所有字符串都是UTF-8编码。Python3默认就是UTF-8,但从某些网站解析出来的文本可能带有特殊字符,入库前用
.encode('utf-8', errors='ignore').decode('utf-8')清洗一遍。
这些操作看起来琐碎,但每一条都是踩过坑之后总结出来的。比如有一次爬某网站的商品标题,里面混了零宽空格(Unicode U+200B),肉眼完全看不出来,但导致去重逻辑失效——两个看起来一模一样的标题,在数据库里是不同的字符串。后来在清洗环节加了一步re.sub(r'[\u200b-\u200f\ufeff]', '', text)才解决。
4.2 用Pandas做中间层:适合分析型场景
如果你的爬虫数据最终是要拿来做统计分析、生成报表,那在写入数据库之前,用Pandas做一层转换会非常高效。
Pandas的DataFrame天然就是二维表结构,和数据库表可以无缝对接。你可以先把爬到的数据攒成一个列表,转成DataFrame,在Pandas里做去重、类型转换、缺失值填充,然后一次性写入数据库:
import pandas as pd import sqlite3 # 假设records是爬虫解析出的字典列表 df = pd.DataFrame(records) # 数据清洗 df['price'] = pd.to_numeric(df['price'].str.replace('¥', ''), errors='coerce') df['sales'] = pd.to_numeric(df['sales'].str.extract(r'(\d+)')[0], errors='coerce').fillna(0).astype(int) df.drop_duplicates(subset=['url'], inplace=True) # 写入数据库 with sqlite3.connect('products.db') as conn: df.to_sql('products', conn, if_exists='append', index=False)to_sql的if_exists='append'表示追加写入,配合drop_duplicates在Pandas层面先去重,比依赖数据库的UNIQUE约束更灵活——你可以在去重时指定保留哪一条(比如保留价格最新的)。
但Pandas方案也有代价:它会把所有数据加载到内存。如果数据量超过内存容量,就得改用分块读取或回到逐条插入的方式。我的经验是,单次处理10万条以内用Pandas很舒服,超过这个量级就要考虑分批次了。
4.3 增量爬取的去重策略
做持续爬取时,去重是绕不开的问题。除了前面说的数据库UNIQUE约束,还有几种常见策略,各有适用场景:
策略一:基于URL去重。最简单,给url字段加UNIQUE索引,插入时用INSERT OR IGNORE。适合内容不会变化的页面(比如新闻文章)。
策略二:基于内容哈希去重。把标题+关键字段拼接后计算MD5,存一个hash列并加UNIQUE约束。适合URL会变但内容相同的场景(比如同一商品的不同推广链接)。
策略三:基于时间窗口去重。只对最近N天的数据做去重检查,更早的数据允许重复。适合价格监控这类需要保留历史记录的场景。
策略四:先查后插。写入前先SELECT一下,根据结果决定INSERT还是UPDATE。这种方式最灵活但速度最慢,适合写入频率不高的场景。
我通常的组合是:url加UNIQUE约束做第一层过滤,内容哈希做第二层,时间戳做第三层。三层下来,重复数据基本无处遁形。
5. 查询与导出:让数据真正产生价值
5.1 常用查询语句速查
数据存进去只是第一步,能查出来、用起来才是目的。下面这些SQL语句是我在爬虫项目里用得最频繁的,基本覆盖了80%的查询需求:
-- 按价格降序取前20条 SELECT title, price, sales FROM products ORDER BY price DESC LIMIT 20; -- 统计每个店铺的商品数量和均价 SELECT shop_name, COUNT(*) as cnt, AVG(price) as avg_price FROM products GROUP BY shop_name HAVING cnt > 5 ORDER BY cnt DESC; -- 筛选价格在100到200之间、销量大于500的商品 SELECT * FROM products WHERE price BETWEEN 100 AND 200 AND sales > 500; -- 模糊搜索标题包含“无线”的商品 SELECT * FROM products WHERE title LIKE '%无线%'; -- 查询最近24小时爬取的数据 SELECT * FROM products WHERE crawl_time > datetime('now', '-1 day');这些语句看起来简单,但组合起来能完成相当复杂的分析。比如“找出每个店铺价格最高的商品”,用窗口函数可以一行搞定:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY shop_name ORDER BY price DESC) as rn FROM products ) WHERE rn = 1;SQLite从3.25版本开始支持窗口函数,现在主流版本都没问题。这个功能在做分组Top N查询时特别有用,比用GROUP BY加子查询简洁得多。
5.2 导出为CSV和Excel的正确姿势
数据在数据库里查出来了,下一步往往是导出给其他人看,或者用Excel做进一步分析。Pandas的read_sql配合to_csv/to_excel是最顺手的方案:
import pandas as pd import sqlite3 with sqlite3.connect('products.db') as conn: df = pd.read_sql('SELECT * FROM products WHERE sales > 100', conn) # 导出CSV,解决中文乱码问题 df.to_csv('output.csv', index=False, encoding='utf-8-sig') # 导出Excel,自动调整列宽 with pd.ExcelWriter('output.xlsx', engine='openpyxl') as writer: df.to_excel(writer, index=False, sheet_name='商品数据') worksheet = writer.sheets['商品数据'] for column in worksheet.columns: max_length = max(len(str(cell.value)) for cell in column) worksheet.column_dimensions[column[0].column_letter].width = min(max_length + 2, 50)这里有两个细节值得注意:CSV导出用utf-8-sig编码,Excel打开时中文不会乱码(utf-8会);Excel导出时自动调整列宽,避免打开后看到一堆“####”。
提示:如果数据量超过Excel的单表上限(约104万行),
to_excel会报错。这时候要么分Sheet导出,要么改用CSV格式。CSV没有行数限制,但Excel打开大CSV文件会很慢,建议用专业工具查看。
5.3 从SQLite迁移到MySQL的时机与步骤
什么时候该从SQLite换到MySQL?我的判断标准是:出现以下任一情况,就该考虑了。
- 多个爬虫进程需要同时写入同一个数据库(SQLite的写锁会导致等待甚至超时)
- 数据量超过500万条,且查询开始变慢(虽然加索引能缓解,但SQLite的单文件架构在超大表上确实吃力)
- 需要远程访问数据库(SQLite是本地文件,没法多台机器共享)
- 需要更细粒度的权限控制(SQLite没有用户概念)
迁移步骤其实不复杂,核心就三步:建MySQL表、导数据、改连接代码。
建表时注意数据类型映射:SQLite的INTEGER PRIMARY KEY AUTOINCREMENT对应MySQL的INT AUTO_INCREMENT PRIMARY KEY,TEXT对应VARCHAR(255)或TEXT,REAL对应FLOAT或DECIMAL。时间戳字段MySQL用DATETIME DEFAULT CURRENT_TIMESTAMP。
导数据可以用Pandas中转:从SQLite读出来,写入MySQL。也可以用sqlite3的.dump命令导出SQL文件,稍作修改后在MySQL里执行。数据量大的话,用mysqldump或专业的ETL工具更稳妥。
改连接代码时,把sqlite3.connect('products.db')换成pymysql.connect(host='localhost', user='xxx', password='xxx', database='xxx'),SQL语句基本不用改。这也是为什么我建议前期用SQLite练手——SQL语法是通用的,迁移成本主要在建表和连接层,业务逻辑几乎不动。
6. 踩坑实录与排查技巧
6.1 常见报错与解决方案速查表
| 报错信息 | 根本原因 | 解决方案 |
|---|---|---|
sqlite3.OperationalError: database is locked | 多个连接同时写,或前一个事务未提交 | 确保每次写完commit,或用timeout参数设置等待时间 |
UNIQUE constraint failed | 插入的数据违反了唯一约束 | 用INSERT OR IGNORE或INSERT OR REPLACE |
InterfaceError: Error binding parameter | 参数类型不匹配,比如把列表当字符串传 | 检查传入的数据类型,确保与表字段类型一致 |
pandas.io.sql.DatabaseError: Execution failed | SQL语法错误或表不存在 | 先在SQLite命令行里手动执行一遍SQL验证 |
| CSV打开中文乱码 | 编码不是utf-8-sig | 导出时指定encoding='utf-8-sig' |
to_sql报“表已存在” | if_exists参数默认为fail | 改为append或replace |
这个表里的每一个报错我都实际遇到过,其中“database is locked”是最常见的。特别是在多线程爬虫里,多个线程同时写SQLite,必然锁死。解决方案要么是加写锁串行化,要么是换MySQL,没有第三条路。
6.2 三个让我损失数据的教训
教训一:没加事务,程序崩溃数据全丢。早期写爬虫,每爬一条INSERT一次,没有显式事务。结果爬了三个小时,程序因为一个未捕获的异常崩了,之前所有INSERT因为自动提交已经写入了——但问题是,最后一批数据只写了一半,数据库处于不一致状态。后来改成批量事务,要么全成功要么全回滚,再也没出现过半截数据。
教训二:字段类型选错,价格排序出错。一开始把price存成TEXT,想着“反正都是数字,存字符串也一样”。结果ORDER BY price时,'99'排在'100'后面(字符串按字典序比较)。改成REAL类型后问题消失。这个坑让我明白:数据库字段类型不是建议,是约束,该是什么类型就存什么类型。
教训三:忘了建索引,查询慢如蜗牛。一张50万条记录的表,按shop_name查询要等十几秒。后来给shop_name加了索引,查询时间降到毫秒级。索引的代价是插入速度略降、占用额外磁盘空间,但对于需要频繁查询的字段,这个代价完全值得。通常我会给WHERE、JOIN、ORDER BY里出现的字段加索引。
6.3 数据备份的简单方案
SQLite的备份特别简单——直接复制.db文件就行。但要注意:如果数据库正在被写入,直接复制可能得到一个损坏的文件。安全的做法是用SQLite自带的备份API:
import sqlite3 def backup_db(source_path, backup_path): source = sqlite3.connect(source_path) backup = sqlite3.connect(backup_path) with backup: source.backup(backup) backup.close() source.close()这个backup()方法会处理锁和事务,保证备份文件的一致性。我习惯在每次大批量爬取结束后自动执行一次备份,文件名带上日期,比如products_20250101.db。这样即使后续操作失误,也能从备份恢复。
对于MySQL,备份用mysqldump命令,导出SQL文件。数据量大的话,建议用--single-transaction参数避免锁表。
7. 从存储到分析:一个完整的闭环示例
7.1 场景设定与数据流设计
假设我们要爬取一个图书网站的分类列表页,抓取每本书的标题、作者、价格、评分、评论数,然后做简单的统计分析。整个数据流是这样的:
爬虫抓取 → 解析字段 → 清洗转换 → 批量写入SQLite → SQL查询分析 → 导出报表
这个流程看起来线性,但实际写代码时,我会把存储层单独封装成一个模块,爬虫只管往模块里丢数据,模块负责建表、去重、写入、索引维护。这样爬虫代码可以专注于解析逻辑,存储逻辑可以独立测试和优化。
7.2 完整代码框架与关键注释
import sqlite3 import re from contextlib import closing class BookStorage: def __init__(self, db_path='books.db'): self.db_path = db_path self._init_table() def _init_table(self): with closing(sqlite3.connect(self.db_path)) as conn: with conn: conn.execute(''' CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT, price REAL, rating REAL, comments INTEGER, url TEXT UNIQUE, crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # 给常用查询字段建索引 conn.execute('CREATE INDEX IF NOT EXISTS idx_author ON books(author)') conn.execute('CREATE INDEX IF NOT EXISTS idx_price ON books(price)') def save_batch(self, books): """books是字典列表,每个字典包含title/author/price/rating/comments/url""" rows = [] for b in books: rows.append(( b.get('title', '').strip(), b.get('author', '').strip(), self._parse_price(b.get('price', '0')), self._parse_float(b.get('rating', '0')), self._parse_int(b.get('comments', '0')), b.get('url', '') )) with closing(sqlite3.connect(self.db_path)) as conn: with conn: conn.executemany( 'INSERT OR IGNORE INTO books (title, author, price, rating, comments, url) VALUES (?, ?, ?, ?, ?, ?)', rows ) return len(rows) @staticmethod def _parse_price(text): match = re.search(r'[\d.]+', str(text)) return float(match.group()) if match else 0.0 @staticmethod def _parse_float(text): try: return float(text) except (ValueError, TypeError): return 0.0 @staticmethod def _parse_int(text): match = re.search(r'\d+', str(text)) return int(match.group()) if match else 0 def query(self, sql, params=None): with closing(sqlite3.connect(self.db_path)) as conn: conn.row_factory = sqlite3.Row # 让查询结果可以按列名访问 cursor = conn.execute(sql, params or []) return [dict(row) for row in cursor.fetchall()]这个类封装了建表、清洗、批量写入、查询四个核心功能。爬虫端只需要:
storage = BookStorage() storage.save_batch(parsed_books) top_books = storage.query('SELECT * FROM books ORDER BY rating DESC LIMIT 10')代码量不大,但覆盖了从存储到查询的完整链路。关键设计点:INSERT OR IGNORE配合url的UNIQUE约束实现自动去重;row_factory让查询结果可以直接转字典;清洗逻辑放在存储层,爬虫端不用关心数据格式。
7.3 性能实测:不同批量大小的写入耗时
我用这个框架做了个简单的性能测试,插入10000条模拟数据,对比不同批量大小下的耗时:
| 批量大小 | 耗时(秒) | 说明 |
|---|---|---|
| 1条/次 | 11.8 | 每条一个事务,最慢 |
| 100条/次 | 0.9 | 事务开销大幅降低 |
| 1000条/次 | 0.4 | 接近最优 |
| 10000条/次 | 0.3 | 提升有限,但内存占用高 |
结论很明确:批量大小在500到1000之间是性价比最高的区间。再小,事务开销占比高;再大,内存占用增加但速度提升不明显。我通常设成500,兼顾速度和内存。
这个测试也验证了前面说的:事务是SQLite写入性能的关键。从1条/次到1000条/次,速度提升了近30倍,而代码改动只是把execute换成executemany、把commit移到循环外面。
8. 存储方案进阶:什么时候该换工具
8.1 MongoDB的适用场景与迁移思路
当你的数据字段经常变化,或者需要存储嵌套结构时,MongoDB会比SQLite更顺手。比如爬取商品详情,每个商品的规格参数数量不同、名称不同,用SQL存就得设计成“属性表+值表”的关联结构,查询时要JOIN,很麻烦。而MongoDB可以直接存成嵌套文档:
{ "title": "某商品", "price": 99.0, "specs": { "颜色": "黑色", "尺寸": "XL", "材质": "纯棉" }, "tags": ["热销", "新品"] }这种灵活性是关系型数据库不具备的。但代价是:查询语法不同、事务支持较弱、内存占用较高。我的建议是,如果数据天然是嵌套结构(比如JSON API返回的数据),用MongoDB;如果数据是规整的二维表,用SQLite或MySQL。
8.2 数据量增长后的分表与归档策略
数据量到千万级以后,单表查询会明显变慢。这时候可以考虑分表:按时间分(每月一张表)、按类别分(每个分类一张表)、或者按哈希分(均匀分布)。
分表带来的问题是查询变复杂——查全量数据要UNION多张表。所以分表通常配合归档策略:热数据(最近三个月)留在主表,冷数据归档到历史表或导出到文件。查询时先查主表,需要历史数据时再查归档。
这个策略在爬虫场景里特别实用,因为爬虫数据往往有明显的时效性——最近的数据查询频繁,旧数据基本不动。我一般会写一个定时任务,每月初把上上个月的数据从主表迁移到历史表,保持主表在可控规模。
8.3 云数据库的取舍:省心与成本的平衡
现在很多云服务商提供托管的数据库服务,省去了自己安装、配置、备份、扩容的麻烦。对于个人项目,免费额度通常够用;对于商业项目,按量付费也比自己维护服务器省心。
但云数据库也有代价:网络延迟比本地高、数据在别人服务器上、长期成本可能更高。我的判断标准是:如果项目需要7x24小时运行、数据量持续增长、团队没有专职运维,云数据库是更稳妥的选择;如果是个人学习、临时分析、数据敏感,本地SQLite或自建MySQL更合适。
不管选哪种,存储层的代码抽象都是值得做的。把数据库操作封装成统一的接口,底层换实现时上层不用改。这个习惯让我在多次迁移中省下了大量重构时间。
9. 我个人的几条实战心得
第一条,先存下来再优化。很多新手纠结于“用哪个数据库最好”,结果数据还没爬几条,时间全花在选型上了。我的做法是:先用CSV或SQLite把数据存下来,跑通整个流程,等数据量上来、遇到具体瓶颈了,再针对性地换方案。没有实际数据支撑的选型都是空想。
第二条,字段设计留余量。建表时我会多留几个备用字段,比如extra1、extra2,类型用TEXT。爬虫过程中经常发现“哎呀这个字段忘了存”,有备用字段就不用ALTER TABLE。当然这不是长久之计,但作为快速迭代期的缓冲很实用。
第三条,定期备份,自动化。我写了一个简单的脚本,每天凌晨把数据库文件复制到备份目录,保留最近30天的备份。这个习惯救过我两次——一次是误删了表,一次是磁盘故障。备份的成本很低,但数据丢失的代价很高。
第四条,查询语句先在命令行验证。复杂的SQL不要直接在Python里调试,先在SQLite命令行或数据库管理工具里跑通,确认结果正确再嵌入代码。这样排查问题时能快速定位是SQL写错了还是Python传参错了。
第五条,日志记录写入结果。每次批量写入后,记录成功插入多少条、忽略多少条、失败多少条。这些数字能帮你判断爬虫是否正常工作、去重策略是否生效、数据源是否有变化。我通常会把日志写到文件,按天分割,方便回溯。
这些心得没有什么高深的技术,但每一条都是实际项目中积累下来的。存储这件事,说到底就是“把数据当资产来管理”——你对待数据的态度,决定了数据能为你产生多少价值。