总会有人提出基础设施问题:支撑 10,000 个并发浏览器会话需要什么?这听上去像一个用硬件回答的容量问题。它其实是一个单位问题,而且单位选错了。

一万个并发会话不是负载,而是一个资源配置决策。负载是每天多少页,两者之间只隔着一行算术,而这行算术在任何硬件被定价之前就已经决定了整个自建还是购买之争。

本文用 TypeScript 和 Playwright 构建真正管住浏览器机群的那一层:一个带硬性并发上限、租借与回收的会话池。然后给 10,000 个会话真正需要的机群定价,把这个数字换算成它所服务的负载,并在相同吞吐量下与托管渲染比较,而不是在相同的标题数字下比较。

要点速览
  • 并发量等于吞吐量乘以服务时间。按每页渲染五秒计算,10,000 个会话就是每秒 2,000 页,也就是每天 172.8 百万页。
  • 大多数要求 10,000 个会话的团队,实际只需要其中约 1%。每天一百万页所需的并发量大约是 58。
  • 支撑 10,000 个会话的机群约为 100 个节点和 3.2 TB 内存,按峰值配置,其余时间闲置。
  • 一个被拦截的页面,在自建机群上消耗的内存秒数与成功页面完全相同。而在 Crawling API 上,失败的请求根本不计费。
  • 节点上的每个 context 都活在同一个浏览器进程里。一次崩溃会把它们全部带走。
通往一个渲染完成页面的两条路径。 自建路径拥有一个控制平面:池把隔离的浏览器 context 租借出去,直到固定上限,超出的一律等待。购买路径删掉整个控制平面,只发一个请求。

10,000 个会话是配置数字,不是负载

利特尔法则就是这个换算。在途操作数等于完成速率乘以每次操作的耗时。对浏览器机群而言,它读作: 并发量等于每秒页数乘以每页秒数。

一个 JavaScript 繁重的页面渲染到 domcontentloaded 需要几秒钟。就算五秒。那么 10,000 个并发会话根本不是一个需求数字,而是这个:

负载 每秒页数 按每页 5 秒所需的并发量
每天 100 万页 11.6 58
每天 1000 万页 115.7 579
每天 172.8 百万页 2,000 10,000

一万个并发会话,是为每天 172.8 百万页所做的配置。如果你的真实需求是每天一百万页,服务它的并发量是 58,而 10,000 个会话的要求比背后的负载大了大约 173 倍。

这值得在给任何东西定价之前先弄清楚,因为整个自建还是购买的比较,会随着哪个数字是真实的而改变。所以这个问题诚实的版本不是「我们能不能跑 10,000 个会话」,而是「我们每天多少页、每页延迟多少、这一对数字推出多大的并发量」。

参考实现

配套项目位于 ScraperHub/scaling-a-headless-browser-fleet-to-10000-concurrent-sessions ,分为三个可运行的部分,各自回答问题的不同侧面。

final/src
session.ts    one browser process, many isolated contexts
pool.ts       the control plane: ceiling, leasing, recycling
capacity.ts   per-session assumptions to node count and RAM
crawlbase.ts  the managed path, one HTTP request
index.ts      the runner for all three modes

它需要 Node.js 18 或更高版本,以及 Playwright 的 Chromium。只有托管路径需要令牌。

bash
git clone https://github.com/ScraperHub/scaling-a-headless-browser-fleet-to-10000-concurrent-sessions.git
cd scaling-a-headless-browser-fleet-to-10000-concurrent-sessions/final
npm install
npx playwright install chromium
cp .env.example .env

会话是一个 context,不是一个浏览器

第一个架构决策是并发的单位。这里一个会话是一个 Playwright 浏览器 context,而不是一个浏览器进程。context 携带自己的 cookies、存储和执行状态,成本却只是新开一个 Chromium 进程的一小部分。正是这一点才让高密度成为可能。

来源: final/src/session.ts

typescript
async open(url: string, timeoutMs: number): Promise<OpenResult> {
  const page = await this.context.newPage();
  try {
    const response = await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
    const title = await page.title();
    return { status: response ? response.status() : 0, title };
  } finally {
    await page.close();
  }
}

页面被刻意做成短命的:打开、导航、读一个信号、关闭。短暂的页面生命周期能防止状态堆积,于是同一个 context 可以先后服务许多任务,而浏览器的生命周期始终留在工厂里。

密度是有爆炸半径的

工厂只启动 一个 Chromium,并在其中创建每一个 context。这既让 context 变得便宜,也意味着浏览器一旦崩溃,会带走该节点上的每一个会话。在每节点 100 个会话的运营上限下,一次崩溃就是 100 个会话,而池的 healthy() 检查只能逐个会话、事后地、在下一次 acquire 或 release 时才发现。密度和爆炸半径是同一个旋钮。

保证住在控制平面里

池决定调用方拿到的是一个已有会话、一个新会话,还是一次等待。这三种结果就是机群稳定性的全部。

