Skip to content

Performance

Hades edited this page Aug 23, 2026 · 6 revisions

性能与容量

服务端并发模型

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

  1. 能同时处理多少个请求
  2. SendBatch 的并发度上限 —— 批量走的是一个服务级的专用线程池(线程名 ipclick-batch),容量同样取 max_workers,所以总并发仍被这一个数字圈住, 不会变成 max_workers × 并发批量数 把下游打爆
  3. gRPC 的准入上限 maximum_concurrent_rpcs 默认取它的 8 倍(默认配置下 100 × 8 = 800),超出的请求排队而不是无限堆积;对应配置项是 [SERVER].max_concurrent_rpcs = 0(0 = 派生,也可以显式写一个值)

第 3 条的倍数是准入余量,显式写 max_concurrent_rpcs 时别贴着 max_workers 写:准入 上限一低,客户端并发一过这个数就会收到 RST_STREAM(REFUSED_STREAM),SDK 按 UNAVAILABLE 重试两次仍被拒——而这时服务端 CPU 可能还远没跑满,拒绝纯粹来自准入闸门。 写成小于 max_workers 的值会直接启动失败(那样线程池永远喂不满)。

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

加线程解决不了 GIL

max_workers 只决定"能同时挂多少活",不决定能用几个核。 服务端是一请求一线程的 CPython 进程,实测 16 核机器上单进程只能用出 1.45 个核,把 max_workers 从 32 调到 256 对吞吐没有任何影响(279.8 / 277.9 QPS,差异在噪声内)。

要用满多核就起多个工作进程:

[SERVER]
processes = 4        # 0 = 按 CPU 核数自动(上限 8);1 = 单进程(默认)

实测 4 进程把吞吐从 313 QPS 拉到 663 QPS。靠 SO_REUSEPORT 共享同一个端口,分发由内核做, 对调用方完全透明(仍然只有一个地址一个端口)。

三个前提:仅 Unix(Windows 上会打告警并降级成单进程)、Web 管理端只在 0 号进程起内存按进程数线性增长(每进程各有一份适配器与连接池,浏览器渲染时尤其要算)。

协程模式([SERVER].async_mode = true,实验性、默认关)解决的是另一个维度:它换掉并发 模型,多进程换掉的是核数上限。两者不是二选一——单进程再怎么优化也只能用一个核。

按 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 之类的中间件——限流状态就在那一个进程里,不需要外部存储。

这句承诺靠的是入口的限流闸门对转发出去的请求同样生效:本地执行的那一支和转发出去的 那一支过的是入口节点上同一个限流器,共用一份配额。所以配 per_host_qps = 10 部三台, 目标站点挨的就是 10 QPS 而不是 30。转发模式下不做配额分片(下游节点各自按完整配额 算),敢这么做靠的正是入口这一道闸——出得去的流量已经被压在配额内了。

客户端分发模式(forward = "off")下,QPS 会按存活节点数自动均分:份额随健康探测 变化,一台挂了幸存者立刻分到更多,不需要协调者,日志里能看到「集群限流分片:X QPS / N 个存活节点 = 本节点 Y QPS」。代价是负载不均时浪费额度——四台里三台闲着,忙的那台 仍然只能用 1/4。

两条边界要记住:分片只管 per_host_qpsper_host_max_concurrent 仍是每台各算各的; [SERVER].processes > 1 时进程之间没有分片,各进程独立计算。

要精确限流,服务端转发是最省事的形态。

请求压缩

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 合法性 → 控制字符占比。

主判据是 UTF-8 合法性,控制字符占比只是最后一道辅助判据、阈值 2%。反过来(只看 "控制字符占比 10%"这一条)不行:随机字节的期望占比就是 10.9%,正好压在阈值上, 同一份数据会有时判可压、有时判不可压。

浏览器渲染的耗时构成

路径 耗时
热路径(浏览器已起来) 200~300 ms
冷启动(首次拉起浏览器) ~1.5 s
永久性导航错误(非法 URL / 被禁协议等) 0.2 s

