定时任务连跑40次全绿,结果一次活都没干
凌晨两点半,一个定时任务准时醒来,从接口拉取新文件,整理后丢进队列,等着第二天早上喂给我的简报。这套流程跑了大约六周,每一次的退出码都是0,日志里干干净净,调度面板上挂着 连续四十次绿色运行 。如果有人问我哪段自动化最靠谱,我会毫不犹豫报上它的名字。 然后我发现简报变薄了。不是空白,是变薄——有些天两条,有些天没有。我一直用"最近没什么新闻"来解释。直到某天起了疑心,翻出日志,才发现自己连怀疑的抓手都没有:日志上写着"已抓取条目,已写入队列",就这一句,没有数量,没有ID,是我自己当初随手写下、从未验证过的一句话。 于是我加了一行代码——一个计数——手动重跑。 零。每一个夜晚,都是零。四十次,全是零。 一个被悄悄改名的参数 HTTP请求返回的是200,附带一个空数组。原因是查询里的一个参数名在对方那边被改掉了,从 since 变成了 from_date ,而接口对未知参数的处理方式是——愉快地忽略它。我的循环遍历了一个空列表,然后正常结束,这正是一个遍历空列表的循环该有的行为。没有异常抛出,没有重试触发,没有任何东西被记成问题,因为在这套技术栈的每一层看来,确实什么问题都没有。 这就是我想写的故障模式。硬故障我有熔断器兜着,濒死的智能体有健康监控盯着,重复触发的任务有去重挡着。这三样东西都在盯着"有事情发生"。没有一样在盯着"没有事情发生"。而"没有事情发生"不会抛异常。 报错会呼你,沉默不会。 这件事拖了六周才被发现,原因在结构上,不在懒惰上。异常有一个瞬间:它触发,处理器捕获,告警发出。而一次无声的空转没有瞬间,它只有一段时间上的形状,而且只有当你画对了图表才看得见。 我的监控画错了图表。我追踪的是运行状态(成功/失败)和运行时长,两项都完美。我没追踪的是唯一重要的那个数字: 产生的效果 。运行完成了,效果是零。我有一块绿色的面板,因为它测量的是系统对自己的评价。 断言效果,而不是断言尝试 修复方案不是加更多日志,而是换一个问题:不问"它跑了吗",问"它改变了什么吗"。 现在每个任务都必须声明一次成功运行在副作用层面长什么样——写入了多少行、发出了多少条消息、创建了多少文件、吐出了多少个ID。然后任务在报告成功之前,必须先断言这一点。 关键不在那个辅助函数,而在于任务现在可以因为"什么都没做"这个罪名而失败。在此之前,"什么都没做"和"什么都做了"是同一个结果——成功。这意味着我的成功信号测量的是另一个宇宙。 有两件事我必须做对,而第一次都做错了。 不能失败的断言不是断言。我的第一版写的是"行数大于等于0",那是一件穿着安全背心的同义反复。它在空列表上通过了,让我又安心了两天。断言必须真的能在你担心的那件事上失败——这话听起来显而易见,直到你在凌晨一点写下它。 断言效果,而不是断言尝试。状态码等于200是尝试检查,写入ID数量大于0是效果检查。整起事故就是所有尝试检查都通过、而效果为零的案例。如果你的断言能被一个什么都没做的系统满足,那它不是断言,是装饰。 预期为空,不等于意外为空 这里我差点矫枉过正,也是最微妙的部分。 我有些任务在大多数时候本来就该什么都不做。一个盯着信息流新条目的观察者,在平静的日子里就该找到零条。如果我把"零效果"在所有地方都设成硬失败,那些任务会每晚尖叫,我又会回到静音告警频道——而我早就知道,那比没有告警更糟。 所以断言不能是"产出大于0",而必须是"数据源被成功查询了,而且这个空是可信的"。这是另一种检查:请求成功了,返回了预期的结构,而且这个空是一个明确的、格式良好的空,而不是一个默认值。在我的案例里,破绽一直摆在那里:响应里没有分页信封。一个真实的空结果会带着游标和总数回来,一个被静默忽略参数的空,回来的是一个光秃秃的方括号。 落到代码上:对那些可能合法地什么都不产出的任务,断言信封,而不是断言数量。请求成功、结构正确,那么这里的零就是可以接受的——因为我们已经证明了查询被真正执行。 这样一来,"平静的一天"和"接口不再理会我的请求"看起来就不一样了,而这正是全部的意义所在。 三种状态,而不是两种 这才是最终让两类任务都能用起来的改动。现在每一次运行都以三种状态之一结束,而它们不是一回事: succeeded_with_effect ——它做了那件事,附带一个计数。 no_work_needed ——它什么都没做,并且证明了"什么都没做"就是正确答案(上面的信封检查通过了)。 failed ——包括 JobProducedNothingError ,也就是说它跑了,而效果的缺席没有得到解释。 只有第三种会告警。第二种是一个正常的、无聊的、预期之中的结果,在简报里表现为一行安静的字。在这个拆分之前,"什么都没做"和"干成了"无法区分——而告警系统也就无从分辨。 特别