某数据管道日志看似正常但实际全天零交付的事故分析,三个独立缺陷叠加导致高价值数据在CRM端完全消失,为监控告警设计提供反面教材。
一个数据源按计划运行了整整一天,每小时都记录了一条健康的数量,最终却向下游交付了零输出。重复删除账本在处理上限应用之前就被盖戳了,所以上限之后的所有内容都被记录为已处理,而实际上从未被处理过。
前一天,一个精选数据源被接入了一条自主发现管道,在每一个观测指标上都表现得很健康。它按计划拉取,每小时日志行报告了稳定的实时条目数量,卫生过滤器报告了过期的被丢弃,整体相关性关卡通过率在它加入后反而上升了。没有报错,没有超时,没有重试。直到有人问起为什么一个明确存在于数据源中的高价值条目从未出现在管道终端的 CRM 中,这个故障才浮出水面。
三个独立缺陷,每个单独都足以隐藏这个条目。第一个也是最严重的:发现阶段将每个通过关卡的条目写入持久化的已读账本,保存后记录了一个成功计数,然后才将返回列表截断到处理上限。连续两行日志分别读作"295 NEW accepted"和"Found 120 new"——中间那 175 条被永久记录为已见过,但实际上从未被任何人看过,而且该账本 21 天的 TTL 把它们埋到了条目本身过期之后。第二个:原本应该保护高价值数据源的排序逻辑把它们分进了二进制优先组而非真正排名,而稳定排序在组内会保留插入顺序,所以最新、最密集的数据源被追加到了最后,大约 888 条来自其他数据源的条目在达到上限之前消耗完了配额。事后测量,其中 279 条记录在账本中处于仅仅"见过"的状态,没有一条曾经到达处理阶段。第三个:基于模型的相关性评判器携带了一个角色描述,声称候选人不手写代码,所以它否决了需要该运营商日常用来交付生产系统的两种语言的角色,违背了它自己的标准——后者列出了几个此类职位作为批准项。还有第四个上游问题:数据源将来源 ATS 中的空 eligibility 字段作为硬性单一国家限制重新发布,导致一个全球开放的职位因为不正确的数据被正确地以地理不合规为由拒绝。
将账本写入改为实际交接工作后才执行——循环现在在上限处中断,在触碰任何无法处理的内容之前,只标记返回的内容,并将剩余部分明确记录为 deferred 而非 dropped,这样下一个周期会立即重新考虑它们,而不是三周之后。将二进制优先组替换为跨数据源的轮询交织,每轮内最富有的优先,这修复的是类而非实例:没有数据源能挤掉另一个,无论它带来多少流量,所以添加一个新的大流量数据源再也不会悄无声息地饿死已有的。将评判器的角色描述纠正为真实约束——日常写生产代码,但不做学位筛选或算法笔试——过滤掉相同的类别但移除虚假前提。给数据源添加了来源检查,用雇主自己的记录重新验证单一国家标签,并且只在雇主明确声明时才放宽,失败时软降为原始值。在一个保护条件下释放了被烧毁的账本条目,只触碰仍处于 seen 状态的记录,让已操作的内容保持不变。
修复前账本显示新数据源的 279 条记录状态分解为每条都是 seen,后续状态为零——这是第一天就应该运行的 outcome check。部署后,一个周期显示 120 条被接受并针对上限标记为 seen,686 条通过关卡的条目被明确留为 unseen 给下一个周期,而之前这些会被烧掉;这 120 条中实际处理的 88 条来自那个整个生命周期都零贡献的数据源。引发调查的那条特定条目现在同时通过了相关性关卡和评判器,评判器引用了正确的理由。评判器在涵盖三种必须通过和五种必须拒绝的角色类型的 fixture 集上进行了八分之八的回归测试,其中包括一个真正的地理限制职位,仍被正确拒绝。来源检查在 103 条重新验证的记录中纠正了两条,确认数据源大多数时候是正确的,纠正是保守的。评估工具运行了 136 个通过和 1 个失败,与变更前相同,那一个失败是一个信用额度已耗尽的不相关提供商,工具正在正确报告。
队列不得确认它没有完成的工作。如果账本写入发生在容量限制之前,超过限制的所有内容都被记录为已处理并消失,没有任何错误——所以只标记实际完成的为完成,并将剩余部分记录为 deferred,这样差异保持可见。表达为成员资格的优先级不是优先级:稳定排序在组内保留插入顺序,所以你最想要排第一的条目最终落在它被追加的位置。而当你添加一个组件时,验证从远端输出了什么,而不是组件是否运行了。每小时记录健康计数但什么都不产出的数据源,在有人问它实际交付了什么之前,与正常工作的数据源无法区分。
为失败模式命名,才能在新地方识别出同样的形态,在它再花掉一个周末之前。
收据证明交付,从不证明处理。
当你把工作交给异步系统——队列、webhook、工作流工具、后台任务——你收到的响应意思是"我已收到此物"。它不意味着"我已完成此事",而且很多时候甚至不意味着"我打算做此事"。
这就是大量"数据凭空消失"事故背后的陷阱。发送端记录成功,接收端从未处理任何东西,两半各自看起来都健康。接受你的消息却从不读取的队列看起来和工作中的队列完全一样。
防御手段,按强度排序:
不要基于确认做分支。如果你的兜底逻辑读作"如果交接失败,自己做",它永远不会运行,因为交接报告成功。让本地路径无条件执行,让幂等性吸收重复。
从另一端确认。检查工作实际完成了——状态端点、结果记录、回调——而不是信任收据。
设定期限。如果预期结果在 N 分钟内未出现,视为失败并采取行动,而不是永远等待。
停掉的 job 会自我宣告。完美运行但输出略微错误的 job 永远不会。
你拥有的几乎所有检查都测量活跃度:它运行了吗,它返回了吗,它退出零了吗,它发布了吗。几乎没有任何检查测量正确性:它产生的东西是否正确。这是不同的属性,昂贵的事故都活在两者之间的差距里。
这种不对称性使它变得危险。停止触发的 job 是响亮的——输出缺失,某人一天内就会注意到。按计划触发但产生微妙错误输出的 job 是沉默的,只要没人读取输出,它就保持沉默,因为你拥有的每一个信号都在报告真相。调度器确实触发了。API 确实返回了 200。文件确实被写入了。每一项检查都诚实通过,而唯一重要的事情却失败了。
两个需要警惕的形态:
调整而非拒绝的安全网。守卫捕获了一个坏条件,然后修改输入以便操作继续——重命名冲突的键、截断超长字段、强制转换错误类型。错误从日志中消失,坏条件还是被交付了。永不拒绝的守卫不是控制手段;它是一个洗白步骤,把真实信号转换成干净的日志行。更倾向于失败关闭:跳过运行是廉价的且可见的,错误运行是昂贵的且不可见的。
与现实漂移的记录。任何与缓存、状态文件或本地账本比较的检查,其质量取决于那个记忆。当记忆可以被重启、新机器、或写入一处读取另一处的路径截断时,检查会悄悄降级并持续返回"正常"。尽可能用工件本身来种子化记忆,并定期断言两者仍一致。
实际防御是添加一个读取输出而非退出码的检查,而且要让它成为人类真正会注意到的东西——一个应该稳定的数量、一个唯一性约束、与上次发布内容的现场比对。你不是在验证一切,而是试图拥有至少一个在 job 错误成功时失败的信号。
这是代价最高的 bug 类,因为时钟在每个人都认为一切正常时继续运转。
静默失败不是崩溃。崩溃是响亮的且会被修复。静默失败是一个组件做出了可辩护的本地决策——丢弃此消息、跳过此记录、返回一个空字符串——而没有告知任何下游。从外部看,完美工作的系统和完全死掉的系统可能产生完全相同的观察:什么都没发生。
防御不是"添加更多日志"。而是使健康状态可证明,这样"什么都没发生"可以与"什么都没应该发生"区分开来。两件事可以做到这一点:
记录结果,而非尝试。"发送通知"什么都不告诉你。"通知 DELIVERED (id 4661)"对比"通知 REJECTED 400"告诉你一切。
运行金丝雀。通过真实路径按计划推送的合成交易,当它没有从远端出来时会大喊。没有金丝雀,你就依赖客户来报告你的中断。
配置告诉你某人打算什么。日志告诉你发生了什么。
一个设置、一个环境变量或一个存在的 API 密钥是意图的声明。它是某人打算让某种行为发生的证据。它不是该行为发生的证据。
两者之间的差距是最长宕机生活的地方,因为读取配置感觉像验证。它产生自信的、错误的声明:密钥已设置,所以提供商工作;计划说每十五分钟,所以它每十五分钟运行一次;文件已部署,所以新代码正在运行。
每一项都有一个便宜的、决定性的检查,成本只需几秒:
探测依赖,不要读它的凭证。存在的密钥不能证明其背后有余额。
搜索动作行,而非设置行。启动横幅证明进程开始了,不能证明它做过自己的工作。
部署后比较时间戳。如果运行中的进程比磁盘上的文件更旧,它仍在从内存中执行前一版本。
这带来的规则:永远不要从配置报告系统行为。搜索证明行为发生的行并引用它。
本文是生产工程经验运行 wiki 的一篇条目——每个概念都链接到教会它的那个事故——在 aideazz.xyz/ai-ops-wiki.html。
这些 write-up 中不出现客户数据、凭证、主机名或内部记录标识符。