bigywater.tech
爬虫工程 · 22

背压:有界队列如何让生产者慢下来

抓取速度比处理速度快的时候,没有上限的队列会把内存吃光。这篇用 asyncio.Queue 量一量背压:有界和无界队列在内存、总耗时上的差别,上限到底改变什么、不改变什么,task_done 的陷阱,以及多段流水线里积压出现在哪里。

242 对 26 MiB
无界队列与 maxsize=10 的峰值内存(2000 个 100 KB 条目,消费者慢)
11.2–11.4 秒
不同队列上限下,全部处理完的总时间——几乎不变
4.5
解析最慢时,抓取→解析队列的平均长度(maxsize=5)

有界队列省的是内存

实测 · 本机执行输出
  队列                   生产者发完用了     队列最长      峰值内存       全部处理完
  无界(maxsize=0)          42 ms     2000     242 MiB    11226 ms
  maxsize=1000         5671 ms     1000     170 MiB    11407 ms
  maxsize=100         10754 ms      100      47 MiB    11324 ms
  maxsize=10          11188 ms       10      26 MiB    11250 ms
  maxsize=1           11251 ms        1      24 MiB    11262 ms
  (起点内存 24 MiB;2000 个 100 KB 的条目合计约 195 MiB)

消费者每个条目 5 ms、生产者不限速,无界队列让生产者 42 ms 就把 2000 个 100 KB 的内容全放进队列(峰值内存 242 MiB);maxsize=10 时生产者被迫等消费者,峰值内存 26 MiB,几乎没比空进程多多少。总处理时间在 11.2 到 11.4 秒之间,基本不受影响。

上限不改变吞吐

实测 · 本机执行输出
  场景                           maxsize     队列最长       全部处理完
  消费者慢(5 ms)、生产者快                    0     1000     5662 ms
  消费者慢(5 ms)、生产者快                   10       10     5635 ms
  生产者慢(5 ms)、消费者快(1 ms)              0        1     5487 ms
  生产者慢(5 ms)、消费者快(1 ms)             10        1     5449 ms

消费者是瓶颈时,有界队列只是把积压从队列里挪到“生产者在等”;生产者是瓶颈时,队列本来就积不起来。整体速度由慢的一端决定。

语义细节

实测 · 本机执行输出
  maxsize=2,已有 2 个,再 put_nowait:QueueFull
  await q.put(3) 一直等到被取消:TimeoutError(等了 301 ms)
  消费者取完了 3 个条目但没调 task_done:q.join() 等了 501 ms 仍未返回(TimeoutError)
  每取一个调一次 task_done:q.join() 在 0.1 ms 内返回

队列满了,put_nowait 抛 QueueFull,await put 则等待。用 join() 等所有条目处理完时,每取出一个条目都必须调一次 task_done(),否则 join() 永远不返回。

多段流水线

实测 · 本机执行输出
  每段耗时(抓取/解析/存储)                  总耗时      理论 = 100×最慢段          抓取→解析 队列平均长度          解析→存储 队列平均长度
  2/2/2 ms                      233 ms             200 ms                   0.0                   0.0
  2/10/2 ms                    1060 ms            1000 ms                   4.5                   0.3
  2/2/10 ms                    1062 ms            1000 ms                   4.5                   4.5
  10/2/2 ms                    1071 ms            1000 ms                   0.5                   0.0

抓取、解析、存储三段各接一个有界队列。最慢的那一段决定总时间(每段 100 个条目,总耗时接近 100 × 最慢段耗时),它前面的队列接近满,更上游的也会逐渐被堵住;最慢的是第一段时,后面的队列几乎是空的。观察队列长度,就能找到瓶颈在哪一段。

分步演示

背压
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。第一、二节在独立子进程里跑,条目是 b"x" 乘以字节数新分配并写满的内容(早先用 bytes(n) 分配零页,物理内存不会真的占用,所以改掉了),峰值内存取 ru_maxrss;第三、四节在同一进程里,流水线队列平均长度由每 5 ms 一次的采样取平均;每项各跑一次,毫秒数仅供参考
    • 没有验证:真实抓取(网络 + 解析 + 写库)里各段耗时的波动下队列长度;多个消费者时的表现;asyncio.Queue 在 Python 其它版本的行为;基于线程的 queue.Queue 的背压;磁盘队列或外部消息队列(Redis、Kafka)的背压做法;task_done 与取消/异常交织时 join() 的行为
    京ICP备2026067502号-1京公网安备11010502063487号