首页 / 资讯中心 / 文章详情

Celery+Playwright:构建高并发分布式爬虫集群的实战指南

Celery+Playwright:构建高并发分布式爬虫集群的实战指南 ★ FEATURED ARTICLE
有时候你会发现需求本身看起来并不复杂——就是把一批页面抓下来解析出结构化数据。可一旦页面变成“动态渲染”、数据量变成“几十万条”、时效要求变成“每小时全量更新”单机脚本立刻就成了瓶颈。我自己的经验是爬虫工程化的第一道坎不是反爬而是调度和浏览器资源的组织方式。这也是我为什么最终把方案落到了 Celery Playwright 上Celery 管任务分发和水平扩展Playwright 管动态页面渲染两者配合就能搭出一个清晰、抗压、能随时加机器的高并发爬虫集群。这篇文章不是泛泛而谈架构图而是我会真实用到的设计思路、踩坑记录和可复现的代码片段。如果你正在纠结“怎么让几十个爬虫任务稳定跑在分布式环境里”或者“Playwright 怎么和消息队列结合”那这篇应该能帮你省掉不少查资料的功夫。我会从选型逻辑开始一路讲到 worker 部署、浏览器复用、任务拆分、监控告警最后收在几个高频问题上。1. 整体设计为什么是 Celery Playwright1.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 default2.2 列表页与详情页的接力用 chain 还是用回调任务之间的依赖关系有几种表达方式chain 是按顺序执行链式任务callback 是当主任务执行完成后向 Broker 投递一个新任务。爬虫场景里我倾向于 callback——因为详情页任务的数量在抓取之前是未知的列表页解析完才知道有多少 URL所以不太适合预先定义好 chain。操作上我在列表页任务的尾部调用crawl_detail.delay(...)或group(...)批量投递这样列表页的抓取和详情页的调度就通过消息队列完成了松耦合衔接。app.task(bindTrue, max_retries3) 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(queuepage_detail)这个设计的价值在于列表页和详情页的执行进度互相独立。详情页队列消费慢的时候列表页任务不会阻塞反过来详情页任务失败时也不会影响列表页正常流转。这是分布式架构里最常用但最容易忽略的一点——任务解耦。2.3 结果存储与去重确认“这个任务做过了”高并发爬虫最容易翻车的不是抓取速度而是重复消费。Celery 默认对同一个任务生成一个 task_id但如果任务本身在业务层面就去重光靠 task_id 是不行的——比如两个不同列表页可能推送了同一个详情页 URL。我通常在任务入口用 Redis 做一层去重以 URL 的 hash 作为唯一键只有 key 不存在时才真正执行抓取逻辑。执行完再写业务结果。app.task(bindTrue) 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( headlessTrue, args[--disable-dev-shm-usage, --disable-blink-featuresAutomationControlled] ) 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(bindTrue, soft_time_limit60, time_limit90, max_retries2) 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_statejson.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_untildomcontentloaded, timeout30_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-child200--max-tasks-per-child200是我特别想强调的参数。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-featuresAutomationControlled减少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 去重这三块骨架打通用几个静态页面验证链路完整性。骨架稳了后面的业务页面逻辑不过是在链路上多挂几个任务而已。
阅读完成 · 觉得有帮助?
咨询建站