代理池可观测性:指标、日志、告警与一分钟内诊断性能退化
引言:没有指标,问题看起来就是「我们什么都慢」
这个场景是不是很熟悉。工作群里冒出一条消息:「代理太慢了」。五分钟后第二条:「错误还特别多」。接下来就开始了工程师们私下称之为「考古」的过程。有人打开终端,手动用 curl 挨个试三个随机地址。有人盯着跟代理毫无关系的 CPU 负载图看。有人给供应商支持写邮件,语气是「你们好像有问题」,手里却连一个数字都拿不出来。一个小时过去了。终于搞清楚:只是池子里的某一个分片退化了,只在某一个国家,而且不是「现在」开始的,是两天前就开始了,只是没人注意到。
一个团队花一小时处理这种事,另一个团队花一分钟,差别不在经验也不在天赋。差别在于:第二个团队提前决定了要测量什么、日志里写什么、在什么条件上设告警。当你有一张按国家和运营商拆分的请求成功率图表时,「什么都慢」这句话就变成了一个具体论断:「从 14:32 起,B 地区运营商 A 的移动代理 p95 首字节时间从 1.8 秒涨到 6.4 秒,错误率没变,重试率翻了三倍」。有了这样的论断,你就能干活了:重新分配负载、带着数据而不是情绪去给供应商开工单,或者发现根本问题不在代理,而在目标资源。
本指南将从头到尾拆解代理池的可观测性。你会了解到:哪四个信号提供了几乎所有有用信息,每条日志里必须有哪些字段、绝对不能有哪些字段,为什么平均响应时间在撒谎、怎么正确计算分位数,怎么按维度标记数据以便看到单个切片的退化,怎么构建那些很少触发但每次都命中要害的告警,以及怎么在仪表盘上花一分钟区分三种典型问题图景。最后我们会讨论最小技术栈、带数字的真实案例,并回答常见问题。
先划清边界。从池子里选具体 IP、健康检查和故障地址隔离,这里不详细讨论——那有单独的材料。这里只谈两件事:测量和告警。以下所有内容都适用于合法的工程场景:监控公开平台的定价、检查广告在不同地区的展示效果、测试自己服务的地理依赖内容、在遵守资源规则的前提下采集公开数据。
一点基础:代理场景下的可观测性是什么
经典定义说,可观测性(observability)是系统的一种属性,允许你通过外部输出了解其内部状态。翻译成工程语言:你可以向系统提出一个事先没有预设的新问题,不部署新代码就能得到答案。监控回答已知问题(「服务活着吗?」),可观测性回答未知问题(「为什么这个域名通过移动地址的请求偏偏在周二变慢了?」)。
可观测性的三大支柱众所周知:指标(数值时间序列,存储便宜、聚合方便)、日志(带上下文的事件,存储昂贵但包含细节)和追踪(单个请求经过各组件的路径)。对代理池来说情况有点特殊。你的代码和目标网站之间隔着你无法控制的外部基础设施:出口地址、运营商、到目标的链路。你没法在移动基站上装探针。因此你唯一能测量一切的点,就是你自己的客户端——那个通过代理发请求的客户端。每个经过代理池的请求都应该留下两个痕迹:指标的增量,和一行结构化日志。追踪很有用,但对大多数团队来说过于复杂,我们只在 FAQ 里再提它。
为什么这对代理特别重要?因为代理退化几乎从不表现为宕机。服务器不响应——这很简单。代理则是「稍微慢一点」、「偶尔多返回点连接错误」、「有时候下载到一半断掉」。每个症状单独看都低于人的注意阈值。但加起来它们就让数据采集成本翻倍、打乱交付期限。只有测量才能发现缓慢退化。
代理的四个信号:成功率、延迟、重试、流量
可靠性工程实践中有「黄金信号」的概念:延迟、流量、错误和饱和度。对代理池我们把这个思路改造一下,得到四个信号,足以应对 90% 的情况。为什么是四个而不是二十个?因为仪表盘上每多一张图,值班人员看对图的概率就少一分。四个信号刚好能放进一屏,十秒钟读完。
信号 1:请求成功率
请求成功率(success rate)是在时间窗口内得到有用结果的请求数与总请求数之比。关键词是「有用」。对代理来说,成功不只是 HTTP 200。成功是指目标资源返回了你预期的内容,而且响应完整到达了。响应体传到一半断掉——不算成功,即使响应头是 200。目标网站返回 403——也不算成功,但这大概不是代理的锅,我们通过结果分类来区分。
实践规则:把每个请求的结果归入少数几个类别之一。例如:ok(收到预期响应)、proxy_connect_error(无法与代理建立连接)、proxy_auth_error(代理拒绝了认证)、upstream_timeout(代理响应了,但目标资源没及时响应)、upstream_4xx、upstream_5xx、truncated(连接在响应体结束前断掉)。这个分类是后续一切分析的基础。成功率按 ok 与总数之比算,但要单独看各类错误的比例,因为它们指向不同的责任方。
信号 2:按分位数计算的延迟
代理的延迟应该在三个点上测量:与代理建立连接的时间、目标资源首字节响应时间(TTFB,time to first byte)和完整请求时间。第一个说明代理节点本身的可用性。第二个说明从代理到目标的链路质量和目标响应速度。第三个说明通道带宽。如果只有第三个在涨,前两个稳定,问题在带宽而不在可用性。如果第一个在涨,问题在你和代理之间。延迟要按分位数算,不能按平均算,下面有专门一节讲这个。
信号 3:重试率
重试率(retry rate)是最被低估的信号。大多数客户端写成这样:出错时自动通过另一个地址重发请求。从可靠性角度这是对的,从可观测性角度这很糟糕:成功率保持在 99%,但底下每个请求都执行了三次。你付了三倍的流量费,目标资源收到三倍负载,而仪表盘上一片绿。重试率能在隐性退化变成显性退化之前把它挖出来。算法是尝试次数与逻辑请求数之比:1.0 表示没有重试,1.5 表示每两个请求就有一个重试过。
信号 4:流量消耗
流量消耗是直接跟钱挂钩的信号,因为很多计费方式——包括 Proxeon 的计费方式——按传输数据量算。但它也有诊断作用。每个成功请求的字节数突然飙升,说明目标资源开始返回别的页面了:重型的占位页、跳转到其他分区的重定向、改动过的版式。突然骤降,说明你收到的不是完整页面而是简短的错误响应。流量要分方向统计,而且必须按成功请求数归一化:「每个成功请求多少 KB」比「每小时多少 GB」信息量大得多。
代码里长什么样
下面是 Python 版 Prometheus 的最小指标集。注意标签集:池、国家、运营商、代理类型。具体代理地址故意不出现在标签里,原因我们会在讲维度标记那一节解释。
from prometheus_client import Counter, Histogram
LABELS = ["pool", "country", "operator", "proxy_type"]
REQUESTS = Counter("proxy_requests_total", "Логические запросы через пул", LABELS + ["outcome"])
ATTEMPTS = Counter("proxy_attempts_total", "Попытки, включая ретраи", LABELS + ["attempt_kind"])
TTFB = Histogram("proxy_ttfb_seconds", "Время до первого байта", LABELS, buckets=(0.1, 0.25, 0.5, 1, 2, 4, 8, 16, 30))
CONNECT = Histogram("proxy_connect_seconds", "Установление соединения с прокси", LABELS, buckets=(0.05, 0.1, 0.25, 0.5, 1, 2, 5))
BYTES = Counter("proxy_bytes_total", "Байты через пул", LABELS + ["direction"])
def observe(ctx, outcome, ttfb, connect, bytes_in, bytes_out, attempt):
base = dict(pool=ctx.pool, country=ctx.country, operator=ctx.operator, proxy_type=ctx.proxy_type)
kind = "first" if attempt == 1 else "retry"
ATTEMPTS.labels(**base, attempt_kind=kind).inc()
if outcome in ("ok", "upstream_4xx", "upstream_5xx", "truncated", "upstream_timeout"):
TTFB.labels(**base).observe(ttfb)
CONNECT.labels(**base).observe(connect)
BYTES.labels(**base, direction="in").inc(bytes_in)
BYTES.labels(**base, direction="out").inc(bytes_out)
if outcome == "ok" or attempt == ctx.max_attempts:
REQUESTS.labels(**base, outcome=outcome).inc()注意一个细节:逻辑请求计数器只有在成功或耗尽尝试次数时才增加。这样成功率反映的是你代码的用户视角结果,而尝试计数器反映的是池子的实际工作量。两者之差就是重试率。
表格:指标、增长含义、第一步动作
| 指标 | 增长意味着什么 | 第一步动作 |
|---|---|---|
| proxy_connect_error 比例 | 代理节点不可达或过载;问题在你和供应商之间 | 检查自己的出口通道和 DNS,然后按切片对比:如果整个池子都是——从自己这边找原因,如果只是一个切片——带着数据通知供应商 |
| proxy_auth_error 比例 | 访问期限到了、凭据变了、连接数超限 | 去供应商面板检查凭据有效期和限额,不要重试这类请求 |
| upstream_timeout 比例 | 目标资源通过该链路响应慢,或代理来不及 | 按目标主机对比 TTFB:如果一个主机——目标的问题,如果全部——代理链路的问题 |
| upstream_4xx 比例(404 除外) | 目标资源在限流或改了访问规则 | 降低对资源的请求频率,检查是否遵守它的规则,不要增加重试 |
| upstream_5xx 比例 | 目标资源侧的问题 | 等一等,降低负载;代理大概率无关 |
| truncated 比例 | 连接在中间断掉:通道不稳,读超时太短 | 检查读超时和响应大小;按运营商对比 |
| 代理连接时间 p95 | 代理节点可用性退化或你的网络栈有问题 | 检查并发连接数和本地端口耗尽 |
| 连接时间稳定时 TTFB p95 上涨 | 从代理到目标的链路变慢,或目标本身变慢 | 按目标主机和池子切片拆解 |
| TTFB 稳定时完整请求时间 p99 上涨 | 代理通道带宽下降,或响应变大 | 看每请求字节数;如果大小没变——带宽问题 |
| 重试率 | 隐性退化:成功靠重试撑着,成本在涨 | 找到哪类错误触发重试,针对性处理 |
| 每个成功请求的字节数 | 目标资源在返回别的内容:占位页、重定向、改动过的结构 | 抽样检查响应内容,更新解析器和校验 |
| 请求数稳定时总流量上涨 | 重试增加或响应变大;直接打击预算 | 拆成两个因子:尝试次数和每次尝试的字节数 |
每个请求的日志写什么——以及绝对不能写什么
指标回答「发生了什么」,日志回答「具体跟什么有关」。当仪表盘显示「国家 X、运营商 Y」这个切片的错误在涨,你需要搞清是这个切片里所有地址都有问题,还是三十个里有两个。带地址标签的指标会炸掉存储,日志不会,因为日志你不是持续聚合,而是按需过滤。
日志行的必填字段
每次通过代理的请求尝试都应该产生一条结构化记录,最好是 JSON 格式。以下是我们认为最小充分的字段集:
- ts——UTC 时间戳,精确到毫秒。不是本地时间。永远不是。
- request_id——逻辑请求的标识符,该请求的所有尝试共用。可以用一条命令把同一个请求的全部历史串起来。
- attempt 和 max_attempts——尝试序号和上限。没有尝试序号你就分不清这是第一次错误还是第三次。
- proxy_id——代理的内部标识符。不是地址加端口,不是连接字符串。是内部标识符,你可以通过一张单独的安全表把它映射到真实地址。
- proxy_type、country、operator、pool——跟指标里一样的维度。这样你能在仪表盘和日志之间跳转而不用换算。
- target_host——目标的域名。不是完整 URL:查询串里可能有令牌和个人数据。
- method——HTTP 方法。
- status——HTTP 响应码,如果有的话,否则 null。
- outcome——上面说的那个结果分类。
- error_class——异常类名或传输错误的短代码,如果结果不是 ok。
- connect_ms、ttfb_ms、total_ms——三个延迟,毫秒。
- bytes_in、bytes_out——响应体大小和请求大小。
- session_id——如果你用粘性会话,会话标识符;这样你能看到错误是否集中在长会话里。
- retry_reason——对第二次及以后的尝试:上一次尝试的结果。
示例行:
{"ts":"2026-03-14T11:32:07.412Z","request_id":"7f3a9c1e","attempt":2,"max_attempts":3,"retry_reason":"upstream_timeout","proxy_id":"px-2841","proxy_type":"mobile","country":"RU","operator":"op_a","pool":"pricing","target_host":"shop.example","method":"GET","status":200,"outcome":"ok","error_class":null,"connect_ms":184,"ttfb_ms":1420,"total_ms":2310,"bytes_in":48211,"bytes_out":612,"session_id":"s-19b"}这样一行大约 400 字节。每秒 50 个请求的话,一天未压缩约 1.7 GB,压缩后 200–300 MB。即使预算紧张也完全扛得住。
日志里不能有的东西
这里我们从工程转向责任。清单很短,但每条都重要:
- 代理访问凭据。最常见的泄露是 http://用户名:密码@主机:端口 这种连接字符串整条进日志,因为开发者「为了调试」把配置整个打进去了。日志会被复制到工单里、转发给同事、存好几年。里面的凭据就是所有看过这个日志的人都有的凭据。
- Proxy-Authorization 头及任何其他认证头,包括发往目标资源的头。
- 响应体。它很大,可能含第三方个人数据,而且诊断代理几乎用不上。如果你需要了解目标资源返回了什么,保存响应体的哈希和长度,响应本身放进单独存储,设短生命周期,且只按抽样保存。
- 两个方向的 Cookies。
- 带查询参数的完整 URL。主机名就够了,必要时加上不带参数的路径。
- 明文形式的代理真实地址,如果日志进入公共存储。内部标识符就够了。
我们推荐的实践:在每条跟代理相关的记录都要经过的单一函数里实现脱敏。不要指望开发者的自觉。
import re
CRED_IN_URL = re.compile(r"(https?|socks5h?)://[^/@:]+:[^/@]+@")
SENSITIVE_HEADERS = {"proxy-authorization", "authorization", "cookie", "set-cookie"}
def redact(record: dict) -> dict:
for key, value in list(record.items()):
if key.lower() in SENSITIVE_HEADERS:
record[key] = "[redacted]"
elif isinstance(value, str):
record[key] = CRED_IN_URL.sub(lambda m: m.group(1) + "://[redacted]@", value)
record.pop("body", None)
return record采样与存储
需要记录每次尝试吗?错误——是的,全部。成功尝试——可以采样:强度超过每秒一百个请求时,5–10% 的成功记录就足够估计延迟了,因为指标已经包含完整图景。重要的是采样决策要按 request_id 做,而不是按每次尝试随机决定:否则你会拿到第二次尝试却没有第一次。详细日志的保留期通常 7–14 天;聚合和指标活得更久。
用分位数代替平均值:为什么平均在掩盖问题
想象一个 100 个地址的池子。90 个响应 0.8 秒。10 个响应 9 秒,因为撞上了过载的基站。平均响应时间——1.62 秒。一周前一切正常时平均是 1.4。涨了 15%。谁会对此有反应?没人。可是与此同时你每十个请求就有一个等九秒,如果你有八秒超时,每十个请求就有一个报错。
算术平均适合描述接近钟形的分布。通过代理的延迟从来不是钟形。它是带长右尾的分布:大多数请求很快,但有些非常慢,而恰恰这些决定了用户体验和成本。而且通过池子的延迟常常是双峰的:有一个「正常」峰和一个「问题地址」峰。平均落在它们之间——一个几乎没有真实请求的点上。
p50、p95、p99 是什么
分位数是低于该值的观测所占比例达到指定水平的值。p50(中位数)——一半请求快于这个值。p95——95% 更快,5% 更慢。p99——只有百分之一更慢。对代理池我们建议看三个分位数,各有用途:
- p50 显示「典型」请求。p50 上涨意味着大多数都退化了。这是罕见且严重的信号。
- p95——告警用的工作指标。它对问题切片敏感(上面的例子里 p95 直接显示 9 秒),但对单个离群值稳健。
- p99——超时和容量规划的指标。你的超时应该略高于「健康」p99,否则你会切断正常请求并把它们变成重试。
怎么算才准确
数据量小的时候(窗口内几千个值)可以精确算:排序后按索引取值。
def percentile(values, p):
if not values:
return None
ordered = sorted(values)
k = (len(ordered) - 1) * p / 100.0
lo = int(k)
hi = min(lo + 1, len(ordered) - 1)
return ordered[lo] + (ordered[hi] - ordered[lo]) * (k - lo)
ttfb_window = [0.8, 0.9, 0.7, 9.1, 0.85, 8.7, 0.78, 0.92, 0.81, 0.79]
print(percentile(ttfb_window, 50), percentile(ttfb_window, 95))精确计算不具扩展性:要算上百万请求一周的 p95,得存所有值。所以工业系统用近似结构。
大规模计算:直方图
Prometheus 及兼容系统把直方图存成一组分桶计数器:有多少观测小于 0.1 秒、小于 0.25 秒、小于 0.5 秒等等。分位数在桶内用线性插值还原。精度取决于分桶边界选得好不好。代理延迟适合用 100 毫秒到 30 秒的对数刻度:0.1;0.25;0.5;1;2;4;8;16;30。如果你的健康 p95 大约 2 秒、超时 15 秒,这个网格在尾部能给出 20–30% 以内的误差,对告警和诊断足够。
histogram_quantile(0.95,
sum by (le, country, operator) (
rate(proxy_ttfb_seconds_bucket{pool="pricing"}[5m])
)
)注意里面 sum by (le, ...) 这步。这是个关键细节:不同客户端实例的直方图必须先合并,再算分位数。反过来——在每个实例上算 p95 再平均——在数学上毫无意义。分位数的平均值不是分位数。这是最常见的错误之一,它能像普通平均一样可靠地藏住问题。
如果你需要尾部高精度(比如你给内部客户卖延迟 SLA),用 t-digest 或 HDR 直方图之类结构,固定内存下精度可达百分之几以内。对大多数团队来说标准直方图就够。
按维度标记:国家、运营商、代理类型
下面这个场景说明维度为什么必要。你有 400 个地址的池子,5% 是某个国家某个运营商的移动地址。这个切片彻底退化了:成功率掉到 10%。池子整体成功率图会显示什么?从 99% 掉到 94.5%。明显,但不致命。值班人员会想「网络在闹」然后就走了。而这个地区的数据客户会一无所获。
按维度标记(Prometheus 术语叫标签,labels)正是解决这个问题:它让你把聚合拆成切片,看到问题被定位了。但标记有代价,得理解它。
选哪些维度
好的维度有两个属性:取值数量少且有限,按它拆分能回答真实的诊断问题。对代理池我们建议指标里用四个维度:
- proxy_type——移动、住宅、数据中心。不同类型的退化模式和成本都不同。
- country——出口地址所在国家。通常 1 到 30 个值。
- operator——地址出口经过的运营商或自治系统。问题常常恰恰定位在这里:一个过载基站或运营商某一处坏掉的互联。
- pool——逻辑用途:哪个产品或任务用这个切片。可以把事故按业务影响分开。
值得考虑的第五个维度是 target_host,目标主机。它用来区分代理问题和目标问题。但如果你访问几千个不同域名,基数会爆炸。折中方案:标签里只保留按量排前 20 的主机,其余合并成 other。
基数算术
每个唯一的标签值组合就是一个独立时间序列。算一下:3 种类型 × 10 个国家 × 15 个运营商 × 4 个池子 × 7 种结果 = 12600 个序列,仅请求计数器。带 9 个桶的直方图再乘 10。总计约 5–6 万序列。对 Prometheus 来说很舒服。现在想象你加了一个代理地址标签(400 个值)。序列数涨 400 倍——到 2000 万。这就不叫「舒服」了,这是存储的死法。
由此得规则:具体代理地址活在日志里,不在指标里。指标回答「哪个切片退化了」,日志回答「切片内具体哪些地址出了问题」。这种分工既给速度也给细节,而不让成本爆炸。
定位切片的查询
诊断的首要问题:「哪里最差?」按成功率排序切片能回答它:
bottomk(5,
sum by (country, operator, proxy_type) (rate(proxy_requests_total{outcome="ok"}[10m]))
/
sum by (country, operator, proxy_type) (rate(proxy_requests_total[10m]))
)第二个问题:「这是新问题还是一直如此?」对比过去能回答它:
(
sum by (operator) (rate(proxy_requests_total{outcome="ok"}[10m])) / sum by (operator) (rate(proxy_requests_total[10m]))
)
-
(
sum by (operator) (rate(proxy_requests_total{outcome="ok"}[10m] offset 1d)) / sum by (operator) (rate(proxy_requests_total[10m] offset 1d))
)负值显示切片比一天前差了多少。仪表盘上有这样一张图,就能把「哪里有点不对」变成「C 国运营商 B 比昨天低 12 个百分点」。
维度值的来源
国家和代理类型通常从池子配置里就知道。运营商可以从供应商元数据里获取:例如 Proxeon 的面板和 API 里,地址带有地理和运营商信息,你在池子初始化时拉取并缓存在代理内部标识符旁边。不要在请求时通过外部库去判定运营商:那会增加延迟并制造额外依赖。
不吵的告警:阈值、窗口、迟滞
告警系统最糟的事不是漏掉事故。最糟的是值班人员一天收到 40 条通知,其中 39 条毫无意义,然后他不再看第四十条。被忽视的告警比没有告警更糟,因为它制造了控制的幻觉。
原则 1:告警针对症状,不针对原因
别在「代理 px-2841 返回了错误」上设告警。单个地址有权生病;那是池子内部隔离机制的事,这里不讨论。把告警设在数据消费者能感受到的东西上:池子成功率低于目标、p95 高于目标、重试高于目标。如果维度标记做对了,原因你能在仪表盘上一分钟找到。告警的任务是叫醒你,不是解释。
原则 2:最小流量条件
五分钟里两个请求,成功率 50%——那是一个失败请求,不是事故。任何基于比例的规则都应该包含窗口内最小请求数条件。否则夜里流量下降时你会被误报淹没。
原则 3:窗口和错误预算消耗速率
如果你定了目标可靠性水平(比如月度 98% 成功请求),你就有错误预算:2% 的请求。好的告警对预算消耗速率做出反应,而不是对瞬时值。可靠性工程里已成为标准的做法是多窗口告警:短窗口(5 分钟)抓骤降,长窗口(1 小时)抓缓慢退化,两者必须同时超限,以排除短暂尖峰。
原则 4:迟滞
迟滞是触发和解除告警用不同阈值。如果成功率低于 95% 触发、回到 95% 以上解除,那么围绕 95% 波动的指标会每几分钟产生一串「触发—解除—触发」通知。解法:95% 触发,97% 解除。或者更简单,要求条件持续满足设定时长(Prometheus 里是 for 关键字),并给解除也设延迟。
原则 5:严重级别和路由
不是所有事都需要打电话。把告警分成两三级:严重(当前影响数据消费者——电话或推送值班)、警告(切片退化、重试上升、流量异常——发频道,工作时间处理)、信息(某个实例数据缺失、接近流量上限——每日汇总)。
规则集示例
groups:
- name: proxy-pool
rules:
- record: pool:success_ratio:5m
expr: sum by (pool) (rate(proxy_requests_total{outcome="ok"}[5m])) / sum by (pool) (rate(proxy_requests_total[5m]))
- record: pool:success_ratio:1h
expr: sum by (pool) (rate(proxy_requests_total{outcome="ok"}[1h])) / sum by (pool) (rate(proxy_requests_total[1h]))
- record: pool:request_rate:5m
expr: sum by (pool) (rate(proxy_requests_total[5m]))
- alert: ProxyPoolSuccessLow
expr: pool:success_ratio:5m < 0.95 and pool:success_ratio:1h < 0.97 and pool:request_rate:5m > 0.5
for: 5m
labels: { severity: critical }
annotations:
summary: "Доля успеха по пулу {{ $labels.pool }} ниже цели"
- alert: ProxyPoolLatencyHigh
expr: histogram_quantile(0.95, sum by (le, pool) (rate(proxy_ttfb_seconds_bucket[10m]))) > 4 and pool:request_rate:5m > 0.5
for: 10m
labels: { severity: warning }
- alert: ProxyPoolRetryRateHigh
expr: sum by (pool) (rate(proxy_attempts_total[15m])) / sum by (pool) (rate(proxy_requests_total[15m])) > 1.4
for: 15m
labels: { severity: warning }
- alert: ProxyPoolSliceDegraded
expr: (sum by (pool, country, operator) (rate(proxy_requests_total{outcome="ok"}[15m])) / sum by (pool, country, operator) (rate(proxy_requests_total[15m]))) < 0.85 and sum by (pool, country, operator) (rate(proxy_requests_total[15m])) > 0.2
for: 15m
labels: { severity: warning }
- alert: ProxyPoolNoData
expr: absent(proxy_requests_total) or pool:request_rate:5m == 0
for: 10m
labels: { severity: warning }注意最后一条规则。数据缺失是最阴险的故障:客户端挂了、没有指标、图表平坦发绿、大家都安心。对沉默的告警是必须的。
每条新告警的检查清单
- 注释里写了接收者该怎么做吗?没写——规则没准备好。
- 有最小流量条件吗?
- 有 for 延迟或迟滞吗?
- 规则在最近一个月历史上验证过吗:会触发多少次、其中多少次是真事故?
- 清楚它属于哪个严重级别、路由到哪里吗?
- 有反向流程吗:如果告警触发结果是误报,谁什么时候复审它?
仪表盘快速诊断:三种典型图景
仪表盘不是图表画廊,而是决策工具。好的代理池仪表盘像漏斗。第一行:全池四个信号过去 6 小时,叠加昨天同期的细线。第二行:同样信号按国家、运营商、类型拆分——表格或热力图,按最差值排序。第三行:按前 10 目标主机的成功率和 TTFB。第四行:连接时间、TTFB 和完整时间的比例关系。再加上部署和池子配置变更的垂直标记。
有了这样的仪表盘,诊断就归结为回答三个问题:「是整个池子退化了还是某个切片?」「是所有目标退化了还是一个?」「三个延迟里哪个涨了?」答案的组合给出三种典型图景。
图景 1:所有切片同时退化
所有国家、所有运营商、所有代理类型同步显示错误或延迟上升,精确到分钟。外部供应商和运营商不会这样坏:他们没有你这一侧的共同故障点。所以问题在你这边:你的出口通道、你的 DNS、文件描述符或本地端口耗尽、最近改过超时或连接池大小的部署、同机器上另一个进程抢 CPU。第一步:看部署标记和代理连接时间。如果连接时间涨了而 TTFB(减去连接后)稳定——问题在你和代理节点之间,几乎肯定是你这边或你的网络提供商。
图景 2:一个切片退化了
某一个国家的某一个运营商,或某一种代理类型,显示下降,其他切片稳定。这是最常见的图景。原因在运营商侧(网段拥塞、计划维护)或代理供应商的某个基础设施切片。第一步:临时降低这个切片在负载分配中的权重(机制不在本文范围),并给供应商支持发消息,带上具体数据:切片、开始时间、前后指标、request_id 示例。Proxeon 支持对这类请求响应更快,不是因为别的,就是因为拿到了现成的定位。第二步:按切片看日志,判断是里面所有地址都退化了还是几个。
图景 3:某个目标主机成功率掉了,延迟正常
池子所有切片访问所有主机都还好,除了一个。对这个主机的 upstream_4xx 比例上升,或每请求字节数变了。代理在这儿完全无辜。目标资源改了行为:加了频率限制、改了页面结构、开始对部分请求返回不同内容。第一步:降低对这个资源的请求强度,检查你的采集是否符合它的使用规则。第二步:抽样检查内容,更新响应校验。这种情况下绝对不要增加重试次数或更频繁换地址——那只会加重资源负担和你的流量成本。
图景 4(补充):重试上升,成功坚挺
成功率 99%,延迟可忍,但重试率从 1.1 涨到 1.8。这是隐性退化。它现在就在花你的钱,一旦尝试次数耗尽就会变成显性退化。第一步:看日志里的 retry_reason 字段,搞清是哪类结果导致重试,然后按前三种图景之一处理。
60 秒诊断检查清单
- 0–10 秒:仪表盘第一行。四个信号里哪个偏离了昨天的线?什么时候开始的?
- 10–25 秒:切片表格。一个切片红还是全部?如果全部——图景 1,去看部署标记和自己的基础设施。
- 25–40 秒:目标主机表格。一个主机还是全部?如果一个——图景 3,跟代理无关。
- 40–50 秒:延迟比例。连接、TTFB 还是完整时间涨了?
- 50–60 秒:说出一句话:「从[时间]起,在[切片]里对[目标]的[指标]从[原来]涨到[现在],推测原因是[假设]」。这句话发到事故频道和给供应商的工单里。
会让可观测性白费的典型错误
我们见过几十次围绕代理的监控落地。错误重复得惊人。
- 把平均响应时间当主指标。已经讲过:平均藏住尾部,而尾部才是问题本身。
- 在实例间平均分位数。合并直方图,别合并算好的 p95。
- 把代理地址当指标标签。基数爆炸,一个月后存储垮掉。
- 日志里有访问凭据。一行「for debug」——密码就在公共日志存储里躺一年。
- 把重试算成成功。只统计最终结果的话,你永远看不到池子工作实际强度是需要的三倍。
- 把目标资源的 429 和 403 记成代理错误。这是分类错误,会把调查引向错误方向并损害跟供应商的关系。
- 一个延迟而不是三个。不分开测连接、TTFB 和完整时间,你就分不清可用性问题和带宽问题。
- 没有最小流量的告警。夜间噪音和告警信任流失。
- 每个单独地址都告警。那是隔离机制的任务,不是通知人的。
- 没有数据缺失告警。客户端挂了——图表还绿着。
- 日志里用本地时间。跟不同时区的供应商和同事数据对齐变成折磨。
- 没有部署标记的仪表盘。一半事故跟代码或配置变更重合。没有标记你会去外面找原因,而原因在里面。
- 永久保留详细日志。又贵又没意义:两周后细节不需要了,聚合已经在指标里。
不复杂化的最小技术栈
可观测性很容易变成为了项目而项目。三个人的团队部署分布式消息队列、列式数据库集群和追踪系统——为了一个每秒 20 个请求的客户端。别这样。技术栈应该匹配规模。
级别 1:单服务,每秒 50 请求以内
结构化 JSON 日志写文件或标准输出,加一个每分钟聚合一次并把四个信号写进简单数据库甚至同一个日志的脚本就够了。仪表盘——任何能按表格画图的工具。告警——cron 脚本比较聚合值和阈值并给消息应用发消息。这样的栈一天能搭起来,覆盖 80% 的需求。
级别 2:多服务,每秒 50–1000 请求
标准组合:Prometheus 做指标、Grafana 做仪表盘、Alertmanager 做告警路由、Loki 或类似存储做带标签索引的日志。客户端通过内置 HTTP 端点导出指标,把 JSON 日志写到标准输出;agent 采集。记录规则(recording rules)预先算好成功率,让仪表盘瞬开。这就是上面例子描述的配置,覆盖绝大多数团队。
级别 3:几十个服务,每秒数千请求
这里才值得考虑 OpenTelemetry 作为统一采集层、支持对数十亿行做任意分析查询的列式日志存储,可能还有用于复杂链路的追踪。但即使在这个级别,四个信号和日志结构也不变。变的只是传输和存储。
供应商数据作为补充
代理供应商看到自己那一侧:每个端口的流量消耗、活跃连接数、上次轮换时间。Proxeon 的面板和 API 提供这些统计,把它们跟自己的指标并排拉取很有用——至少用来核对流量:如果你的字节计数器和供应商的计数差超过 10–15%,你的测量有问题(最常见的是没算头部、TLS 握手或断掉的响应)。但供应商数据不能替代你自己的:只有你知道从任务角度看哪个请求是成功的。
客户端封装作为唯一测量点
不管什么级别的栈,所有测量都应该经过 HTTP 客户端上的同一个封装。不允许「这里测了那里忘了」。示意代码:
import time, json, sys, requests
def fetch_via_proxy(ctx, url, timeout=(5, 20)):
t0 = time.perf_counter()
outcome, status, err = "ok", None, None
ttfb = connect = total = 0.0
bytes_in = 0
try:
session = ctx.session # уже настроена на прокси по внутреннему proxy_id
resp = session.get(url, timeout=timeout, stream=True)
ttfb = time.perf_counter() - t0
status = resp.status_code
body = resp.content
bytes_in = len(body)
total = time.perf_counter() - t0
if status >= 500: outcome = "upstream_5xx"
elif status >= 400: outcome = "upstream_4xx"
elif not ctx.validate(body): outcome = "invalid_content"
except requests.exceptions.ProxyError as e:
outcome, err = "proxy_connect_error", type(e).__name__
except requests.exceptions.ReadTimeout as e:
outcome, err = "upstream_timeout", type(e).__name__
except requests.exceptions.ChunkedEncodingError as e:
outcome, err = "truncated", type(e).__name__
finally:
total = total or (time.perf_counter() - t0)
observe(ctx, outcome, ttfb, connect, bytes_in, len(url), ctx.attempt)
record = redact({"ts": ctx.now_utc(), "request_id": ctx.request_id, "attempt": ctx.attempt,
"proxy_id": ctx.proxy_id, "proxy_type": ctx.proxy_type, "country": ctx.country,
"operator": ctx.operator, "pool": ctx.pool, "target_host": ctx.host(url),
"status": status, "outcome": outcome, "error_class": err,
"ttfb_ms": int(ttfb * 1000), "total_ms": int(total * 1000), "bytes_in": bytes_in})
sys.stdout.write(json.dumps(record, ensure_ascii=False) + chr(10))
return outcome, status注意 invalid_content 这个结果:HTTP 200 但没通过你的校验的响应(比如缺少预期数据块的页面)——不算成功。恰恰是这类响应最常掩盖目标资源的行为变化。
案例:可观测性的实战样子
案例 1:一个运营商、一个国家,每周少两小时停机
一个做公开交易平台价格监控的团队,通过约 300 个移动和住宅地址的池子工作,强度每秒约 40 请求。在引入运营商维度标记之前,他们只看到一件事:整体 p95 TTFB 时不时从 1.6 秒跳到 3–4 秒,持续几个小时,成功率掉 3–5 个百分点。每次这种事件的排查要一到两小时,多数以「自己好了」收场。加上 country 和 operator 标签后,第一次事件两分钟就定位了:某个国家某个运营商的移动地址,那里 p95 是 11 秒,正常是 2 秒。其余 85% 的池子完全没问题。团队降低了切片在分配中的权重,给供应商发了带时间范围和指标的报表,几小时后收到确认——运营商计划维护。一个季度的结果:诊断时间从 90 分钟降到 5 分钟,事件数量没变,但对整体 p95 的影响变得不可察觉。
案例 2:隐性重试和 +38% 的流量账单
另一个团队发现一个月流量涨了 38%,而任务数不变、成功率稳定在 98.5%。第一假设是目标页面变重了。每成功请求字节数指标否定了它:46 KB,跟以前一样。但之前没人看的重试率从 1.08 涨到 1.52。翻日志里的 retry_reason 字段发现:几乎所有重试都是某个目标主机的 upstream_timeout 导致的,那个主机响应变慢了,但第二次尝试就能在超时内完成。代理根本不是原因。团队把那个主机的读超时从 8 秒提到 15 秒(健康 p99 是 11 秒),降低了对它的请求强度,三天内流量恢复到原来水平。教训:重试率是关于钱的指标。
案例 3:从每周 200 条通知到六条
第三个团队继承了一套告警系统,规则是「任何切片 1 分钟成功率低于 90%」,没有流量限制。夜里有些切片每分钟只过两三个请求,规则触发几十次。值班人员关掉了声音。一次真实事故——整个一段可用性中断四小时——早上才因为投诉被发现。重写规则用了一天:加了最小流量条件,引入两个窗口(5 分钟和 1 小时),加了 10 分钟 for 延迟,切片降到 warning 级别,池子级别只留一个 critical。结果:每周约六条通知,其中四五条需要实际动作。值班人员又把声音打开了。
FAQ:代理池可观测性常见问题
有指标和日志还需要追踪吗?
对大多数团队——不需要。通过代理的请求不经过十个微服务;它只有一个跳转。这里追踪的角色由日志里的 request_id 扮演:通过它把同一个请求的所有尝试串起来。当代理客户端只是长链条(任务队列、解析器、校验器、入库)的一环而你需要在其中定位时间损失时,追踪才有用。
如果 HTTP 客户端不直接给 TTFB 怎么测?
大多数客户端在要求流式读取(stream=True 或类似)时,收到头部就返回控制权。返回的时刻就是 TTFB 的近似。连接时间在传输层测更准:很多库有连接事件的钩子,有些直接提供各阶段信息。如果没有钩子,可以用单独的低层测量测第一个请求字节发出前的时间,但实际用途上流式读取的近似就够了。
怎么区分代理错误和目标资源错误?
按发生位置。如果跟代理的连接没建立,或代理返回了自己的代码(比如认证问题时的 407,或网关特有响应),那是代理错误。如果代理接受了请求、之后你收到目标服务器的代码或等待响应的超时,那是代理到目标之间或目标本身的错误。就在这条边界上分类,然后按切片对比:目标错误会同时出现在所有切片,代理错误只在特定切片。
健康的重试率是多少?
取决于目标和代理类型。移动地址 1.05–1.15 可以算正常:无线网络天生不那么可预测。数据中心 1.01–1.05。绝对值的稳定性比绝对值本身更重要:相对周基线涨 30% 就该查,即使绝对值看起来还谦虚。
没有历史怎么选告警阈值?
先观察不告警:收集两周数据,看成功率和 p95 按天按小时的分布。critical 阈值要设得让这两周最多触发一两次,而且两次你都同意那是事故。warning 阈值——每周触发几次。然后每月复审:从没导致动作的告警,要么收紧要么删除。
至少为错误保存响应体值得吗?
传输错误没有体。对非预期内容的响应(invalid_content),可以保存前几百字节或哈希加长度,放进单独存储,生命周期一两天,必须清理个人数据。完整响应体不用存:又重又可能含第三方数据,而对诊断代理来说,搞清来了什么类型的页面几乎总是够了。
怎么跟供应商核对流量统计?
两个方向都算字节,包括头部,把按端口或会话的日总量跟供应商面板显示的对比。5–10% 差异正常:你没算 TLS 开销和断掉的连接。超过 20% 的差异——查查是不是只算了响应体、是不是漏了读体前就异常的尝试。
从零落地这套方法要多久?
对已有 HTTP 封装的单客户端:一天做指标和日志结构,一天仪表盘,一天告警规则,再加两周观察后再开 critical 告警。如果多个客户端用不同语言,主要时间花在统一结果和标签集上——这里别急,因为积累一年历史后改标签名非常痛。
如果部分地址没有供应商的运营商数据怎么办?
设 unknown 值并盯住它的占比。如果 unknown 超过池子的 10–15%,你的运营商切片就没意义了——向供应商要完整元数据,或在加入池子时手动标记一次。
结论:一分钟代替一小时
回到开头的场景。「代理太慢」——这句话不含任何信息。本文所有内容服务于一个目标:把它变成一句带数字、带切片、带时间和假设的话。四个信号——成功率、分位数延迟、重试率、流量消耗——给出池子状态全貌。带请求 ID、代理 ID、尝试序号、状态码、三个延迟和大小的结构化日志——提供排查细节。分位数代替平均——显示尾部,真实问题就在那里。按国家、运营商和类型标记——把退化定位到切片。带最小流量、双窗口和迟滞的告警——很少触发但每次都命中。漏斗式仪表盘——一分钟回答三个问题。
明天从哪开始?第一步:客户端封装加统一结果分类和单一记录函数。第二步:TTFB 直方图和带国家、运营商、类型标签的尝试计数器。第三步:一条所有日志毫无例外都要经过的脱敏函数。第四步:四张图加一张切片表的仪表盘。第五步:观察两周,然后才上告警。一个月后你看着重试率曲线,很可能会惊讶:池子工作强度比你想象的明显更高。一个季度后,「我们什么都慢」这句话会从你的聊天里消失——不是因为问题没了,而是因为每个问题你都会叫得出名字。