如果你也在维护一个工具运行时,大概率会对着监控屏跳过这行日志:“获取首页数据失败: exception: 伺服器错误 502: <!doctype html><html lang=...” 更头大的是那句“建立安全连接失败 由于不能验证所收到的数据是否可信,无法显示您想要查看的页面”。我在开发 P04 工具运行时的过程中,几乎每天都要和这类失败打交道。起初我也习惯看到失败就重试、清缓存,后来我彻底换了一个思路:失败是数据,不是敌人。P04 最大的改版点,就是给所有外部调用统一装了一个失败采集器,让每一个异常都变成可检索、可统计、可归因的结构化样本。这篇文章就把 P04 在“失败是数据”这条线下的设计思路、落地细节和踩坑记录完整盘一遍,适合正在做自动化跑批、爬虫调度、API 网关类工具的同学参考。
P04 本身是一套跑批型工具运行时,内部负责调度、执行、数据采集、通知分发,每天要跑几百个任务,大部分时间在做 HTTP 接口调用、公开页面抓取、HTML/JSON 解析这类脏活。外部环境不可控,502、证书校验失败、超时、解析断裂几乎是家常便饭。这篇文章的核心不是劝你把失败消灭干净,而是说清楚怎么把失败变成数据,再用数据驱动重试、熔断、告警和解析器更新。
1. 项目概述与设计思路:为什么失败必须被当作数据对待
1.1 P04 工具运行时到底在解决什么问题
P04 解决的核心问题是:当外部依赖不稳定时,工具运行时不能跟着一起摆烂。早期版本里,每个任务都有自己的错误处理逻辑,A 任务超时打一行 log,B 任务解析失败直接 throw,C 任务 502 后原地重试三次。表面上看每个任务都“处理”了失败,实际上没有一个地方能把失败汇总成全景视图,出了问题只能一台机器一台机器翻日志。
后来我把 P04 重新定义成“带失败可观测性的执行环境”。它不关心你的任务逻辑是什么,只关心三件事:任务有没有按预期跑完、如果没跑完失败发生在哪个阶段、失败样本能不能被结构化留存。这样的设计让运行时和业务解耦,业务方只需要遵循统一的异常上报协议,其余交给 P04 去收尾。
1.2 为什么“失败是数据”而不是“异常”
传统编程语言里,异常是一个需要被捕获和处理的控制流分支。try/catch 一包,打印完 exception 就没了,失败信息只存在于日志文件里,既不能压缩成指标,也不能做多维对比。把失败当数据,意味着我们要给每个失败建立字段、写入存储、定期统计、长期追踪。这个差别很像交通事故记录:异常模式只记录“发生了碰撞”,数据模式则要记录碰撞地点、车速、天气、路况、车辆型号和驾驶员状态。
拿热词里的两个典型失败来看。502 和“建立安全连接失败”在异常模式下都是“请求失败”,但在数据模式下是两个完全不同的物种:一个偏向网关/上游服务问题,一个偏向 TLS 证书校验问题。前者适合重试加退避,后者重试一百次也没有用。只有把失败标准化成数据,才能让后续的自动化策略拥有判断依据。
1.3 三个设计目标:稳定、可观测、可自愈
P04 在“失败是数据”这条线上设了三个目标。第一是稳定,任何单点失败都不能拖垮整个运行时;第二是可观测,任何失败都能在十分钟内被定位到具体阶段和原因;第三是可自愈,运行时能基于失败数据自己决定是重试、熔断还是告警。
其中一个很容易被忽略的点是“失败本身也要稳定”。采集失败的模块如果在高并发下把存储打爆,那失败数据反而会变成新的灾难来源。所以 P04 的失败数据链路设计成了两段式写盘:先落到本地临时文件,再异步合并到中心存储。这样即使中心存储不可用,失败样本依然保得住,等存储恢复后重放,不会漏掉任何一条关键线索。
2. 失败数据标准化:从一行日志到一条结构化记录
2.1 一条失败样本应该包含哪些字段
要回答“失败是数据”,先得定义什么叫“数据”。P04 里一条失败样本至少包含以下字段:样本 ID、时间戳、所属任务、目标 URL、失败阶段、错误类型、错误码、消息指纹、消息摘要、响应头、响应片段、重试次数、耗时、处理动作、额外上下文。不要觉得字段多,真正排查问题的时候,少一个字段就可能少一条线索。
这些字段的设计逻辑很简单:时间戳用来做趋势,任务和 URL 用来定位范围,阶段用来区分“连接坏了”还是“数据坏了”,错误类型和错误码用来做分类,消息指纹用来聚合相似问题,响应片段用来观察上游返回的原始内容。P04 落地时还加了一个“resolved_by”字段,记录这次失败最终是由重试解决、熔断解决,还是人工介入解决,这能让后面的改进策略越跑越准。
2.2 用统一结构收纳失败样本:一个可直接抄的代码框架
统一结构的第一步,是把散落的 exception 转成一个 dataclass。下面是一个简化版示例:
from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any @dataclass class FailureRecord: source_task: str target_url: str stage: str # dns / connect / tls / request / parse / validate error_type: str # HttpError, SSLError, Timeout, ParseError error_code: str # 502, 403, CERTIFICATE_VERIFY_FAILED, ... message_hash: str # 对归一化后的msg做hash,用于聚类 message_summary: str # 截断后的关键错误文本 raw_headers: dict = field(default_factory=dict) response_snippet: str = "" # 只保留前512个字符 retry_count: int = 0 latency_ms: int = 0 resolved_by: str = "" extra: dict = field(default_factory=dict) created_at: datetime = field( default_factory=lambda: datetime.now(timezone.utc) )实际捕获异常时,不要只做 log 打印。要把requests.exceptions.RequestException这类异常解构成上面的字段。如果是 HTTP 错误,从response.status_code和response.headers拿数据;如果是 SSL 错误,从certificate verify failed这种消息里提取具体原因;如果是解析失败,把解析器收到的原始片段存进response_snippet。等这一步做完,一条失败样本才是“数据”,而不是一句人眼才能看懂的日志。
2.3 标准化过程中最容易踩的三个坑
第一个坑是消息截断。很多人只存异常str(e)的前 200 个字符,但像“建立安全连接失败 由于不能验证所收到的数据是否可信”这类消息,真正的证书信息在后面。建议截断策略分两层:普通错误摘要截 512 字符,响应片段单独存且最多 1024 字符,关键头部如Server、Via、X-Request-Id必须完整保留。
第二个坑是敏感信息混入。目标 URL 可能带 token、签名参数,响应片段里可能含用户个人信息或 Cookie,直接入库等于把隐私写到磁盘上。P04 做了一层脱敏:URL 中的 query 参数按配置过滤,响应片段里匹配到token、cookie、authorization等关键词后用***替换。
第三个坑是时间戳不带时区。如果用本地时间存储,跨机器统计时会发现 0 点到 1 点的数据缺了一截。P04 统一用 UTC 存储,展示到页面时再转换成本地时间,所有按小时聚合的报表都必须基于 UTC 字段。
3. 典型失败样本分诊:从 502 到证书验证失败
3.1 “伺服器错误 502”的分层判断
502 是一个很“虚”的状态码,它只能告诉你网关出了问题,不能告诉你网关后面发生了什么。可能是上游服务崩溃,可能是负载均衡把请求转发到了一个正在滚动的节点,可能是网关读超时,也可能是上游返回了非法的响应头。P04 拿到 502 后,第一件事不是重试,而是看失败样本里有没有Via和Server头、有没有网关日志里的upstream_status,再结合最近十分钟这个 URL 的失败率判断。
如果同一时刻其他任务调同一个上游都失败,基本可以判定是服务端故障,此时重试只会增加上游压力。P04 对这种场景的默认策略是:第一次 502 记录样本并快速重试一次;连续三次以上或失败率超过阈值,触发熔断,停止继续调用并告警。如果只是单个任务偶发 502,自动重试加指数退避通常就能消化掉。
3.2 “建立安全连接失败 由于不能验证所收到的数据是否可信”到底是哪里出了问题
这句话是典型的 TLS 证书校验失败提示。程序里通常对应SSLCertVerificationError或CERTIFICATE_VERIFY_FAILED。出现这个错误,意味着本地的 CA 信任链里找不到能验证对方证书的可信根,或者证书已过期、域名不匹配、证书链不完整。有些环境的网络设备会替换证书做内容审计,也会导致同样的报错。
处理这种失败,P04 的底线是绝不开verify=False去绕过校验。关闭证书校验等于把 HTTPS 降级成明文,这个口子一开,后续所有失败样本都会失去真实性。正确做法是分环境处理:对内网自建服务,把对应根证书打包进运行时的ca_bundle,用verify=custom_bundle的方式认证;对外部公开服务,如果证书校验失败,当作基础设施级故障记录并告警,由运维侧更新信任链。
3.3 用失败数据快速定位与自愈的流程
当失败数据已经标准化后,定位流程可以做到半自动化。P04 的规则引擎是这样跑的:先解析错误类型;再按“目标域名 + 错误类型 + 消息哈希”聚合同类失败;接着看该聚合维度最近一小时的成功率和失败量;最后执行决策表里的动作。
决策表并不复杂,核心就几行:网络超时且失败率低,延迟重试;502/503 且失败率高,熔断并告警;证书错误且不是内网域名,告警且不重试;JSON 解析错误,写入解析器变更队列;HTTP 403/404 且之前成功过,升级为契约变更。这套动作让运维同学不用再盯着每一条报错,只需要处理被数据筛选出来的异常样本。
3.4 失败样本统计分析表:把热词变成可执行的指标
为了直观理解失败分布,P04 每天会生成一张统计表。下面是一天内的示例数据:
| 错误类型 | 出现次数 | 主要目标 | 建议动作 |
|---|---|---|---|
| HTTP 502 | 137 | 外部资讯源 | 重试+退避,失败率超过40%熔断 |
| TLS 证书校验失败 | 12 | 某个第三方接口 | 更新信任链,不自动重试 |
| 连接超时 | 58 | 多个慢接口 | 调整超时时间,开启慢请求告警 |
| JSON 解析失败 | 9 | 内部数据平台 | 通知开发更新解析器 |
| 字段缺失 | 31 | 爬虫列表页 | 触发内容结构变更工单 |
这张表的价值在于,过去“报错多”只能换来一句“网络不好”,现在可以用占比、趋势、目标域来指认具体责任人。502 很多不代表网络差,可能只是某个上游下午发生了滚动发布;证书错误出现 12 次,就说明信任链已经坏了一段时间,需要马上修。
4. 失败驱动的改进闭环:把失败数据变成运行时免疫力
4.1 失败分类:瞬时故障、配置故障、结构变化
要做闭环,第一步是对失败样本进行业务层面的分类。P04 把失败分成三类:瞬时故障、配置故障、结构变化。瞬时故障包括网络抖动、偶发超时、暂时性 502,特点是恢复后任务可以继续;配置故障包括证书失效、鉴权失败、请求参数被改,重试往往没用,得主动修配置;结构变化是数据和契约层的失败,比如 HTML 改版导致字段抓不到、API 少返回了必填字段。
这种分类不是人为拍脑袋,而是由失败样本里的stage和error_type映射出来的。stage=connect时大概率是瞬时或配置问题,stage=parse时基本是结构变化。P04 会把两类问题分发给不同的处理通道:瞬时故障走运行时策略,结构变化自动创建变更工单,配置故障直接进运维队列。
4.2 动态重试与熔断:不要一把梭用“固定重试三次”
固定重试三次是最常见的错误姿势。如果上游已经过载,三次重试等于三次补刀。P04 改用“按窗口失败率”的熔断策略,每个目标域名维护一个滑动窗口,窗口默认 120 秒,窗口内失败率超过 50% 就熔断 30 秒,期间请求直接失败并记录原因,不再发送给上游。熔断结束后进入半开状态,放少量请求去试探,成功率达到预期再把流量恢复。
重试间隔用指数退避加随机抖动。基础延迟 1 秒,每次翻倍,上限 60 秒。增加随机抖动是为了防止大量任务在同一时间点同时重试,否则退避算法反而会制造一波新的峰值。这段逻辑不复杂,但没有失败数据的支撑,阈值根本定不出来,因为你不知道“正常波动”和“异常雪崩”的分界线在哪。
4.3 告警阈值:失败率与失败形态,哪个更值得盯
很多监控系统只盯全局失败率,99% 的时间没有意义,真出问题的时候又会因为抖动被淹没。P04 的经验是双维度并行:全局维度看失败率持续时间,比如失败率超过 3% 并持续 3 分钟才触发 P2 告警;单体维度看单任务或单错误类型的失败形态,比如同一任务连续失败 5 次,或某个错误类型 30 分钟内出现 10 次。
失败形态比失败率更能反映真实问题。一个高频接口偶尔失败 5 次,失败率不高,但对用户的影响不低;一个低频任务失败 3 次,失败率看起来很高,实际影响很小。P04 把所有失败数据投喂给告警判断器,用“失败量 + 失败率 + 错误类型”三个维度加权计算告警级别,这样出来的告警才能直接转给对应负责人。
4.4 从失败样本中提取“结构变化信号”
这是 P04 最让我惊喜的部分。最初我只把失败当数据来自动重试,后来发现失败样本里藏着上游结构变化的信号。当同一个 URL 的历史失败消息是指“超时”或 “502”,突然变成“字段缺失”或“HTML 解析为空”,说明上游页面或接口刚改过版,网络层没有问题,是契约变了。
P04 会为这种信号单独建一个队列,队列里的样本自动带上response_snippet和解析器版本号。开发同学只要打开这个队列,就能直接对比“解析器期望的结构”和“上游实际返回的结构”,不用再去线上抓包。后来我们甚至给这个队列加了一个简单的差分提示:如果原始 HTML 长度比历史成功样本短 30%,直接标记为“页面结构异常”。这一步让失败数据真正的价值被用起来了。
5. 实操记录:P04 失败数据平台的落地细节
5.1 数据链路:从异常产生到分析视图
P04 的失败数据链路可以分为五个环节:产生、采集、清洗、落库、分析。异常在任务执行器里产生,由统一异常钩子捕获;采集器负责把异常转成FailureRecord并先写本地队列;清洗器做字段校验、脱敏、补全阶段信息;清洗完的数据写入 SQLite 的failures表;分析层通过 SQL 视图和定时任务生成统计报表。
这个链路看起来繁琐,实际跑起来并不重。小规模运行时不需要上 Kafka 或 ClickHouse,SQLite 加上几个索引足够支撑每天几万条失败样本。等到单日样本量超过百万级,再考虑换成列式存储。P04 的经验是先跑通完整链路,再考虑性能优化,千万别为了上大数据组件而牺牲迭代速度。
5.2 关键配置与参数选择
让失败数据链路稳定运行,几个参数需要认真调:
| 配置项 | 推荐值 | 选择理由 |
|---|---|---|
| 失败样本响应片段长度 | 1024 字符 | 足够看到 HTML 结构变更,又不会撑爆存储 |
| 失败样本保留时间 | 明细 7 天,汇总 90 天 | 既能排查近期问题,又能看趋势 |
| 本地队列上限 | 5000 条 | 防止采集模块自身拖垮运行时 |
| 重试基础延迟 | 1 秒 | 对小跑批任务足够快,又不会压制上游 |
| 熔断失败率阈值 | 50% | 低于这个值多半是抖动,高于就要主动保护 |
| 敏感字段 | token, cookie, authorization | 脱敏必须前置,不能入库后再补 |
这些参数不是死的。P04 在早期把失败样本保留时间设为 30 天,结果磁盘涨得很快,后来改成明细 7 天、汇总 90 天的组合,排查能力没有下降,存储成本却降了一大半。
5.3 轻量可视化:不引入重型 BI 直接看趋势
失败数据如果不能被方便地查看,等于没做。P04 没有上 Grafana 以外的重型 BI,而是直接用 FastAPI 起了一个轻量看板,核心只有两个页面:失败清单和失败趋势。失败清单支持按任务名、错误类型、时间范围过滤;失败趋势展示最近 24 小时按小时的失败量聚合曲线。
背后的 SQL 可以写成这样:
SELECT error_type, count(*) AS failure_count, count(DISTINCT target_url) AS url_count, date_trunc('hour', created_at) AS hour_slot FROM failures WHERE created_at >= now() - interval '24 hours' GROUP BY error_type, hour_slot ORDER BY hour_slot DESC;这几行 SQL 足够支撑第一次复盘。后续如果要加维度,直接在GROUP BY里增加字段,不需要改数据采集端。先让数据看得见,再谈自动化。
5.4 从失败数据中更新解析器的实际案例
分享一个 P04 实际经历的例子。某个列表页任务连续成功两周,某天开始失败样本全部指向stage=parse,错误消息是“字段缺失”。看response_snippet,发现页面仍然返回了 HTML,但列表容器里一件商品都没有。如果只按成功失败判断,这个任务会被标记为失败;但真正的原因是页面改版后默认展示空态。
我们随后更新了解析器逻辑:解析前先判断 HTML 长度和空节点特征,如果确认是空态页面,不再浪费时间解析,而是记录一条“上游空态触发”的样本,并把任务标记为“成功(空数据)”。这样一来,失败数据被消化成了运行时规则,后续同样的问题不会再打扰到告警系统,也让业务方更早知道了页面改版的影响范围。
6. 常见问题与排查技巧实录
6.1 失败数据积压导致存储膨胀怎么办
失败数据也是数据,不控制就会成为垃圾。最容易膨胀的是response_snippet,如果每个样本都存完整 HTML,几天就能撑满磁盘。P04 的做法是分级采样:详细样本每类错误每天最多保留 200 条,其余只保留统计聚合信息;对超大响应体,只截取出现目标关键字位置前后各 200 字符。
另外,定期清理一定要纳入调度。SQLite 里可以直接用定时任务删除七天前的明细记录,再用汇总表把小时级数据聚合为天级数据。注意清理时不能简单DELETE FROM failures WHERE created_at < ...一把梭,要按批次删除,避免长时间锁表导致写入阻塞。
6.2 错误信息相似但根因不同,如何区分
“ConnectionError”这个词可以同时掩盖 DNS 解析失败、TCP 连接被重置、TLS 握手失败、上游主动断连四种完全不同的根因。如果只看str(e),会把不同问题聚到同一个桶里,后面所有统计都会失真。
P04 使用“错误指纹”来区分:把error_type + stage + error_code + 关键异常信息片段组合成一个哈希,用哈希做聚合。比如同样是 “ConnectionError”,stage=tls和stage=connect会被分开;同样是“超时”,connect_timeout和read_timeout也要分到不同的小类。这个指纹一旦建好,再遇到“看起来一样”的报错,就能追到真正的根因分类。
6.3 采集模块自身抛异常如何兜底
这是一个容易被忽视的边界场景。P04 里失败采集本身也会失败,比如中心存储连不上、本地磁盘写满、序列化出错。如果不处理,采集器挂了之后新的失败会彻底消失,系统反而变得更不可观测。
解决办法是两段式写盘加降级。采集器先把FailureRecord序列化成 JSON 行写入本地failures_pending.log,每 10 秒批量合并一次。如果合并失败,文件继续累积;等网络恢复后,再从文件末尾继续重放。同时,采集器和主执行流程之间用队列隔开,采集器的处理时间不阻塞正在跑的业务任务。只要写盘成功,这条失败数据就算保住了。
6.4 如何向团队解释“失败是数据”
如果团队还停留在“异常就是 bug,bug 就要消灭”的阶段,这套思路推起来会比较吃力。我的建议是用体检来打比方:一个人不会因为一次血压偏高就觉得自己完蛋了,但需要通过多次血压记录来判断是偶发还是持续问题。失败的日志就像血压读数,单看没有意义,连成趋势才能说明问题。
落地时先不要让大家改所有代码,只要求一件事:所有外部调用统一走运行时提供的safe_call入口,把异常交给入口包装。当新的失败样本开始出现在看板上,团队自己就会提出“这个错误类型怎么这么多”“能不能自动重试”。到这一步,“失败是数据”就不再是一句口号,而是大家每天都在用的工作方式。
P04 这套系统跑了大半年,我最深的体会是:失败并不会消失,它只是不停地在变形状。过去我们把失败当成测试没写好的证据,现在我把每一条失败样本都当作运行时给出的体检报告。真正有价值的不是那行“伺服器错误 502”,而是它背后可以追踪到的时间、目标、阶段、指纹和趋势。如果你也在做类似的工具运行时,不妨从明天开始,给异常处理加一个结构化字段,再坚持存两周数据,你会看到另一个世界。