On Scheduled Jobs as Production CodeCron 不是兜底 · 是第二条生产线
一个租赁归还的订单, 卡了快一天。
东西早就还回去了, 系统死活不认。我去查的时候才发现, 不是某一个 bug, 是五个问题叠在一起: 上游一个接口那阵子被平台封了, 主路径直接废; 退而求其次的那条降级路径, 恰好也碰到页面不返回需要的数据; 交易另一方的客户端没开, 那个视角根本没接上; 每次交易物品的内部标识都会变, 按旧标识去比对永远是错的; 最后, 没有任何一条路径去看那个最硬的事实 —— 东西到底从对方手里转走了没有。
这五个里随便修好哪一个, 这单都不会卡。但五个一起塌, 它就死在那。那次之后我对周期任务的看法变了。它不是兜底。它是第二条生产线 —— 平时穿着"兜底"的衣服, 手里却握着和主链路一样的写权限。
兜底的幻觉
会想要兜底是正常的。真实系统总有毛刺: 用户断网, 支付回调慢, webhook 在重试, 数据库连接抖一下, worker 被重启。指望同步链路一次把所有中间态都收干净, 不现实。
所以系统里会长出一排定时任务。每隔几分钟扫一遍, 把卡住的订单往前推, 把过期的占用释放, 把没跑完的补偿补上。这些任务本身没问题。出问题的是把它们当"不重要的兜底"对待 —— 于是它们没有主链路那一套边界检查, 没有同等粒度的日志, 没人拿真实环境验证过, 时间窗也是拍脑袋定的。可它们改的是订单、库存、钱包。事故模型大概长这样:
主链路: 用户刚付完款, 状态还没完全回写
定时任务: 扫到"已移除 / 已过期 / 挂太久"
动作: 清库存, 或者把订单取消
结果: 两条链路都觉得自己是对的
问题不在谁更聪明。问题在于系统里有两个能改同一份资产的执行者, 而它们没有共用同一套安全判断。
一个时间戳不是事实, 是中间信号
有一类事故我印象很深。判断"东西是不是已经移走"的逻辑很朴素: 某个"已移除"的时间戳字段有值, 而且超过几分钟, 就当这件事成立, 推进后面的流程。
结果上游状态回滚解锁后, 库存快照漂了一下, 好几单在本该结束的十几个小时之前, 被误判成已经完成。那个字段有值, 看着像铁证, 其实它只是某条链路里的中间信号 —— 写进去那一刻, 后面的支付、库存、通知、下一次完整快照都还在路上, 任务抢在它们前面盖了章。
再往深想一层: 用单侧的状态快照去判一件跨服务的事, 本身就不成立。快照会漂, 而且漂还会自我修复 —— 下一次完整快照把状态重置回去, 可基于错误状态已经做掉的决策不会跟着回退。真正不会撤回的是"事件"本身: 一笔交易完成了就是完成了, 这条记录一旦写下就不会变。靠状态字段推断"事情发生没有", 和直接认那条不可撤回的事件, 是两个可靠性级别。后来这块的根本解法, 就是从"查状态"换成"认双方都确认过的那个事件"。
所以现在看到定时任务靠"已移除""已过期""太旧"这类信号去触发写操作, 我都会先问一句: 这是不可撤回的事实, 还是还在传播路上的边缘态。
宽限期是承认现实会抖
宽限期常被嫌不够实时。占用过期了为什么不立刻释放, 信号出现了为什么不立刻清。
因为分布式系统里的"立刻"基本是幻觉。浏览器可能刚把请求发出去, 支付服务可能刚收到回调, worker 可能刚开始重试, 主表事务提交了但旁路索引还没跟上。你在一条链路上读到的时间, 不代表整个系统已经稳定。
还有一层更硬的兜底是物理时间。有些平台本身就有强制规则: 某类资产在一段时间内, 物理上根本无法转移。那么这段时间里冒出来的任何"已完成"信号, 必然是假的 —— 这不是业务上的保守, 是外部世界的硬约束, 可以直接当成一条写死的过滤条件。宽限期不是拖延, 是给现实的抖动留缓冲。我宁可要一个晚十几分钟清理、但不会误删用户资产的系统。
入口可以不同, 刹车必须相同
主链路和定时任务触发时机不一样没关系, 安全边界不一样就要命。有个方案差一点就上线, 让我对这件事记得很牢。
当时在设计一个跨服务的补偿流程。第一版想得很顺: 检测到异常后, 先插一条"待处理"记录, 让一个对账定时任务过几分钟接手, 把它推进到下一步。问题是那个对账任务的职责, 从来就不是"主动去推进"。它的设计目的只是验证已经发生过的动作有没有对上账, 外加超时补偿。它只验证, 不主动做那个关键的跨服务动作。于是系统自己插进去的那条记录, 永远等不到有人替它做那一步, 必然被判成失败, 那笔本该回滚的资源永远回不来。
最让我后背发凉的是: 这个方案过了好几轮内审和外审, 全程没人发现。直到最后有人真的把那个对账任务的代码从头读了一遍, 才把这个藏着的假设挖出来。结论很简单也很贵: 触发方必须自己把关键动作端到端做完, 不能"先记一笔, 等定时任务来收"。对账任务维持它的本职, 谁也别给它加"主动推进"的活, 否则它会和主动触发抢同一件事。
那次之后, 所有会写状态的周期任务我都拿这几个问题过一遍:
它写什么状态?
这个状态主链路是不是也会写?
主链路写之前有哪些前置检查?
定时任务复用了同一套检查吗?
它误判一次, 用户那边的后果是什么?
能不能先空跑 / 只审计 / 加宽限期?
哪个任务的答案是"它就清理一下", 我就自动把这句翻译成: 它有破坏现场的能力, 只是平时看着不像。
fail-safe 是行为上限, 不是 try/catch
代码里有 try/catch、有重试, 不等于有 fail-safe。fail-safe 的关键不是"出错别崩", 是"无论怎么错, 都不许越过某条行为上限"。
清理任务可以失败, 可以跳过, 可以报警, 可以转人工队列, 但在不确定用户资产状态时不许删东西。支付核对可以慢, 可以重跑, 可以标异常, 但不许因为一个中间信号就把已付款订单当孤儿处理掉。
说白了, 兜底自己也得有兜底。第一层是定时任务: 主链路没收干净, 它来扫。第二层是它自己不确定的时候必须停手 —— 证据不够就别推进不可逆的状态, 报警, 转人工。开头那个卡住的订单, 要是早有这第二层, 不会拖快一天, 会在第一个问题出现时就被推到人工面前。
看一个周期任务先问三句
有没有写权限? 只读监控和会写状态的任务不是一回事。只要会写, 就按生产链路的标准审。
有没有和主链路共享同一个对象? 它要是能改订单、库存、钱包、通知, 它就会和主链路抢同一件事的解释权。
不确定的时候有没有停手机制? 没有停手机制的兜底不叫可靠, 叫自信过度。
这三句比"它多久跑一次"重要得多。频率只决定事故来得多快, 边界才决定事故会不会发生。
第二个你
这件事跟那篇讲订单不被丢失 (№ 006)、跟讲密码轮换 (№ 011), 其实是同一件事的不同切面 —— 分布式系统里没有"原子操作", 数据库事务给你的那点原子性, 一跨服务边界就失效。周期任务不是舱底角落里那个安静的脚本, 它是带着写权限、能动用户资产、和你共用一份数据的第二个你。
开头那个卡了快一天的订单, 最后是手工捞出来的。代价不算大, 但那段时间一直提醒我一句话: 后台没有特权, 只有更少的目击者。"它只是兜底"这句, 我现在听到就自动翻译成"它能闯祸, 只是没人看着"。
「后台没有特权, 只有更少的目击者。」
BASTION NOTE · 013