1. 从一场502故障说起:P04工具运行时的失败处理重构
先交代一下背景。P04 是我们内部"工具运行时"项目的第四个迭代版本,这个运行时干的事不复杂:它负责编排各种工具的调用——从 HTTP API 请求、数据库查询、消息队列消费,到更复杂的多步骤任务。你可以把它理解成一个"中台级的执行器",所有外部依赖的调用都经过它,再往下就是具体的工具插件和适配器。
做到 P04 这一版,我遇到的最大设计分岔点不是性能、不是调度,而是失败处理。当时线上有一个非常典型的场景:运行时要周期性地从一个第三方平台拉取首页数据,然后做聚合分析。某个下午,第三方平台网关异常,返回了"伺服器错误 502",而且这个 502 出口是个完整的 HTML 错误页,不是 JSON 错误体。紧接着我就看到了日志里一串非常经典的消息链:
获取首页数据失败: exception: 伺服器错误 502: <!doctype html><html lang=... 建立安全连接失败 由于不能验证所收到的数据是否可信,无法显示您想要查看的页面。第一行是业务层报错,第二行是某个客户端库在 TLS 握手阶段的报错。注意看,这两条错误信息根本不在同一个抽象层级,但被输送到日志系统时,全被压扁成了同一种"字符串格式"。这种"压扁"就是问题所在:信息全部乱序地堆在一起,只能靠人肉去读、去猜、去逐行解析。
从那一刻起,我决定在 P04 里把失败处理彻底重做一遍,核心口号就八个字:失败是数据。这篇文章我把整个设计思路、建模方法、落地过程和踩过的坑完整记录下来。适合正在做或者打算做运行时、任务编排、API 网关、Agent 平台这类系统的开发者,也适合所有被线上失败搞得头秃的运维和全栈工程师。
2. 把失败当成数据,和传统异常处理差在哪
2.1 失败从"一次性事件"变成"有结构的实体"
传统代码里,失败是什么?是一次事件——发生、传播、被捕获、被记录,然后结束。它没有身份证、没有档案、没有后续。异常被 catch 之后,除非你手动上报到某个监控系统,否则它就消失了,没有人会回溯一次异常,异常也没有"生命周期"的概念。
把失败当数据,意味着每次失败都要有一个"实体"级别的存在。我在 P04 里设计了统一的失败结构,每个失败实例至少包含以下几组信息:
- 标头:失败 ID、时间戳、来源组件、失败类型(超时/拒绝/序列化/业务错误/协议错误等)
- 上下文:所属任务 ID、调用链 ID、输入参数摘要、当前重试次数
- 载荷:原始错误信息、上游状态码、响应头摘要、抛出位置
- 关联:同类失败聚类 ID、是否触发过重试、最终是否恢复
这样设计之后,失败就不再是日志里的一行字符串,而是结构化数据里的一个对象。它可以进存储、可以被查询、可以和其他失败做关联分析。这是一个很朴素的转变,但它打开了后面所有功能的可能性——重试策略、告警降噪、长尾分析,全是建立在这个"实体化"基础之上的。
2.2 代码内部用异常,组件边界传数据
这里有个容易混淆的点需要澄清:把失败当数据,不是要否定异常机制本身。异常作为一种控制流机制,在代码层面依然有用,问题在于边界的契约。
代码内部的模块之间,你想用异常就用异常,这是实现细节。但是在系统组件之间、服务之间的边界上,失败必须以数据的形态传递。举个例子:运行时进程内部某个插件抛出了 RuntimeException,这是内部控制流;但这个插件调用外部 API 拿到 502、然后这个 502 要作为一条失败数据进入运行时的事件总线,这才是边界的契约。
这个区分的实际价值在于:你不可能让所有组件共享同一个异常体系,但你可以让所有组件共享同一个数据格式。Java 的 Exception、Python 的 BaseException、一个 HTTP 状态码、一个 MQ 消息头的重试标记——这些完全异构的东西,统一进入失败数据模型后,就可以在同一个地方被消费。你不需要让 Go 的 error 去理解 Java 的 checked exception 是什么鬼,你只需要让它们都翻译成同一种 JSON 结构。
2.3 重新定义"什么算失败":不是状态码,是业务语义
还有一个更微妙的设计判断:什么算失败?我见过太多系统把所有非 2xx 的 HTTP 响应都当成失败来兜,结果监控面板上全是误报,真正的故障被淹没在噪音里。在 P04 里我明确了一个原则:失败不是由"响应码"定义的,而是由"业务语义"定义的。
比如 502 是失败吗?分情况。如果我们的任务就是探测某个站点是否可用,那拿到 502 恰恰是任务成功的产出——"目标当前不可用"这条信息本身是有效的。再比如开头那个"建立安全连接失败",如果业务需求是获取用户数据,这当然是失败;但如果是安全巡检任务,这反而是"成功发现一个异常端点"。
所以我在失败数据模型里加了一个字段叫expected——这个失败是否在本次调用的预期之内。预期的"失败"走正常的指标统计,非预期的才走告警。这个字段看着很小,实际用起来之后告警准确率的提升非常明显,后面我会详细讲。
3. 失败数据建模与流转链路:从错误到结构化对象
3.1 失败数据结构的核心字段设计
直接贴核心结构,我们用 Go 实现,但概念是通用的:
type Failure struct { ID string `json:"id"` // 全局唯一失败ID TaskID string `json:"task_id"` // 所属任务 TraceID string `json:"trace_id"` // 调用链ID Component string `json:"component"` // 来源组件 Category FailureCategory `json:"category"` // 失败分类 Code string `json:"code"` // 紧凑错误码 Message string `json:"message"` // 人类可读信息 Hint string `json:"hint"` // 建议排查方向 RetryCount int `json:"retry_count"` // 已重试次数 Expected bool `json:"expected"` // 是否预期失败 CreatedAt time.Time `json:"created_at"` // 原始失败时间 ReceivedAt time.Time `json:"received_at"` // 运行时接收时间 Raw map[string]any `json:"raw,omitempty"` // 上游原始数据 }这里面有几个字段我得单独说明为什么这么设计。
Code 与 Message 分离。很多人习惯把错误信息直接写成人话,比如"伺服器错误 502: <!doctype html><html lang=...",然后拿去全文检索。问题是这种信息既不适合程序做判断,也不适合做聚合统计。Code 才是给程序看的——HTTP_502、TLS_CERT_UNTRUSTED、MQ_CONSUME_TIMEOUT——紧凑、稳定、可枚举。Message 是给人类看的补充说明,可以带详情。两者混在一起,程序没法判断,人看着也费劲。
Hint 字段是我后来加上的。最初没有这个字段,每次排查都得去翻文档回忆"这个错误码是什么意思"。后来我要求所有失败定义时必须带上 Hint,比如"检查上游证书是否在信任链内"或者"确认负载均衡器健康检查是否通过"。这个字段在故障响应时特别好用——值班同学不需要是领域专家,照着 Hint 就能动手。
Raw 字段用来保存上游原始的乱七八糟内容,比如那个 502 返回的 HTML 页面。为什么保存它?因为调试时你可能要确认"这个 HTML 是不是网关的统一错误页、里面有没有包含具体的后端节点信息"。但要注意给这个字段设体积上限,防止一个几百 KB 的错误页把存储撑爆。我一般对 Raw 做截断,超过 16KB 就只保留前 16KB 再加一段标记。
3.2 错误转换与统一收口链路
结构定好了,接下来是整个链路怎么走。我在 P04 里接了一个轻量的失败事件总线,所有经过运行时的调用,无论成功失败,都会产出一个事件;失败事件里就带着上面这个 Failure 对象。
链路大致是这样的:
- 运行时发起外部调用前,先构造一个带有 TraceID 的调用上下文;
- 调用结束后,不管拿到正常响应、还是异常,都走到统一的收口逻辑;
- 收口逻辑判断"这算不算失败"——按业务语义判断,而非状态码;
- 如果算失败,就把异常/错误转换为 Failure 对象,填充各字段;
- Failure 对象同时进入三个出口:本地缓冲(用于快速查询)、事件总线(用于订阅和联动)、持久化存储(用于离线分析)。
这个链路的要点是"统一收口"。我见过很多系统在几十个调用点各写一套 try/catch,每个点自己拼错误字符串,最后错误格式五花八门。统一收口之后,新增一个外部调用只需要复用同一个出口,失败数据的格式一致性就有了保证。这个改造有个额外好处:后续要加字段、加逻辑,只需要改收口函数,不用去几十个调用点里做手术。
3.3 对原始错误信息做"特征提纯"
回到开头那个 502 的例子。你看原始的错误信息:"获取首页数据失败: exception: 伺服器错误 502: <!doctype html><html lang=...(后面一大串 HTML)"。这种信息里真正有用的信号是什么?
一是"伺服器错误 502"——说明上游网关的类型和状态码;二是"<!doctype html>"这个前缀——说明网关返回的是 HTML 错误页而不是 JSON。这两个信号可以各自映射到 Code 的细分值上,比如UPSTREAM_502_HTML_PAGE这种。
我在转换层里加了一层"错误信息提纯":把长文本里稳定出现的模式提炼成特征字段,塞进 Failure 的 Raw,而 Message 只保留精简版。实现方式不复杂,就是一组正则加关键词规则,比如"包含 <!doctype html> 且状态码为 502 → 打上 HTML 错误页特征"。这层提纯的收益在后面——做失败聚类统计时,能直接按特征字段分组,而不是按整段 Message 做模糊匹配。同样一条报错,处理前是一坨人眼都懒得看的字符串,处理后是几个清晰的结构化标签。
4. 让失败数据反哺系统:重试、告警与长尾分析
4.1 失败数据驱动的重试与熔断
把失败变成数据之后,很多原本靠"手写逻辑"的稳定性机制就可以数据化了。最典型的是重试策略。
之前我们的重试逻辑是写死的:每个调用点自己配"重试三次,每次等两秒"。这种静态配置的问题在于——它完全无视失败的类型。一个 400 客户端错误重试一百次也没用;一个 502 服务端错误如果持续五分钟,你设置的固定间隔根本躲不过这个故障窗口。
在 P04 里,重试策略是"读数据"的:失败数据落地后,一个重试决策引擎会读取最近一段时间同类失败的分布(比如"过去 60 秒内同类 502 的占比超过 40%"),动态决定要不要重试、用什么退避间隔、甚至要不要直接熔断。这个机制本质上就是把失败数据变成了控制系统的输入信号。
具体执行上我用了带抖动的指数退避加熔断阈值的组合,逻辑上大致是这样:
def should_retry(failure: Failure, recent_stats: dict) -> bool: if failure.category == CLIENT_ERROR: return False # 客户端错误重试没有意义 if recent_stats[failure.code].rate_1min > 0.4: return False # 上游持续高失败率,主动退避 if failure.retry_count > max_retries: return False return True这个模块上线后的效果很直接——我们对外部依赖的无效请求量降了约六成。大量本来会"盲目重试三次"的场景,现在因为读取到"上游已经挂了"的失败数据而提前退避了。这里的关键是:失败数据让系统的重试决策有了"记忆能力",而不是每次都从零开始猜测。
4.2 用 expected 字段做告警降噪
再来说告警。前面提到的expected字段,就是在告警这一层发挥作用的。做告警规则的时候,我把告警分成了两档:
- 业务可用性告警:只看"非预期失败"的占比,这个比值超过阈值才触发告警;
- 噪声告警:预期失败(比如主动探测、竞态抢占、上游明确返回 429 限流)只进入统计面板,不触发任何通知。
这个划分看起来简单,但实际落地后我的体验很直观:周告警量从三位数掉到个位数,真正的问题一个没漏。原因也不复杂——你不再和"失败字符串"搏斗了,你能精确地告诉告警系统"这一条不算,那一条算"。
还有一点值得说:告警规则本身也要能从失败数据里动态生成。比如某个 Code 在最近 15 分钟内的失败占比突然从 1% 跳到 30%,这种"跳变"比固定阈值更值得告警。因为失败数据是结构化的,做这种趋势检测非常顺手。
4.3 失败数据的长期价值:长尾分析
失败数据不只是给实时系统用的。因为每个失败都被持久化成了结构化对象,我每周会跑一次长尾分析:把所有失败按 Code 加 Hint 分组,看 Top 10 的失败模式是什么。这个分析在之前的"字符串日志时代"几乎做不出来——你不可能对着一堆自由文本做可靠的分组。
这个分析帮我发现过好几个"隐形问题"。比如有一次分析发现,某个第三方服务返回的 502 里,有接近三成的 Raw 数据长度异常——超过 50KB——进一步排查发现是那个平台的网关把整个错误页连同静态资源一起回传了。这种洞察如果纯靠翻日志,不知道要多久才能拼出全貌。
另外,失败数据积累多了之后,你还能对每个外部依赖做"健康画像":哪个上游周几容易抖、哪个接口在发版窗口容易飘、哪个代理在晚间高峰期延迟飙升。这些东西在故障排查时都是极有用的背景信息。
5. 实操中踩过的坑:失败数据落地全记录
5.1 失败数据别只存"最后一次"
第一个坑,是刚开始落地时踩的。当时我把失败数据存在一个环形缓冲里,只保留最近一次同名错误——目的是省内存。结果排查问题的时候发现:缓冲区里全是"最新的失败",最早的根因早就被覆盖了。失败的根因往往出现在最早的那一次(比如首次 TLS 握手失败建立起错误状态),后面的失败都是它的"衍生失败"。
教训是:失败数据的存储策略要和失败的生命周期匹配。根因失败要单独标记并保留较长时间,衍生失败可以走短的过期时间。我在 Failure 结构里加了RootCauseID字段,专门做根因链关联。排查故障时优先看根因节点的数据,效率提升了不少。
5.2 别把失败数据做成"只写不读"
第二个坑更隐蔽。说实话,最开始做失败数据采集,我潜意识里的目标就是"出了事能翻日志"。所谓"只写不读"——数据确实存下来了,但没有人消费它。
直到一次严重故障后复盘,我才意识到:失败数据的价值在于流动,而不在于囤积。它要被重试引擎读取、被告警系统读取、被容量分析读取、被值班面板读取。如果只是存下来等人来查,那本质上还是传统日志,只是换了个更贵的存储。
所以 P04 里我定了一个规则:任何写入失败总线的事件,必须有至少一个下游消费者。没有消费者的失败事件,不落存储,直接降采样。这条规则虽然有点矫枉过正,但它强制团队去思考"这个失败数据能用来做什么"。每次新增一种失败类型,先回答"谁消费它",再谈采集。
5.3 "建立安全连接失败"这类TLS错误的排查思路
回到开头第二行那个错误:"建立安全连接失败 由于不能验证所收到的数据是否可信,无法显示您想要查看的页面。"
这是一类非常典型的 TLS 证书校验错误。在失败数据框架下,排查思路会清晰很多:
- 先看 Failure 的 Category 是不是
TLS_VERIFY_ERROR; - 看 Raw 里有没有证书相关信息(证书链、过期时间、签发方);
- 区分两种可能:一是上游证书过期或自签,二是客户端信任库没有包含正确的根证书;
- 如果是自签证书,判断业务上是否接受——接受就把该证书指纹加入信任列表,不接受就按安全策略拒绝。
在没有失败数据框架之前,这种问题每次都让人抓狂:错误信息里没有证书指纹,没有过期时间,每次都要手工抓包。现在 Raw 字段在握手失败时自动带上证书摘要和验证链信息,排查从小时级降到分钟级。这个改进的关键不在于"多存了几个字段",而在于你事先知道排查这类问题需要什么信息,然后在失败发生的瞬间把它捕获下来——等事后再想去补,信息早就丢了。
5.4 重试风暴的教训:失败数据也讲究"置信度"
再补充一个关于重试风暴的教训。失败数据驱动重试之后,最大的风险变成"数据一致性延迟"。比如上游 502 是 10:00:01 开始的,但失败统计窗口是 60 秒,所以在 10:00:02 发生的重试请求看到的统计里还没有足够的失败样本——于是它照着重试了。等到 10:00:30 统计窗口内失败率超过阈值时,已经有几百个重试请求打过去了。
解决方案是在重试决策的"冷启动"阶段加一个保守策略:统计窗口内样本不足时,默认不重试或者用更长的退避。说白了,失败数据要足够新、足够多,才值得信任。这个教训直接促成了我们"失败样本置信度"字段的引入——达不到置信度的决策,一律保守执行。另外,退避间隔的抖动也是必须的,否则一堆客户端同时重试,很容易把自己打到限流。
6. 一点个人体会
做了 P04 这一轮失败处理的重构,我最大的体会是:系统设计里最值钱的不是"让失败不发生",而是"让失败看得清"。失败不可能被消灭,你只能选择让它在系统里以什么形态存在——是散落各地、无法检索的日志字符串,还是一个有结构、可分析、能反馈的数据对象。
回过头看,那句"失败是数据"最深刻的影响其实不在技术层面。它改变了我面对故障时的心态:看到 502 不再抓狂,而是会下意识地想"这条失败数据带着什么信息、它能告诉我什么"。这种心态转变,大概才是这个设计真正值钱的地方。如果你也在做类似的运行时或者编排系统,我建议从今天起,把你代码里的每个 catch 都当成一次"数据采集的机会",而不是一次"事故善后"。