ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大数据爬虫解决方案:从调度去重到反爬落库的工程实践

大数据爬虫解决方案:从调度去重到反爬落库的工程实践

简介:这份PPT资源面向数据采集工程师、爬虫开发初学者及需要构建大规模抓取系统的技术团队,系统梳理了大数据爬虫从架构设计到落地应用的完整知识体系。内容围绕大数据基础管理平台架构、爬虫控制层与执行层分工、代理IP访问策略、分布式多线程模式、验证码反爬处理及安全设计方案展开,并深入讲解网络爬虫核心软件系统架构、可视化规则制定、语义字段精确抓取、增量抓取与自动补抓等平台特性。资源包内含1个pptx文件,大小约4.53MB,以图文并茂的幻灯片形式呈现,便于快速理解与团队内部分享。目前已有150人学习浏览。读者可从中获取爬虫平台能力指标说明、网页抓取五步流程、动态与静态网页兼容策略,以及精准决策情报产品等应用案例,适合作为方案选型、技术汇报或爬虫系统设计的参考材料。

1. 大数据爬虫解决方案:从一份 PPT 标题里拆出的真实工程链路

很多人看到「大数据爬虫解决方案.pptx」这个标题,第一反应是去找一份现成的演示文稿,指望里面有几张架构图、几段伪代码就能照搬上线。我早年也这么想过,结果翻车得很惨——真正跑起来才发现,PPT 里画的「采集层、存储层、计算层」三块方框,落到工程里是几十个必须自己填的坑:调度怎么防重复、反爬怎么降级、增量怎么判定、数据怎么保证不丢不重。所以这篇不聊某份具体文稿,而是顺着这个标题,把「大数据场景下的爬虫方案」当成一个真实系统来拆:它要解决的是海量目标站点的高并发采集、结构化落库、增量更新和长期稳定运行,适合手里已经有几十万到上千万 URL 要抓、单机脚本已经扛不住的团队和个人。下面按「先立住选型逻辑,再动手复现,最后讲坑」的顺序推下去,每一步都给到能抄的参数和命令。

2. 大数据爬虫和单机脚本的分水岭:调度、去重与增量

单机爬虫和大数据爬虫最本质的差别不在「抓得快不快」,而在「状态管不管得住」。单机脚本可以用一个 set 在内存里去重,进程一重启全丢;大数据场景下 URL 量级到千万,内存放不下,重启还得接着上次的进度跑,这就逼着你把调度、去重、增量三件事从业务代码里抽出来,做成独立的基础设施。这一章先把这三块的选型理由讲透,再给最小可跑的骨架。

2.1 为什么去重必须外置到 Redis 或布隆过滤器

内存 set 的容量上限取决于机器内存,1000 万条 URL 按平均 80 字节算就是 800MB,加上 Python 对象开销轻松破 2GB,多开几个 worker 直接 OOM。常见做法是把去重状态外置:小规模用 Redis 的 SET,大规模用布隆过滤器(Bloom Filter)压内存。布隆过滤器的代价是有假阳性——它说「见过」的时候可能其实没见过,会漏抓少量 URL;但它说「没见过」的时候一定没见过,不会重复抓。对绝大多数采集任务来说,漏抓一点点可以接受,重复抓才是灾难(浪费带宽、触发反爬、污染数据)。

Redis 原生支持布隆过滤器模块,也可以用 Python 的pybloom_live在本地维护。生产上我更倾向 Redis,因为多个 worker 要共享同一份去重状态,本地布隆过滤器没法跨进程同步。

import redis from redis import Redis # 连接 Redis,decode_responses=True 让返回的是字符串而不是 bytes r = Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) # 用 RedisBloom 模块的 BF.ADD,返回 1 表示新元素,0 表示可能已存在 def is_new_url(url: str, key: str = "crawler:seen") -> bool: # 误判率 0.001,容量预估 1000 万,这两个参数决定内存占用 try: r.execute_command("BF.RESERVE", key, 0.001, 10_000_000) except Exception: pass # 已存在则忽略 added = r.execute_command("BF.ADD", key, url) return added == 1 if __name__ == "__main__": print(is_new_url("https://example.com/a")) # True print(is_new_url("https://example.com/a")) # False