来源: final/src/pool.ts

typescript
if (this.live < this.maxConcurrency) {
  // Reserve the slot synchronously, before the await.
  this.live += 1;
  this.created += 1;
  this.inUse += 1;
  this.peakInUse = Math.max(this.peakInUse, this.inUse);
  try {
    return await this.factory.create();
  } catch (error) {
    this.live -= 1;
    this.inUse -= 1;
    throw error;
  }
}

// Fleet is full. Queue and wait for a release.
return new Promise<Session>((resolve) => {
  this.waiters.push(resolve);
});

有一个细节承载了全部保证:槽位是在 之前 等待 factory.create()就被预留的。因为 await 会让出执行,否则多个调用方都会读到 live < maxConcurrency ,而此时第一个 context 还在创建中。配置为五的上限可能短暂变成二十。同步预留、失败时回滚,才让这个上限成为真正的限制,而不是一条建议。

一次租借,从头到尾。 acquire 在任何 context 存在之前就先预留容量。release 要么回收会话,要么直接交给正在等待的调用方,于是这组数量有限的 context 服务的任务数远超它自身的规模。

release 路径闭合了这个循环。超龄或健康检查不通过的会话会被回收,而当已有调用方在等待时,池会立即创建替代者,这样容量不会在每次会话退役后悄悄缩水。

争用之下是什么样子

运行比上限更多的任务:

bash
npm run pool
output
tasks=20 ok=20 elapsed=541ms throughput=37.0/s
pool: maxConcurrency=5 peakInUse=5 created=5 recycled=0
peak utilization=100%

第二行才是关键。二十个任务全部完成,只存在过五个 context,而 peakInUse 从未越过上限。其余十五个任务是靠租借并归还同样这五个会话完成的。把 MAX_SESSION_AGE_MS=0 设为该值,改为增长的就是 recycled 计数器,这会走一遍退役路径,正是它让长期运行的机群不会堆积陈旧状态。

请注意这次运行并 没有 证明什么。这是五个 context 打 example.com 、跑在一台机器上,所以它验证的是分配逻辑,与规模无关。那个吞吐量数字,每秒 37 个请求,是一个平凡页面加一个本地浏览器的属性,不是预测。

给机群定价

容量模型把每会话的假设换算成节点数量和内存账单。它被刻意做得很小,因为重点是算术,不是工具。

来源: final/src/capacity.ts

typescript
const usableRamMb = (input.nodeRamGb - input.nodeReserveGb) * 1024;

const ramBoundSessionsPerNode = Math.max(
  1,
  Math.floor(usableRamMb / input.ramPerSessionMb)
);

const effectiveSessionsPerNode = Math.min(
  input.configuredSessionsPerNode,
  ramBoundSessionsPerNode
);

const nodes = Math.ceil(input.targetSessions / effectiveSessionsPerNode);

按每会话 250 MB、每节点 32 GB 且各留出 4 GB、以及每节点 100 个会话的运营上限计算:

output
target sessions:            10000
RAM-bound sessions/node:    114
effective sessions/node:    100
nodes required:             100
total fleet RAM:            ~3200 GB

单看内存,每个节点可以放下 114 个会话。模型取 100,因为那才是你真正会跑的数字,而两者之间的差距就是规格表与生产机群的区别。结果大约是 100 个节点和 3.2 TB 内存,这还没算上自动扩缩、浏览器镜像维护、崩溃恢复、监控、发布,以及维持这一切运转的值班。

100 个节点是怎么来的。 可用内存把上限定在每节点 114 个会话;运营上限 100 定下真正的数字。两个数字都重要,而你实际运行的只有其中一个。

节点数看不到的两笔成本

100 个节点是标价。真正的价格由两件事决定,而这两件事都偏向决策中利用率更高的那一边。

机群按峰值配置,却要一直付费

容量是按最忙的那一小时配置的,却要为其余所有小时付租金。峰值是均值三倍的机群,利用率大约只有三分之一,于是每一个有用页面所承担的硬件成本,大约是规格表暗示的三倍。自动扩缩能缩小这个差距,却不能抹平它:浏览器需要预热,而按一个以秒为单位波动的指标来伸缩,要么滞后,要么留余量。

失败的页面和成功的页面一样贵

一个返回验证挑战、超时或软封锁的页面,消耗的 context、内存和真实时间,与一个返回数据的页面完全相同。在自建机群上,你为它付一样的钱。而在 Crawling API 上不是这样:失败的请求不计费,所以对不稳定目标的重试只会改变你的延迟,不会改变你的账单。

这个差距随目标的敌意程度而放大。在一个温顺的语料上,它是舍入误差。在有真正反爬防御的站点上,当相当一部分尝试以验证挑战收场时,它是账单里很大的一块,而两种模式中只有一种会把它算到你头上。

托管路径

购买路径删掉了控制平面。没有池、没有预热、没有回收,也没有什么容量要规划,因为浏览器跑在一次 API 调用的另一端。

