接手过线上告警的人基本都经历过这种时刻:日志面板刷出一片“获取首页数据失败”“建立安全连接失败”,再往下翻是半截被截断的 502 HTML 页面,浏览器里则干脆是“无法显示您想要查看的页面”。大多数人的第一反应是赶紧把异常修掉,让系统重新“看起来正常”。但我在自己维护的工具运行时里摸索了一段时间后,形成了一个反直觉的结论:失败不是故障的遗留物,失败本身就是运行时最有价值的数据。
所谓“工具运行时”,你可以理解成一套负责调度执行各种自动化任务、采集任务、批量作业的系统。它不像业务前台那样直接面对用户,但它运行的每一个步骤——请求首页、建立连接、解析数据、写库——都依赖外部环境和上下游服务。这套系统越复杂,失败就越频繁,而每一次失败,都在回答同一个问题:系统此刻到底处在什么状态里?
这篇文章不打算讲怎么消灭失败,那是痴人说梦。我想分享的是另一条路线:怎么把失败当成数据接入运行时的观测管道,让失败去指导下一次执行,甚至反哺产品决策。这也解释了为什么当别人急匆匆把 502 页面丢掉时,我却会把那半截 HTML 原样保存下来——那是我能拿到的最诚实的信息。
1. 先把“失败”的定义改掉:运行时不存在垃圾信息
在深入技术细节之前,我们必须解决一个认知问题:你怎么定义失败?
我在团队里做过一个很小的实验,让不同背景的同事描述同一次线上事故。后端工程师说“网关返回了 502”,爬虫工程师说“首页请求失败,重试了三次也没用”,产品经理说“用户看到的是白屏”。同一件事,三种描述。后来我想明白了,之所以大家鸡同鸭讲,是因为我们对“失败”这个词的理解没有对齐。
1.1 失败不是状态的终结,而是状态本身
传统开发习惯里,失败就是异常,异常就该被捕获、记录、丢弃。我们习惯把这个过程叫“兜底”。但兜底恰恰是最浪费数据的行为——你把异常吞掉的同时,把系统当时的状态信息也一起吞掉了。
举一个最简单的例子。你去请求一个首页,返回了 502。这个 502 本身携带了多少信息?
第一,它告诉你是一个网关层的东西在报错,而不是业务代码抛了一个空指针;第二,它告诉你提供 502 的那个组件接触不到上游服务;第三,如果 502 页面里带了具体的网关标识或请求 ID,你还能追溯内部路由链路。这些信息没有一条是“垃圾”,它们全是关于系统硬邦邦的状态描述。
所以第一步是调整心态:运行时里出现的一切失败输出,都是系统在那个瞬间的心跳和体温记录。
1.2 界线的重新划分:可预期失败和不可预期失败
为了把失败当数据用,我习惯先给失败分类。不是按错误码分,而是按“可预期性”分。
可预期失败,指的是那些你知道它一定会出现、只是不知道具体什么时候出现的失败。网络抖动导致的连接超时、上游偶发 502、数据源页面结构临时调整导致的解析字段缺失,都属于这一类。它们有规律、有模式、可以建模。
不可预期失败,指的是代码里根本没有考虑到的情况,比如突然出现的编码问题、上游接口整个字段类型变更导致的内存异常。这类失败没有先验知识可以套用,唯一的应对策略是保证失败信息足够完整,完整到事后能定位问题。
这两类的处理策略完全不同。可预期失败可以被纳入重试、退避、熔断策略,甚至可以做成指标监控;不可预期失败则需要被完整采集进入另一个通道,等待人工或自动化分析介入。把这两者混为一谈,是运行时设计里最粗糙的做法。
2. 从三个真实失败片段看运行时状态的面貌
我们回到开头的三个热词,它们不是凭空造出来的,而是运行时里每天都会刷屏的真实输出。逐条拆开看,每一条背后都写着系统状态的信息。
2.1 “502: 服务器错误”这半截 HTML 里藏着链路信息
一个典型的 502 响应体,通常是半截超文本,像这样:
GET 首页数据失败: exception: 伺服器错误 502: <!doctype html><html lang=这行内容看起来像乱码一样的失败现场,其实包含了好几层信息。
先说“获取首页数据失败”这个描述,它告诉你失败发生在业务代码的哪一层——是调度程序在获取首页数据时失败,而不是在解析数据时失败。这个描述就划分了责任域。
再往后,“exception: 伺服器错误 502”,这里的“伺服器”是某些网关或源站系统里的叫法,翻译过来就是“服务器”,这透露出响应它的是一个远程服务端,而不是本地缓存或本地文件。响应方是谁,决定了排查方向。
最后那半截<!doctype html><html lang=是最容易被忽略的宝藏。一个正常返回 200 的页面不会以 doctype 开头,所以这半截内容恰恰证明了网关返回的是一个错误页。那么问题来了:谁构造了这个错误页?是网关,还是源站?这半截 HTML 的源信息就是一条重要的状态线索。我把这类响应体原样存档,平时看起来没什么用,真正遇到“页面间歇性无法打开”的问题时,它就是第一手证据。
2.2 “建立安全连接失败”是最难保存的一条数据
“建立安全连接失败,由于不能验证所收到的数据是否可信”,这是 TLS 握手层的失败描述,比 502 更底层,也更难处理。
原因很简单:安全连接失败发生在数据链路层级的加密握手阶段,你只能拿到失败这一件事,拿不到下层传输的响应体。是凭证过期?证书链不完整?还是中间设备劫持了流量做了不合规的证书替换?这些信息全都丢失了。
也正因为如此,这类失败数据极其宝贵。我遇到过几次莫名其妙的“建立安全连接失败”,排查到最后根本不是代码逻辑问题,而是某个办公室出口代理设备的时间漂移,导致系统时间走出了证书有效期。这种问题的答案没法从别的地方找到,只能靠积累这类失败数据的时间线和频率,才能画出完整的因果路径。
2.3 “无法显示您想要查看的页面”是用户侧的最后一道感知
第三个热词是“无法显示您想要查看的页面”,这通常是浏览器或者客户端在多次握手尝试全部失败后,对外给出的一句话。
它看起来像是最没有技术含量的一条描述,但它其实是最接近用户感知层的失败。你在运行时要做的,不是把这个页面原样抛给用户,而是把它当成“最终兜底状态”记录下来。要知道,用户侧看到这句话之前,系统内部可能已经经历了多次重试、多次退避、多次回源尝试,而这些消耗全部被压缩成了用户面前的一句话。如果不记录这句话,你就永远不知道系统把一个多复杂的失败过程,简化成了什么结果给用户。
你现在应该明白了,这三个热词对应的是三个不同层级的失败:链路层、握手层、用户感知层。它们的表现形式完全不同,但都指向同一个要求:运行时必须有能力把失败分层采集,而不是一视同仁地记录成一行日志或者一个堆栈。
3. 把失败接进运行时管道的具体做法
理念说清楚了,接下来是实操。这部分我讲的是我自己的架构经验,不一定适用于所有系统,但思路是通用的。
3.1 改造的第一步:统一失败事件模型
要处理失败,先得定义失败长什么样。我采用的方案是把一切失败都结构化成一个“失败事件”,而不是散乱的日志行。一个失败事件至少包含这些字段:
| 字段 | 含义 | 举例 |
|---|---|---|
event_type | 失败的类型标签 | http_502、tls_handshake_failed |
component | 发生失败的组件名 | index_fetcher |
target | 被请求的目标标识 | homepage |
error_class | 错误类别,用于区分可预期/不可预期 | expected/unexpected |
context | 失败发生时的现场上下文 | 请求头、响应体片段、耗时 |
timestamp | 精确时间戳 | 2025-01-01 00:00:00.123 |
recovery_action | 系统当时做出的恢复动作 | retry_1、fallback_cache |
这个模型的意义在于,它把失败从“发生了什么事情”的叙事,转化成了“当时状态是怎样的”的数据结构。有了统一结构,后续的存储、聚合、检索才能成立。
3.2 失败事件的管道设计:双通道机制
我采用的不是把所有失败事件都放进同一个仓的做法,而是分成两个通道。
第一个通道叫实时通道。处理的是可预期失败,它对接重试队列、退避调度器和熔断器。比如获取首页数据失败 502,如果被标记为expected,处理后会被立即送入重试调度,然后在指数退避的时间点重新发起请求。这个通道的核心评价指标是“失败恢复率”。
第二个通道叫归档通道。处理的是不可预期失败和全部失败事件。所有信息——包括原始响应体、请求上下文、时间线——原样保存,进入专门的分析时段。这个通道的核心评价指标是“分析可用性”。也就是说,半年之后你想分析某次失败,还能不能从归档里把现场完全还原。
双通道机制的好处是:实时通道不用承担过多存储开销,决策快速;归档通道不用承担实时性压力,可以安心做全量保存。两个通道互不干扰,失败既没有阻碍流程推进,也没有丢数据。
3.3 重试不应该是失败的终点
重试是最容易被滥用的失败处理手段。我在早期犯过一个经典错误:任何失败都重试三次,三次都失败就把错误记录数据丢掉。后来才发现,重试三次成功时,那个原始失败事件根本没被记录;重试三次全失败时,前面的失败过程也没有被记录。等于说,重试机制把最宝贵的失败上下文给抹平了。
正确的做法是:重试是失败事件处理流程中的一个环节,而不是失败事件的终点。每次重试动作本身都应该产生一条子事件,记录重试的理由、间隔、结果。最终无论重试成功还是失败,这条失败链路的所有事件都要进入归档通道。这样才能确保,即便系统最终自愈了,我们依然保有一条完整的“失败录音”,后续可以分析为什么这里要重试、是什么条件触发了重试。
3.4 连接失败的时间戳价值被严重低估
说到具体字段,timestamp是最容易被轻视的一个。很多日志框架默认只记录到秒级,但对于网络类失败,毫秒级甚至微秒级的时间记录都非常有价值。
举个例子,你在归档数据里发现,某个失败事件发生在 10:00:00.123,而它对应的上游服务从监控面板看在 10:00:00.900 才标记为不健康。这两条数据一对,就能快速判断:不是上游先挂了才导致我们失败,而是我们的某个请求触发了上游的某种行为,导致它在后来的几百毫秒内出现状态变化。这两条时间线的先后关系,就是一次完整的事故因果链起点。
所以,所有归档通道里的失败事件,我都要求时间戳精确到毫秒,并且额外记录一个system_offset时间戳和operational_timestamp时间戳。不要嫌字段冗余,排查问题的时候,时间线对不上是最让人崩溃的。
4. 失败数据的真正用途:它不仅在修复,更在预测
把失败数据存下来只是第一步,真正让它发挥价值的是后续的分析。这里我讲三个我已经在用的方向。
4.1 用失败数据做根因分析,而不是等告警后再翻日志
传统模式是:故障发生 → 告警弹出 → 人工翻日志 → 猜测根因。这个模式的问题是,故障发生到人介入之间的时间差里,现场信息已经大量流失。
我的做法是把失败数据当作尽量保证完整的链路记录来用。系统运行中,每条失败事件会通过关联 ID 串联成事件链。一次完整的请求失败,可能包含:域名解析失败事件、TCP 连接失败事件、TLS 握手失败事件、应用层 502 事件、浏览器显示“无法查看页面”事件。如果没有关联 ID,这五个事件会散落在五个地方;有了关联 ID,它们就是一条完整的时间线。
实际排查中,这条时间线往往直接指到根因。比如一次“获取首页数据失败”的完整链路里,如果每一步都失败了,但耗时逐级增长,那就说明问题大概率在链路末端,也就是目标服务本身,而不是中间的某个环节。根因分析这件事,不是靠人力去猜,而是靠失败数据的结构去逼出答案。
4.2 失败率趋势比绝对成功率更有信息量
很多人盯着“系统可用性 99.9%”这种指标,觉得数字高就可以高枕无忧。但看绝对成功率几乎看不出问题,因为 99.9% 和 99.0% 的区别,在日常过程中几乎感觉不到。
失败率趋势就不一样了。同样是 2% 的失败率,如果最近 7 天都是 2%,那就很稳定,不需要处理;如果昨天还是 0.5%,今天突然变成 2%,这就是一个强烈的变化信号。把失败事件按照时间段做聚合,按失败类型做分组,再叠加时间趋势,就能看到一个很明显的“失败率跃迁点”,这个时间点再去对照部署记录、上游变更记录,信息量就大了。
我通常会在运行时里做两个基础报表:一是失败率的五分钟滑动窗口图,用来捕捉瞬时抖动;二是每日各失败类型的占比变化表,用来捕捉渐进恶化。前者抓急性问题,后者抓慢性问题。
4.3 失败数据也是产品反馈
这一点是最容易被技术团队忽视的。
失败数据里藏着的,不只是技术故障,还有产品和业务层面的反馈。举一个实际例子,某个采集工具运行时不间断地在凌晨两点执行批量任务,失败事件里频繁出现“获取首页数据失败”,而进一步分析发现,失败时间点附近还有大量超长耗时请求。顺着这条线查下去,最后定位到目标网站在凌晨两点做数据备份,整个站点几乎不可用。
这个结论有意义吗?技术上,你可以调整任务执行时间避开凌晨两点;业务上,这还说明了一个事实:你的数据源在后半夜会有一个维护窗口,而你之前的所有排期都没有考虑到这一点。这就是失败数据反馈出来的业务洞察。没有失败数据,这个洞察永远要等人来汇报才能显现。
5. 把失败当成数据之后,我踩过的几个坑
既然是实战经验分享,这部分我集中讲几个踩过坑之后才想明白的点。
5.1 坑之一:失败事件也会失败——采集通道本身的可靠性
你建了双通道,把所有失败事件都存进去了,但有没有想过,当时运行时本身也处于不稳定状态呢?
有一次,我们的归档通道依赖一个远程对象存储服务,而这个对象存储服务某天下午正好在升级。结果那半天内产生的所有失败事件,一条都没能写入归档。等发现的时候,半天数据已经彻底丢了。
后来我的解决方案是:本地磁盘永远保留一份全量失败事件的缓冲区,归档通道可以慢,但不能没有;如果归档通道连不上,缓冲区的数据会在通道恢复后按顺序补写。这套机制保证了对失败数据的采集,不依赖任何可能失败的远端服务。说得夸张一点,连失败采集系统本身,也要做成失败的容错模式。
5.2 坑之二:把失败数据的存储视同普通日志存储
失败数据比普通日志贵,贵在它需要长期保留和快速检索。
普通日志可能是滚动式的,保留三天就能满足需求,失败数据则往往要被翻出来做好几周甚至好几个月的趋势分析。如果一开始没规划好存储方案,要么是时间久了归档被清理掉,要么是检索性能差到根本翻不出要的数据。
我的实践是把失败事件存成列式存储,按时间分片,按失败类型做索引。这样查询“过去 30 天 TLS 握手失败的时间分布”时,可以几秒内出结果。如果你用的还是普通行式日志库,这个查询就会慢到让人放弃。存储结构这件事,要在最开始设计时就想明白,后期迁移的成本太高。
5.3 坑之三:失败数据的隐私边界不能含糊
失败事件里往往带着上下文信息,而上下文里很容易混入敏感数据。比如你记录了一个包含完整请求体的失败事件,如果请求体里有用户输入的信息,那你等于把隐私数据也归档了。
这需要在采集源头就做识别和脱敏。我的原则是:请求体默认不采集,只有明确标记为需要采集的请求体才进入事件上下文;错误信息里的 URL 参数要做键保留、值匿名化处理;响应体只保存前 N 个字节。这样做的代价是,当问题真的和某个请求体的具体值有关时,能获取的信息会变少。但为了合规底线,这个牺牲是值得的,安全问题的代价只会更大。
5.4 四条可以立刻落地的建议
如果你看完前面这些,决定从今天开始就把失败当数据来管,我建议你从这四件事起步:
第一,建立失败事件的统一模型。不要求一步到位,先在一个核心执行器上把失败从纯日志改为结构化事件。
第二,把失败连接进一条可观测链路。确保能通过关联 ID,串联同一次请求的多次失败。
第三,信心没有建立之前尽量全量保存。不要过早设计采样策略,你不知道哪一条失败数据会是未来某个问题的唯一线索。
第四,每周看一次失败数据趋势图。创造一个人工介入的节奏,用稳定频率主动审视失败数据里的蛛丝马迹,而不是等到告警炸了才被动查看。
6. 说点个人感受
每次看到有人在代码里写except Exception: pass,我都觉得那条失败信息被无声无息地杀死了。我知道不是每个人都认同“失败是数据”这个理念,毕竟它天然和“尽快恢复正常运行”的直觉相悖。
但我自己用了这套思路之后,最大的改变是心态上的:我不再把失败当成一个需要尽快消灭的敌人,而是把它当成一个每天都在更新数据的传感器。系统运行的健康状况、上下游依赖的真实可靠性、用户实际感知的服务状态,全都从这些“不好看”的失败数据里流露出来。
如果你也被各种莫名其妙的连接错误、502、超时折磨得头痛,不妨试着改一改工作流的起点:别再急着把失败删掉,先把它接进管道,让失败成为系统向你汇报的第一手语言。动手记录和分析的第一天,你就会发现自己对整个运行时的理解上了一个台阶。