逻辑说明:BF.RESERVE只在 key 不存在时创建,误判率 0.001 意味着大约千分之一的「没见过」会被误判成「见过」,容量 1000 万是预估上限,超了误判率会上升。BF.ADD返回 1 代表确定是新元素,返回 0 代表可能存在。参数上,误判率每降低一个数量级,内存大约翻倍,0.001 是采集场景里比较平衡的取值。

2.2 调度层选型:Celery、Scrapy-Redis 还是自研队列

调度要解决的是「谁去抓、抓完把新 URL 塞回队列、失败了怎么重试」。三种常见路线:

方案适合规模优点代价
Celery + Redis十万级任务生态成熟,重试/定时开箱即用任务粒度粗,URL 级调度偏重
Scrapy-Redis百万级 URL天然分布式,去重队列一体绑死 Scrapy 框架,定制受限
自研 Redis 队列千万级完全可控,能塞任意元数据重试、超时、监控全自己写

我一般会:如果团队已经在用 Scrapy,直接上 Scrapy-Redis,改造成本最低;如果是多语言混合栈、或者采集逻辑不是标准 HTTP 抓取(比如要调浏览器、要处理 WebSocket),就自研一个基于 Redis List 或 Stream 的队列。Redis Stream 比 List 多了消费组和 ACK 机制,能保证「任务被取走但没处理完」时不会丢,这一点在长时间运行的系统里很关键。

# 用 Redis Stream 建一个采集队列,并创建一个消费组 redis-cli XADD crawl:stream * url "https://example.com/1" depth 0 redis-cli XGROUP CREATE crawl:stream workers 0 MKSTREAM # worker 读取:> 表示只读新消息,COUNT 控制每次拉取量 redis-cli XREADGROUP GROUP workers worker-1 COUNT 10 STREAMS crawl:stream >

逻辑说明:XADD的*让 Redis 自动生成消息 ID,depth字段用来控制抓取深度,避免无限扩散。XGROUP CREATE的0表示从最早的消息开始消费,MKSTREAM在流不存在时自动创建。XREADGROUP用>只拿没投递过的消息,处理完要用XACK确认,否则消息会留在 pending 列表里,可以据此做失败重试。

2.3 增量判定:时间戳、指纹还是版本号

增量抓取的核心问题是「怎么知道这个页面变了」。三种常见做法:

  • 时间戳比对:页面有明确的Last-Modified或发布时间,直接比。最省事,但很多站点不提供或提供的是假的。
  • 内容指纹:对正文做哈希(如 SimHash、MD5),和上次存的比。准确,但要存全量指纹,存储成本高。
  • 版本号/ETag:HTTP 头里的ETag,服务端支持的话最优雅,请求时带If-None-Match,没变返回 304,连正文都不用传。

实际工程里我一般组合用:先看ETag,没有就退到Last-Modified,再没有才拉全量做指纹比对。指纹用 SimHash 而不是 MD5,因为页面里往往有广告、推荐位这些噪声,MD5 一字节不同就判定为变,SimHash 能容忍小改动,只报「实质性变化」。

import hashlib from simhash import Simhash def content_fingerprint(text: str) -> str: # 先做简单清洗,去掉空白和常见噪声 cleaned = "".join(text.split()) # SimHash 对局部改动不敏感,适合判断「实质性变化」 return str(Simhash(cleaned).value) def md5_fingerprint(text: str) -> str: return hashlib.md5(text.encode("utf-8")).hexdigest() # 用法:存 SimHash,比对时算汉明距离,阈值一般取 3 old = Simhash("原始正文内容") new = Simhash("原始正文内容 加了一行广告") print(old.distance(new)) # 距离小于阈值就认为没实质变化

逻辑说明:SimHash 把文本映射成 64 位整数,相似文本的汉明距离小。阈值取 3 是经验值,太小吃不下正常编辑,太大漏掉真实更新。MD5 适合对精确性要求极高的场景,比如价格监控,一个数字变了就必须报。

3. 把采集链路跑起来:从 URL 队列到结构化落库

选型定完,这一章落到能跑的代码。整条链路是:种子 URL 入队 → worker 取任务 → 抓取并解析 → 新 URL 回队 → 结构化数据落库。每一段都给最小实现和参数说明,你可以直接拼起来跑通一个 demo。

