有时候你会发现,需求本身看起来并不复杂——就是把一批页面抓下来,解析出结构化数据。可一旦页面变成“动态渲染”、数据量变成“几十万条”、时效要求变成“每小时全量更新”,单机脚本立刻就成了瓶颈。我自己的经验是:爬虫工程化的第一道坎不是反爬,而是调度和浏览器资源的组织方式。这也是我为什么最终把方案落到了 Celery + Playwright 上:Celery 管任务分发和水平扩展,Playwright 管动态页面渲染,两者配合,就能搭出一个清晰、抗压、能随时加机器的高并发爬虫集群。
这篇文章不是泛泛而谈架构图,而是我会真实用到的设计思路、踩坑记录和可复现的代码片段。如果你正在纠结“怎么让几十个爬虫任务稳定跑在分布式环境里”,或者“Playwright 怎么和消息队列结合”,那这篇应该能帮你省掉不少查资料的功夫。我会从选型逻辑开始,一路讲到 worker 部署、浏览器复用、任务拆分、监控告警,最后收在几个高频问题上。
1. 整体设计:为什么是 Celery + Playwright
1.1 Celery 不只是“队列”,它把调度这件事变得可维护
很多人一提到分布式任务调度,第一反应是 Kafka、RabbitMQ 这类消息组件,或者干脆自己写个线程池加数据库状态表。Celery 的价值在于,它把“任务的产生、分发、执行、重试、结果存储”这套链路封装成了统一的编程模型。你写一个普通的 Python 函数,加上 task 装饰器,它就能被扔进队列、被远程 worker 拉取执行、执行失败自动重试、结果存进后端。这种抽象对爬虫场景特别友好,因为爬虫天然适合被拆成“种子任务”和“页面抓取任务”两个层级,Celery 的链式任务和路由规则刚好能承接这种层级关系。
同时,Celery 的 worker 是一个独立进程模型,这就意味着它天然支持多进程并行、水平扩展和优雅停止。我见过不少团队用 threading + requests 硬扛动态页面,最后调度逻辑散落在业务代码里,想加一台机器都费劲。Celery 把这个问题前置解决了。对我来讲,选它最大的理由不是“功能多”,而是“省心”:重试机制、超时控制、任务优先级、队列隔离,这些都是爬虫集群早晚要面对的,Celery 直接用成熟方案给掉,不需要自己从零造轮子。
1.2 Playwright 解决了“页面到底长什么样”的问题
动态页面爬虫最大的麻烦是:数据不在 HTML 源码里,而是在 JavaScript 执行后的 DOM 里。requests + BeautifulSoup 这套组合面对 SPA 几乎无能为力,传统方案是 Selenium,但 Selenium 的浏览器驱动管理、显式等待、并发下的稳定性都让人头疼。Playwright 的设计更现代:它有统一的 browserType API,自动管理 Chromium 的下载和启动,选择器功能强大,还能做网络拦截、文件下载、多页面标签管理。它内置的 auto-wait 机制对开发者特别友好——你不需要频繁 sleep 去猜页面加载时间,元素出现条件由框架自己判断。
真正让我决定用它的,是 Playwright 对并发场景的支撑。每个 Browser Context 之间天生就是隔离的,存储状态互不干扰。这意味着同一个浏览器进程里可以开出多个独立会话,每个会话有自己的 cookie、localStorage,非常适合模拟不同用户视角的信息抓取。再加上它原生支持持久化上下文,登录态可以作为文件存下来,跨任务复用,省掉反复扫码登录的痛苦。
1.3 为什么不选 Scrapy + Selenium 的组合?
Scrapy 在静态页面抓取和解析链路里依然是王者,但它的 JavaScript 渲染方案基本是依赖 Selenium 或 Splash 这类外部中间件。我之前在模拟项目X里试过 Scrapy 搭配 Selenium 的中间件方案,最大的问题是“两套调度体系”互相打架——Scrapy 自己的调度器和 ation 调度器都在控制请求,一旦请求量上来,中间件的连接管理经常会 leak,回调函数的执行时机也难控制。Celery 这边则是纯粹的“任务驱动”:一个任务对应一个明确的抓取单元,Playwright 只对当前任务负责,跑完即释放,天然契合函数式任务模型。所以最终方案里,抓取引擎不承担调度职责,调度完全由任务编排控制。
2. 任务拆分与消息编排:核心细节解析
2.1 任务拆分的粒度怎么定?
分布式任务调度里,任务粒度过细会明显增加消息队列的吞吐压力;粒度过粗又会导致单节点抓取时间过长,集群空转。我自己的经验是:把任务拆成“列表页发现任务”和“详情页抓取任务”两层。列表页任务拿到一批详情页 URL,然后把这些 URL 分批投递给下游的详情页队列。这样做的直接好处是失败重跑的粒度可控——某一条详情页失败了只需要重放那一条,而不是整批重抓。
对于详情页任务,我一般还会按域名再细分队列,避免某一站点响应过慢拖垮所有 worker。举个例子,假设集群有 10 个 worker 进程,如果它们同时去抓一个慢站点,其他快站点的任务就会堆积。用 Celery 的 queue 路由,把“慢站点”任务路由到专用队列,再让部分 worker 只消费这个队列,就能做到故障隔离。代码层面,我会用 task_routes 做显式路由,给不同任务指定不同的队列名:
# celeryconfig.py task_routes = { 'spider.tasks.crawl_detail': {'queue': 'page_detail'}, 'spider.tasks.crawl_list': {'queue': 'page_list'}, 'spider.tasks.slow_site': {'queue': 'slow_site'}, } task_default_queue = 'default'2.2 列表页与详情页的接力:用 chain 还是用回调?
任务之间的依赖关系有几种表达方式:chain 是按顺序执行链式任务;callback 是当主任务执行完成后向 Broker 投递一个新任务。爬虫场景里我倾向于 callback——因为详情页任务的数量在抓取之前是未知的,列表页解析完才知道有多少 URL,所以不太适合预先定义好 chain。操作上,我在列表页任务的尾部调用crawl_detail.delay(...)或group(...)批量投递,这样列表页的抓取和详情页的调度就通过消息队列完成了松耦合衔接。
@app.task(bind=True, max_retries=3) def crawl_list(self, page_url): html = fetch_page(page_url) urls = parse_detail_links(html) # 将详情页任务批量投递 jobs = [crawl_detail.s(url) for url in urls] job = group(jobs) job.apply_async(queue='page_detail')这个设计的价值在于:列表页和详情页的执行进度互相独立。详情页队列消费慢的时候,列表页任务不会阻塞;反过来详情页任务失败时,也不会影响列表页正常流转。这是分布式架构里最常用但最容易忽略的一点——任务解耦。
2.3 结果存储与去重:确认“这个任务做过了”
高并发爬虫最容易翻车的不是抓取速度,而是重复消费。Celery 默认对同一个任务生成一个 task_id,但如果任务本身在业务层面就去重,光靠 task_id 是不行的——比如两个不同列表页可能推送了同一个详情页 URL。我通常在任务入口用 Redis 做一层去重:以 URL 的 hash 作为唯一键,只有 key 不存在时才真正执行抓取逻辑。执行完再写业务结果。
@app.task(bind=True) def crawl_detail(self, detail_url): url_digest = sha256(detail_url.encode()).hexdigest() if redis_client.exists(url_digest): return {'status': 'duplicated', 'url': detail_url} # 实际抓取逻辑 data = fetch_detail(detail_url) redis_client.setex(url_digest, 3600 * 24, 'done') save_to_db(data)这里有个细节值得注意:setex的过期时间要根据任务周期来设。如果详情页内容是每天更新的,那么去重窗口设 24 小时就够;如果是周粒度更新的数据,去重窗口至少要覆盖一周。过期时间的误设,可能造成该抓的数据被当做重复跳过,也可能导致一个月前已经抓过的数据被重新抓一遍。
3. Playwright 在 Celery Worker 里的实操要点
3.1 浏览器实例到底放在哪里?
这是整个方案里坑最多的地方。Celery worker 默认是 fork 模式,每个 worker 进程都会执行任务函数。如果你在每个任务内部直接sync_playwright().start()再启动浏览器,那并发一高,每个 worker 子进程都会拉起一个完整浏览器实例,内存直接吃满。正确思路是:每个 worker 只持有有限的浏览器实例,并且尽量复用。
我自己用的是一个简单但有效的方案:在 Celery worker 启动时初始化一个 Playwright 浏览器池。具体来说,自定义一个 Celery 的 Worker 子类,在on_start回调里启动playwright并创建 Chromium launch 实例;在on_stop回调里关闭浏览器。每个任务执行时,从浏览器实例的 context 中创建一个新的页面上下文,用完关闭 context,不关闭浏览器本身。
class CrawlWorker(Worker): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.playwright = None self.browser = None def on_start(self): super().on_start() self.playwright = sync_playwright().start() self.browser = self.playwright.chromium.launch( headless=True, args=['--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled'] ) def on_stop(self): if self.browser: self.browser.close() if self.playwright: self.playwright.stop() super().on_stop()这段代码的核心原则是:重资源尽量长生命周期,轻资源短生命周期。浏览器进程作为重资源常驻;页面 page 和 context 作为轻资源,随每个任务创建和销毁。否则每个任务都重新启动 Chromium,光是冷启动耗时就会把并发优势吃光。
3.2 prefork 并发模型下的隐藏问题
Celery 的默认并发模型是 prefork,好处是每个子进程独立,任务崩溃不会拖垮主进程。但坏处是:Playwright 的 sync API 绑定的是当前线程的事件循环,在 prefork 模式下如果某个任务里直接调用 sync API,通常没问题,但一旦涉及page.wait_for_timeout(0)这类内部事件循环调用,偶尔会产生事件循环冲突。用 async API 配合 prefork 则会出现另一个问题——子进程没有运行中的事件循环,asyncio.run()每次都要重新建立循环,代价不小。
我的经验是:在 Celery 任务里直接用 Playwright 的 sync API,同时给任务设置超时。Celery 的soft_time_limit和time_limit是双层防护:软超时让任务内部主动抛出SoftTimeLimitExceeded异常,可以捕获后保存部分解析结果;硬超时则直接杀进程。对浏览器类任务,这个机制比单纯page.set_default_timeout更可靠,因为浏览器可能整体卡死,页面级的超时不一定管用。
@app.task(bind=True, soft_time_limit=60, time_limit=90, max_retries=2) def crawl_detail(self, url, context_id): context = browser.contexts[context_id] page = context.new_page()3.3 登录态如何跨任务复用?
很多目标站点的数据需要登录后可见,而登录动作本身是高频反爬点。Playwright 提供storage_state这个概念,它把 localStorage、cookies 和 session 序列化到一个 JSON 文件。在 Celery worker 里,我们可以在一个独立的初始化任务里登录一次,然后把storage_state存入 Redis;后续的详情页任务拿到这个storage_state字符串,直接带进 context。
context = browser.new_context(storage_state=json.loads(state_json)) page = context.new_page()这个做法大大减少登录频率。但要注意,storage_state是会过期的,cookie 里的 session 失效后,再用旧状态打开页面只会拿到登录页。我通常在每个 worker 里挂一个定时任务,检查storage_state的有效性——就是用它访问一个需要登录的轻量接口,看返回的 JSON 是否是登录态结构。如果失效,就触发一次重新登录任务,并把新的storage_state推回 Redis。
3.4 页面加载策略的取舍
Playwright 的默认加载策略是load,即等所有资源包括图片、广告、异步请求全部完成后才算加载完毕。这在很多低质页面里会让单页抓取时间膨胀到十几秒甚至更久。我实测下来,大部分结构化数据的核心数据接口在domcontentloaded阶段就已经完成,后面的图片和埋点根本不重要。所以基本都改成domcontentloaded。
page.goto(url, wait_until='domcontentloaded', timeout=30_000)如果有页面特别刁钻,数据必须等某个接口回应后才渲染,这时候不要盲目增加等待时间,而是用page.wait_for_selector定向等待某个条件。例如等待表格里的某一行出现,再开始解析。这种策略比固定 sleep 要高效得多,也避免“多等了好几秒还拿不到数据”的情况。
4. 集群部署与监控:从“能跑”到“敢跑”
4.1 队列容量规划和 worker 并发数怎么设
分布式调度的容量规划,核心是估算单个任务的耗时和浏览器内存消耗,然后反推 worker 数量。拿我常跑的一个数据采集场景来举例:一个详情页任务平均抓取耗时约 2 秒,主要是页面加载加解析;单个 Chromium 页面 context 大概占用 30MB 到 60MB 内存。如果给 worker 节点分配 4GB 可用内存,那么单机同时跑 50 个 context 已经接近极限。所以我通常把单个 worker 的并发数设为 8 到 12,宁可多开几个 worker 进程,也不要在一个进程里堆太多节假日。
实际操作里,启动 worker 时这样控制并发:
celery -A spider worker -l info -Q page_list -c 8 --max-tasks-per-child=200--max-tasks-per-child=200是我特别想强调的参数。Playwright 的浏览器跑得久了,即使 context 关闭,Chromium 进程的内存碎片和脏页面也会累积。定期让子进程处理完固定任务后自杀重建,能有效避免 worker 内存缓涨。
4.2 任务优先级:高优先级队列如何不拥堵
爬虫任务其实有优先级差异:比如实时价格监控需要分钟级延迟,而全量历史数据抓取可以容忍小时级延迟。Celery 官方的优先级支持依赖消息队列的优先级功能。用 RabbitMQ 作为 broker 时,一台机器上的优先级队列天然支持。为了不让“全量抓取”任务把“实时监控”任务堵死,我会建两个独立队列:urgent和batch。实时任务进 urgent,全量任务进 batch,然后用不同数量的 worker 去消费。
task_routes = { 'spider.tasks.monitor_price': {'queue': 'urgent'}, 'spider.tasks.history_batch': {'queue': 'batch'}, }有时候 urgent 队列的任务数不多,但每个任务要跑较长时间,如果 worker 数量分配不合理,依然会导致监控延迟变大。这里我的调试经验是配合 Flower 看队列积压量。Flower 是 Celery 的实时监控面板,能看到每个队列的 pending 任务数、每个 worker 的存活状态和任务耗时分布。当 urgent 队列持续积压超过 500 个任务时,我就会从 batch 队列临时调 2 个 worker 过去帮忙。
4.3 监控告警体系:别等任务全挂才知道
爬虫集群一旦跑起来,最大的痛点是“无人值守”。部署上,我会用 systemd 或容器来托管 worker 进程,设置进程崩溃自动重启。但更关键的是业务层面:某个站点改版、登录态失效、反爬触发,这些不会导致进程崩溃,只会导致任务失败的轨迹发生变化。所以我给每个任务都加了结构化日志,通过日志平台聚合。核心指标是三个:
- 任务失败率:环比超过 10% 就触发告警,一般说明目标站结构变了或环境被限制。
- 队列积压量:积压持续上升且消费速度低于产生速度,说明 worker 不足或任务耗时变长。
- 单页加载耗时 P95:这个值持续上升,通常是被限流或网络环境恶化的早期信号。
这三个指标可以从 Flower 的 API 拉取,也可以自己上报到时序数据库。我早期偷懒只看进程活着没有,结果反被真实数据打脸——进程活着,但所有任务都在登录页打转,白跑了一整夜。所以,核心教训是:集群监控必须盯“任务有效性”,不能只盯“进程存活”。
5. 常见问题与排查实录
5.1 Worker 内存持续上涨,最终 OOM
这个问题我在双十一类的大促模拟抓取场景里遇到过一次。现象是 worker 跑了两三天后,内存占用从 1GB 一路涨到接近 6GB,然后触发 OOM。排查下来有两个原因:一是某个任务里创建了 context 但没有正常关闭,导致浏览器页面句柄泄漏;二是 Chromium 本身的渲染进程会缓存一定资源,不彻底关闭浏览器就无法回收。
解决办法分两层。代码层面,任务必须用 try/finally 确保context.close();即使页面解析抛出异常,也要兜底关闭。运维层面,给 worker 设置--max-tasks-per-child,定期重启子进程,相当于变相做内存回收。如果你发现关闭 context 后内存依然降不下来,可以在任务完成后主动调用gc.collect(),对某些场景有明显效果。
排查要点:不建议把全部 worker 并发数调低来躲避 OOM,那只是延后问题。关键是把“每个任务稳定关闭浏览器资源”变成硬性代码规范。
5.2 反爬检测:页面能打开,但数据被替换成验证码
这是动态页面爬虫最烦的情况。Playwright 确实比普通 requests 隐蔽得多,但不意味着免疫。常见风险点是navigator.webdriver属性暴露、浏览器指纹特征和登录行为模式异常。我的做法是:
- 启动参数加
--disable-blink-features=AutomationControlled,减少navigator.webdriver暴露概率。 - 用
context.add_init_script重写几个常见探测对象,让页面脚本误认为这是正常浏览器环境。 - 控制同一 Browser 实例下的并发 context 数,避免同一 IP 突然出现大量“新开的页面”这种异常模式。
context.add_init_script( "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})" )但有个分寸问题:过度伪装不仅带来维护成本,还可能触碰合规边界。我的底线是只做“正常的浏览器行为还原”,不主动突破验证码或恶意攻击目标系统。从长期看,很多公开接口其实不需要伪装,策略性地控制抓取频率更稳妥。
5.3 任务结果丢失:Retry 后不需要重复入库
Celery 开启acks_late时,任务只有在执行成功后才会告知 broker 移除消息。这提高了可靠性,但也带来了重复执行的可能:如果任务在入库后、回执前崩溃,重启后它会再次执行同一个抓取逻辑。负责去重的 Redis 键恰好救了场——因为任务在入库前写过去了重键,所以第二次执行会直接识别为重复,不会产生脏数据。
这一点我觉得值得特别强调:任务幂等设计是分布式爬虫里不能妥协的一环。建议每个任务都具备“重复执行不造成影响”的属性。不只是去重,数据库写入也要设计成自然键 upsert 的形式,而不是盲目 INSERT。这会在你后续排查故障时省下大量对账的时间。
5.4 Flower 看不到任何任务数据?
Flower 默认从 Celery 的事件系统拉数据,而事件系统是需要在 worker 端手动开启的。如果没开事件,Flower 能看到 worker 列表但看不到任务明细。解决方式是启动 worker 时加-E参数。另一个容易被忽略的问题:Flower 只能看到发送过事件的 worker,如果你用的是多个独立队列,但没把所有 worker 都加-E,那从某些队列发起的任务就会“消失”。
celery -A spider worker -l info -E -c 8启动后用浏览器访问 Flower 的默认端口,在 Tasks 页面确认能看到每条任务的时间线。如果任务显示 pending 很久不动,一般不是 Flower 的问题,而是队列里积压太严重,或者 worker 没有订阅这个队列。
写在最后的个人体会
这套方案我从最初的原型到现在生产可用,最大感受是——分布式爬虫的核心不在于“爬得有多快”,而在于“让失败的代价足够小”。Celery 的可靠性配合 Playwright 的动态渲染能力,再加上必要的幂等设计,整个集群的运维重心就会从“救火”转向“日常参数调优”。最初搭建时最容易犯的错是贪并发,单台机器一下子挂几十个浏览器,结果 CPU 满载、网络连接数爆炸,反而拖低了整体吞吐。后来我学乖了,先把压力测试跑起来,观察单 worker 的内存曲线和任务耗时分布,再慢慢调高并发数,直到找到那个稳定拐点。
如果你也准备从零搭一套类似的集群,我建议第一步不要急着写具体业务代码,而是先把 Celery worker、Playwright 浏览器复用、Redis 去重这三块骨架打通,用几个静态页面验证链路完整性。骨架稳了,后面的业务页面逻辑,不过是在链路上多挂几个任务而已。