写在前面
很多爬虫工程师在数据量突破百万级后都会遇到同一个瓶颈:写入速度跟不上采集速度,去重逻辑拖慢整体吞吐,重启任务后大量重复请求浪费资源。问题的根源往往不是爬虫框架本身,而是存储层设计不合理。本文不讲理论,只分享一套在生产环境中稳定运行、日均处理2000万条数据的MongoDB+Redis双引擎存储方案。所有代码和配置均来自真实项目脱敏后的实践,重点解决三个核心痛点:高速写入不丢数据、精准去重不耗内存、断点续爬不重复。
一、 为什么是MongoDB+Redis?不是MySQL,不是Elasticsearch
在选型之前,先明确爬虫数据存储的三个刚性需求:
- Schema灵活性:不同站点字段差异大,且同一站点字段可能随版本迭代变化,固定表结构维护成本极高;
- 写入吞吐优先:爬虫是典型写密集型场景,读取多为批量导出或增量同步,对实时查询要求不高;
- 去重与状态管理:URL去重、任务队列、断点记录需要毫秒级读写,且数据具有时效性。
基于这三点,我们对比了主流方案:
| 方案 | Schema灵活 | 写入性能 | 去重/队列能力 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|---|
| MySQL | ❌ | ⭐⭐ | ⭐⭐ | ⭐⭐ | 结构化报表、强事务 |
| Elasticsearch | ✅ | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐ | 全文检索、复杂聚合 |
| MongoDB | ✅ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | 文档型爬虫数据 |
| Redis | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | 去重/队列/缓存 |
| MongoDB+Redis | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 大规模爬虫生产环境 |
结论:MongoDB负责持久化存储原始数据与清洗结果,Redis负责URL去重、任务调度、热点缓存。两者职责清晰,互为补充,而非互相替代。
⚠️ 常见误区:试图用Redis做持久化存储(AOF/RDB有丢失风险),或用MongoDB做高频去重($in查询随集合增大急剧变慢)。让专业组件做专业的事。
二、 架构总览:数据流与组件分工
- Redis:承担“快”角色——URL去重(Set/Bloom Filter)、任务队列(List/ZSet)、断点状态(Hash)、热点数据缓存;
- MongoDB:承担“稳”角色——原始HTML/JSON存储、结构化数据落盘、索引支持增量查询;
- Buffer层:爬虫Worker不直接写MongoDB,而是攒批后批量插入,将单次IO开销摊薄到百条级别。
三、 Redis层设计:去重、队列、状态三位一体
3.1 URL去重:从Set到Bloom Filter的演进
阶段一:Redis Set(<500万URL)
importredis r=redis.Redis(host='localhost',port=6379,db=0)defis_duplicate(url:str)->bool:"""SISMEMBER O(1),但内存占用高"""returnr.sismember("spider:url_seen",url)defmark_seen(url:str):r.sadd("spider:url_seen",url)问题:500万URL约消耗800MB内存,1亿URL需16GB+,且无法设置过期时间(Set不支持TTL per member)。
阶段二:Bloom Filter(>500万URL)
使用redisbloom模块或Python端pybloom_live+Redis String模拟:
frompybloom_liveimportScalableBloomFilterimporthashlib,redisclassRedisBloomFilter:def__init__(self,redis_client,key,capacity=10_000_000,error_rate=0.001):self.r=redis_client self.key=key# 本地构建BF,序列化存Redis;或直接使用RedisBloom模块self.bf=ScalableBloomFilter(initial_capacity=capacity,error_rate=error_rate)self._load_from_redis()def_load_from_redis(self):data=self.r.get(self.key)ifdata:self.bf=ScalableBloomFilter.from_bytes(data)defadd(self,url:str):url_hash=hashlib.md5(url.encode()).hexdigest()# 压缩key节省内存ifurl_hashnotinself.bf:self.bf.add(url_hash)self.r.set(self.key,self.bf.to_bytes())# 定期持久化defcontains(self,url:str)->bool:url_hash=hashlib.md5(url.encode()).hexdigest()returnurl_hashinself.bf生产建议:若Redis版本≥4.0,直接使用BF.ADD/BF.EXISTS命令(RedisBloom模块),性能比Python端高一个数量级,且支持集群。
💡 关键细节:URL先做MD5/SHA1哈希再存入BF,可将平均key长度从120字节压缩到32字节,内存节省70%以上。误判率设为0.001时,1亿URL仅需约140MB内存。
3.2 任务队列:优先级与公平性兼顾
# 高优先级任务(如首页、列表页)用LPUSH/RPOP实现FIFOr.lpush("spider:queue:high",json.dumps(task))# 普通任务用ZSet按优先级+时间戳排序r.zadd("spider:queue:normal",{json.dumps(task):priority_score})# 调度器取任务:先high后normaldefget_next_task():task=r.rpop("spider:queue:high")iftask:returnjson.loads(task)# ZPOPMIN返回最低分值(最高优先级)成员result=r.zpopmin("spider:queue:normal")ifresult:returnjson.loads(result[0][0])returnNone防积压设计:为每个队列设置MAXLEN,超限时丢弃最低优先级任务并告警,避免内存无限增长。
3.3 断点状态:Hash记录进度
# 记录每个站点的爬取进度state_key="spider:state:example_com"r.hset(state_key,mapping={"last_page":156,"last_timestamp":"2024-05-20T10:30:00","total_items":48230,"status":"running"})r.expire(state_key,86400*7)# 7天过期,防止僵尸状态重启任务时读取该Hash,从last_page+1继续,无需重新扫描已处理数据。
四、 MongoDB层设计:写入优化与索引策略
4.1 批量写入:Buffer + bulk_write
绝对禁止逐条insert。实测单条insert TPS约800,bulk_write(500条) TPS可达12000+。
frompymongoimportMongoClient,InsertOne,UpdateOnefromdatetimeimportdatetimeimportthreading,timeclassMongoBuffer:def__init__(self,collection,batch_size=500,flush_interval=3):self.collection=collection self.batch_size=batch_size self.flush_interval=flush_interval self.buffer=[]self.lock=threading.Lock()self._start_auto_flush()defadd(self,doc:dict):withself.lock:self.buffer.append(InsertOne(doc))iflen(self.buffer)>=self.batch_size:self._flush()def_flush(self):ifnotself.buffer:returntry:self.collection.bulk_write(self.buffer,ordered=False)exceptExceptionase:# 记录失败批次,后续重试或落盘log.error(f"Bulk write failed:{e}, count={len(self.buffer)}")finally:self.buffer.clear()def_start_auto_flush(self):"""定时刷新,防止低流量时数据滞留"""defloop():whileTrue:time.sleep(self.flush_interval)withself.lock:self._flush()t=threading.Thread(target=loop,daemon=True)t.start()⚠️
ordered=False是关键:允许部分成功,避免因单条文档校验失败导致整批回滚。配合应用层重试机制保证最终一致性。
4.2 文档结构设计:原始数据与清洗数据分离
// 原始数据集合:保留完整响应,用于回溯与重新解析db.raw_pages.insertOne({url:"https://example.com/item/123",html:"<html>...</html>",// 或压缩后的binaryheaders:{"Content-Type":"text/html"},crawled_at:ISODate("2024-05-20T10:30:00Z"),spider_name:"product_v2",checksum:"a1b2c3d4..."// 内容指纹,用于变更检测})// 清洗后数据集合:业务字段,供下游消费db.products.insertOne({source_url:"https://example.com/item/123",title:"无线蓝牙耳机",price:299.00,sku:"BT-2024-001",crawled_at:ISODate("2024-05-20T10:30:00Z"),updated_at:ISODate("2024-05-20T10:30:05Z"),_raw_id:ObjectId("...")// 关联原始文档})优势:解析逻辑变更时,可从raw_pages重新提取,无需重爬;清洗数据集合保持精简,查询更快。
4.3 索引策略:只为必要查询建索引
爬虫MongoDB的索引原则:写入优先,按需建索引。
// 必建索引db.raw_pages.createIndex({url:1},{unique:true})// 原始数据去重db.raw_pages.createIndex({crawled_at:-1})// 按时间范围查询db.products.createIndex({sku:1},{unique:true})// 业务唯一键db.products.createIndex({updated_at:-1})// 增量同步// 禁止行为// ❌ 对html/content等大字段建索引// ❌ 对频繁更新的字段建复合索引(写入放大)// ❌ 未使用的索引不及时删除💡 监控指标:通过
db.collection.stats().indexSizes定期检查索引大小,单个索引超过集合数据量30%时需评估必要性。写入密集期可临时禁用非关键索引,空闲期重建。
五、 缓存层:减少重复解析与外部调用
并非所有数据都需要走MongoDB。以下场景应优先用Redis缓存:
| 缓存对象 | TTL | Redis类型 | 说明 |
|---|---|---|---|
| 站点配置(robots.txt/sitemap) | 1h | String | 避免每次请求都解析 |
| 用户代理池 | 永久 | List/Set | 轮询使用,无需持久化 |
| 热门商品详情 | 15min | Hash | 短时间内多次访问同一页面 |
| API Token/OAuth凭证 | 按有效期 | String | 避免重复认证 |
| 解析规则版本 | 永久 | String | 规则变更时主动失效 |
# 示例:带缓存的页面解析defparse_product(url:str)->dict:cache_key=f"cache:product:{md5(url)}"cached=r.hgetall(cache_key)ifcached:return{k.decode():v.decode()fork,vincached.items()}# 未命中,正常抓取+解析data=fetch_and_parse(url)# 写入缓存r.hset(cache_key,mapping=data)r.expire(cache_key,900)# 15分钟returndata六、 生产环境避坑清单
6.1 Redis相关
- 禁止KEYS *:用
SCAN游标遍历,或在设计时用Tag+List替代模糊搜索; - 大Key预警:单个Set/ZSet超过10万元素即视为大Key,拆分为多个slot或使用BF;
- 持久化策略:爬虫Redis以性能优先,推荐
save ""关闭RDB,仅开启AOF everysec; - 连接池复用:每个Worker进程独立连接池,避免多线程竞争socket。
6.2 MongoDB相关
- WiredTiger缓存:设置为物理内存的50%-60%,预留足够给OS文件缓存;
- Journal提交间隔:写入密集时可设为
commitIntervalMs: 200(默认100),牺牲少量耐久性换吞吐; - 分片时机:单集合超过5000万文档或100GB时考虑分片,提前规划shard key(推荐
url_hash或site_id+crawled_at); - 备份策略:使用
mongodump --oplog保证时间点一致性,备份期间避开写入高峰。
6.3 通用原则
- 监控先行:部署Prometheus+Grafana,监控Redis内存/命中率、MongoDB opcounters/wt_cache、爬虫QPS/错误率;
- 优雅降级:Redis不可用时自动切换为内存Set去重(限流保护),MongoDB写入失败时暂存本地SQLite;
- 数据生命周期:raw_pages保留30天后归档至对象存储,products永久保留但冷数据迁移至低频存储;
- 权限最小化:爬虫账号仅有readWrite权限,禁止drop/createCollection等DDL操作。
七、 性能基准参考(单机16C32G NVMe SSD)
| 指标 | 数值 | 备注 |
|---|---|---|
| Redis SISMEMBER QPS | 120,000 | 单线程,网络带宽未饱和 |
| Redis BF.EXISTS QPS | 85,000 | RedisBloom模块 |
| MongoDB bulk_write TPS | 12,000 | 500条/批,ordered=False |
| MongoDB 单条insert TPS | 800 | 对比基准 |
| 端到端延迟(去重+写入) | <5ms | P99,不含网络RTT |
| 内存占用(1亿URL去重) | ~180MB | Bloom Filter + Redis元数据 |
八、 总结:存储设计的本质是权衡
MongoDB+Redis方案并非银弹,它的核心价值在于将爬虫存储的复杂性分解为两个可独立优化的子问题:
- Redis解决“快”与“临时”:去重、队列、缓存,容忍一定数据丢失;
- MongoDB解决“稳”与“持久”:原始数据、结构化结果,保证最终一致性。
在实际落地中,请记住三条铁律:
- 永远批量写入,永远不要逐条insert;
- URL去重尽早压缩,内存是爬虫最贵的资源;
- 原始数据与业务数据分离,为未来留后悔药。
技术选型没有最优解,只有最适合当前规模与团队能力的解。当你的爬虫从十万级迈向亿级时,这套双引擎架构能为你提供足够的扩展空间,而不是成为瓶颈。
本文所述方案已在电商、资讯、招聘等多个爬虫项目中验证,代码片段已脱敏。转载或引用请注明出处。