Skip to content

Performance

HadesTop edited this page Aug 17, 2026 · 6 revisions

性能与容量

服务端并发模型

一请求一线程,阻塞 IO。 [SERVER].max_workers(默认 100)同时决定三件事:

  1. 能同时处理多少个请求
  2. SendBatch 的并发度上限 —— 批量不会再开一个自己的池,否则总并发会变成 max_workers × 批量数,把下游打爆
  3. gRPC 的 maximum_concurrent_rpcs 设为它的两倍,超出的请求排队而不是无限堆积

调它之前要知道的:按 host 限流的等待也占着线程per_host_max_concurrent 设成 2、 同时有 200 个请求打向同一个 host 时,198 个线程在排队——其他 host 的请求会跟着饿死。 两者要一起调。

按 host 限流

两道闸门,可以单独用也可以叠加。

并发闸门

[DOWNLOADER.concurrency]
per_host_max_concurrent = 0     # 0 = 不限制
per_host_wait_timeout = 30      # 等不到额度就返回 RESOURCE_EXHAUSTED
per_host_idle_ttl = 300         # host 条目空闲多久回收
max_tracked_hosts = 10000       # 同时跟踪的 host 数上限

严格限制:超出的请求在服务端排队,等不到就返回 RESOURCE_EXHAUSTED, 而不是无限期占着线程。

per_host_idle_ttlmax_tracked_hosts 是必需的:爬虫会碰到无穷多域名, 不回收就是内存泄漏。

速率闸门(令牌桶)

[DOWNLOADER.rate_limit]
per_host_qps = 0        # 0 = 不限制;可以是小数,0.5 = 两秒一个
per_host_burst = 0      # 桶容量;0 = ceil(per_host_qps),即最多攒一秒的量

两者的区别:并发闸门管同时有几个在飞,速率闸门管每秒发起几个。 对方限的是 QPS 就用后者,对方扛不住并发就用前者。

集群下的限流

限额只在本进程内计算。

服务端转发模式下任务统一从入口节点进来,在那一台上算就是全局的——所以不需要 Redis 之类的中间件(0.3 起已移除 redis 后端)。

客户端分发模式下每台各算各的,实际总量是 节点数 × 限额。需要全局精确控制就用转发模式。

请求压缩

gRPC 消息级 gzip,服务端透明解压,无需协议改动

[CLIENT]
compression = "auto"          # auto / gzip / none
compression_threshold = 1024  # auto 的体积门槛(字节)
行为
auto(默认) 够大且看起来可压缩才压
gzip 一律压。链路带宽紧、CPU 富余时用
none 一律不压。同机通信、或链路上已有压缩时用

主要收益在自动化脚本上automation_script 传的是整个脚本文件, 实测 8158 字节压到 350 字节。批量发几百个带脚本的请求时差出一个数量级。

普通 GET 请求约 200~400 字节,压它省不下什么——所以有 1024 字节的门槛。

auto 还会跳过已经压过的二进制(图片、gzip 包、PDF 等)——再压一遍只会变大还费 CPU。 判断顺序:魔数前缀 → 含 NUL 字节 → UTF-8 合法性 → 控制字符占比。

早期版本只用"控制字符占比 10%"这一条判定,而随机字节的期望占比是 10.9%—— 正好压在阈值上,同一份数据有时判可压有时判不可压。改成以 UTF-8 合法性为主判据、 阈值降到 2% 之后稳定了(500 次重复无一误判)。

浏览器渲染的耗时构成

路径 耗时
热路径(浏览器已起来) 200~300 ms
冷启动(首次拉起浏览器) ~1.5 s
永久性导航错误(域名不存在等) 0.2 s

0.3.0 之前实测有单次点击耗时 296 秒的情况。修的是九处互相叠加的放大器, 其中影响最大的四个:

  1. 浏览器实例没有真正复用 —— 每次使用前用 is_connected() 检查; 死了就丢弃重建,而不是拿着一个死连接反复超时。
  2. 超时预算一刀切 —— 现在按任务算:冷启动额外给 60s 宽限、常规开销给 15s。 固定值会让冷启动被误杀、或者热路径等太久。
  3. 永久性导航错误照样重试 —— ERR_NAME_NOT_RESOLVEDERR_CONNECTION_REFUSED 重试三次还是同样结果,白白多花 16 秒。
  4. 缓存键用的是 browser 而不是解析后的引擎 —— adapter="browser"adapter="camoufox" 会各自起一个浏览器,内存翻倍。

自己能做的优化

