Chrome 无头浏览器规模化(二):CDP 连接池耗尽与 Tab 累积
120 秒超时依然失败、WebSocket 积压、TCP 连接耗尽——当并发数上去后,先撑不住的不是 CPU,是网络连接基础设施。
一个典型的故障现象
你先打开一个页面,加载成功。然后在这个浏览器实例里打开第二个标签页,超时。尝试第三个标签页,也超时。你把超时时间从 30 秒调大到 120 秒,再试试——依然超时。
这不是页面加载慢。这是连接池被占满的表现。
Steel 的一个 GitHub Issue 详细描述了这个问题。用户的配置:
- Steel SDK 版本 0.15.0
- Playwright 1.57.0
- 超时时间:1,600,000ms(超过 26 分钟)
- 代理:未启用
结果:第一个标签页正常加载。第二个、第三个标签页几乎必超时。
这个问题的三个独立根因
1. CDP WebSocket 的多路复用限制
每个 CDP 连接使用一个 WebSocket。当你通过 browser.newPage() 创建新标签页时,CDP 内部需要协商一个新的 session ID。Playwright 和 Puppeteer 在并发打开多个标签页时,WebSocket 帧的序列化/反序列化会形成队头阻塞(Head-of-Line Blocking)。
具体来说:如果标签页 A 的页面加载了一个大资源(10MB 图片),CDP 的 Page.frameStoppedLoading 事件要等到资源传输完成后才能发送。在此期间,标签页 B 和 C 的 CDP 请求在同一个 WebSocket 连接上排队等待。
这是协议层面的约束,不是配置能解决的。
2. 浏览器内部的请求调度
即使 CDP 层面没有阻塞,Chrome 本身对同一浏览器实例内的并发请求也有限制。每个标签页共享同一个 Browser 进程的网络栈。Chrome 内部对同一 host 的并发连接数默认为 6 个(HTTP/1.1)或 256 个(HTTP/2),但当目标页面本身包含数百个子资源时,这些连接会被迅速占满。
3. Tab 不释放导致的累积泄漏
这是最隐蔽的问题。在自动化任务中,每个 newPage() 调用创建一个新的标签页。但 close() 调用不一定真的释放了所有资源。
操作序列:
page = await browser.newPage() # 创建标签页 A
await page.goto(url1) # 导航
data = await page.content() # 提取数据
page = await browser.newPage() # 创建标签页 B(标签页 A 没有被显式关闭)
await page.goto(url2)
data = await page.content()
此时标签页 A 的 Renderer 进程可能还在运行。
循环 100 次 → 100 个未关闭的标签页占用 300-500 个进程。很多自动化脚本的作者没有显式调用 page.close(),因为他们假设下一次 newPage() 会自动覆盖之前的引用。JavaScript 的垃圾回收只能回收引用,不能回收操作系统进程。
钢 铁 Issue 247 的实际教训
Steel Issue #247 中有几个关键数据点值得记住:
- 即使在禁用代理的纯净环境下,第二个并发标签页也会超时
- 超时时间不是问题的关键——用户从 30 秒试到 160 万毫秒(超过 26 分钟),问题没有消失
- 问题是可复现的——不是随机故障,是与并发数强相关的系统行为
这说明问题不在目标服务器的响应速度,而在于浏览器内部处理并发标签页的机制。
怎么解决
方案一:每个任务独享浏览器实例
不要在一个浏览器实例里开多个标签页。每个任务使用独立的浏览器实例:
# 错误做法:一个浏览器多个标签页
browser = await launch()
for url in urls:
page = await browser.newPage()
await page.goto(url)
# 正确做法:每个任务独立浏览器
for url in urls:
browser = await launch()
page = await browser.newPage()
await page.goto(url)
await browser.close()这对启动延迟有影响(每个任务需要新启动一个浏览器),但避免了连接池争抢和 Tab 泄漏。衡量取舍:20 次任务 × 每个新浏览器启动 2 秒 = 40 秒额外开销,但避免了不可预期的超时失败。
方案二:控制并发数到每个浏览器实例
如果一定要在一个浏览器实例里开多个标签页(例如需要保持登录状态),限制并发标签页数:
MAX_TABS_PER_BROWSER = 3
async def process_with_tab_limit(urls):
browser = await launch()
semaphore = asyncio.Semaphore(MAX_TABS_PER_BROWSER)
async def process_one(url):
async with semaphore:
page = await browser.newPage()
try:
await page.goto(url)
return await page.content()
finally:
await page.close()
results = await asyncio.gather(*[process_one(u) for u in urls])
await browser.close()
return results方案三:显式管理 WebSocket 连接
对于高并发场景,考虑使用底层的 CDP 连接管理而不是 Playwright/Puppeteer 的高层封装。agent-browser 的做法是每个命令使用独立的 IPC 管道而不是共享 WebSocket,避免队头阻塞。Lightpanda 引擎的轻量级架构在这个场景下也有优势——每个实例的资源开销更小,允许在同等服务器资源下运行更多实例。
方案四:监控连接池状态
# 监控 WebSocket 连接数
ss -tlnp | grep -c 9222 # 默认 CDP 端口
# 监控 ESTABLISHED 连接数
ss -tan | grep ESTAB | wc -l
# 监控 TIME_WAIT 连接数(连接泄漏的信号)
ss -tan | grep TIME_WAIT | wc -lTIME_WAIT 连接数的持续增长是连接池泄漏的典型信号。如果每分钟的 TIME_WAIT 都在增加但没有下降,说明有连接没有被正常回收。
总结
连接池耗尽和 Tab 累积是浏览器自动化规模化中第二常见的故障模式。它的本质问题是:
- CDP WebSocket 的设计约束——一个连接上的多路复用存在队头阻塞
- Chrome 内部请求调度——同一浏览器实例的并发连接数有上限
- Tab 未显式关闭导致的进程泄漏——垃圾回收不等同于进程清理
三个问题的共同解决方案很简单:一个浏览器实例只做一个任务,做了就关。 看似浪费,实际上在规模化场景中,这是最不容易出错的模式。
下一篇文章会分析第三个也是最后一个规模化瓶颈:浏览器集群的扩缩容——为什么 Kubernetes 原生的 HPA 在此场景下不够用。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。