后台任务状态机:让排队、取消和重试可解释

后台任务最难排查的时候,不是它失败,而是你说不清它现在算什么。表上同时有是否运行、是否取消、重试次数和完成时间。运行标记是真,取消标记也是真,完成时间是空,重试次数是三。值班的人无法回答:它还会不会再跑,取消到底生效没有,这三次重试里哪一次把外部调用写了一半。把状态收成一台机器,每次变化只走允许的边,边上留下原因,排队、取消和重试才变得可解释。

后台任务状态机,让排队、取消和重试可解释
后台任务状态机,让排队、取消和重试可解释放大图解 ↗

这篇用“发布笔记”当例子。任务不是线程,线程死了任务还在;任务也不是消息,消息丢了任务还应该能被找到。任务是一行有状态的记录。消息和线程都只是让这行记录往前走的工具。

用状态代替一组互相打架的标记

一台够用的机器不需要很多状态,但每个状态的含义要独占。可以是这些:queued 表示已接受、尚未被拿走;leased 表示某个工作器持有租约、正在执行;succeeded 表示副作用已经完成;failed 表示这次尝试失败、还会再排队;cancel_requested 表示调用方要求停止、执行者尚未确认;cancelled 表示已经确认不会再产生副作用;dead 表示尝试用尽或错误被判定为不可重试,需要人来看。

标记列做不到独占。运行与取消可以同时为真,因为它们是两次写入。状态列只有一个值,迁移用条件更新:where id = ? and state = ?。受影响行数为零,说明别人已经把它迁走了,当前这个动作作废,重新读取再决定。不要读出来放在内存里判断完再无条件写回,那会把旧状态覆盖到新状态上。

终态是 succeeded、cancelled 和 dead。终态不再被工作器拿走。若业务允许人工重放,那是一条新的、显式的边,例如从 dead 到 queued,带上操作者和原因,并增加一次人工重放计数。不要把终态改回运行中而不留痕迹。failed 如果只是“等待回到队列”的瞬态,就不要让它停留很久,否则界面上会同时出现失败和即将重试两种说法。要么迁移记录写清“失败后已重新排队”,要么界面只展示当前态和下一态时间。

有人会再加一个 running,和 leased 并存。两个词若不能说出差别,就删掉一个。差别若是“已领取但还没开始副作用”和“正在调用外部”,那可以保留,但两边的进入和离开都要有边。空有名字、没有边的状态,最终会有人用脚本直接改进去,机器上出现一条不存在的路。

排队时就要能说明它为何在队列里

进入 queued 的原因不止一种。用户刚提交,是新任务。上次尝试失败,是重试。人工从 dead 拉起来,是重放。这三种对调用方的意义不同。表上除了状态,留下尝试次数、下次运行时间和原因。新任务的尝试次数为零,下次运行时间是现在。失败重试把尝试次数加一,下次运行时间按退避计算。重放要写明是谁要求的。

队列里可以有延迟。不到下次运行时间的行不会被拿走。扫描条件是状态为 queued 且时间已到。取消一个还没到点的任务,就是把它从 queued 迁到 cancelled。这一步没有工作器参与,原因写“在执行前取消”。若此时正好被拿走,条件更新会失败,调用方再看一眼:已经是 leased 的话,改为写入 cancel_requested,而不是报一个含糊的冲突。冲突不是失败,是状态已经变了,调用方要顺着新状态再走一步。

可见性要比“发到消息中间件”更明确。中间件负责投递,数据库负责解释。两边都有一份状态时,以数据库为准,中间件消息只是唤醒。消息丢了,扫描仍能把到点的 queued 拿走。消息重复了,条件更新保证只有一个工作器把 queued 改成 leased。不要以“消息还在不在”作为任务是否存在的依据。只靠消息、不落库的任务,进程一重启就无法回答用户“我刚才点的发布在哪里”。

入队本身要幂等。用户双击,或网关重试了提交请求,不应该变成两条发布。用调用方提供的幂等键,或用“笔记编号加目标动作加资源版本”生成一个唯一约束。冲突时返回已有任务的编号和当前状态,不要再插一行。两条任务会让取消变得尴尬:人取消了看见的那条,没看见的那条仍会发出。

租约让崩溃可以被看出来

工作器拿走任务时写入持有者、租约到期时间和尝试编号,状态变为 leased。执行过程中周期性延长租约。工作器崩溃后不再延长,租约到期,扫描把它从 leased 拉回 queued,原因写“租约到期”。这样不会有永远停在运行中的行。

