说实话,爬虫项目做久了,你会发现一个挺尴尬的事实:单机上 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(我用 Redis),Worker 进程异步拉取并执行。它在爬虫场景里天然适配:
- 任务就是一个函数加参数(比如
fetch_page(url)),序列化后交给 Worker; - Worker 可以部署在多台机器上,代码一致、依赖一致,天然集群化;
- 自带重试、超时、优先级队列、定时任务(
celery beat)等能力; - 通过 Flower 可以直接看任务状态、 Worker 存活情况。
对比直接用 multiprocessing 或自己写 socket 调度,Celery 省掉了我自己维护"任务队列 + 结果回传 + 失败处理"的成本。而且它足够轻量,一个 Redis 就当了 Broker 和 Backend,不引入额外重型中间件。
1.3 Playwright 解决的是"最后一公里"
Playwright 的作用很纯粹:打开真实浏览器,执行 JS,模拟点击和滚动,拿到渲染后的完整 HTML。相比 Selenium,Playwright 的启动速度更快,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)发任务; - Broker:Redis,存放待消费的任务队列;
- 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 个 Worker,default队列跑到 8 个,high_priority队列用单独进程保持低延迟。同一个任务函数根据域名路由到不同队列,生产端代码不感知细节。
2.4 并发参数的几个关键设置
Celery Worker 的并发参数直接决定了集群的总速度,但又和 Playwright 的浏览器实例数强相关。我常用的启动参数大致是这样:
celery -A crawler.celery_app worker \ --pool=prefork \ --concurrency=8 \ -Q default,high_priority \ --hostname=worker-%h \ --prefetch-multiplier=1这里最容易被忽略的是--prefetch-multiplier=1。Celery 默认每个 Worker 进程会预取很多任务,看起来吞吐量高,但爬虫场景里非常容易造成"一个进程手里积压一堆任务,其他进程饿死",而且遇到限流时会疯狂抢跑。prefetch-multiplier=1能保证一个进程最多预取一条任务,配合acks_late,调度均衡性会明显改善。
每个 Worker 进程内的 Playwright 并发数,我一般控制在 1 个 Browser 实例、且最多同时开 4 个 Context。原因后面讲内存时会展开,这里记住结论:Chromium 的并行 Context 不是免费的,一个进程塞 10 个页面上下文,内存直接爆炸。
3. 核心实现:在 Celery 任务里正确使用 Playwright
3.1 先选对 API:sync 版还是 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', broker='redis://localhost:6379/0', backend='redis://localhost:6379/1', include=['crawler.tasks'], ) app.conf.update( task_serializer='json', result_serializer='json', accept_content=['json'], timezone='Asia/Shanghai', enable_utc=True, worker_max_tasks_per_child=50, worker_prefetch_multiplier=1, task_acks_late=True, task_reject_on_worker_lost=True, task_time_limit=120, task_soft_time_limit=100, ) 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(bind=True, max_retries=3, default_retry_delay=30) 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(exc=exc, countdown=30 * (self.request.retries + 1)) def _render_page(url: str, wait_js: bool = True): """核心渲染函数:打开页面、等待网络空闲、下拉滚动、返回 HTML""" with sync_playwright() as p: browser = p.chromium.launch( headless=True, args=[ '--no-sandbox', '--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled', ], ) context = browser.new_context( user_agent=random.choice(USER_AGENTS), viewport={'width': 1366, 'height': 768}, locale='zh-CN', ) page = context.new_page() page.goto(url, wait_until='networkidle', timeout=30000) 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,所以外层timeout=30000必须设置,避免任务挂死。
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( headless=True, args=['--no-sandbox', '--disable-dev-shm-usage'], ) return _browser有了这个全局 Browser,任务函数里就只需要开新的 context:
def _render_with_pool(url: str): browser = get_browser() context = browser.new_context(user_agent=random.choice(USER_AGENTS)) try: page = context.new_page() page.goto(url, wait_until='networkidle', timeout=30000) 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_child=100,每处理 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 = f'rl:{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(countdown=10) # 触发重试,等下一窗口 # ...渲染逻辑...这个限流方案虽然简单,但非常实用。每个域名独立计数,窗口期精确到秒级,任务的retry(countdown=10)让 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_backoff=True自带这个能力。 - 给重试设上限。我一般设
max_retries=3,超过后任务进入失败状态,由监控系统告警。
4.4 幂等处理:同一个任务被执行两次怎么办
用acks_late=True时,任务执行中如果 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看到一堆--type=renderer的 Chromium 子进程没有退出。根因有两个方向:
- 某些情况下
context.close()发出关闭命令,但渲染进程还挂在 host 上,或者页面里有 WebSocket 等资源没被 GC 释放; - 浏览器层面虽然调用了
browser.close(),但驱动通信异常时,子进程不会收到退出信号。
我的处理方案是组合拳:finally中强制context.close()、设置worker_max_tasks_per_child、加一个定时的ps清理脚本把超过 2 小时的孤儿 Chromium 进程清掉。生产环境不能理想化地只靠代码,多一道系统层防线永远是好的。
5.3 页面卡死与带wait_until='domcontentloaded'的取舍
wait_until='networkidle'虽然能拿到完整渲染结果,但有相当比例的页面会以长轮询、实时推送等方式保持网络连接,导致任务一直卡在等待。我后来改成分段策略:先goto(url, wait_until='domcontentloaded'),再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 --port=5555有了 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 队列天然支持多机器消费。如果你的爬虫场景也需要长时间稳定运行、需要跨机器调度、需要精细化的限流重试,那这个方案很值得照着搭一遍。踩坑的过程我都写在前面了,希望你能少走一点弯路。