首页 / 文章 / 为何提高并发性会导致HTTP 429错误:起始速率与单主机限制

为何提高并发性会导致HTTP 429错误:起始速率与单主机限制

在不控制启动速率和每主机上限的情况下增加工人数量,会导致流量激增从而引发429错误。吞吐量则依赖于有节奏的并发处理、退避机制以及合理的队列设计。

3571 词

简而言之:提升并发性常被宣传为免费的并行处理:工人越多,请求就越多,处理的数据也就越多。10个似乎已经不错,20个肯定更好,而50个则令人难以抗拒。但问题在于,增加线程池数量并不仅会提升并发性,还会决定客户端在最初时刻对主机的访问强度;在多主机工作负载中,还会在不经任何人设置的情况下决定每个主机的并发程度。当这些隐含的参数出现故障时,状态码往往会是HTTP 429,这显然意味着“并发请求过多”。于是团队们会缩小线程池规模或添加代理服务器,然后继续工作。由于没有找出真正的原因,他们便放弃了本可提升的吞吐量。

第一部分 — 并发性与启动速率:一个线程池设置控制两种限制

工作线程池的大小(p-limit(n)、信号量、线程数量)决定了同时进行的操作数量上限。它并不限制新任务开始处理的速度。在t=0时,一个包含五十个等待任务的空闲线程池可以在一个时间单位内启动五十个请求——即出现启动速率的峰值——尽管后续的稳态并发数看起来“只有五十”。速率限制器同样重视这种初始的峰值现象,也关注持续的并行处理能力。

测量启动速率峰值

使用可重复的测试工具会有所帮助。选取一小部分Arch Linux Wiki文章的URL:

https://wiki.archlinux.org/title/Arch_Linux
https://wiki.archlinux.org/title/Installation_guide
https://wiki.archlinux.org/title/Pacman
https://wiki.archlinux.org/title/Systemd
...and so on

通过循环访问这些列表来生成100个请求的工作负载:

function buildWorkload(urls, n) {
  return Array.from({ length: n }, (_, i) => urls[i % urls.length]);
}
async function runPooledFetch(urls, concurrencyLimit, agent, options = {}) {
  const limit = pLimit(concurrencyLimit);
  const results = await Promise.all(
    urls.map((url) => limit(() => fetchOne(url, agent)))
  );
  // ...
}

使用不同的连接池大小进行测试,将每种结果分类为正常或429错误(以及其他故障),同时记录每秒完成的请求数与正常响应的数量。这种区分很重要:即便返回的是快速的429错误,也会计入“每秒完成的请求数”这一虚荣指标,但实际上并未交付任何页面内容。

随着全局并发量的增加,实际可用吞吐量会下降

在固定100次请求的工作负载下,仅改变连接池大小时,直接连接的性能表现较差。当并发数为1时,几乎所有100次请求都能成功处理,完成速率接近2.4次/秒——由于无法并行启动处理,必须等待所有请求完成。当并发数为10时,有效数据量大幅下降:约有42页请求成功处理,而58%的请求返回429错误码,此时完成速率上升至约23.5次/秒。在并发数为25和50时,成功处理的请求比例继续下降(分别为约24/100和16/100),而完成速率仍保持在十几到二十几次/秒的水平(约18.2次/秒和21.8次/秒)。

虚荣峰值时的并发数为10,每秒完成请求数为23.5次——是表格中的最高值——但成功响应数却是最差的之一。若以每秒完成的请求数作为优化目标,就会选择那些在封禁机制上消耗最多资源的设置。真正有用的指标是每秒成功响应数,而非单纯的完成请求数。

在相同并发数下设置启动速率上限即可解决此问题

保持任务池大小不变,同时限制下一个请求可以开始处理的时间。可根据每秒最大请求数设定来确定最小间隔时间:

// minGapMs derived from --max-rps; pool concurrency is untouched
if (minGapMs > 0) {
  const now = Date.now();
  const waitMs = Math.max(0, nextStartAt - now);
  if (waitMs > 0) await sleep(waitMs);
  nextStartAt = Math.max(Date.now(), nextStartAt) + minGapMs;
}