租约时间要比单次执行的正常时长长,又不能长到崩溃后干等半天。执行若知道自己还要很久,就主动延长,而不是把全局租约改成一个巨大的数字。延长也是条件更新:只有持有者仍是自己且状态仍是 leased 才延长。人已经取消、状态已经离开 leased 时,延长必须失败,执行者看到失败就停止后续副作用。

租约到期不等于上一轮一定没做完。工作器可能在写完外部系统之后、把状态改成 succeeded 之前崩溃。恢复时另一轮会再执行。所以执行必须能重入:同一个任务编号执行两次,外部效果只相当于一次。做不到重入,就不要在崩溃后自动再排队,改为进入 dead 并写明“租约到期但副作用不确定”,让人核对外部系统后再重放。自动重试不确定的副作用,会把“可能发出了”变成“又发出一次”。

持有者编号要足够具体。只写主机名,一台机器上的两个进程会互相延长租约,崩溃的那个进程留下的工作会被活着的进程误认为还在进行。持有者写成进程实例加启动编号,重启后编号变化,旧租约不会被新进程续上。扫描到期租约时,也不要要求旧进程自己来清理。旧进程已经不在了,清理是扫描器的事。

取消是一条请求,不是当场杀死进程

调用方点取消的那一刻,进程未必能停。它可能正在等外部平台的响应,没有安全的中断点。所以取消分成两态。还在 queued,直接到 cancelled。已经 leased,先到 cancel_requested。工作器在步骤之间查看这个状态,确认不会再调用下一步,再迁到 cancelled。

步骤之间要选在副作用的边界上。已经向外部平台提交成功,就不能再迁到 cancelled 假装没发生,应迁到 succeeded 或一个“已提交、等待回执”的状态,并记下“取消晚于提交”。用户看到的解释是:取消没有赶上提交。这比过几分钟突然变成已发布、却没有任何原因要好。

强制杀死进程可以作为运维手段,但它不是状态机里的普通边。杀死之后租约会到期,随后的去向仍按上面的规则:能重入才回队列,不能重入就进入 dead。不要提供一个“强制改成已取消”的按钮却不检查外部副作用。那个按钮会把真实世界和表写分裂。

取消请求本身只接受一次。重复点击要看到同一个结果,而不是把 cancelled 再迁走。接口按任务编号查询当前状态,终态就返回终态的原因。若产品允许“取消之后再次发布”,那是创建一条新任务,不是把旧任务从 cancelled 拉回 queued。旧任务的历史要保持可读,新任务用新的编号。两条记录把“曾经取消过”和“现在又要发”分开,界面才不会把旧的失败原因安到新的尝试上。

重试要分得清错误

失败不是一种状态的终点。短暂失败记录错误类别、错误摘要、第几次尝试,然后迁回 queued,并写上下次运行时间。不可重试的失败直接进入 dead。类别要在代码里枚举,不要用错误信息的文本匹配来决定会不会重试。文本会变,类别是契约。

哪些算短暂:网络超时、外部返回的拥挤、数据库死锁。哪些算不可重试:参数不合法、资源已不存在、授权不再成立。授权失效尤其不要重试。重试不会让人重新加入账号,只会把同一条拒绝重复写进日志,让人误以为是外部平台不稳。

尝试次数有上限。到达上限进入 dead,原因包含最后一次错误类别和尝试次数。上限不是一个全局魔法数字。向外部平台发布的上限可以是几次,本地生成预览的上限可以更高。每次上限都要让 dead 的原因里看得见。

重试不要立刻开始。立刻重试会在外部故障时把请求打成一堵墙。下次运行时间按退避计算,并加上抖动,避免同一批任务在同一秒醒来。退避的具体算法可以单独设计,状态机只要求:存在下一次时间,且这行在那之前不会被扫描走。扫描若只看状态、不看时间,退避就只是一个被忽略的字段,所有失败仍会立刻再跑。

部分成功不要整单重试。图片已经传完、正文提交超时,若把任务无条件放回 queued 并从开头执行,图片会再传一次,外部平台上出现两套素材。步骤进度要记在任务上:哪一步已确认,下一步从哪里继续。进度的写入和状态迁移一样,用条件更新,避免两个工作器同时把同一步记成完成。

每次迁移都留下可查询的边

