-
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 条的倍数是准入余量,显式写
max_concurrent_rpcs时别贴着max_workers写:准入 上限一低,客户端并发一过这个数就会收到RST_STREAM(REFUSED_STREAM),SDK 按UNAVAILABLE重试两次仍被拒——而这时服务端 CPU 可能还远没跑满,拒绝纯粹来自准入闸门。 写成小于max_workers的值会直接启动失败(那样线程池永远喂不满)。
调它之前要知道的:按 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 之类的中间件——限流状态就在那一个进程里,不需要外部存储。
这句承诺靠的是入口的限流闸门对转发出去的请求同样生效:本地执行的那一支和转发出去的
那一支过的是入口节点上同一个限流器,共用一份配额。所以配 per_host_qps = 10 部三台,
目标站点挨的就是 10 QPS 而不是 30。转发模式下不做配额分片(下游节点各自按完整配额
算),敢这么做靠的正是入口这一道闸——出得去的流量已经被压在配额内了。
客户端分发模式(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 合法性 → 控制字符占比。
主判据是 UTF-8 合法性,控制字符占比只是最后一道辅助判据、阈值 2%。反过来(只看 "控制字符占比 10%"这一条)不行:随机字节的期望占比就是 10.9%,正好压在阈值上, 同一份数据会有时判可压、有时判不可压。
| 路径 | 耗时 |
|---|---|
| 热路径(浏览器已起来) | 200~300 ms |
| 冷启动(首次拉起浏览器) | ~1.5 s |
| 永久性导航错误(非法 URL / 被禁协议等) | 0.2 s |
这条路径上有四个能把单次点击的耗时放大到几十上百秒的陷阱,当前实现都躲开了—— 列出来是因为自建脚本容易重新踩上:
-
浏览器实例没有真正复用 —— 每次使用前用
is_connected()检查; 死了就丢弃重建,而不是拿着一个死连接反复超时。 -
超时预算一刀切 —— 预算按任务算:冷启动额外给 60s 宽限、常规开销给 15s,
wait_until = "networkidle"时再把 settle 那几秒加进去。固定值会让冷启动被误杀、 或者热路径等太久。 -
永久性导航错误照样重试 ——
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)是白付的开销——批量本来就是"高频、每次很多任务"的用法。
「边收边发」:不等客户端把所有任务推完就开始返回结果,否则一个超长的批次 会让首个结果迟迟不到。