通过控制启动节奏,之前导致服务器不堪重负的相同并发数现在几乎可以处理完所有页面,因为那种突然的大量请求现象消失了。正确的思维方式是考虑两个参数:并发数(正在处理的请求数)和启动速率(每秒允许处理的请求数)。将它们合并为一个数值会掩盖故障的本质。

为何池化HTTP客户端会在t=0时发送大量请求

池化机制并不了解所谓的“适度节奏控制”,它只知道哪些资源是空闲的。系统启动时所有资源都处于空闲状态,因此所有可以运行的排队任务都会立即执行。连接复用与HTTP/2多路复用技术会使网络上传输的请求密度进一步增加。唯有准入控制器——如令牌桶、最小间隔机制、漏桶算法——才能独立于当前处理上限来控制请求的启动频率。

家用代理能否解决请求起始速率过高的问题?

通过控制节奏可以确保系统的正常运行:在不过度负担目标服务器的情况下收集数据。但实际工程中有时需要更高的速度,而2.4次/秒的速率限制无法满足需求。即便通过家用代理来采用同样的简单策略——不设置请求起始速率上限,让池化在时立即发送大量请求——在不同并发级别下也能保持大约98–99/100的成功率,且几乎不会被封禁,而直接连接的方案则仍会遭遇严重问题。

这并不意味着代理服务器就能消除理解起始速率的必要性。它们会改变IP的声誉以及网站对流量的判定方式。它们能够掩盖那些可能导致单个出口IP被封禁的突发流量。如果产品要求是“在不会显得像蜂拥而上的情况下进行收集”,那么节奏控制依然是核心手段;代理服务器只是提升容量和改善声誉的辅助工具,无法替代对访问量的实际测量。

代理服务器的构建通常会从环境中获取凭证:

function buildProxyAgent() {
  const user = process.env.BRIGHT_DATA_PROXY_RESIDENTIAL_USERNAME;
  const pass = process.env.BRIGHT_DATA_PROXY_RESIDENTIAL_PASSWORD;
  if (!user || !pass) return null;
  const host = process.env.BRIGHT_DATA_PROXY_HOST || 'brd.superproxy.io';
  const port = process.env.BRIGHT_DATA_PROXY_PORT || '33335';
  const proxyUrl = `http://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${host}:${port}`;
  return new HttpsProxyAgent(proxyUrl);
}
const residentialAgent = buildProxyAgent();
await runPooledFetch(tasks, 50, residentialAgent);

应谨慎使用这些凭证,记录每次请求是直接发起还是通过代理进行的,并继续记录观察到的起始RPS值,这样才能知道是哪种方式影响了最终结果。

第二部分 —— 每台主机的并发数

全局池还会隐藏另一个潜在的限制因素。请考虑以下情况:

const limit = pLimit(50);
await Promise.all(urls.map((url) => limit(() => fetchOne(url))));

虽然有50个全局任务分配到多个主机上,但这并不意味着每个主机最多只能处理50个正在处理的请求。队列顺序和响应时间差异决定了某个主机实际要承担的请求数量。从理论上来看,混合列表似乎是平衡的:

1. arch
2. github
3. arch
4. mdn
5. npm
6. arch
7. cloudflare

但当这些请求的URL集中在某个主机可处理的范围内时,该主机仍会面临大量请求涌入的情况。

检测单个主机的并发处理能力

使用两个主机来重复进行测试:

  • 一个严格限制的主机——Arch Linux Wiki的10篇文章URL,如同第一部分所述,在高负载下会出现阻塞现象。
  • 一个宽松限制的主机——books.toscrape.com的目录页面(10个URL),几乎不会出现阻塞,用作对照组。如果沙箱环境无法正常工作,则说明客户端存在问题。

交替列表在全局并发数为50时,可生成20个URL×5次重复 = 100次请求。固定列表及生成工具存放在open concurrency-trap-bench仓库中(例如urls-mixed-arch-books.txt)。

https://wiki.archlinux.org/title/Arch_Linux
https://books.toscrape.com/catalogue/page-1.html
https://wiki.archlinux.org/title/Installation_guide
https://books.toscrape.com/catalogue/page-2.html
https://wiki.archlinux.org/title/Pacman
https://books.toscrape.com/catalogue/page-3.html
...and so on

有序工作负载可采用轮询、按主机阻塞或通过种子值进行随机排序:

