-
Notifications
You must be signed in to change notification settings - Fork 0
Performance
一请求一线程,阻塞 IO。 [SERVER].max_workers(默认 100)同时决定三件事:
- 能同时处理多少个请求
-
SendBatch的并发度上限 —— 批量走的是一个服务级的专用线程池(线程名ipclick-batch),容量同样取max_workers,所以总并发仍被这一个数字圈住, 不会变成max_workers × 并发批量数把下游打爆 - gRPC 的准入上限
maximum_concurrent_rpcs默认取它的 8 倍(默认配置下 100 × 8 = 800),超出的请求排队而不是无限堆积;对应配置项是[SERVER].max_concurrent_rpcs = 0(0 = 派生,也可以显式写一个值)
第 3 条在 0.5.0 之前写死成 ×2,于是默认配置下客户端并发一过 200 就会收到
RST_STREAM(REFUSED_STREAM),SDK 按UNAVAILABLE重试两次仍被拒——实测 500 并发 成功率只有 68.7%,而那时服务端 CPU 才用了 1.45 个核。看到旧文档写"两倍"的话, 那是被修掉的版本。
调它之前要知道的:按 host 限流的等待也占着线程。per_host_max_concurrent 设成 2、
同时有 200 个请求打向同一个 host 时,198 个线程在排队——其他 host 的请求会跟着饿死。
两者要一起调。
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,实验性、默认关)解决的是另一个维度:它换掉并发
模型,多进程换掉的是核数上限。两者不是二选一——单进程再怎么优化也只能用一个核。
两道闸门,可以单独用也可以叠加。
[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_ttl 和 max_tracked_hosts 是必需的:爬虫会碰到无穷多域名,
不回收就是内存泄漏。
[DOWNLOADER.rate_limit]
per_host_qps = 0 # 0 = 不限制;可以是小数,0.5 = 两秒一个
per_host_burst = 0 # 桶容量;0 = ceil(per_host_qps),即最多攒一秒的量两者的区别:并发闸门管同时有几个在飞,速率闸门管每秒发起几个。 对方限的是 QPS 就用后者,对方扛不住并发就用前者。
限额只在本进程内计算。
服务端转发模式下任务统一从入口节点进来,在那一台上算就是全局的——所以不需要 Redis 之类的中间件——限流状态就在那一个进程里,不需要外部存储。
⚠️ 含 2.0.0 在内的已发布版本并没有做到这一点。 入口节点只对本地执行的请求过闸, 转发出去的那一支完全不过;而开了转发又不做分片,于是每个下游节点各按完整配额放行—— 配per_host_qps = 10部三台,目标站点实际挨 30。这个偏差从任何单台机器的视角看都 完全正常,极难自查。2.0.0 之后的修复让入口对转发流量也过闸,上面这句承诺才真正成立。
客户端分发模式(forward = "off")下,QPS 会按存活节点数自动均分:份额随健康探测
变化,一台挂了幸存者立刻分到更多,不需要协调者,日志里能看到「集群限流分片:X QPS /
N 个存活节点 = 本节点 Y QPS」。代价是负载不均时浪费额度——四台里三台闲着,忙的那台
仍然只能用 1/4。
两条边界要记住:分片只管 per_host_qps,per_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 合法性 → 控制字符占比。
早期版本只用"控制字符占比 10%"这一条判定,而随机字节的期望占比是 10.9%—— 正好压在阈值上,同一份数据有时判可压有时判不可压。改成以 UTF-8 合法性为主判据、 阈值降到 2% 之后稳定了(500 次重复无一误判)。
| 路径 | 耗时 |
|---|---|
| 热路径(浏览器已起来) | 200~300 ms |
| 冷启动(首次拉起浏览器) | ~1.5 s |
| 永久性导航错误(非法 URL / 被禁协议等) | 0.2 s |
这条路径曾经实测有单次点击耗时 296 秒的情况,根因是九处互相叠加的放大器。 其中影响最大的四个(现在都已处理,列出来是因为自建脚本容易重新踩上):
-
浏览器实例没有真正复用 —— 每次使用前用
is_connected()检查; 死了就丢弃重建,而不是拿着一个死连接反复超时。 - 超时预算一刀切 —— 现在按任务算:冷启动额外给 60s 宽限、常规开销给 15s。 固定值会让冷启动被误杀、或者热路径等太久。
-
永久性导航错误照样重试 ——
ERR_UNSAFE_PORT、ERR_INVALID_URL、ERR_UNKNOWN_URL_SCHEME、ERR_DISALLOWED_URL_SCHEME、ERR_BLOCKED_BY_CLIENT这类"URL 本身就不该访问"的错误,重试三次还是同样结果,白花 16 秒。 注意ERR_NAME_NOT_RESOLVED、ERR_CONNECTION_REFUSED不在这个名单里—— DNS 和连接失败可能是暂时的,照常重试。 -
缓存键用的是
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 = 0或wait_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=60、max_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=True 或 d.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)。批量本来就是"高频、每次很多任务"的用法,这份创建开销是白付的。
「边收边发」的行为不变:不等客户端把所有任务推完就开始返回结果,否则一个超长的批次 会让首个结果迟迟不到。