资讯动态

Celery+Playwright构建分布式爬虫集群实战

发布时间:2026/10/22 4:18:54 来源:尧图企业网站定制
说实话爬虫项目做久了你会发现一个挺尴尬的事实单机上 Python 爬虫写得再溜一旦需要跑几十个浏览器实例、每天抓几十万页面的阶段原本的代码几乎都得推倒重来。不管是 threading 还是手动 multiprocessing 去怼并发瓶颈都会从 CPU 慢慢转移到内存、网络出口和调度逻辑上。我在这条路上折腾了很长时间最后定型下来一套组合拳——Celery 负责分布式任务调度Playwright 负责真实浏览器渲染抓取。这篇文章就是把这套集群从零到能稳定运行的全过程分享出来适合已经能熟练写同步爬虫、但正在为多机扩展和调度管理头疼的开发者。整套方案能解决三个核心问题分布式切分海量 URL 任务、跨机器的 Worker 弹性扩展、以及任务失败后的自动重试与限流。你不需要一开始就引入各种重型调度框架用 Celery Redis Playwright 就能搭出一个结构干净、扩展路径明确的爬虫集群。下面我按从方案取舍到架构设计再到具体实现和排坑的顺序来写全程干货。1. 为什么是 Celery Playwright单机爬虫的瓶颈与选型逻辑1.1 单机爬虫到底卡在哪里我自己早期写过很多一次性脚本式爬虫用 requests 加上几十个线程处理纯静态页面确实绰绰有余。但业务一复杂就原形毕露目标网站大量使用 JavaScript 动态渲染requests 拿到的 HTML 根本不含真实数据单机线程数一旦超过某个阈值目标站点开始疯狂出现验证码或封禁多线程脚本里只要有一个请求卡死整个调度节奏立刻失控代码里到处是time.sleep()遇到失败重试就堆try...except逻辑散落各处。Playwright 能解决浏览器渲染的问题但它在并发调度上本身不提供任何能力。你把 Playwright 丢进一个脚本里写个 for 循环跑几十个页面没问题可一旦想跨机器分配任务、统一控制并发速率、失败自动重试就必须引入调度层。这就是我最终选择 Celery 的主要原因。1.2 Celery 在这个架构里的角色Celery 是 Python 生态里最成熟的分布式任务队列核心模型非常简单生产者把任务丢进 Broker我用 RedisWorker 进程异步拉取并执行。它在爬虫场景里天然适配任务就是一个函数加参数比如fetch_page(url)序列化后交给 WorkerWorker 可以部署在多台机器上代码一致、依赖一致天然集群化自带重试、超时、优先级队列、定时任务celery beat等能力通过 Flower 可以直接看任务状态、 Worker 存活情况。对比直接用 multiprocessing 或自己写 socket 调度Celery 省掉了我自己维护任务队列 结果回传 失败处理的成本。而且它足够轻量一个 Redis 就当了 Broker 和 Backend不引入额外重型中间件。1.3 Playwright 解决的是最后一公里Playwright 的作用很纯粹打开真实浏览器执行 JS模拟点击和滚动拿到渲染后的完整 HTML。相比 SeleniumPlaywright 的启动速度更快API 设计更现代而且对 Chrome DevTools Protocol 的封装更稳定浏览器实例的并发创建browser.new_context()开销相对可控。我在选型时候对比过 Puppeteer 和 Playwright最终选 Playwright 还有一个原因它同时提供 sync 和 async API在 Celery 的 prefork 多进程模型里比较容易适配这点后面详细讲。1.4 这套组合的适用边界别指望这套方案能扛住亚马逊、大型电商这类超强反爬系统的深度对抗。它的定位很明确目标站点数量在几十到几百个量级总任务量在每天几十万页面以内对数据实时性要求是分钟级即可满足可以接受一定比例的失败页通常 5% 以内靠重试兜底。如果需求超出了这个范围我建议考虑更重的分布式执行引擎或者购买专业采集服务而不是自己硬造轮子。2. 集群架构设计任务粒度、队列划分与并发参数2.1 整体分工与数据流向最终我定的架构是这样的几个角色各司其职生产者业务侧代码负责把需要采集的 URL 整理好调用fetch_page.delay(url, domain)发任务BrokerRedis存放待消费的任务队列Worker 集群多台机器运行同一个 Celery Worker每台机器内部持有 1 个常驻的 Playwright Browser 实例池存储层渲染后的 HTML 直接写入对象存储或数据库再由后续解析任务处理。这里有个关键点不要把解析和渲染塞进同一个任务。渲染任务只负责拿 HTML拿完就结束解析任务单独编排这样渲染 Worker 不需要安装一堆解析库重启成本也更低。2.2 任务粒度一个页面一个任务我试过把整个目录页 所有详情页塞进一个任务结果极其痛苦一个页面超时整个任务就处在长时间运行状态重试时又会把已经成功的页面重抓一遍。最后落地的规则是一个任务只抓一个页面。粒度细化之后好处非常明显单个任务最长执行时间可控方便设置超时阈值失败重试只会影响单个页面不会连带重抓一批任务量可以精确统计便于监控和调参。一个页面一个任务会带来一定的序列化开销但相比收益完全可以忽略。2.3 队列划分不同站点不同策略有些站点访问频率限制严格比如每 10 秒只能请求一次有些则相对宽松。把所有网站混在同一个默认队列里限流策略很难精细化。我在 Celery 里配置了多队列# config.py task_routes { crawler.tasks.fetch_page_default: {queue: default}, crawler.tasks.fetch_page_high: {queue: high_priority}, crawler.tasks.fetch_page_limited: {queue: limited_sites}, }不同队列可以启动不同数量的 Worker 进程limited_sites队列单独跑 2 个 Workerdefault队列跑到 8 个high_priority队列用单独进程保持低延迟。同一个任务函数根据域名路由到不同队列生产端代码不感知细节。2.4 并发参数的几个关键设置Celery Worker 的并发参数直接决定了集群的总速度但又和 Playwright 的浏览器实例数强相关。我常用的启动参数大致是这样celery -A crawler.celery_app worker \ --poolprefork \ --concurrency8 \ -Q default,high_priority \ --hostnameworker-%h \ --prefetch-multiplier1这里最容易被忽略的是--prefetch-multiplier1。Celery 默认每个 Worker 进程会预取很多任务看起来吞吐量高但爬虫场景里非常容易造成一个进程手里积压一堆任务其他进程饿死而且遇到限流时会疯狂抢跑。prefetch-multiplier1能保证一个进程最多预取一条任务配合acks_late调度均衡性会明显改善。每个 Worker 进程内的 Playwright 并发数我一般控制在 1 个 Browser 实例、且最多同时开 4 个 Context。原因后面讲内存时会展开这里记住结论Chromium 的并行 Context 不是免费的一个进程塞 10 个页面上下文内存直接爆炸。3. 核心实现在 Celery 任务里正确使用 Playwright3.1 先选对 APIsync 版还是 async 版Playwright 官方提供sync_api和async_api这是第一个容易踩坑的分叉路。我最早用的是 async API因为爬虫任务里想要高并发直觉上觉得 async 才能并发。但问题是Celery 默认的 prefork Worker 是同步多进程模型每个进程里根本没有事件循环。两种方案我都实测过方案 A任务函数里写asyncio.run(_render(url))。能用但每次任务都会重新创建事件循环和底层连接Playwright 的浏览器实例也无法跨任务复用每个任务都要承担一笔可观的启动开销实际跑下来慢很多方案 B在 Worker 进程启动时手动创建并长期持有事件循环浏览器实例也注册到该循环里。性能好但编码复杂要处理信号和循环关闭的边界。后来我发现Celery 的 prefork 模型里直接用sync_api是最直白的做法每个 Worker 进程是独立进程进程内部保持一个同步 Playwright 浏览器实例完全够用。如果非要用 async 版那也是为了在单个进程内扛更多并发而不是为了适配 Celery。在爬虫场景下用异步事件循环去并发管理浏览器上下文收益远小于复杂度所以我最终采用 sync API 作为主力方案并在单个 Worker 内配合线程池控制并发。3.2 基础任务代码渲染一个页面下面这个任务函数是所有抓取逻辑的核心我把完整实现贴出来# crawler/tasks.py import random import time from celery import Celery from playwright.sync_api import sync_playwright app Celery( crawler, brokerredis://localhost:6379/0, backendredis://localhost:6379/1, include[crawler.tasks], ) app.conf.update( task_serializerjson, result_serializerjson, accept_content[json], timezoneAsia/Shanghai, enable_utcTrue, worker_max_tasks_per_child50, worker_prefetch_multiplier1, task_acks_lateTrue, task_reject_on_worker_lostTrue, task_time_limit120, task_soft_time_limit100, ) USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chromium/118.0.0.0 Safari/537.36, ] app.task(bindTrue, max_retries3, default_retry_delay30) def fetch_page(self, url: str, domain: str , wait_js: bool True): try: result _render_page(url, wait_js) _save_html(domain, url, result) return {ok: True, url: url, size: len(result[html])} except Exception as exc: # 区分可重试异常和不可重试异常这里简化处理 if self.request.retries self.max_retries: raise raise self.retry(excexc, countdown30 * (self.request.retries 1)) def _render_page(url: str, wait_js: bool True): 核心渲染函数打开页面、等待网络空闲、下拉滚动、返回 HTML with sync_playwright() as p: browser p.chromium.launch( headlessTrue, args[ --no-sandbox, --disable-dev-shm-usage, --disable-blink-featuresAutomationControlled, ], ) context browser.new_context( user_agentrandom.choice(USER_AGENTS), viewport{width: 1366, height: 768}, localezh-CN, ) page context.new_page() page.goto(url, wait_untilnetworkidle, timeout30000) if wait_js: for _ in range(3): page.mouse.wheel(0, 2000) time.sleep(1) html page.content() context.close() browser.close() return {html: html}这段代码有几个细节我要强调sync_playwright()上下文管理器负责启动和结束 Playwright 驱动。每次任务都打开全新 browser 和 context虽然会牺牲一些性能但最不容易出内存和进程残留问题。性能优化后面章节单独讲。--disable-dev-shm-usage是个救命参数。在容器或内存不足的 Linux 机器上Chromium 默认用/dev/shm做共享内存容量吃满就会崩溃加这个参数让它退回普通内存。networkidle等待策略能覆盖大部分 SPA 站点但有些站点长轮询导致永远达不到 networkidle所以外层timeout30000必须设置避免任务挂死。3.3 跨任务复用 Browser 实例的正确姿势上面每个任务都重新launch一个浏览器实测渲染一个页面的平均耗时在 3~6 秒其中浏览器启动就占了近 1 秒。如果想要更高吞吐量就需要让多个任务共享同一个浏览器实例。做法是在 Worker 进程里维护一个全局 Browser用线程锁控制并发访问# crawler/playwright_pool.py import threading from playwright.sync_api import sync_playwright _thread_local threading.local() _lock threading.Lock() _pw None _browser None def get_browser(): global _pw, _browser if _browser is not None and _browser.is_connected(): return _browser with _lock: if _browser is None or not _browser.is_connected(): _pw sync_playwright().start() _browser _pw.chromium.launch( headlessTrue, args[--no-sandbox, --disable-dev-shm-usage], ) return _browser有了这个全局 Browser任务函数里就只需要开新的 contextdef _render_with_pool(url: str): browser get_browser() context browser.new_context(user_agentrandom.choice(USER_AGENTS)) try: page context.new_page() page.goto(url, wait_untilnetworkidle, timeout30000) html page.content() return {html: html} finally: context.close()注意每个任务必须独立 new_context不能直接复用同一个 context。Context 是隔离存储、Cookie、UA 等信息的单元多个页面混在一个 context 里会把不同站的登录态串起来而且会把一个页面异常比如弹窗传染给其他页面。3.4 资源回收聊天一样自然的收尾资源回收是 Playwright 爬虫的生命线。我在任务里吃了大亏之后总结了一套强制规则context.close()放在finally里确保异常时也一定执行每次任务结束关闭 context而不是关闭 browser因为 browser 要留着复用在 Celery 配置里设置worker_max_tasks_per_child100每处理 100 个任务强制重启 worker 进程。这是最粗鲁但也最有效的兜底手段——哪怕前面所有资源管理逻辑都失效了固定周期重启进程内存都会回到初始状态。4. 高并发下的稳定性限流、重试、去重一个都不能少4.1 分布式限流按域名控制请求速率爬虫集群最怕的不是跑得慢而是跑得太快把目标站点和自家出口 IP 双双玩死。我实现了一个基于 Redis 的滑动窗口限流器按域名分配配额# crawler/ratelimit.py import time import redis r redis.Redis.from_url(redis://localhost:6379/2) class DomainLimiter: def __init__(self, max_requests: int 10, window_seconds: int 60): self.max_requests max_requests self.window_seconds window_seconds def acquire(self, domain: str) - bool: key frl:{domain}:{int(time.time() / self.window_seconds)} current r.incr(key) if current 1: r.expire(key, self.window_seconds 10) return current self.max_requests在任务里这样使用def fetch_page(self, url, domain): if not limiter.acquire(domain): raise self.retry(countdown10) # 触发重试等下一窗口 # ...渲染逻辑...这个限流方案虽然简单但非常实用。每个域名独立计数窗口期精确到秒级任务的retry(countdown10)让 Worker 先处理其他任务10 秒后再回来尝试。要注意的是限流器在 Redis 上做incr是原子操作天然支持多 Worker 之间的共享状态。4.2 随着数据量增加Bloom Filter 去重URL 去重是所有爬虫的常规需求。任务量小可以用 Redis Set但到了几百万级别Set 的内存会快速膨胀。我现在用的是 Bloom Filter 的思路底层还是 Redis# crawler/dedup.py import mmh3 import redis r redis.Redis.from_url(redis://localhost:6379/3) BLOOM_KEY crawler:bloom BLOOM_SIZE 2 ** 20 # 1048576 bit NUM_HASH 7 def _set_bit(offset): r.setbit(BLOOM_KEY, offset, 1) def _get_bit(offset): return r.getbit(BLOOM_KEY, offset) def is_seen(url: str) - bool: seen True for i in range(NUM_HASH): offset mmh3.hash(url, i) % BLOOM_SIZE if not _get_bit(offset): seen False _set_bit(offset) return seen这样大约占用 128KB 内存但能容纳几百万条 URL 的去重。当然 Bloom Filter 存在误判概率所以在真正入库阶段还需要用数据库唯一索引做最终校验任务可能重复执行但数据库里同一条 URL 的记录不会重复。4.3 重试策略的几个原则Celery 自带retry机制但用起来有几个原则必须遵守区分可重试和不可重试异常。网络超时、连接重置这类异常可以重试反爬封禁比如 HTTP 418、URL 参数非法这类异常重试一百次也是浪费直接记录失败日志并跳过。指数退避 抖动。秒级定长重试会让所有失败任务同时撞上同一时间窗口指数退避分散重试时间。Celery 的retry_backoffTrue自带这个能力。给重试设上限。我一般设max_retries3超过后任务进入失败状态由监控系统告警。4.4 幂等处理同一个任务被执行两次怎么办用acks_lateTrue时任务执行中如果 Worker 崩溃Celery 会把任务重新投递所以同一个 URL 可能被执行两次。因为渲染是无副作用的纯读取操作问题不大但后续解析入库时必须保证幂等。最有效的手段是给数据库表设置唯一索引比如 URL 的哈希插入时直接忽略冲突而不是先查再插。5. 踩坑实录Celery Playwright 组合最容易翻车的几个细节5.1 sync_playwright 在 Worker 多线程环境下的崩溃前面我在playwright_pool.py里用全局_browser加上threading.Lock()其实是踩过坑才这样设计的。一开始我图省事在任务里直接with sync_playwright() as p: ...每个任务都独立with sync_playwright()本身没问题。但如果我在单个 Worker 进程内用 ThreadPoolExecutor 去并发跑多个with sync_playwright()实例就会出现连接崩溃和驱动冲突。Playwright 的 Python 驱动在底层通过子进程通信管理浏览器同一个进程里同时启动多个独立 Playwright 实例很容易互相干扰。最终的解决办法就是全局唯一一个 Playwright 实例加线程锁线程锁保证任意时刻只有一个任务在驱动这个实例。5.2 内存泄漏与僵尸 Chromium 进程连续跑几天集群后我发现 Worker 进程的 RSS 内存肉眼可见地上涨最后变成了 OOM。排查过程很有趣用ps aux | grep chrome看到一堆--typerenderer的 Chromium 子进程没有退出。根因有两个方向某些情况下context.close()发出关闭命令但渲染进程还挂在 host 上或者页面里有 WebSocket 等资源没被 GC 释放浏览器层面虽然调用了browser.close()但驱动通信异常时子进程不会收到退出信号。我的处理方案是组合拳finally中强制context.close()、设置worker_max_tasks_per_child、加一个定时的ps清理脚本把超过 2 小时的孤儿 Chromium 进程清掉。生产环境不能理想化地只靠代码多一道系统层防线永远是好的。5.3 页面卡死与带wait_untildomcontentloaded的取舍wait_untilnetworkidle虽然能拿到完整渲染结果但有相当比例的页面会以长轮询、实时推送等方式保持网络连接导致任务一直卡在等待。我后来改成分段策略先goto(url, wait_untildomcontentloaded)再wait_for_timeout(3000)最后滚动页面触发懒加载。网络空闲不是必须的关键是给页面足够的时间把首屏和必要的异步内容加载完。这样可以大幅降低单个任务的执行时间减少超时重试。5.4 限流参数调不好触发反爬封禁最开始我把某个站点的限流配成每秒 5 次结果对方的 WAF 直接把我整个出口 IP 段拉黑了。真正稳定下来之后我的经验是先按对方能容忍的最大速率的一半起步观察一周再缓慢上调。不同网站的抓取频率差异极大没有一成不变的参数爬虫工程师的核心工作之一就是给每个目标站点找到舒适区。5.5 忘了装系统依赖Playwright 启动即崩溃Playwright 的 Chromium 依赖了不少系统动态库在干净的 Linux 机器上直接启动会报一堆缺少libnss3、libatk之类的错误。我在部署新 Worker 节点时踩过这个问题。现在的标准操作是python -m playwright install --with-deps chromium这条命令会自动安装浏览器二进制和全部系统依赖。但要注意很多内部环境的机器没有外网权限需要提前准备好离线安装包或通过内部的包代理服务器安装。6. 集群扩展思路从固定 Worker 数到动态伸缩6.1 用 Flower 做集群监控和故障排查Celery 生态自带的 Flower 工具强烈建议加上。它是一个 Web 界面能实时看到任务状态、Worker 存活情况、队列积压数。部署非常简单flower -A crawler.celery_app --port5555有了 Flower你可以很直观地发现某个队列积压了几万条任务但对应 Worker 已经挂了这类问题不需要登录每台机器去查日志。我通常还会配一个简单的钉钉/企微告警脚本定时扫描 Flower API 里的失败任务数超过阈值就发通知。6.2 按队列积压量动态扩缩容固定 Worker 数量的集群在业务量波动时会显得很笨。我在很多机器上把 Worker 打包成了容器并在调度层做了一个简单的按积压量调 Worker 数的逻辑监听 Redis 队列长度超过阈值就加一台 Worker 容器低于阈值就缩掉。这个方案不依赖重量级编排组件一台装了 Docker 的机器就能玩起来。具体做法写一个小脚本每隔 30 秒读一次llen(default)和其他队列的长度对比当前运行中的 Worker 数量调用 Docker API 启动/停止celery worker容器。6.3 把 Playwright 抽成独立渲染服务如果你的爬虫种类很多一部分用 requests 就够了一部分才需要浏览器渲染那更漂亮的架构是把 Playwright 渲染能力独立成一个微服务。Celery Worker 抓到任务后如果发现需要渲染就调本机或局域网的渲染服务接口而不是每个爬虫进程都内置 Playwright。这样做的好处是渲染实例的生命周期独立管理、可以按渲染复杂度单独扩容、requests 型爬虫的 Worker 镜像会小很多。实现上就是用一个基于 FastAPI 的服务包住 Playwright对外暴露一个POST /render接口内部维护浏览器池。6.4 优先队列与抢占式调度高优先级的页面比如用户主动触发的实时更新和普通爬取任务混在同一个队列里会被前面积压的低优任务堵死。我在生产环境里维护了三条队列urgent用户实时触发单独一两个 Worker 常驻消费速度极高default普通列表页和详情页主集群limited需要严格控制频率的站点Worker 少且限流参数严格。Celery 原生支持按task_routes把任务路由到不同队列我前面已经给了配置片段。这个设计几乎不用额外编码却能极大地避免一个慢站点拖垮整个集群的连锁反应。6.5 后面还能怎么演进如果你发现 Celery 本身成为瓶颈或者对任务编排有更复杂的需求比如有向无环图的任务依赖可以考虑迁移到更重的工作流引擎。但就爬虫场景来说Celery 的简单可靠已经足够。我从这套架构里收获的最大体会是先跑通再优化稳定性永远高于花哨特性。我个人在实际项目里的建议是第一步先把一个页面一个任务、独立渲染、失败重试、按域名限流这四个基本点做扎实再考虑复用浏览器、动态扩缩容、独立渲染服务这些进阶玩法。基础不稳任何优化都是在沙地上盖楼。这套 Celery Playwright 的组合拳单机吞吐量大概能做到每个 Worker 每分钟渲染 10~20 个页面表面上看不算快但它的核心价值在于横向扩展几乎零成本加机器就是加吞吐量Redis 队列天然支持多机器消费。如果你的爬虫场景也需要长时间稳定运行、需要跨机器调度、需要精细化的限流重试那这个方案很值得照着搭一遍。踩坑的过程我都写在前面了希望你能少走一点弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