这条路径上有四个能把单次点击的耗时放大到几十上百秒的陷阱,当前实现都躲开了—— 列出来是因为自建脚本容易重新踩上:

  1. 浏览器实例没有真正复用 —— 每次使用前用 is_connected() 检查; 死了就丢弃重建,而不是拿着一个死连接反复超时。
  2. 超时预算一刀切 —— 预算按任务算:冷启动额外给 60s 宽限、常规开销给 15s, wait_until = "networkidle" 时再把 settle 那几秒加进去。固定值会让冷启动被误杀、 或者热路径等太久。
  3. 永久性导航错误照样重试 —— ERR_UNSAFE_PORTERR_INVALID_URLERR_UNKNOWN_URL_SCHEMEERR_DISALLOWED_URL_SCHEMEERR_BLOCKED_BY_CLIENT 这类"URL 本身就不该访问"的错误,重试三次还是同样结果,白花 16 秒。 注意 ERR_NAME_NOT_RESOLVEDERR_CONNECTION_REFUSED 不在这个名单里—— DNS 和连接失败可能是暂时的,照常重试。
  4. 适配器缓存键用 browser 这个别名而不是解析后的引擎 —— 那样 adapter="browser"adapter="camoufox" 会各自起一个浏览器、内存翻倍;先把别名解析成实际引擎再做键, 两者才命中同一个实例。

自己能做的优化

[BROWSER]
block_resources = ["image", "media", "font"]   # 默认已开,最有效
wait_until = "networkidle"                     # 默认值;抓得全,最坏多花 settle 秒
max_pages = 4
headless = true
  • block_resources 是收益最大的一项。只要 HTML 的话,图片/字体/媒体全是纯浪费。
  • wait_until 是最影响耗时的一项,默认值是 networkidle。它的经典风险(长连接 页面永远等不到静默)已经兜住了:先按 load 完成导航,再用 [BROWSER.timeout].settle (默认 5 秒)额外等网络空闲,等不到只打一条 warning 并按 load 时的内容返回。所以 WebSocket / SSE / 轮询页面最坏多花 5 秒,不会耗满 page_load。想省掉这几秒就 settle = 0wait_until = "load"——但要知道代价:load 之后才由 JS 填进来的 内容会被静默丢掉,响应仍是 200、也没有任何报错。
  • 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。超了会开始换页, 请求从几秒变成几分钟——看起来完全像卡死,但其实是在换页

不想自己算就写 max_pages = 0:按可用内存推导——单页预算按引擎分(camoufox 400 MB、Chromium 系 250 MB),给系统留 1 GB 余量,再用 CPU 核数和硬上限 16 封顶, 至少 1。容器里优先读 cgroup 限额而不是 /proc/meminfo:后者报的是宿主机内存, 照它推导会在 512 MB 的容器里开十几个页面然后被 OOM killer 杀掉,而现象只是 "容器莫名其妙重启"。

默认是 4 而不是 0,因为自动推导会因机器而异,静默改变既有部署的行为不合适。 ipclick config-info 的"页面上限:"一行会显示实际生效值(自动推导时带 auto 标注)。

磁盘

链路记录一条约 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

两组默认值要分开看,混起来算会差五倍:

SDK 侧默认 timeout=60max_retries=3,最坏约 60 × 4 + (1+2+4) ≈ 247 秒。 客户端 deadline 由 SDK 自己按 timeout × (max_retries+1) + 30s 算好(默认 270 秒), 不用手动留余量——你调大 timeout,deadline 会跟着涨。

服务端侧 [DOWNLOADER].download_timeout = 300 只在请求没有显式传 timeout 时 才生效(自己写 gRPC 客户端、不填 timeout_seconds 就是这一档)。这时最坏约 300 × 4 + 7 = 1207 秒。

集群转发的默认超时就是按这个公式推的(任务 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 约束:批量走一个服务级专用线程池ipclick-batch), 容量同样取 max_workers,所以总并发不会变成 max_workers × 并发批量数 把下游打爆。

批量路径上有两处实现细节值得知道(写出来是因为自己实现批量时容易踩上):

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

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

下一步

Clone this wiki locally