function buildOrderedWorkload(urls, pattern, { repeatsPerUrl = 5, seed = null } = {}) {
  const n = urls.length * repeatsPerUrl;
  let list = Array.from({ length: n }, (_, i) => urls[i % urls.length]);
if (pattern === 'block') {
    // AxN, BxN, and so on. Repeat each URL before advancing.
    const out = [];
    for (const url of urls) {
      for (let i = 0; i < repeatsPerUrl; i++) out.push(url);
    }
    return out;
  }
if (pattern === 'shuffle') {
    // Fisher–Yates with a fixed seed so the run is reproducible
    for (let i = list.length - 1; i > 0; i--) {
      seed = (Math.imul(1664525, seed) + 1013904223) >>> 0;
      const j = seed % (i + 1);
      [list[i], list[j]] = [list[j], list[i]];
    }
  }
  // 'round-robin' leaves the alternating list as-is
return list;
}

可通过开始/结束事件来测量每个主机的最高处理量:

function maxConcurrentPerHost(results) {
  const eventsByHost = new Map();
  for (const r of results) {
    const host = new URL(r.url).hostname;
    if (!eventsByHost.has(host)) eventsByHost.set(host, []);
    const end = r.startedAt + r.ms;
    eventsByHost.get(host).push({ t: r.startedAt, delta: 1 }, { t: end, delta: -1 });
  }
  // sort events by time, sweep: +1 on start, -1 on finish, track max
}

全局并发数保持不变,仅排序方式不同。这样就能将各主机的压力与资源池大小分开考虑。

队列顺序如何改变各主机的并发程度

真实的爬虫永远不会永远保持完美的轮询顺序。站点地图、依赖关系图以及重试队列都会重新分配任务,因此相同的全球设置也可能导致各主机的最高处理量有所不同。

1. 轮询:在各个主机之间均匀交替

交替列表(A B A B …)能够分散处理任务。由于另一台主机会持续占用资源,严格限制的主机上的任务高峰值相对较低。

2. 分组:将同一主机的请求集中处理

先将所有Arch相关URL归为一组,再将所有书籍相关URL归为另一组,这样严格限制的主机就能连续处理大量任务。尽管“并发数仍为50”,该主机上的任务高峰值仍会接近整个系统的工作量上限。

3. 随机排序(种子值42)

使用固定种子值的随机排序方式介于上述两种极端情况之间,其结果类似于实际生产环境中的随机顺序。任务高峰值会随种子值的变化而变化,这说明关键在于对参数敏感度的把控,而非某种神奇的排序算法。

全局并发数相同,但各主机的压力不同

在那些模式中,严格主机路径下的正常率在其峰值出现时的变化趋势比恒定的全局 c=50 更为贴近实际飞行中的数值。宽松主机则能保持正常状态。若为了“修复”严格主机而降低全局配额,也会对宽松主机造成影响,而且即便在顺序不利的情形下也无法将严格主机的峰值控制在理想水平。

解决方案:分别限制每个主机的并发数

第一部分在资源池之外增加了启动速率上限。第二部分则在资源池之外加入了每个主机的上限:即对任意单个主机名所能处理的飞行中请求数设定的嵌套限制。