3.1 用 aiohttp 做高并发抓取的最小骨架

单机想提高吞吐,同步 requests 加线程池是常见起点,但线程切换开销大,几千并发就顶不住。异步 aiohttp 在单机上跑几千并发更轻松,代价是代码要写成 async。下面是一个带超时、重试、并发限制的骨架。

import asyncio import aiohttp from aiohttp import ClientTimeout # 并发上限,太大容易触发目标站限流,一般 50-200 CONCURRENCY = 100 # 单请求超时,连接 5 秒,读取 15 秒 TIMEOUT = ClientTimeout(total=20, connect=5) async def fetch(session, url, sem): async with sem: # 信号量控制并发 for attempt in range(3): # 最多重试 3 次 try: async with session.get(url, timeout=TIMEOUT) as resp: if resp.status == 200: return await resp.text() if resp.status in (429, 503): # 被限流,退避重试 await asyncio.sleep(2 ** attempt) except Exception as e: await asyncio.sleep(1) return None async def main(urls): sem = asyncio.Semaphore(CONCURRENCY) connector = aiohttp.TCPConnector(limit=CONCURRENCY, ttl_dns_cache=300) async with aiohttp.ClientSession(connector=connector) as session: tasks = [fetch(session, u, sem) for u in urls] return await asyncio.gather(*tasks) if __name__ == "__main__": urls = [f"https://example.com/page/{i}" for i in range(1000)] results = asyncio.run(main(urls)) print(sum(1 for r in results if r))

逻辑说明:Semaphore控制同时在跑的请求数,避免一次性打爆目标站或本地文件描述符。TCPConnector的limit和信号量保持一致,ttl_dns_cache=300缓存 DNS 解析 5 分钟,减少解析开销。重试用指数退避2 ** attempt,遇到 429/503 时给服务端喘息时间。参数上,并发数不是越大越好,超过目标站承受能力会直接封 IP,从 50 起步逐步加压更稳。

3.2 解析与字段抽取:lxml 还是 parsel

解析层选型看两点:速度和容错。lxml 底层是 C,速度最快,XPath 表达力强;parsel 是 Scrapy 抽出来的库,同时支持 XPath 和 CSS,容错好,写起来舒服。纯性能场景选 lxml,要快速迭代选 parsel。下面用 parsel 演示抽取标题、正文、发布时间三个字段。

from parsel import Selector html = """ <html><body> <h1 class="title">示例标题</h1> <div class="content"><p>正文第一段</p><p>正文第二段</p></div> <span class="date">2024-01-01</span> </body></html> """ sel = Selector(text=html) title = sel.css("h1.title::text").get(default="").strip() content = "".join(sel.css("div.content p::text").getall()) date = sel.css("span.date::text").get(default="").strip() print(title, content, date)

逻辑说明:::text取文本节点,get()取第一个匹配,getall()取全部。default=""保证字段缺失时不抛异常,返回空串,后续入库时再统一处理空值。实际项目里 XPath 和 CSS 混用很常见,CSS 写起来短,XPath 能处理「父节点包含某文本」这类复杂条件。

3.3 落库:批量写入与去重约束

数据落库最容易翻车的地方是「一条一条 insert」,几百万条下来数据库直接跪。正确做法是批量攒够 N 条或等 M 秒再写,同时用唯一索引兜底去重。下面以 PostgreSQL 为例,用ON CONFLICT DO NOTHING保证重复 URL 不报错。

import psycopg2 from psycopg2.extras import execute_values # 建表时给 url 加唯一约束,这是去重的最后一道防线 DDL = """ CREATE TABLE IF NOT EXISTS pages ( id BIGSERIAL PRIMARY KEY, url TEXT UNIQUE NOT NULL, title TEXT, content TEXT, fetched_at TIMESTAMP DEFAULT NOW() ); """ def batch_insert(conn, rows): # rows 是 (url, title, content) 的列表 sql = "INSERT INTO pages (url, title, content) VALUES %s ON CONFLICT (url) DO NOTHING" with conn.cursor() as cur: execute_values(cur, sql, rows, page_size=1000) conn.commit() # 攒够 1000 条或每 5 秒刷一次,避免频繁小事务

