异步任务最让人不安的时刻,不是它明确报错,而是它看起来已经完成了。
这次检查几个核心自动化流程时,我先把问题分成了 4 类:没有响应、执行超时、连接中断、重复调用。原本我以为,既然流程已经有超时和恢复机制,剩下的大概只是把边缘情况补齐。但沿着每个状态往下追,我发现更危险的不是“失败没有被处理”,而是“没有完成却被写成完成”。
一个任务可能只留下了一段助手文本,就被判断为成功;一次投递实际上失败了,却留下了一个像是已跟踪的任务外壳;回执来自另一条消息,却因为属于同一个线程而被接收;卡住的任务被重新触发,没有去重,于是同一件事被连续投递了几次。表面上看,系统一直在工作。真正的问题是,成功信号是否真的对应这一次任务。
一段助手文本,不等于一次任务完成
我一开始最容易接受的判断是:只要后台有进程退出,或者对方返回了一段助手文本,就可以把任务标成成功。这个判断在顺利路径上很省事,因为大多数任务确实会以一段文本结束。可是状态的含义不能由“看起来像结果”来决定。
一次异步任务至少要经过发送、接收、进展、完成、失败或超时这些阶段。发送成功,只能说明某个请求被交给了通信层;接收成功,只能说明对方看到了它;出现文本,只能说明产生了某种输出。它们都不能直接证明任务已经完成,更不能证明这段输出属于本次任务。
复核时我看到,失败、错误和终止状态曾经与完成状态共用同一种绿色终态。上层只要发现有助手文本,就可能判成功。这样做的后果很具体:真正的错误会被吞掉,后面的流程不会重试,使用者看到的却是一个已经结束的标记。等到发现产物缺失时,最初那次失败已经很难还原。
异步任务的成功,必须由明确的完成终态和产物验收共同证明,不能由一段看起来像答案的文本代替。
这也是我判断状态机必须先于通知界面的原因。界面上的绿色勾只是状态机的一个投影。如果底层没有把“已发送”“已收到”“已完成”和“已验收”分开,界面越清楚,反而越容易把错误放大给人看。
真正的难题是把结果和任务对上
这件事表面上是回执处理,实际上是身份管理。回执之所以会误导人,不一定是它的内容完全错误,而是它有可能属于另一件相似的事。
当时的匹配规则只按线程做粗匹配,没有同时校验任务身份和回复关系。后发消息只要出现在同一个线程里,就可能顶替真正应该等待的回执。若两个任务都要求对方继续处理,而它们的文本又相近,系统甚至不会立刻暴露异常。它只会把一个合理的答案放到另一个任务名下。
这种错配比明确失败更难排查。失败至少会留下一个需要处理的红灯,错配却可能留下一个内容完整、语气正常、看起来很专业的绿灯。如果不处理,后续动作会沿着错误结果继续执行,越往后越难判断是哪一步把身份弄丢了。
所以我选择把每次任务看成一个有唯一身份的对象。发送时生成任务标识,接收时校验任务标识、所在线程和回复所对应的原消息;进展可以更新这个对象,但不能另建一个含义不明的成功状态;完成还要指向可检查的产物。这样做不是为了增加字段,而是为了回答一个最基本的问题:这条回执究竟在回答谁?
超时不是重投按钮
最容易误判的地方,是看到超时就直接重投。因为任务没有动静,人的直觉会把它理解成“对方没收到”,于是再发一次似乎最积极。但超时只说明在规定时间内没有拿到足够证据,它没有证明第一次投递一定失败。
如果第一次任务已经在执行,第二次投递就可能造成重复执行;如果第一次只是回执丢失,重投会让同一项工作出现两个结果;如果第一次已经完成,只是上层没有读到,补发反而会制造新的冲突。恢复机制没有锁,也没有幂等记录时,重复触发会给同一会话灌入多条继续指令。系统越努力恢复,越可能把原本单一的故障变成叠加故障。
我没有直接把超时统一改成失败,是因为超时是一种“证据不足”的状态,不等同于业务失败。它需要先进入待确认或恢复中的状态,再根据任务身份、已知进展和时间窗决定是等待、查询、补发还是终止。这里的保守不是拖慢流程,而是避免用一次不确定的判断制造更多不可逆动作。
重试的前提不是时间到了,而是确认上一次动作没有产生可接受的效果,并且本次重试不会重复改变外部状态。
方法上,恢复动作至少要有锁和幂等记录。锁负责避免多个恢复进程同时处理同一个任务;幂等记录负责记住某个恢复动作是否已经发出、是否仍在时间窗内、是否已经得到结果。即使进程被中断,下次也应该能够根据记录继续判断,而不是把“没在内存里看到结果”当成“从未执行过”。
投递成功,也可能只是任务壳成功
另一个容易被绿色状态遮住的问题,是投递失败之后,系统仍然留下了“已跟踪”的任务。任务壳存在,会让后台认为自己已经接管了这件事,但它并不代表消息真的到达,也不代表后续有人会读取。
非唤醒消息尤其容易落入这个缝隙。它可能被写入某个接收位置,却不触发对方读取;接收方永远不去取,消息就会永久悬空,系统仍然把它当成已送达。这里缺的不是更多通知,而是对“送达”这个词的定义:写入队列是投递尝试,收到确认是通信证据,被正确消费才是接收证据。三者不能共用一个布尔值。
后台进程的退出码也必须被上层真正读取。否则进程已经失败,上层却只看到它曾经被启动过;任务因此从“执行失败”变成“看起来有人处理”。如果不处理,后续 sweep 只能在一堆不可信的任务壳里猜哪些仍在运行,最终要么漏掉失败,要么对已经成功的任务重复提醒。
我会把投递过程拆成两条证据链:一条记录是否成功发出,另一条记录是否得到正确接收和消费。任何一条缺失,都不能直接推进到完成。后台扫描也要设置时间窗,并记录已经提醒过的任务,避免每次扫描都把同一个悬空任务重新提醒一遍。
从状态机到验收证据
经过这次检查,我把验收顺序重新排了一遍。先建立任务身份,再记录发送证据;随后等待带身份的接收或进展;只有收到与任务、线程和回复关系都匹配的回执,才允许更新状态。完成时还要检查产物是否存在、是否属于本任务、是否满足最小要求。没有产物的“完成”,最多只能算收到文本。
失败、终止和超时不再共享完成颜色,也不共享同一个后续动作。失败要记录发生阶段,说明是发送、接收、执行还是验收出了问题;超时要记录最后一次可信进展和下一次检查时间;终止要说明是用户取消还是系统保护。每一种状态都应该能让下一个动作自然确定,而不是让操作者重新翻日志猜。
这件事让我重新意识到,一个流程成熟的标志,不是它能跑通一次,而是它能在不确定时保持诚实。诚实意味着不知道就标成未知,没验收就不说完成,可能重复就先停住。自动化的价值不是把所有动作都往前推,而是把证据链保留下来,让人知道什么时候可以放心继续。
可靠的异步系统不是让所有任务都显示成功,而是让每一个成功都能被追溯、被匹配、被验收。
下次我会怎么做
下次设计异步任务,我会先画出最小状态机,再写通知和恢复逻辑。状态至少区分发送、已接收、进行中、已完成、失败、超时和已终止;每次状态变化都带着同一个任务身份,不能只靠线程或文本相似度关联。
我会把 4 个问题作为固定门槛:这条证据证明了哪一步?它是否属于这一次任务?如果回执丢了,系统能否知道动作是否已经发生?如果再次触发,会不会产生重复副作用?只有这 4 个问题都能回答,才考虑自动重试。
最后,我会把“成功”拆成两个动作:先确认任务进入完成终态,再验收实际产物。后台 sweep 只处理超过时间窗且没有已提醒记录的任务;恢复动作必须经过锁和去重;投递失败必须向上冒泡,不能留下一个让人误以为仍在运行的壳。
表面上我是在检查几个异常分支,其实我是在重新定义什么叫完成。真正难的不是让系统在顺利时更快,而是在无响应、超时、断开和重复发生时,仍然不把猜测包装成事实。这个判断可以复用在任何异步自动化里:先问成功信号是否对应这一次任务,再问它是否真的交付了结果。