请回顾这三个术语:

  • 全局并发数——共享资源池的大小(例如 p-limit(50)),由你设定。
  • 每个主机的并发数——某个主机实际处理的请求数量(peak_inflight),由系统测量得出。
  • 每主机上限——在全局池之下,您可为每个主机名设定的独立并发调用最大数量限制。
  • 如果缺少这一主机级限制,所有空闲的全局资源都会被那些准备就绪的URL占用——甚至可能导致大量请求集中流向某个有严格限制的源地址。通过设置嵌套限制,每个主机名都可以拥有自己的队列控制机制:

    async function runPooledFetch(urls, concurrencyLimit, agent, { perHostLimit } = {}) {
      const globalLimit = pLimit(concurrencyLimit);
      const hostLimiters = new Map();
      async function fetchWithLimits(url, execute) {
        return globalLimit(async () => {
          if (perHostLimit && perHostLimit >= 1) {
            const host = new URL(url).hostname;
            if (!hostLimiters.has(host)) hostLimiters.set(host, pLimit(perHostLimit));
            return hostLimiters.get(host)(execute);
          }
          return execute();
        });
      }
      // ...
    }
    

    这样一来,那些有严格限制的URL集群就无法同时占满所有全局资源,而那些限制较宽松的主机仍可使用剩余的容量。禁令措施与您希望控制的那个上限变量直接相关。

    为何共享工作池会导致负载集中在某个主机上

    池子会用所有可运行的任务来填满空位。性能较高的主机能快速释放空位并承接更多自己的工作——直到一组严格限制的主机URL能够同时运行并共享突发流量。突发流量的大小取决于任务顺序、延迟以及任务类型组合,而这些因素都不会体现在单一的并发计数中。正因如此,按主机设置限制比盲目缩减全局池更有效。

    实用检查清单

    • 将启动速率视为独立设置。信号量并非RPS限制器,应在并发设置之外再添加明确的启动速率上限。
    • 将每主机的并发数视为独立设置。多主机工作负载需要在全局池中为每个主机设置限制器。
    • 记录observed_start_rps及每主机的peak_inflight数值。若不进行测量,就无法合理设定限制。
  • 优化实际可用吞吐量。应关注每秒正常处理请求数,而非每秒完成请求数;即便响应时间仅为429毫秒,仍属于失败情况。
  • 在单次博客运行中设定的具体阈值并不具备通用性:限流机制具有状态依赖性,其效果会受时间、流量历史以及IP信誉的影响。可行的解决方案是采用隔离策略。那些看似由“并发数过高”导致的故障,实际上可能是启动速率问题、单个主机调度问题,或是两者兼有。在未分离这些变量之前,单纯减少资源池规模只能解决表面症状——而且往往还是错误的症结。

    正确解读指标,避免误判

    每当客户端能够快速建立套接字时,每秒完成的请求数就会上升——即便大多数响应都是拒绝。那些不设置成功过滤条件、只关注并发量的控制面板会推荐有害的配置。应将每个吞吐量图表与成功率一起展示,可能的话再配上保留的有效内容字节数。如果您的处理流程会重试429错误,应单独统计重试次数,以免大量的重试被误认为是高效的并行处理。

    启动率日志应记录每次请求被接受的时刻,而不仅仅是完成时刻。通过这些记录,可以还原出前100–500毫秒内的请求峰值,此时无论您配置的稳定状态池大小如何,许多批量处理的客户端表现都会十分相似。如果两种配置的初始请求峰值相同,那么它们出现相同的封禁模式也就不足为奇了。

    代理、信誉以及权衡取舍的透明度

    住宅或数据中心代理能够重新分配身份信息。它们可以将单一IP的流量集中传输转化为多个较为分散的流量流。这有助于数据收集工作,同时也能在目标端对IP数量有限制的情况下掩盖较差的访问控制问题。不过,这并不能免除你对数据来源站点应尽的伦理与合同责任,也无法消除你需要了解自身客户端情况的工程需求。在能够控制客户端时,应优先考虑控制传输节奏;只有当产品确实需要在多个身份标识下实现更高的整体吞吐量时,才使用代理——并且要持续监测每个身份标识及每台主机的负载情况,避免在代理层背后盲目操作。

    将这两部分结合起来

    生产环境中的爬虫通常需要两者兼备:一方面要在本地设置资源池以确保安全,另一方面要设定起始请求速率上限以体现对服务方的尊重;当多个目标共享同一资源池时,则还需为每个主机设置速率上限。若缺少其中任何一项,都会重新出现那种在HTTP状态码中表现为“并发”的故障模式。解决此问题的方法并非什么神奇手段,而是通过相关监控工具,再加上针对实际观察到的变量设置的第二个限制器即可。

    利用设计注意事项确保比较的公平性

    在多次测试中保持成功分类标准一致:正常的 HTML 响应、HTTP 429 状态码、其他 4xx/5xx 状态码、超时以及解析失败都应在每次测试中以相同方式标记。只需更改被测试的变量——如线程池大小、最小启动间隔、代理开关或队列顺序。尽可能预热 DNS 和 TLS,这样曲线起始阶段的数值就不会受到冷启动过程的干扰,除非明确将冷启动作为测试内容。

    重复执行工作负载足够的次数以降低噪声影响,但次数不宜过多,以免网站的自适应限制机制在实验过程中发生永久性变化。当这些限制机制具有状态特性时,需记录具体时间以及之前的封禁是否仍在生效中。同时公布随机排序的种子值,以便他人能够复现相同的排序效果。

    URL固定地址应保持稳定。如果在研究过程中wiki标题或沙盒目录页面消失,就会导致正确数据数量出现错误。应像concurrency-trap-bench项目那样,在版本控制中与相关代码一起保存固定列表,这样图表才能参考已知的输入数据。

    在处理流程中“有效吞吐量”的表现形式

    下游系统关注的是已被接受的文档,而非套接字连接的数量变化。如果抓取工具将数据传递给索引器,则应统计每分钟被索引的文档数量;如果传递给价格数据库,则应统计经过验证的记录数。优化目标应与相应业务部门的需求保持一致,否则工程团队往往会最大化某个间接指标——即完成的HTTP请求次数——而这类请求中往往包含大量返回429状态的响应。

    重试操作与无节奏控制的资源池存在严重冲突。一旦因突发流量导致封禁,紧接着又进行重试,会进一步加剧启动频率。面对429错误时应加入随机延迟,并在存在Retry-After头时予以遵守,绝不能让重试风暴绕过启动频率限制。准入控制器必须将重试视为新的请求启动。

    多租户与多主机调度器

    那些代表众多客户处理请求的服务通常已为进程安全设置了全局并发上限。不过它们仍需要按目标地址划分的配额,以避免某个客户的URL列表占用有限的源服务器资源。全局、每租户、每主机这些不同层级的限制需要相互配合。应通过明确的层级结构来实现这些限制,而非寄希望于单一信号量就能实现公平排队。

    当各主机的延迟差异达到几个数量级时,工作窃取池会偏向速度更快的主机。这种倾向有助于提升资源利用率,但对于那些速度较慢且要求严格的主机而言则不利,因为它们的URL最终可能只能成批处理。针对单个主机的限制可以减轻这种负面影响;而可选的单一主机权重则能进一步体现礼貌性策略。

    理性解读代理结果

    如果在相同的池设置下直接出站失败而通过代理出站成功,那么目标站点很可能是根据网络身份来实施访问控制的。这属于有用的操作知识,但并不能证明起始速率的机制发生了变化。在代理背后,仍应按每次出站的身份以及目标主机记录访问情况。否则只是将问题隐患转移到了其他地方。

    合规性与机器人相关政策仍需您遵守。通过多个出口提升整体吞吐量会扩大逻辑漏洞的影响范围。即便启用了代理,也会设置功能开关控制节奏及每台主机的上限,这样无需重新部署身份验证基础设施即可降低系统负载。

    从实验到默认值的闭环

    一旦测量结果显示,启动速率和每台主机的峰值比仅考虑全局总量更能准确预测封禁情况,就应将这些发现作为默认值编码到客户端库中:要求设置每秒请求数(或最小间隔)参数,为多主机模式设定每台主机的上限,并同时导出相关指标。文档中应同时展示不良状态下的仪表板(已完成请求数上升而正常请求数下降)与良好状态下的仪表板。通过了解故障模式,可避免后续团队因试图解决并发问题而导致有用数据丢失。

    规范地复现启动速率实验

    锁定Pin Node及undici(或您所使用的HTTP框架)的版本,以确保连接池行为具有可比性。在测试过程中禁用与浏览器无关的重试中间件。如果测试工具对主机的解析方式不同,请在直接运行和通过代理运行之间清除DNS缓存。记录整个测试批次的实际耗时,以及每条请求的开始和结束时间戳,这样就能绘制出前半秒内的请求接收情况——在这一时间段内,无论您设定的稳态并发度如何,连接池中的客户端表现都应一致。

    在绘制图表时,务必在每秒完成的请求数旁显示成功请求数。那些隐藏成功请求数的双轴图表只会带来虚假的指标数据。请从测试工具中导出CSV文件,这样他人无需依赖截图即可重新进行计算。

    解读Arch Wiki风格的严格标准

    公共文档中提到的托管服务各不相同:有的按IP和路径限制访问速度,有的按User-Agent限制,有的按并发连接数限制,还有的根据滑动时间窗口内的请求频率来限制。今天出现429错误的情况,随着运营商政策的变动,明天可能会变成较为轻微的延迟。正因如此,文章中的数据只是示例性模式,并非恒定不变的数值。真正重要的是方法论:分别设定资源池大小、接入速率以及每台托管服务器的峰值负荷,然后一次只改变一个变量。

    如果您使用自己设定的严格限制条件的托管服务器,同时在同一测试环境中保留一台宽松限制条件的控制服务器。一旦控制服务器出现故障,您的客户端就会出问题;而只有严格限制条件的托管服务器出故障时,您才能研究该服务器的政策与您测试计划之间的相互作用。

    起始速率上限的实现细节

    基于最大请求每秒数的最低间隔限制对于单进程客户端而言简单且有效。令牌桶机制能够在保证长期平均值的的同时允许短时间的高频请求——当你需要快速响应的交互式数据获取,同时又希望批量爬取时操作更为温和时,这种机制非常有用。而漏桶算法则能更好地平滑请求节奏。无论选择哪种算法,都应将其应用于请求的启动阶段,包括重试机制。若绕过限制直接进行大量重试,就会在首次被禁止后再次出现t=0时的疯狂请求现象。

    多进程爬虫需要分布式访问锁(如Redis、etcd或中央调度器)。仅靠本地间隔限制无法协调五个工作进程,因为每个进程都可能认为自己每秒可以发起两次请求。

    主机级限制的实现细节

    以主机名为键的嵌套信号量是常见模式:先获取全局信号量,再获取对应主机的信号量,然后进行数据读取,最后按相反顺序释放。需确定www与apex是否共享同一键值。还需确定导致主机数量变化的重定向如何影响上限限制。同一站点的路径型分片通常仍共享一个主机配额,除非有明确的CDN相关证据表明 otherwise。

    需输出以下指标:inflight_global、inflight_per_host{host}、admissions_per_second、http_429_total{host}。应在429错误率上升时触发警报,而不仅是在5xx错误配额耗尽时才报警。

    生产环境调度器中的队列排序

    站点地图的排序、从种子节点开始的广度优先搜索、“重要”URL的优先队列以及重试队列,都能改变每个主机上的流量峰值。如果将失败的Arch URL放入重试队列的头部,可能在部分服务中断后意外地重新形成阻塞顺序。在各个主机之间实现公平的排队机制——即为每个主机设置轮询式的就绪队列——即便在尚未达到硬性限制之前也能避免流量意外集中。当仅靠公平性无法控制不同延迟条件下的峰值时,硬性限制仍然是必要的。

    不自我欺骗的代理

    家庭网络会改变身份分布的情况,但物理规律依然存在:如果每个身份仍然会同时发起五十次请求,那些依据行为而非IP地址进行判断的目标服务器仍可能封禁这些请求。需记录每次请求对应的出口身份信息,礼貌地轮换使用代理,遵守机器人协议及合同条款。即便启用了代理,也应控制请求速度,以免漏洞导致大规模的流量冲击。

    数据中心代理成本更低,也更容易被识别;而家用代理则价格更高,且在伦理层面存在争议。请谨慎选择,切勿将“代理”视为“解决方案”的同义词。

    从实验到默认设置

    在爬虫使用的共享HTTP客户端封装中加入两个必需的参数:maxInFlight和maxStartsPerSecond;当存在多个主机时还需添加maxInFlightPerHost。若没有针对每个主机的限制,就不要为多主机模式构建客户端。提供可展示每秒正常请求量的控制面板模板,并通过双图表进行培训——一方面显示高并发下的虚荣性吞吐量,另一方面则展示因此丢失的有用页面数量。

    扩展后的检查清单

    • 不仅限制正在处理的请求数,还要限制请求启动次数。
    • 对全局池中的每个主机设置上限。
    • 每次运行时都要测量请求接收量及每个主机的峰值情况。
  • 应以有用页面的数量作为成功标准,而非套接字连接完成数。
  • 对重试操作实施准入控制。
  • 在混合测试中采用较为宽松的控制策略。
  • 将代理请求的成功视为信誉变化的体现,而非调度机制正常的证明。
  • 随着目标环境政策的变化需重新评估相关数据,但方法论应保持不变。
  • 在这些习惯形成之前,团队仍会不断“解决并发问题”,结果只是让故障以更隐蔽的方式出现,依然会返回HTTP 429状态码,并继续消耗爬取预算。