[BROWSER]
block_resources = ["image", "media", "font"]   # 默认已开,最有效
wait_until = "load"                            # 别用 networkidle,除非确实需要
max_pages = 4
headless = true
  • block_resources 是收益最大的一项。只要 HTML 的话,图片/字体/媒体全是纯浪费。
  • wait_until = "networkidle" 是最大的隐形开销。页面有 WebSocket、SSE 或定时轮询时 网络永远不会静默,每个请求都会耗满 page_load 才返回。
  • wait_for_selector 优于拉长 wait_for_timeout —— 前者一出现就继续。

容量规划

内存

组成 大约
纯 HTTP 的服务端 几十 MB
Chromium 系引擎(playwright / patchright / DrissionPage) ~600 MB / 实例
camoufox 更高(Firefox + 一整套扩展)
每个额外页面 几十~上百 MB

集群里每个会执行渲染的节点各占一份。 三节点集群开浏览器就是 3 × 600 MB—— 这是转发架构的固有开销,不是 bug:每台都要能自己渲染,才谈得上分担和故障转移。

≤4 GB 内存的机器上用 camoufox 请把 max_pages 设成 1~2。超了会开始换页, 请求从几秒变成几分钟——看起来完全像卡死,但其实是在换页

磁盘

链路记录一条约 200~400 字节,见链路记录 · 磁盘占用

浏览器本体:camoufox ~1 GB,chromium ~150 MB。

线程

max_workers × 每线程栈(Linux 默认 8 MB 虚拟、实际按需)。100 个 worker 不是问题, 上千个就要考虑改成多进程部署。

超时怎么算

timeout单次尝试的上限,不是总时长。

最坏总耗时 ≈ timeout × (max_retries + 1) + 退避总和

退避总和 = initial_backoff × (1 + exponent + exponent² + …),每项封顶 max_backoff

默认值(timeout=300、max_attempts=3、initial=1、exponent=2)下最坏约 300 × 4 + (1 + 2 + 4) = 1207 秒。客户端的 deadline 要留够,否则会在服务端还在重试时 就先超时。

集群转发的默认超时就是按这个公式推的(任务 timeout × (重试+1) + 15s), 浏览器适配器另加冷启动宽限。见集群

客户端到服务端这一跳

[CLIENT]
rpc_max_retries = 2
rpc_retry_backoff = 0.5     # 实际等待 = 基数 × 2^已重试次数

这跟适配器内部的重试是两回事:适配器重试解决"目标站点抖了",这里解决"我们自己的 服务端抖了",互不覆盖。

只有 UNAVAILABLE 会重试(连接压根没建起来、请求没到过服务端)。 DEADLINE_EXCEEDED 刻意不重试——请求可能已经在服务端执行了,只是回复没赶上, 这时重发一个 POST 就是重复下单。

大文件

stream=Trued.stream(),响应体不进内存:

s = d.stream("https://example.com/big.zip")
with open("big.zip", "wb") as f:
    for chunk in s:
        f.write(chunk)

断点续传(自动 Range + If-Range):

from ipclick.resume import download_to_file
download_to_file(d, url, "big.zip")

流式请求在集群里不转发,永远由收到请求的节点自己执行——把每个分片再中转一次会让 入口带宽翻倍。要让子节点出流量就用客户端分发模式直连它。

批量

tasks = [DownloadTask(uuid=u, url=u) for u in urls]
for resp in d.batch(tasks):
    print(resp.request_uuid, resp.status_code)

一次 RPC 发全部,服务端并发执行,按完成顺序返回(不是提交顺序)—— 所以必须靠 request_uuid 对应,不能靠下标。

并发度受 max_workers 约束,且不会为批量另开一个池——否则总并发会变成 max_workers × 并发批量数,把下游打爆。

0.4 修了批量路径上的两处开销:

  • 完成检测从 O(N²) 降到 O(N)。 旧实现每提交一个任务就把全部在途任务扫一遍找 已完成的,一千个任务就是约五十万次状态检查,而且全压在推任务的那个线程上,反而 拖慢了提交本身。改成完成方主动报到(add_done_callback + 队列),两边都是 O(1)。
  • 线程池改为共用。 旧实现每次批量调用都新建再销毁最多 max_workers 个线程 (默认 100)。批量本来就是"高频、每次很多任务"的用法,这份创建开销是白付的。

「边收边发」的行为不变:不等客户端把所有任务推完就开始返回结果,否则一个超长的批次 会让首个结果迟迟不到。

Clone this wiki locally