来源: final/src/crawlbase.ts

typescript
export async function crawlbaseRender(
  url: string,
  token: string,
  timeoutMs: number
): Promise<RenderResult> {
  const endpoint = `https://api.crawlbase.com/?token=${token}&url=${encodeURIComponent(url)}`;

  const response = await fetch(endpoint, { signal: controller.signal });
  const body = await response.text();

  const cbStatus = Number(response.headers.get('cb_status') ?? response.status);

  return { cbStatus, bytes: body.length, ms: Date.now() - started };
}

JavaScript 令牌才是让它成为浏览器而不是一次 fetch 的原因:它在另一端驱动一个真正的渲染引擎。请读 cb_status 而不是 HTTP 状态码,因为那才是描述 目标发生了什么的字段,而不是描述你这次 API 调用发生了什么。

这里的并发量是一项套餐设置而不是一支机群,并且可以按需调高。这正是拿它和会话数量比较之所以不对的原因:有意义的比较发生在相同吞吐量下。拿你的每天页数除以 86,400,乘以你的每页秒数,再把得到的并发量与同一个数字所需要的机群作比较。

Crawlbase Crawling API

无需机群的渲染并发:真正的浏览器在我们这一侧运行,走轮换住宅 IP,返回一个干净的响应。失败的请求不计费,所以敌意目标花掉的是你的延迟而不是账单。用 1,000 个免费请求开始,无需信用卡。

做出决定

一旦负载用每天页数表达出来,这个选择就不再是立场之争。

自建机群,当 购买渲染,当
浏览器执行本身就是产品 渲染服务的是另一个产品
你需要掌控完整的浏览器生命周期 你需要的是页面,不是浏览器
平台工程师本来就在编制内并参与值班 这些人力放在下游更有价值
利用率既高又可预测 需求忽高忽低,按峰值配置的容量在空转
目标温和,失败率很低 目标会反击,失败占尝试的相当一部分

最后两行恰恰是团队最容易低估的。它们也是容量模型看不见的两行,因为两者都是负载的属性,而不是硬件的属性。

结语

一万个并发会话,是对一个大多数团队并未问准的问题的回答。换算成负载是每天 172.8 百万页;反过来算,每天一百万页需要的并发量是 58。把这两个数字弄清楚,比任何基准测试都更能了结这场争论。

控制平面本身并不是难的部分,参考实现说明了原因:一个有界上限、一次租借、一条回收路径,以及一个精心放置在 await之前的自增。这是几百行代码,而且已经写完了。

写不完的是运营:100 个节点按峰值配置,而它们一天中大部分时间都在峰值之下;每个节点一个浏览器进程,把一百个会话交给一次崩溃处置;还有一张账单,对一个被拦截的页面和一个成功交付的页面收同样的钱。搭建是一个周末,机群是一份值班表。

常见问题

会话是浏览器还是浏览器 context?

是浏览器 context。它以独立 Chromium 进程一小部分的成本,携带隔离的 cookies、存储和执行状态,正是这一点才让每节点一百个会话变得可行。代价是节点上的所有 context 共用一个浏览器进程,所以一次崩溃会把它们一起带走。

为什么在 await 之前预留并发槽位如此重要?

因为 await 会让出执行。如果计数器在 context 创建之后才自增,那么在这个空隙里到达的每个调用方读到的都是旧值并通过上限检查,于是配置为五的限制可能短暂创建出二十个 context。同步预留槽位并在失败时回滚,正是「限制」与「建议」之间的区别。

为什么要区分受内存限制的会话数和每节点有效会话数?

一个是内存允许的,另一个是你真正会跑的。在 32 GB 节点上留出 4 GB、每会话 250 MB 的条件下,内存允许 114 个;而 100 这个运营上限才是给机群定规模的数字。跑在内存极限上,就没有余量应对流量高峰或一个泄漏的 context。

哪些假设得出 100 个节点这个估算?

每会话 250 MB、每节点 32 GB、每节点留出 4 GB,以及每节点 100 个会话的运营上限,目标为 10,000 个会话。这四项都可以在 capacity.ts中配置,而且在引用结果之前,这四项都值得换成你自己的测量值,因为每会话内存尤其会随页面实际加载的内容而剧烈变化。

我该怎么把托管并发量和会话数量作比较?

不要比,它们是不同的单位。先把两边都换算成吞吐量:把每天页数除以 86,400 得到每秒页数,再乘以你实测的每页秒数,得到各自需要的并发量。在相同吞吐量下比较,并按这个数字而不是按标题上的会话数来选择套餐规模。

演示里的每秒 37 个请求能预测机群吞吐量吗?

不能。那个数字来自五个 context 在一台机器上打 example.com ,所以它衡量的是分配逻辑在一个平凡页面上的表现,而不是真实条件下的渲染。一个 JavaScript 繁重的页面需要的是秒而不是毫秒,而正是这个延迟决定了目标吞吐量需要多少并发量。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量