1. 为什么爬完的数据不能直接扔进文本文件很多人写爬虫跑完第一反应就是把结果一股脑写进result.txt或者data.csv觉得“数据到手就行”。我早期也这么干过直到有一次需要从三万条记录里筛出“某个字段为空且另一个字段重复”的条目用文本编辑器打开直接卡死写脚本逐行解析又因为字段里混着换行符和逗号切分逻辑改了三版才对。那次之后我才真正理解爬虫的终点不是“抓到”而是“存好”。数据结构化存储要解决的核心问题就三个存得进去、查得出来、扩得动。文本文件在数据量小、字段少、不需要复杂查询时确实够用但一旦字段超过五六个、记录上万条、或者需要按条件筛选和关联它就会变成负担。结构化存储的本质是把“一堆字符串”变成“有类型、有约束、有关系”的数据让后续的清洗、分析、展示都能站在一个稳定的地基上。这一章我会把爬虫数据落地的几条主流路径讲透从最轻量的 CSV 和 JSON到单机数据库 SQLite再到适合中小规模的 MySQL最后聊一聊什么时候该上 MongoDB 这类文档型存储。每种方案我都会说清楚它适合什么场景、字段怎么设计、代码怎么写、坑在哪里。如果你已经能稳定抓到数据但每次存数据都靠“随手写个文件”那这篇内容应该能帮你把最后这一段路走扎实。2. 先想清楚你的数据到底该用什么结构存2.1 从“字段”和“关系”两个维度做判断选存储方案之前先别急着打开数据库客户端。拿张纸把你要存的数据字段列出来然后问自己两个问题。第一个问题这些字段之间有没有“一对多”或“多对多”的关系比如你爬的是商品列表每个商品有多个规格、多张图片、多条评论。如果全塞进一张表就会出现大量重复字段改一个商品名要更新几十行。这时候就该拆成“商品表 规格表 评论表”用外键关联。反过来如果每条记录都是独立的字段固定那单表就够了。第二个问题字段结构会不会变有些数据源今天返回五个字段明天加一个“促销标签”后天又改字段名。这种“结构不稳定”的场景用固定表结构的 MySQL 会很痛苦每次都要改表。而 MongoDB 这种文档型存储每条记录可以有自己的字段集合天然适应这种变化。我一般用下面这张表做快速判断判断维度适合关系型MySQL/SQLite适合文档型MongoDB适合文件CSV/JSON字段是否固定固定经常变固定是否有表间关系有弱或无无数据量级万到千万万到百万千以内是否需要复杂查询需要一般不需要是否多人协作是是否2.2 字段类型设计别让“看起来是数字”骗了你这是新手最容易踩的坑。爬下来的数据全是字符串但存进去的时候要转成正确的类型。比如价格29.90应该存成DECIMAL(10,2)而不是VARCHAR否则后面排序会出现100 99这种荒唐结果。时间字段2024-01-15 10:30:00要转成DATETIME不然按时间范围查询只能靠字符串比较遇到格式不统一就崩。但也不是所有“数字”都该转。比如手机号、身份证号、订单号它们虽然由数字组成但不参与数学运算而且可能有前导零这种必须用字符串存。我见过有人把订单号存成BIGINT结果前导零全丢了对账时对不上。还有一个细节空值怎么表示。爬虫经常遇到某个字段没抓到的情况。在数据库里NULL表示“未知”空字符串表示“抓到了但是空的”两者语义不同。设计表的时候要想清楚别混用。比如“评论数”没抓到应该是NULL而不是0因为0表示“确实没有评论”。2.3 主键和去重爬虫数据的生命线爬虫最怕重复抓取。同一个页面可能因为分页逻辑、重试机制、增量更新被多次抓到。如果没有去重机制数据库里就会堆满重复记录后续统计全错。最稳妥的做法是给每条记录一个业务主键。比如爬商品就用商品 ID爬文章就用文章 URL 的哈希值。这个主键在数据库里设成UNIQUE约束插入时用INSERT ... ON DUPLICATE KEY UPDATEMySQL或INSERT OR REPLACESQLite这样重复数据会自动更新而不是新增。如果实在找不到天然主键就自己生成一个把几个关键字段拼起来做 MD5。但要注意拼接顺序和分隔符要固定否则同样的内容会算出不同的哈希。提示不要用自增 ID 做主键来去重。自增 ID 只能保证“每条记录唯一”不能保证“业务内容唯一”。去重必须靠业务字段。3. 轻量方案CSV 和 JSON 的正确打开方式3.1 CSV 不是“逗号分隔”那么简单很多人以为 CSV 就是“用逗号把字段连起来”写代码时直接,.join(fields)。结果字段里本身有逗号整个文件就乱了。正确的 CSV 写法要处理三种特殊情况字段含逗号、字段含换行符、字段含双引号。标准做法是用双引号把字段包起来字段内的双引号用两个双引号转义。Python 的csv模块已经帮你处理好了这些别自己手写拼接。下面是一个标准写法import csv with open(products.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[id, name, price, url]) writer.writeheader() for item in data: writer.writerow({ id: item[id], name: item[name], price: item[price], url: item[url] })注意encodingutf-8-sig这个-sig会写入 BOM 头让 Excel 打开时不乱码。如果你不用 Excel 看用utf-8就行。newline是为了防止 Windows 下多出空行。CSV 的优点是通用任何工具都能打开。缺点是没有类型所有字段都是字符串没有索引查一条数据要全表扫描不支持嵌套结构遇到“一个商品多个规格”就只能拆成多行或者把规格塞进一个字段里。所以 CSV 只适合最终导出给非技术人员看不适合做中间存储。3.2 JSON 适合存“不规则”的数据JSON 的优势是能表达嵌套结构。比如一个商品有多个规格直接写成数组{ id: A1001, name: 某商品, specs: [ {color: 红, size: M, price: 29.9}, {color: 蓝, size: L, price: 32.9} ], tags: [新品, 包邮] }这种结构用 CSV 表达就很别扭。JSON 的另一个好处是和 Python 的 dict/list 无缝转换json.dumps()和json.loads()两行搞定。但 JSON 做存储有几个硬伤。第一不能追加。每次保存都要把整个列表序列化再写文件数据量大了内存扛不住。第二不能按条件查询。想找“价格大于 30 的商品”只能全部加载到内存再遍历。第三文件损坏风险高。如果程序在写文件时崩溃整个 JSON 文件可能变成半截所有数据都读不出来。所以我的建议是JSON 只用来做临时交换格式或配置文件不要用来做爬虫的最终存储。如果非要用就按天分文件比如2024-01-15.json每天一个文件降低单文件损坏的影响面。3.3 文件存储的适用边界说了这么多文件存储也不是一无是处。下面这些场景用文件反而更合适数据量小于一千条且不需要频繁查询需要把数据发给别人对方没有数据库环境作为数据管道的中转比如爬虫写 JSON另一个程序读 JSON 再入库做数据备份数据库导出成 CSV 存档关键是要认清文件是“输出格式”不是“存储引擎”。你可以用文件做最终交付但中间过程最好有数据库兜底。4. SQLite单机爬虫的性价比之王4.1 为什么我推荐新手从 SQLite 开始SQLite 是一个“嵌入式数据库”整个数据库就是一个文件不需要安装服务、不需要配置账号密码、不需要启动进程。Python 标准库直接带sqlite3模块import就能用。对于单机爬虫来说它几乎完美有表结构、有索引、支持 SQL 查询、支持事务、单文件方便备份。我自己的很多小项目都是 SQLite 起步。比如爬一个站点的文章列表几万条数据用 SQLite 存查询、去重、导出都很方便。等到数据量真的上来了或者需要多台机器同时写再迁移到 MySQL迁移成本也不高因为 SQL 语法基本通用。SQLite 的另一个好处是事务安全。爬虫最怕写一半程序崩了数据处于“写了一半”的状态。SQLite 支持事务你可以把一批插入放在一个事务里要么全成功要么全回滚不会出现半截数据。4.2 建表从爬虫字段到表结构假设我们爬的是某站点的文章字段有文章 ID、标题、作者、发布时间、正文、原文链接、抓取时间。建表语句如下CREATE TABLE IF NOT EXISTS articles ( article_id TEXT PRIMARY KEY, title TEXT NOT NULL, author TEXT, publish_time DATETIME, content TEXT, url TEXT UNIQUE, crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_publish_time ON articles(publish_time); CREATE INDEX IF NOT EXISTS idx_author ON articles(author);几个设计点解释一下。article_id用TEXT而不是INTEGER因为很多站点的 ID 是字符串可能含字母。url加了UNIQUE约束防止同一篇文章重复插入。crawled_at用DEFAULT CURRENT_TIMESTAMP插入时不用手动传数据库自动填当前时间。索引加在publish_time和author上因为这两个字段最可能出现在查询条件里。注意索引不是越多越好。每个索引都会增加插入和更新的开销。只给“经常出现在 WHERE 子句里”的字段加索引。4.3 插入与去重UPSERT 的两种写法SQLite 从 3.24 版本开始支持UPSERT语法写法如下INSERT INTO articles (article_id, title, author, publish_time, content, url) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(article_id) DO UPDATE SET title excluded.title, author excluded.author, publish_time excluded.publish_time, content excluded.content, crawled_at CURRENT_TIMESTAMP;excluded是 SQLite 的关键字代表“本次想要插入的那一行”。ON CONFLICT(article_id)指定冲突检测的字段。如果article_id已存在就执行UPDATE把标题、作者等字段更新成最新抓到的值同时刷新抓取时间。如果你的 SQLite 版本较老可以用INSERT OR REPLACEINSERT OR REPLACE INTO articles (article_id, title, author, publish_time, content, url) VALUES (?, ?, ?, ?, ?, ?);但INSERT OR REPLACE有个坑它会先删除旧行再插入新行如果表上有外键关联可能触发级联删除。所以能用UPSERT就用UPSERT。4.4 批量插入别一条一条来新手最容易犯的错是每抓一条就execute一次INSERT。这样每条都要开一次事务、写一次磁盘速度极慢。正确做法是攒一批比如 500 条再统一插入放在一个事务里import sqlite3 conn sqlite3.connect(articles.db) cursor conn.cursor() batch [] for item in data: batch.append((item[id], item[title], item[author], item[publish_time], item[content], item[url])) if len(batch) 500: cursor.executemany( INSERT INTO articles (article_id, title, author, publish_time, content, url) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(article_id) DO UPDATE SET title excluded.title, content excluded.content, crawled_at CURRENT_TIMESTAMP , batch) conn.commit() batch.clear() if batch: cursor.executemany(..., batch) conn.commit() conn.close()实测下来批量插入比逐条插入快几十倍。500 这个数字不是固定的可以根据内存和单条数据大小调整。原则是“批越大越快但别大到内存放不下”。4.5 SQLite 的并发限制与应对SQLite 默认是“写独占”的同一时间只能有一个写操作。如果你的爬虫是多线程的多个线程同时写会报database is locked。解决办法有三个第一单线程写。爬取可以多线程但把数据放进一个队列由一个专门的写线程消费。这是最稳妥的方案。第二开启 WAL 模式。WALWrite-Ahead Logging允许读写并发写操作不阻塞读操作PRAGMA journal_modeWAL;第三设置超时。连接时加timeout参数让程序在锁冲突时等待而不是立刻报错conn sqlite3.connect(articles.db, timeout30)我一般三个一起用WAL 模式 单写线程 30 秒超时。这样即使偶尔有锁冲突程序也能自己恢复。5. MySQL从单机走向多人协作5.1 什么时候该从 SQLite 换到 MySQLSQLite 虽好但有几个场景它扛不住。一是多台机器同时写SQLite 的文件锁机制在网络上不适用。二是数据量超过千万级单文件查询性能下降明显。三是需要远程访问比如爬虫跑在服务器上分析程序跑在本地。四是需要更细的权限控制不同人只能看不同的表。这时候就该上 MySQL 了。MySQL 是客户端-服务端架构数据存在服务器上多个客户端可以同时连接。它支持更丰富的字段类型、更复杂的查询、更完善的权限体系。代价是需要安装和配置服务运维成本比 SQLite 高。5.2 表结构设计字符集和引擎的选择建 MySQL 表时有两个参数必须显式指定字符集和存储引擎。字符集用utf8mb4不要用utf8。MySQL 的utf8其实是“最多三个字节的 UTF-8”存不了 emoji 和某些生僻字。utf8mb4才是完整的四字节 UTF-8。爬虫数据里经常有表情符号用utf8会直接报错或丢数据。存储引擎用InnoDB不要用MyISAM。InnoDB 支持事务、支持行级锁、支持外键是现在的默认选择。MyISAM 虽然查询稍快但不支持事务写一半崩了数据就坏了。CREATE TABLE articles ( article_id VARCHAR(64) PRIMARY KEY, title VARCHAR(512) NOT NULL, author VARCHAR(128), publish_time DATETIME, content MEDIUMTEXT, url VARCHAR(1024), crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_url (url(255)), INDEX idx_publish_time (publish_time), INDEX idx_author (author) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;注意url字段用了VARCHAR(1024)但索引只取前 255 个字符url(255)。因为 MySQL 的索引有长度限制太长的字段不能全字段索引。取前 255 个字符做唯一性判断对绝大多数 URL 来说已经足够区分。5.3 连接池别每次插入都新建连接MySQL 每次建立连接都要经过 TCP 握手、身份验证开销不小。如果每插一条数据就connect一次性能会惨不忍睹。正确做法是用连接池程序启动时建好一批连接用的时候从池里取用完还回去。Python 里常用DBUtils或SQLAlchemy的连接池。下面是一个基于pymysql和DBUtils的例子from dbutils.pooled_db import PooledDB import pymysql pool PooledDB( creatorpymysql, maxconnections10, mincached2, hostlocalhost, port3306, usercrawler, passwordyour_password, databasecrawler_db, charsetutf8mb4 ) def save_batch(rows): conn pool.connection() try: with conn.cursor() as cursor: cursor.executemany( INSERT INTO articles (article_id, title, author, publish_time, content, url) VALUES (%s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE title VALUES(title), content VALUES(content), crawled_at CURRENT_TIMESTAMP , rows) conn.commit() finally: conn.close()maxconnections10表示最多同时有 10 个连接。mincached2表示启动时先建 2 个备用。ON DUPLICATE KEY UPDATE是 MySQL 的 UPSERT 语法和 SQLite 的ON CONFLICT作用一样。5.4 批量插入的两种方式对比MySQL 批量插入有两种写法。一种是executemany把多条INSERT拼成一次网络请求。另一种是拼成一条INSERT INTO ... VALUES (...), (...), (...)的多值语句。方式优点缺点适用场景executemany代码简单参数化安全每条语句独立解析通用场景多值 INSERT解析一次速度更快语句长度有限制拼接麻烦大批量、字段少我一般用executemany因为pymysql内部会做优化而且参数化能防 SQL 注入。如果追求极致速度可以把executemany的batch_size调大或者用LOAD DATA LOCAL INFILE直接导入 CSV但那个配置起来更麻烦。注意MySQL 默认的max_allowed_packet是 4MB 或 16MB一次插入的数据不能超过这个限制。如果批量太大报错就减小批次大小。6. MongoDB当字段结构不稳定时怎么办6.1 文档型存储的适用场景有些爬虫目标的数据结构天生就不固定。比如爬电商平台的商品不同类目的商品属性完全不同手机有“屏幕尺寸”“电池容量”衣服有“尺码”“材质”食品有“保质期”“配料”。如果硬要用 MySQL 存要么建几十个字段大部分为空要么用“属性表”做键值对存储查询起来很别扭。MongoDB 把每条记录存成一个文档类似 JSON不同文档可以有完全不同的字段。查询时用点号访问嵌套字段比如specs.color。这种灵活性对爬虫来说很友好因为爬虫面对的数据源经常变今天加个字段明天删个字段用 MongoDB 不用改表结构。但 MongoDB 也不是银弹。它不支持事务早期版本新版本支持但性能有代价不支持多表关联查询数据一致性弱于 MySQL。所以它适合“数据结构多变、查询简单、不需要强一致”的场景。6.2 文档设计嵌套还是引用MongoDB 有两种组织关联数据的方式嵌套embedding和引用referencing。嵌套是把子数据直接放进父文档里引用是存一个 ID 指向另一个集合。判断标准是子数据是否总是和父数据一起查询如果是就嵌套。比如文章的评论每次看文章都要看评论那就把评论嵌在文章文档里。如果子数据会被独立查询或者数量很大就引用。比如用户和订单订单需要独立按时间查询就单独存一个集合。嵌套的优点是一次查询拿到全部数据不用做关联。缺点是文档有大小限制MongoDB 单文档最大 16MB而且更新嵌套数组里的某个元素比较麻烦。引用的优点是灵活缺点是需要多次查询。我一般这样处理一对少比如一个商品几个规格用嵌套一对多比如一个作者几百篇文章用引用。6.3 插入与更新upsert 的写法MongoDB 的 upsert 通过update_one的upsertTrue参数实现from pymongo import MongoClient, UpdateOne client MongoClient(mongodb://localhost:27017/) db client[crawler_db] collection db[products] operations [] for item in data: operations.append(UpdateOne( {product_id: item[id]}, {$set: { name: item[name], price: item[price], specs: item[specs], crawled_at: datetime.now() }}, upsertTrue )) if operations: collection.bulk_write(operations)UpdateOne的第一个参数是查询条件第二个是更新操作upsertTrue表示“如果不存在就插入”。bulk_write把多个操作打包发送比逐条更新快很多。6.4 索引MongoDB 查询优化的关键MongoDB 默认只给_id建索引其他字段查询都是全集合扫描。数据量大了必须手动建索引collection.create_index(product_id, uniqueTrue) collection.create_index(price) collection.create_index([(crawled_at, -1)])uniqueTrue保证product_id不重复。[(crawled_at, -1)]是降序索引适合“按抓取时间倒序取最新”的查询。注意MongoDB 的索引也占空间而且会拖慢写入。只给真正需要查询的字段建索引。可以用explain()查看查询是否走了索引。7. 常见问题与排查技巧实录7.1 数据存储常见问题速查表问题现象可能原因排查方法解决方案插入报“字段太长”字段长度超限查看报错字段和值长度扩大字段长度或截断中文变问号字符集不对SHOW VARIABLES LIKE character%改成 utf8mb4重复数据越来越多没有唯一约束查询重复记录加 UNIQUE 索引 UPSERT插入越来越慢索引过多或批次太小查看索引数量删无用索引增大批次连接数爆满连接未关闭SHOW PROCESSLIST用连接池确保 close时间字段查不到时区不一致对比程序时间和数据库时间统一用 UTC 或统一时区批量插入报 packet 错误单次数据超限查看 max_allowed_packet减小批次或调大参数7.2 我踩过的三个坑第一个坑用浮点数存价格。早期我用FLOAT存商品价格结果29.9存进去变成29.8999996对账时差几分钱。后来全部改成DECIMAL(10,2)精确到分。浮点数在计算机里是二进制近似不能用来存钱。第二个坑忘记提交事务。用 MySQL 时INSERT之后没有commit程序跑完数据一条没进去。因为 InnoDB 默认是事务模式不提交就不生效。后来我养成了习惯要么用autocommitTrue要么在finally里确保commit或rollback。第三个坑URL 做唯一索引但长度不够。我把url设成VARCHAR(255)并加唯一索引结果有些 URL 超过 255 字符被截断导致不同 URL 截断后相同唯一约束误判。后来改成VARCHAR(1024)索引只取前 255 字符问题解决。7.3 数据校验存进去之前先看一眼我的习惯是每批数据入库前先做一次快速校验字段数量对不对、必填字段有没有空、数字字段能不能转成数字、时间字段格式对不对。校验不通过的就记日志不入库。这样能避免脏数据污染数据库。def validate(item): required [id, name, url] for field in required: if not item.get(field): return False, fmissing {field} try: float(item[price]) except (ValueError, TypeError): return False, invalid price return True, None这个校验函数很简单但能拦住大部分低级错误。等数据量大了再回头清洗成本要高得多。8. 存储方案选型与迁移建议8.1 一张表帮你做最终决定场景推荐方案理由学习练手数据量小CSV/JSON零配置直观单机爬虫万级数据SQLite零配置有 SQL单文件多机协作十万级以上MySQL支持并发远程访问字段结构多变MongoDB无固定表结构需要全文搜索Elasticsearch专业搜索但运维复杂需要做数据分析数据仓库列式存储聚合快选型的核心原则是先用最简单的方案跑通遇到瓶颈再换。不要一上来就上分布式数据库那是给自己找麻烦。我见过太多项目数据还没一千条先搭了一套集群结果维护成本比开发成本还高。8.2 从 SQLite 迁移到 MySQL 的注意事项迁移时最容易出问题的是数据类型映射。SQLite 是动态类型MySQL 是静态类型。SQLite 里存成TEXT的字段到 MySQL 要选合适的VARCHAR长度。SQLite 的DATETIME到 MySQL 也是DATETIME但格式要统一。迁移步骤我一般这样走先用 SQLite 的.dump导出 SQL然后手动改数据类型和语法差异再导入 MySQL。或者写个脚本从 SQLite 读出来批量插入 MySQL。后者更可控因为可以在插入时做校验和转换。迁移完成后别急着删 SQLite 文件保留一段时间做对照。确认 MySQL 里的数据和 SQLite 一致再清理。8.3 数据备份别等丢了才后悔不管用什么存储备份都是必须的。SQLite 直接复制文件就行但要在没有写入的时候复制否则可能拿到损坏的文件。MySQL 用mysqldump导出 SQLMongoDB 用mongodump导出 BSON。我的习惯是每天凌晨自动备份一次保留最近七天的备份。备份文件压缩后存到另一个目录有条件的再同步到另一台机器。备份脚本要写日志每次备份成功或失败都记下来不然出了问题都不知道备份有没有在跑。提示备份之后一定要验证恢复流程。我见过有人备份了半年真出事的时候发现备份文件是空的因为脚本里路径写错了。定期做一次恢复演练确保备份真的能用。9. 写在最后存储是爬虫的“下半场”爬虫这个事抓取是上半场存储是下半场。上半场拼的是反爬对抗、请求调度、解析效率下半场拼的是数据建模、查询优化、运维稳定。很多人上半场打得漂亮下半场随便写个文件就交差结果数据用不起来等于白抓。我自己的经验是在写第一行爬虫代码之前先把存储方案定下来。字段有哪些、类型是什么、主键是什么、去重怎么做、查询需求是什么这些问题想清楚了后面写代码就是填空。反过来如果先抓再想怎么存大概率要返工。SQLite 和 MySQL 的语法差异不大学会一个另一个很快上手。MongoDB 的思维方式和关系型数据库不同但核心概念文档、集合、索引也不难理解。关键是动手试拿几百条真实数据跑一遍比看十篇教程都管用。最后分享一个小技巧给每批数据加一个batch_id字段记录这批数据是哪次爬取任务写入的。这样如果发现某批数据有问题可以按batch_id批量删除或回滚不用一条条找。这个字段平时用不上但出问题的时候能救命。