部署流水线本质是状态机:节点 RUNNING → 终态 → DAG 传播 → 计划收尾。这条链路上任何一枚事件丢了,用户看到的就是「部署卡死、无任何按钮」。这篇文章复盘我们的兜底设计——结论先行:可靠消息系统覆盖不了真实环境里最常见的丢失场景,最终方案是给整条链路加一个定时扫描器。
一、链路拆开看:两段,两种丢失
外部系统(Jenkins、K8S、ArgoCD)完成构建后回调平台,平台内部再经事件总线驱动后续节点。整条链路有两个可能丢失的位置,性质完全不同:
| 丢失位置 | 根因 | 可靠消息能否解决 |
|---|---|---|
| ① 外部回调丢失 | Jenkins 任务被 kill、回调 Job 超时、网络抖动 | ❌ 回调根本没进来,与消息系统无关 |
| ② 内部事件丢失 / 重复 | 轻量开发基座的 fire-and-forget;生产环境 at-least-once 带来重复投递 | ✅ 大部分消除,残余靠消费端幂等 |
这就是核心洞察:broker 解决的是「自己人之间」的可靠性,覆盖不了「外人根本不回调」——而后者恰恰是真实环境里发生频率最高的故障。所以在生产环境换上 RocketMQ/Kafka(方案 A,随交付部署落地)之外,我们还需要一个全覆盖的兜底(方案 B)。
二、扫描器:不是新机制,是「另一个回调入口」
设计上最关键的一个决定:扫描器发现超时后不发自研事件,而是同步直调领域服务的同一个入口——和 HTTP 回调走完全相同的代码路径。
为什么?想想兜底组件的处境:它存在的理由就是「事件不可信」,如果它自己再发一枚 fire-and-forget 事件,丢了怎么办?再套一层兜底?递归没有意义。
直调之后的好处自动成立:
- 终态守卫(幂等):回调先到就跳过,不会误杀刚完成的节点;
- DAG 传播、告警推送、计划收尾:全部复用既有逻辑,一行不写;
- 兜底链收敛:扫描器发出的下游事件若又丢,下一轮扫描周期再兜——最多晚一个周期,不会永久丢失。
这是「把兜底设计成普通入口」的思路:系统里触发状态流转的入口可以是多个(HTTP 回调、消息消费、定时扫描),业务规则只在一处。
三、防误杀的四个细节
兜底机制最大的风险是把正常的慢操作杀掉。我们在规则里做了四层防护:
- 默认阈值保守:全局默认 30 分钟,覆盖最慢的构建类型;
- 最小下限 60 秒:即使节点误配了 10 秒超时,也按 60 秒兜底——配置错误不该变成生产事故;
- 时钟起点明确:以节点启动时间为判定基准,而不是进入队列的时间;
- 误杀可恢复:超时标记的 FAILED 不是终审——节点支持重试、计划支持重新发起,用户一键即可恢复。
另外还有一个隐蔽场景:DAG 断链。所有节点都已终态,但中间一枚事件丢失导致传播中断——计划永远 RUNNING,却没有 RUNNING 节点可供扫描。对策是计划级检测:RUNNING 计划若长期(宽限 30 分钟)无任何活动节点,判定为内部事件丢失,整体标 FAILED 可重试。
四、实施成本与边界
这套机制的落地成本出乎意料地低:
- 零数据迁移——建表时预留的超时列直接启用;
- 零新增领域逻辑——扫描器与回调入口、消息消费者对称放置在适配层,三者共用同一个领域服务;
- 配置即纪律——扫描间隔、各阈值全部配置化,开发环境可以调短到秒级做演练。
两条边界需要如实记录:其一,当前单实例部署下定时任务天然安全,未来多副本需要加分布式锁;其二,扫描是秒级精度的兜底,不能替代实时链路的告警——它的定位是最后防线,与 K8S rollout timeout、消费端幂等这些局部兜底互不冲突、逐层收敛。
小结
可靠性设计里最容易被高估的是消息中间件,最容易被低估的是「用一个笨但确定的扫描器兜住一切」。中间件解决系统内部的传输问题,而分布式系统真正的不可靠往往来自边界——第三方不回调、时钟不同步、人配置错了。给状态机的每个可能卡死的位置找一个确定性的出口,是比堆中间件更划算的投入。