逻辑说明:execute_values把多行拼成一条 INSERT,比逐条快一个数量级,page_size=1000控制单次 SQL 大小,太大可能超过数据库的包限制。ON CONFLICT (url) DO NOTHING依赖唯一索引,即使上游去重漏了,这里也不会写重。参数上,批量大小和数据库的max_allowed_packet(MySQL)或内存有关,1000 是通用安全值。

4. 反爬对抗与稳定性:限流、代理池与失败重试的边界

采集系统跑起来只是开始,能不能长期稳定跑才是分水岭。这一章讲三个最常被低估的问题:怎么控制请求节奏不被封、代理池怎么管、失败重试到什么程度该放弃。

4.1 请求节奏控制:令牌桶比固定 sleep 更聪明

新手最常见的做法是每个请求time.sleep(1),问题是它既慢又不安全——固定间隔容易被识别成机器人,而且遇到慢响应时整体吞吐被拖垮。更好的做法是令牌桶:以固定速率往桶里放令牌,请求来了先拿令牌,拿不到就等。这样既能控制平均速率,又允许短时突发。

import time import threading class TokenBucket: def __init__(self, rate, capacity): self.rate = rate # 每秒放多少个令牌 self.capacity = capacity # 桶容量,决定突发上限 self.tokens = capacity self.last = time.time() self.lock = threading.Lock() def acquire(self, n=1): with self.lock: now = time.time() # 按流逝时间补充令牌,不超过容量 self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate) self.last = now if self.tokens >= n: self.tokens -= n return True return False # 用法:每秒 10 个请求,允许突发 20 个 bucket = TokenBucket(rate=10, capacity=20) while True: if bucket.acquire(): # 发请求 break time.sleep(0.05)

逻辑说明:rate是长期平均速率,capacity是短时突发上限。acquire里先按时间差补令牌,再判断够不够。参数上,rate 设成目标站能承受的 QPS 的 70% 左右留余量,capacity 一般取 rate 的 2 倍,允许页面加载时的突发。

4.2 代理池的维护:可用性检测比数量更重要

代理池的误区是「越多越好」,实际上 100 个可用代理比 10000 个死代理强得多。代理池要解决三件事:定期检测可用性、按目标站分组(不同站对代理的容忍度不同)、失败自动剔除。下面是一个最小可用性检测脚本。

import asyncio import aiohttp async def check_proxy(session, proxy, test_url="https://httpbin.org/ip", timeout=8): try: async with session.get(test_url, proxy=f"http://{proxy}", timeout=timeout) as resp: if resp.status == 200: return proxy, True except Exception: pass return proxy, False async def check_all(proxies): async with aiohttp.ClientSession() as session: tasks = [check_proxy(session, p) for p in proxies] results = await asyncio.gather(*tasks) return [p for p, ok in results if ok] # 每 5 分钟跑一次,把可用代理写回 Redis 的可用集合

逻辑说明:检测用一个小而稳定的测试地址,超时设 8 秒,超过就判定不可用。检测频率别太高,5 分钟一次足够,太频繁会浪费代理流量。可用代理写回 Redis 后,抓取时从可用集合里随机取,失败一次就临时移出,过一段时间再放回去复检。

4.3 失败重试的边界:什么情况该放弃

重试不是越多越好。我的经验是分三类:网络超时、连接错误可以重试 3 次;429/503 这类限流要退避重试,但最多 5 次;404/403 这类明确拒绝不要重试,重试只会加重封禁。重试次数和退避时间要写进配置,别硬编码。

失败类型是否重试退避策略上限
连接超时是固定 1s3 次
429 限流是指数退避5 次
503 服务不可用是指数退避5 次
403 禁止访问否换代理后重试2 次
404 不存在否直接丢弃0

5. 避坑与排查:五个真实踩过的坑

这一章全是血泪经验,每条按「现象 → 原因 → 解决」写,你遇到时可以直接对号入座。

5.1 内存持续上涨直到 OOM

