有一段时间,我在做一个多站点的网页采集项目。目标看起来挺简单的:同时运行一批 Playwright 实例去抓取数据。
但一开始,我只开了二十几个浏览器,机器就快冒烟了。CPU 一路飙满,内存狂涨,Docker 直接报错退出。那时候我才意识到——Playwright 真的是吃资源的怪兽。
于是我开始折腾各种优化手段,从容器拆分、异步控制到代理分流,整整调了一个月,才终于让100 个浏览器能稳定跑起来,而且一晚上都不崩。
这篇文章就分享整个过程:从最初的性能瓶颈,到最终压测结果,再到中间的那些关键“拐点”。
一、性能瓶颈:为什么 Playwright 跑多了就挂
刚开始的方案其实很“直男”:
我在一台 8 核 16G 的机器上,用 Python 异步任务批量启动 Playwright 实例。理论上讲,多核机器嘛,几十个浏览器应该轻松搞定。
结果完全相反。
运行到第 20 个实例时,CPU 就快顶不住了。到第 30 个时,Playwright 直接报错 browser disconnected,然后容器 OOM(内存溢出),服务挂掉。
我用 htop 和 docker stats 看了一下资源占用情况:
- 每个浏览器进程大概吃 150~250MB 内存;
- CPU 时间主要消耗在页面渲染和网络等待上;
- Python 主进程成了调度瓶颈。
简单来说,就是单容器跑太多浏览器,会把系统拖垮。
二、性能基线:没对比就没有改进
在优化之前,我先测了三个关键指标,算是给后面调优做参考。
- 启动耗时:平均打开一个浏览器并创建上下文,大约 3.8 秒;
- CPU 占用:在 20 并发下就能吃满 96% 的 CPU;
- 稳定运行时长:大约 18 分钟容器就会崩掉。
这三个数字基本说明问题了:系统既不快,也不稳,更撑不久。
三、优化过程:三板斧搞定
1. 容器拆分:别把所有浏览器塞进一个锅
我第一步做的,就是让每个 Docker 容器只跑少量实例。具体来说,一个容器只负责 10 个浏览器。
这样做有几个好处:
- 内存压力更分散;
- 某个容器崩了不会拖垮全局;
- 可以更灵活地扩容或缩容。
我用 docker-compose 启动多个轻量容器,每个容器限制 2 核 CPU、2GB 内存。这样一台 8 核机器就能稳定跑 45 组,也就是 4050 个浏览器实例。如果再开一台机器,就能轻松上百。
这种拆分方式看似简单,其实解决了大部分稳定性问题。
2. 网络层优化:用代理把出口分流开
第二个问题是IP 限制。
高并发请求时,目标网站很快就把固定出口 IP 封掉。
于是我接入了爬虫代理,给每个浏览器分配独立代理。
下面是关键代码片段
import asyncio
from playwright.async_api import async_playwright
# ==== 亿牛云爬虫代理配置 (www.16yun.cn) ====
PROXY_HOST = "proxy.16yun.cn" # 代理域名
PROXY_PORT = "3100" # 代理端口
PROXY_USER = "your_username" # 用户名
PROXY_PASS = "your_password" # 密码
TARGET_URL = "https://www.baidu.com" # 测试目标
async def fetch_page(playwright, index):
browser = await playwright.chromium.launch(
proxy={
"server": f"http://{PROXY_HOST}:{PROXY_PORT}",
"username": PROXY_USER,
"password": PROXY_PASS
},
headless=True
)
context = await browser.new_context()
page = await context.new_page()
# 设置 User-Agent 模拟不同设备访问
await page.set_extra_http_headers({
"User-Agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Scraper-{index}"
})
try:
await page.goto(TARGET_URL, timeout=15000)
title = await page.title()
print(f"[{index}] OK - {title}")
except Exception as e:
print(f"[{index}] Error: {e}")
finally:
await browser.close()
async def main():
async with async_playwright() as p:
tasks = [fetch_page(p, i) for i in range(1, 11)] # 每个容器跑10个实例
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(main())
每个浏览器实例都有独立的代理和 User-Agent,这样不仅能防封禁,还能分散请求压力。
我在实际采集中发现,这个改动直接让失败率从 20% 降到了 2%。
3. 异步与延迟启动:不要让 CPU 同时尖叫
原本我是一口气启动所有浏览器任务,这会在几秒钟内拉满 CPU 和内存。
于是我改成“分批启动”,每隔几百毫秒启动一部分,让系统有喘息空间。
# 延迟分批启动浏览器,避免瞬时峰值
for i in range(0, len(tasks), 5):
batch = tasks[i:i+5]
await asyncio.gather(*batch)
await asyncio.sleep(0.3)
这个小改动带来的效果非常明显:CPU 峰值从接近 100% 降到了 70% 左右。
容器不再频繁重启,整体运行更平稳。
四、压测结果:数字不会骗人
经过这三步调整后,我重新做了一轮压测。
优化前:
- 平均启动耗时 3.8 秒
- CPU 占用接近 100%
- 内存 15GB 左右
- 稳定运行不到 20 分钟
优化后:
- 平均启动耗时降到 1.6 秒
- CPU 占用控制在 75~80%
- 内存下降到 8~9GB
- 稳定运行超过 8 小时无异常
这些变化意味着什么?
意味着我不需要再盯着监控屏幕等崩溃了。容器运行稳定,任务调度更顺畅,爬取速率也稳中有升。
五、最终方案:更像一个小型集群
最后我的整体架构变成了一个小型分布式集群:
- 一个控制器进程负责分配任务和监控状态;
- 每个 Docker 容器负责一组浏览器实例;
- 代理服务负责出站流量分流;
- 所有容器的数据结果汇总回主控制器统一存储。
这种结构最大的优点是可水平扩展。
要采更多网站,只需多开几组容器;要稳定性更高,就调小单容器实例数量。非常灵活。
六、经验与反思
回过头看,其实容器化只是解决方案的一部分。
真正让系统稳定下来的,是三个关键思想:
- 拆分负载 —— 不要把所有实例塞进同一个进程或容器;
- 代理隔离 —— 不让所有请求走同一个出口;
- 分批启动 —— 让系统有呼吸的节奏。
我后来又在别的采集项目上复用了这套方案,效果一样稳。
总结一句话:
性能优化的目标,不是让程序更快,而是让它在高负载下还能“活得久”。
如果你现在也在调 Playwright 的并发性能,不妨先别想着“多快”,先让它“别挂”。
从容器拆分、代理分流和异步控制入手,或许就能让你的系统跑得又稳又久。