
结论先行:测速最常见的错误,是把一次"感觉很快"的单点数字当成整个池子的能力边界。 有效的测速要同时做三件事------口径统一(三个耗时指标分开记)、百分位报表(P50/P95/P99 而不是均值)、基线对比(和首周基线比漂移)。本文给出一套可直接跑的采样脚本与分级标准。
一、三个让测速结论失效的陷阱

把峰值当成持续能力。 单轮请求恰好撞上空闲出口,跑出 180ms 的好成绩;连续 30 轮里却有 12 轮超过 1.2 秒。拿峰值做容量规划,等于按晴天的高速公路通行量去设计救护车调度。
没有对照基线。 测出 P95=780ms 之后,团队里没人说得清这是"正常"还是"已经劣化"。数字本身不构成结论,只有和基线比出来的差异才是结论。
口径混着用。 连接建立、首字节、完整下载三个指标被揉成一个"快不快"。这三个数字的故障含义完全不同:连接建立慢指向路径或端口,首字节慢指向上游转发或排队,下载慢指向出口带宽被挤占。混着看,就像把三个科室的化验单装订成一本,然后说"他身体还行"。
二、三个指标分开记
| 指标 | 计量区间 | 反映什么 | 常被误当成 |
|---|---|---|---|
| 连接建立 | TCP 握手 + TLS 完成 | 路径可达性、端口质量 | 网络整体快慢 |
| 首字节 | 发出请求 → 收到首个字节 | 上游转发效率与排队深度 | 出口带宽 |
| 下载耗时 | 首字节 → 定长文件收完 | 出口可用带宽 | 响应灵敏程度 |
三个指标同时记录之后,故障定位从"这个 IP 慢"变成"这个 IP 的握手正常、首字节 1.4 秒"------后者直接指向服务商侧的上游排队,沟通成本差一个量级。
三、采样规范与百分位报表
单 IP 至少 30 轮,前 2 轮丢弃。 冷启动那两轮要付 DNS 解析与 TLS 会话建立的成本,和稳态不是一回事。把冷启动混进报表,会让"新建连接的代价"污染"稳态能力"的结论。
单 IP 并发压到 8 以内。 有人为了快,给单个出口开 50 并发,测出来的其实是他自己制造的排队。测速的前提是不干扰被测对象。
报 P50/P95/P99,不报均值。 30 轮里只要有 3 轮超时,均值照样体现在"还行"的区间,P99 早就爆了。容量规划和超时配置要看的都是尾部,不是中点。
目标站选稳定的中立站点,用定长文件。 别拿某个首页 HTML 当目标------体积每次都不一样,吞吐数据没法横向比。100KB 定长文件测建立开销占比,5MB 定长文件测持续带宽。
四、三档分级:S / A / B 的判定线
| 等级 | P95 首字节 | 适配的任务形态 |
|---|---|---|
| S | < 300ms | 实时会话、需要即时返回的交互类任务 |
| A | 300ms -- 800ms | 高频接口调用、页面抓取 |
| B | 800ms -- 2s | 成批采集、离线归档 |
| 复核 | > 2s | 出池复检,不进生产调度 |
分级线不是行业标准,是从任务的超时预算倒推出来的:任务总超时 3 秒,代理环节最多占三分之一即 1 秒;若一次任务串两次出口,单跳就得压到 500ms 以内。
一个真实的反例:某团队把 S 级当默认门槛,1200 个 IP 里只筛出 90 个够格,可用率 7.5%,天天喊池子不够。按任务实际预算重算后,A 级同样能承接他们的抓取任务,可用率回到 61%------池子没变,是门槛定错了。
五、吞吐能力的两种测法
单连与并发各测一遍。 并发 1 / 5 / 20 / 50 四档,画出总吞吐曲线找拐点:并发往上加而总吞吐不再涨,说明这个出口已经饱和,再加并发只是把排队时间拉长。
小文件与大文件分开测。 同样 P95 首字节 400ms 的两个 IP,5MB 下载可能差三倍------首字节相近只能说明"门开得快",不代表"水管粗"。做文件同步、镜像拉取的场景,必须看大文件吞吐。
单位统一成 KB/s。 报告里 KB/s 与 Mbps 混用是高频事故,8 倍换算错误足够让一份验收报告彻底失真。
六、基线漂移与告警阈值
首周数据落成基线(每个 IP 一条 ttfb_p95),之后每天算漂移率 = 当日 P95 ÷ 基线 P95 − 1:
- 漂移 > 20%:进观察名单,连续两天不回落则降级一档
- 漂移 > 30% 且连续三天:出池替换,不等它彽底失效
- 整池过半 IP 同时漂移:先查目标站与本地出口,这不是代理侧的问题------这一步能省掉一整轮误判和一次无效的换供应商
下面的脚本把采样、百分位与基线对比串在一起,直接输出漂移率与处置建议。
python
# speed_test.py ------ 并发采样 · 百分位报表 · 基线漂移判定
import json
import time
import urllib.request
import concurrent.futures
from datetime import date
TARGET = "https://httpbin.org/bytes/102400" # 中立目标,100KB 定长
ROUNDS, CONCURRENCY, WARMUP = 30, 8, 2 # 30 轮 / 单IP并发 8 / 丢前 2 轮
def one_round(proxy_url: str) -> dict:
handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
opener = urllib.request.build_opener(handler)
t0 = time.monotonic()
with opener.open(TARGET, timeout=15) as resp:
first = resp.read(1) # 首字节时刻
t1 = time.monotonic()
rest = resp.read()
t2 = time.monotonic()
return {"ttfb_ms": round((t1 - t0) * 1000),
"total_ms": round((t2 - t0) * 1000),
"kbps": round(len(first + rest) / max(t2 - t1, 1e-6) / 1024)}
def pct(vals: list[int], p: float) -> int:
"""最近秩百分位:小样本下比插值更贴近真实观测值。"""
if not vals:
return 0
s = sorted(vals)
return s[min(int(len(s) * p), len(s) - 1)]
def sample(entry: dict) -> dict:
proxy_url = "http://{user}:{passwd}@{host}:{port}".format(**entry)
rows = []
with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
futs = [pool.submit(one_round, proxy_url) for _ in range(ROUNDS)]
for f in concurrent.futures.as_completed(futs):
try:
rows.append(f.result())
except Exception:
rows.append({"ttfb_ms": 15000, "total_ms": 15000, "kbps": 0}) # 超时按惩罚值计
ttfb = [r["ttfb_ms"] for r in rows][WARMUP:]
total = [r["total_ms"] for r in rows][WARMUP:]
return {"host": entry["host"], "port": entry["port"], "n": len(ttfb),
"ttfb_p50": pct(ttfb, .50), "ttfb_p95": pct(ttfb, .95), "ttfb_p99": pct(ttfb, .99),
"total_p95": pct(total, .95), "kbps_p50": pct([r["kbps"] for r in rows], .50)}
def grade(p95: int) -> str:
return "S" if p95 < 300 else "A" if p95 < 800 else "B" if p95 < 2000 else "复核"
if __name__ == "__main__":
entries = json.load(open("pool_entries.json", encoding="utf-8"))
base = json.load(open("baseline.json", encoding="utf-8")) # 首周基线
out = []
for e in entries:
r = sample(e)
key = f'{e["host"]}:{e["port"]}'
b = base.get(key, {}).get("ttfb_p95")
r["grade"] = grade(r["ttfb_p95"])
r["drift"] = round(r["ttfb_p95"] / b - 1, 3) if b else None
d = r["drift"] or 0
r["action"] = "出池候选" if d > 0.30 else "观察" if d > 0.20 else "正常"
out.append(r)
json.dump(out, open(f'speed_{date.today().isoformat()}.json', "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
for r in sorted(out, key=lambda x: x["ttfb_p95"], reverse=True)[:10]:
print(f'{r["host"]}:{r["port"]} P95={r["ttfb_p95"]}ms 等级={r["grade"]} '
f'漂移={r["drift"]} → {r["action"]}')
脚本有两个刻意的设计:超时按 15000ms 计入样本而不是直接丢弃,否则"最差的那部分"会被静默剔除;百分位用最近秩而非插值,30 轮这种小样本下插值会算出从未观测到的漂亮数字。
常见问题
Q:测速会不会影响正常业务? 会占用出口容量,所以安排在业务低峰跑,单 IP 并发控制在 8 以内。测速的本质是抽样体检,不是压力测试------想测极限承载,那是另一套方法。
Q:一个 IP 测 30 轮要多久? 单轮 0.5--1 秒,30 轮约半分钟;1000 个 IP 分片跑,单机一晚足够跑完并出报表。
Q:为什么不能用均值? 均值会被长尾稀释。30 轮里 3 轮超时,均值仍然漂亮,但 P99 已经说明这个出口在关键时刻会掉链子。看尾部才能配置超时和重试。
Q:首字节和下载耗时哪个更值得看? 看任务形态。页面抓取、接口调用看首字节;文件同步、镜像拉取看大文件吞吐。两个指标都低的 IP 才适合混用。
Q:基线多久重建一次? 不轻易重建。基线是参照物,跟着现状改就失去意义;只有机房迁移、线路变更这类结构性变化之后,才重建基线并标注变更日期。
本文部分内容包含 AI 辅助创作。
测速不是为了给池子发一张成绩单,而是为了在业务报错之前,先把尾部那批 IP 挑出来。