只改当前状态,仍然解释不了“它怎么变成这样的”。另有一张迁移记录:任务编号、从哪一态、到哪一态、原因码、错误类别、操作者或工作器编号、时间、尝试次数。当前状态是这张记录的最后一条的投影。排查时按时间读边,而不是猜标记列。

原因码用稳定的词,例如租约到期、执行前取消、提交后才取消、授权失效、尝试用尽。句子给界面用,可以改。不要只存一句给人看的话,下次改文案,旧数据就无法归类。原因码一旦发出去被外部系统依赖,就不要复用旧码表示新的意思。新情况用新码,旧码保持原义。

用户问“为什么没发出去”,界面应当能回到这些边:已排队,被拿走,外部超时,计划在某时重试,重试前用户取消,于是停在已取消。这条叙述不需要值班的人查三张表对时间。若叙述对不上外部平台的实际调用,就是迁移记录漏记了,去补记录,而不是在界面上再堆一个状态。

迁移记录只追加,不修改。写错了原因,再追加一条更正,不要把旧行改掉。旧行是当时的判断,更正是事后的判断。覆盖掉之后,你就无法解释为什么当时会重试。审计和状态机在这里是同一张时间线,不是两套各写各的日志。

和真实副作用对齐

状态迁到 succeeded 的时机,是副作用已经确认,不是任务刚被拿起来。先改成成功再去调用外部,调用失败就会出现成功态下面没有结果。先调用再改状态,崩溃会重入,所以调用要带上任务编号作为幂等键。外部平台若没有幂等键,就在本系统记“已提交”的证据:外部返回的受理编号。恢复时先查证据,有受理编号就去问结果,不要盲重新提交。

发件箱可以用来把“数据库里的迁移”和“对外的调用”绑在同一事务里:事务内写入状态和一条待发送记录,事务提交后才发送。发送器崩溃,待发送记录还在,可以继续。这样解释权仍在数据库,不会出现消息已发、状态还停在排队的长期分裂。短暂分裂会有,投递器还没跑完的那几秒,查询要能显示“已提交、发送中”,而不是显示成失败。

等待外部回执是一个独立状态,不要占用 leased 太久。提交已经完成,就释放租约,进入“等待回执”。回执到来再迁到成功或失败。若一直不来,由扫描器按超时迁到 dead 或重新查询,而不是让工作器握着租约睡到回执出现。握着租约会把工作器占满,也会让取消的语义变模糊:人取消的是等待,还是已经提交的发布。两个动作要能分开解释。

边界

并行工作器不止一个。正确性靠条件更新和租约持有者,不靠“我们平时只开一个进程”。只开一个进程的部署一旦扩成两个,没有条件更新的代码会双发。测试里用两个并发的领取来断言只有一个成功。

时钟会影响下次运行时间和租约。工作器所在机器时间超前,会提前领取延迟任务;时间滞后,租约会看起来老是过期。对时是运行条件。在此之上,租约判断用数据库的时间,不要用应用机器的本地时间写进表再和另一台机器比。

人为改表是状态机之外的路径。紧急修复若直接改状态列,补一条迁移记录,写明是人工修正。否则下一周的排查会看到一条不存在的边。禁止写一条没有记录的更新脚本。脚本若必须批量把某类 dead 重新排队,它就是一条正式的迁移,输入是原因码和时间范围,输出是每条任务的新状态,而不是一句不带条件的更新。

子任务不要复用父任务的状态。渲染、审核、发布若是三步,每步有自己的状态,父任务的状态由子任务汇总。把三步塞进一个整数状态里,取消渲染和取消发布会没法分开解释。父任务可以说“发布已取消、渲染已完成”。这比一个笼统的“已取消”更能回答用户。汇总规则要写死:有一步进入 dead,父任务不要还显示成排队;有一步仍在等待,父任务不要显示成全部完成。

收束

让每个任务在任一时刻只处在一个含义明确的状态里。领取、延期、成功、失败、请求取消、确认取消、耗尽,都是条件更新的边,边上写原因。还没执行的取消直接生效;正在执行的取消在副作用边界上确认;失败按错误类别决定回队列还是进入需人工处理的终态;崩溃靠租约暴露出来,再按能否重入决定要不要自动再跑。能把一条任务从入队讲到停住,而不用猜测几个布尔值哪一个算数,这台机器就够用了。解释不了的状态,不要先加给界面,先补一条边。

见字如晤

图解