懂得礼貌失败的自动化

让定时任务在上游服务不配合时依然可观察、可重试、有明确边界,并且不会扩大损害的设计模式。

发布于
2026年4月21日
更新于
2026年8月2日
阅读进度
2 分钟
标签
engineering, tooling, testing, systems
Read the English version

失败也是接口的一部分

自动化并不是在成功路径运行一次后就算完成。真正完成的标准是:未来接手的人能够知道发生了什么、哪些状态被改变、下一步怎样做才安全。定时任务经常发生在没有人注视的时候,因此它的失败路径必须比一个交互按钮携带更多上下文。

我倾向于使用明确的结果类型,把可以预期的运行故障与程序错误分开。调用者不需要解析任意文本,就可以决定应该重试、告警还是立即停止。

type RunResult =
  | { ok: true; processed: number; cursor?: string }
  | {
      ok: false
      code: 'UPSTREAM_TIMEOUT' | 'INVALID_INPUT' | 'PARTIAL_WRITE'
      retryable: boolean
      message: string
    }

async function runJob(): Promise<RunResult> {
  const input = await loadValidatedInput()

  try {
    const processed = await processBatch(input)
    return { ok: true, processed }
  } catch (error) {
    return classifyOperationalFailure(error)
  }
}

类型只是契约的一部分。任务还需要稳定的写入幂等键、短于调度器最终期限的超时、带 run identifier 的结构化日志、有上限的重试策略,以及处理部分进度的明确规则。

故障 危险反应 礼貌处理
上游超时 无限重试 达到上限后停止并保存 cursor
输入无效 悄悄跳过 写入前拒绝并指出具体字段
部分写入 重新执行完整批次 只对账最小的已确认单元
未知错误 一律标记可重试 停止、保留证据并要求人工检查

恢复顺序

  1. 如果幂等性不确定,先停止重复写入。
  2. 保存本次运行标识和最后确认的 cursor。
  3. 检查故障发生后,上游状态是否已经改变。
  4. 对比计划写入与实际确认写入。
  5. 只重试最小的安全单元。
  6. 把最终解决方法记录在原始告警旁边。 有用的告警应该具体,但不能暴露 secret。“任务失败”几乎不包含信息。“导入在第 12 批因为内容 API 超时而停止;cursor 8f2 之后没有写入;可以安全重试”,则为下一位处理者提供了明确起点。
  • 同一批次执行两次不会产生重复数据。
  • 日志不出现 secret 或完整请求正文。
  • 第一次写入之前已经完成校验。
  • 超时返回可识别的错误码。
  • 部分写入有记录清楚的对账路径。
  • 重试延迟有最大次数。
  • dry-run 可以说明将要发生的变化。

什么时候应该直接 throw?

违反程序员假设,或者出现调用方无法安全解释的状态时,应当 throw。对可以预期的外部故障,则返回类型明确的运行结果。这个区分应当保持小而清楚;过于庞大的错误层级,常常会重新制造它试图消除的模糊。

最好的自动化不需要英雄式操作。它会从故障现场留下一条狭窄但照明清楚的路,带人回到已知状态。

Bojin Li

写软件、系统,以及那些还没定型的部分。