Chrome 无头浏览器规模化(二):CDP 连接池耗尽与 Tab 累积

120 秒超时依然失败、WebSocket 积压、TCP 连接耗尽——当并发数上去后,先撑不住的不是 CPU,是网络连接基础设施。

亿牛云技术团队2026年4月3日5 分钟阅读

一个典型的故障现象

你先打开一个页面,加载成功。然后在这个浏览器实例里打开第二个标签页,超时。尝试第三个标签页,也超时。你把超时时间从 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 -l

TIME_WAIT 连接数的持续增长是连接池泄漏的典型信号。如果每分钟的 TIME_WAIT 都在增加但没有下降,说明有连接没有被正常回收。

总结

连接池耗尽和 Tab 累积是浏览器自动化规模化中第二常见的故障模式。它的本质问题是:

  1. CDP WebSocket 的设计约束——一个连接上的多路复用存在队头阻塞
  2. Chrome 内部请求调度——同一浏览器实例的并发连接数有上限
  3. Tab 未显式关闭导致的进程泄漏——垃圾回收不等同于进程清理

三个问题的共同解决方案很简单:一个浏览器实例只做一个任务,做了就关。 看似浪费,实际上在规模化场景中,这是最不容易出错的模式。

下一篇文章会分析第三个也是最后一个规模化瓶颈:浏览器集群的扩缩容——为什么 Kubernetes 原生的 HPA 在此场景下不够用。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。