现象:worker 跑几个小时内存从几百 MB 涨到几 GB,最后被系统杀掉。原因:aiohttp 的 session 没复用、响应体没及时释放,或者去重集合在内存里无限增长。解决:全局复用一个 ClientSession,用async with确保响应释放;去重状态外置到 Redis;定期用tracemalloc抓内存快照定位泄漏点。

5.2 抓到的数据一半是乱码

现象:入库的中文全是问号或方块。原因:响应编码没正确处理,aiohttp 默认按 HTTP 头猜编码,很多站点头里写的是错的。解决:优先用resp.content拿 bytes,再用chardet或charset-normalizer检测编码,最后 decode。别直接信resp.text()。

5.3 目标站突然全部返回 403

现象:昨天还好好的,今天所有请求 403。原因:IP 被识别,或者 User-Agent、请求头指纹太单一。解决:轮换 UA 和代理,补全Accept、Accept-Language等头,降低并发,加随机延迟。如果还不行,说明对方上了更高级的指纹检测,得考虑浏览器方案。

5.4 增量抓取把全量数据又抓了一遍

现象:明明做了增量,结果每次跑都全量重抓。原因:指纹算法对噪声太敏感,页面里时间戳、广告位一变指纹就变。解决:抓取前先做正文抽取,只对正文算指纹;用 SimHash 替代 MD5;把「变化」定义成汉明距离超过阈值,而不是完全相等。

5.5 数据库写入越来越慢

现象:前期写入很快,数据到几百万行后单条 insert 要几百毫秒。原因:唯一索引在数据量大时维护成本上升,加上逐条提交事务。解决:改批量写入,page_size调到 1000;把唯一索引从 URL 全文改成 URL 的哈希值,减少索引大小;写入和抓取解耦,用消息队列缓冲。

6. 进阶:把采集系统做成可观测、可回放的数据管道

到这一步,系统能跑了,但离「可信」还差一截。可信的标志是:出问题能定位、数据能回放、指标能预警。我一般会给采集系统加三样东西:结构化日志、指标埋点、原始响应归档。

结构化日志用 JSON 格式,每条日志带url、status、耗时、重试次数,直接进 ELK 或 Loki,排查时按 URL 一搜就能看到完整链路。指标埋点至少覆盖:队列长度、抓取成功率、平均响应时间、去重命中率。队列长度持续上涨说明消费跟不上,成功率骤降说明被封了,去重命中率异常高说明有循环抓取。

原始响应归档是最容易被忽略但最值钱的一环。把每次抓取的原始 HTML 按日期/域名/URL哈希存到对象存储,保留 7 到 30 天。好处是解析规则改了以后,不用重新抓,直接拿归档数据回放重跑解析,省时省流量。下面是一个归档的写法。

import hashlib import gzip from pathlib import Path def archive(url: str, html: str, base_dir: str = "/data/archive"): # 用 URL 哈希做文件名,避免特殊字符和过长路径 h = hashlib.md5(url.encode()).hexdigest() # 按日期分目录,方便按时间清理 from datetime import date d = date.today().isoformat() path = Path(base_dir) / d / h[:2] / f"{h}.html.gz" path.parent.mkdir(parents=True, exist_ok=True) # gzip 压缩,HTML 压缩比通常能到 5:1 以上 with gzip.open(path, "wt", encoding="utf-8") as f: f.write(html) return str(path)

逻辑说明:用 MD5 做文件名是为了路径安全,h[:2]做二级目录避免单目录文件过多。gzip 压缩后存储成本大幅下降,100 万页面大约占几十 GB。归档和主流程解耦,用异步队列写,别阻塞抓取。

回放的时候,写一个脚本遍历归档目录,把 HTML 喂给解析函数,输出结构化数据,和线上库对比差异。这个能力在解析规则迭代时特别有用——你可以先在归档上验证新规则,确认没问题再上线,而不是直接改线上然后祈祷。

最后说个我自己的习惯:任何采集系统上线前,我都会先用 1000 条 URL 跑一轮完整链路,把成功率、去重率、落库量三个数字记下来,作为基线。之后每次改动都对比这三个数,掉一个点就回滚。这个习惯帮我省了无数次「改完看着没问题、跑一天发现数据少了一半」的后悔药。采集系统没有银弹,能观测、能回放、能对比,就是最大的稳定。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进