Post

生图超时以后,能不能再试一次?

从事务提交到供应商调用,追踪一次生图超时留下的中断窗口,解释 Outbox、执行租约与重试决定为什么必须各自有据。

项目 阅读 19 点赞 0 评论 0

生图超时以后,能不能再试一次?

从一次付费生成,理解异步任务的提交、重试与结果不明

用户点下“生成”,等了很久,最后看到超时。界面上最自然的补救是一个“重试”按钮。

但对后端来说,超时可能意味着几件完全不同的事:任务还没进入队列,worker 没有领到任务,图片供应商正在生成,或者图片已经生成,成功响应却丢在了网络里。界面显示同一个“超时”,后端却不能采取同一种补救。

尤其是付费生图,再执行一次可能得到另一张图,也可能再产生一次费用。一个自动重试策略如果只看请求是否报错,就可能在用户毫不知情时重复消费。

我在 ProductFlow 里处理这条链路时,逐渐把可靠性的判断拆成三个问题:用户的请求是否留下了持久记录,当前是谁有权执行,以及外部世界已经发生了什么。 数据库、任务队列和恢复器分别提供一部分答案,任何一个组件都无法独自回答全部问题。

1 用户的一次点击,跨过了几次独立提交

ProductFlow 是一个商品图片工作台。商家提供商品照片与资料,编写或确认画面方案,再生成主图、卖点图和场景图。生成通常比普通 HTTP 请求耗时更长,因此业务 API 接受任务以后,由后台执行器继续处理。

把具体产品名称拿掉,这也是许多 AI 应用的共同结构:

用户提交生成数据库记录业务任务任务进入队列Worker 领取并执行供应商接受生成请求保存图片与业务结果用户查看结果

图 1. 一次生成经过多个独立系统。 箭头表示工作推进,不能被理解为一笔覆盖全链路的事务。

数据库能够保证自己那笔事务的原子性。Redis 管理队列消息,图片供应商管理生成请求;它们分别提交,也分别失败。进程恰好在两个步骤之间退出时,系统就会停在一种“前一步已经发生,后一步尚未确认”的状态。

假设数据库里已经创建任务,应用还没发送队列消息就退出。用户刷新页面能看到任务,但 worker 永远不知道有这项工作。反过来,先发送消息再写数据库,也可能让 worker 收到一个尚未成立的任务。

调整这两步的先后顺序,只是在移动中断窗口。我们需要让任务提交之后,仍有一份可以反复找到的发送意图。

2 Outbox 留下的是执行意图

我的做法是把业务任务与待投递记录写入同一笔数据库事务。事务成功,两者一起存在;事务失败,两者一起回滚。另一个常驻进程 dispatcher 扫描待投递记录,将任务身份发到队列。这就是事务型 Outbox 在这里的用途。

数据库事务:
    创建图片任务
    写入对应的待投递记录
提交

Dispatcher:
    查找到期记录
    尝试交给队列
    保存投递进展

这一改变给出了一个有限但有用的保证:只要业务请求已经提交,系统就不会仅仅因为提交进程退出而忘记投递意图。 它仍然依赖数据库可用、dispatcher 持续运行,以及失败记录能够被重新处理。

为什么需要两份记录

业务任务回答商家的问题:哪张图正在生成,使用什么方案,是否完成,结果在哪里。待投递记录回答执行系统的问题:哪个执行器要处理它,何时可以发送,发送尝试到了哪一步。

如果把两者压成一个状态,就很难解释常见的组合。图片仍在等待,但上一份队列消息已经消费;信封正常处理结束,但业务任务因为资料错误而失败。它们描述的事实本来就不同。

本文将待投递记录称为“信封”。同一次逻辑投递具有稳定身份,重复写入这份意图时可以识别已有记录。但信封身份不能直接成为供应商的幂等保证:供应商根本不一定认识这个键。

这个区分很重要。数据库里没有重复任务行,仍然可能发生两次外部请求。

为什么等待也要成为一条记录

生图任务通常不是马上就能执行。前面可能有别的图片占着生成名额,也可能需要在一次失败后稍等再尝试。最初看起来只是“等一下”,真正需要保存的却包括:等的是哪项工作、何时再来、期间能否取消、重启以后还能否找到它。

这些信息本来就与业务任务一起变化。把它们写进数据库,可以让取消、结果与下次投递时间在同一处核对。worker 收到的消息只带身份,不携带一份可能已经过时的完整任务快照。

这也是我们没有让一个长时间存活的 goroutine 独自记住等待的原因。进程可以随时重启,执行机会需要能够重新建立。至于数据库怎样从大量记录中高效找到到期任务,则是调度篇里的另一个问题。

3 一个容易被误读的状态:SENT

当前 ProductFlow 在发送消息之前,先将信封标为 SENT,再调用 enqueue;发送报错时保留这个阶段,记录错误,等待后续对账。

这个名称比它实际表达的保证更强。这里的 SENT 表示进入了可以消费、需要对账的发送阶段,不能据此认定队列已经确认收件。

为什么要提前改变状态?因为 worker 可能在发送函数返回之前就收到消息。消费端需要回数据库检查信封是否允许领取,如果此时记录还停在未发送阶段,它就会遇到队列与数据库之间的时序矛盾。

提前写状态也有代价:进程可能刚写完 SENT 就退出,消息根本没发出。另一种情况则是队列已经收到,客户端却没等到成功响应。这两种情况在发送方眼里都可能缺少成功证据。

中断发生的位置数据库看到的状态队列里可能发生了什么
标记发送阶段后,调用队列前SENT消息尚不存在
队列收到消息,响应返回前SENT消息已经可消费
发送返回明确成功后SENT消息已被接收,仍需看消费进展

因此,恢复器会核对发送时间与消费执行权。足够陈旧、又没有有效消费 lease 的信封,才按规则重新安排;尝试达到上限时也可能停止投递。

保留 SENT 的价值在于把歧义留给一个有证据的判断过程。它没有消除重复消息的可能性。迟到的旧消息仍可能在重投后到达,消费端必须能够应对。

4 消息到达,不等于获得执行权

4.1 Worker 还要回数据库领取

worker 收到信封以后,检查任务身份与状态,通过条件更新取得消费 lease。lease 是一份有期限的执行权,携带本次领取的 token。后续消费确认也需要匹配这份身份。

这样,两个 worker 即使拿到了重复消息,也不能仅凭消息中的任务 ID 同时认定自己拥有同一份信封。没领到执行权的调用可以结束,而不是继续发起生图。

消费结束之后,信封可以变成 CONSUMED。但它仍然不能代替图片任务的成功状态:业务执行器可能已经妥善记录了失败,随后正常返回。用户看到什么,应从业务对象投影,而不能把队列的处理成功直接翻译成“图片已生成”。

4.2 Lease 过期不会杀死旧进程

考虑一种交错:

  1. worker A 获得执行权并开始工作。
  2. A 暂时失去数据库连接,无法维持执行状态。
  3. 原执行权过期,系统允许新的执行者接管。
  4. A 网络恢复,带着旧结果回来提交。

如果更新条件只有任务 ID,A 就可能覆盖新一轮状态。更新还必须检查“我是谁、我是否仍然拥有这轮执行”。这类拒绝旧写入的机制,通常称为 fencing。

把这个条件简化成 SQL,就能看清它保护的是什么:

UPDATE task_execution
SET status = 'succeeded', result_id = :result_id
WHERE task_id = :task_id
  AND execution_token = :my_token
  AND status = 'running';

影响行数为零时,旧执行者不能继续假定自己提交成功。

需要注意,消费 lease 与业务执行身份保护不同写入位置。前者管理信封处理,后者管理业务运行。仅在队列入口挡住重复领取,不能推定所有业务终态写入都已拒绝迟到结果。

这些检查可以保护本地状态。A 已经发给供应商的 HTTP 请求,却不会因为数据库拒绝写入而自动撤回。

5 最难的窗口在供应商接受请求以后

5.1 本地能看到什么

假设供应商已经接受生成,可能正在计费和计算。worker 在结果保存之前退出。恢复器看到的是:任务没有完成,本地也没有可用图片。

据此推断“供应商失败了”,证据不足。成功响应可能尚未到达,也可能到达后还没来得及持久化。重新调用会把一次无法确定结果的操作,变成另一次确定发出的请求。

图片供应商业务数据库Worker图片供应商业务数据库Worker请求可能已经被接受网络中断或进程退出尚无可确认的生成结果记录进入外部调用阶段提交生成恢复只能读到已持久化的证据

图 2. 请求越过网络以后,本地记录可能停在成功结果之前。

即使在发请求前写入“即将调用”的记录,也不能精确证明请求已经越过网络边界:进程可能写完记录就退出。因此,保守的阶段记录也会产生假阳性,把一些实际尚未发出的请求留为不确定。

这正是代价所在。系统选择避免无依据重发,就会牺牲一部分自动完成机会。

5.2 为什么要保留 unknown

ProductFlow 的工作流恢复会检查执行阶段。对已过期、又无法证明可以安全重排的供应商调用,保留 unknown,普通重试路径不能将其重新当作明确失败执行。

unknown 描述的是知识边界:本地不足以确定结果。它应与“供应商明确拒绝请求”“本地参数校验失败”“图片已经成功保存”分别呈现。

对用户,单独展示一个英文状态当然不够。合理的产品表达应说明:生成结果暂时无法确认,自动重试已经停止;若再次发起生成,会作为新的操作处理,并提示可能的额外消耗。更好的方案是恢复已有结果,但这依赖供应商提供足够能力。

5.3 哪些证据能让系统继续

可获得的能力或证据可以支持的下一步需要核实的条件
本地已有完整、可核验结果补齐业务状态,复用结果图片与本次请求身份对应
供应商提供请求状态查询查询原请求,取回已有结果查询身份在中断前已持久化
供应商支持幂等键按同一操作身份重试有效期、参数约束与计费语义明确
明确证明尚未进入外部调用按业务规则重新安排证据覆盖真实发送入口
只有超时或连接错误继续保留不确定错误本身不足以证明请求未执行

适配器需要按供应商实际能力选择恢复方式。只有统一的 provider 接口,没有可查询的请求身份,依然取不回那次已发出的请求。

6 为什么不把重试全部交给队列

在 ProductFlow 中,Asynq 信封配置为 MaxRetry(0),繁忙、延后与失败后的重新安排由应用协议控制。这样,重试决定可以使用业务状态与副作用阶段,而无需让队列仅依据 handler 的错误判断。

这也给应用增加了明确责任:投递记录需要对账,执行权需要回收,业务任务需要恢复。队列配置并没有让这些工作消失。

繁忙、失败与结果不明,需要不同的后续动作

在实现里,排不到生成名额会释放这次消费机会,把信封放回待投递状态,稍后再来。这种情况没有发生供应商调用,也没有必要把它记成一次生图失败。连续批次中,一张图片保存后让出执行机会,再安排下一张,也属于正常推进。

真正失败时,应用才根据任务的尝试次数和执行阶段决定下一步。两类情况若都用一个普通 error 交给队列处理,队列就只能重复同一个 handler,无法知道是在等待容量,还是已经越过了付费调用的边界。

这里还有一个容易忽视的反馈:依赖越慢,失败越多;重试越急,依赖收到的请求越多。于是恢复动作反过来延长故障。稍后再试可以缓和竞争,次数上限可以限制一项任务继续投入的成本,但它们都不能替代“这次是否允许再调用”的判断。

数据库去重,为什么还不能保证只生成一次

可以分别数三件事:队列交付了几次消息,数据库提交了几次结果,供应商收到几次请求。它们不一定相等。

唯一约束能挡住重复身份入库,执行 token 能挡住旧结果写回,但供应商已经接收的两次请求,不会因为本地只留下一个结果就合成一次。要约束外部效果,需要对方认可同一个操作身份,或能按请求 ID 查回原结果。

把 HTTP 调用包进长数据库事务也没有帮助。数据库回滚时,供应商不会跟着回滚;事务还会在等待网络期间占用连接和锁。我们因此在调用前后用短事务记录阶段与结果,让外部等待发生在事务之外。

7 测可靠性,要让系统停在不方便的位置

正常生成一次,只能证明一条路径可以跑通。要验证前面的设计,需要控制中断发生的位置,并观察恢复之后的真实效果。

例如,为“供应商已接收、结果未落库”准备一个可控测试供应商:它先记录调用次数,再阻塞返回。让 worker 在这时退出,启动恢复,然后检查系统是否偷偷发起第二次请求。

只断言最后状态为失败或 unknown 不够。系统完全可能已经重复调用,随后才写了一个看起来正确的状态。

注入的条件关键断言
业务事务回滚任务与投递意图一起消失
事务提交后发送进程退出持久投递意图仍可被发现
enqueue 收件后模拟响应丢失不因一次错误立刻开启无约束双投
重复消息同时到达消费领取符合身份与状态条件
新执行者接管后放行旧写入旧身份无法改变当前状态
外部请求发出后中断恢复依据阶段处理,调用计数不无故增加
完整结果已保存,后续步骤中断检查能否复用结果,避免重做已完成工作

我们为提交通知、发送阶段、消费与执行权等机制设置了队列测试。这些测试的范围仍需与生产主张区分:测试适配器能稳定制造时序,真实供应商的幂等与计费行为需要另行验证。

对外宣称“任务绝不丢失”或“生图恰好一次”,要求远高于几个状态测试通过。文章中的这套设计支持的是有条件的恢复和明确的不确定性处理。

8 让重试成为一项可解释的决定

回到最初的超时页面,用户希望知道的是这次工作是否还在继续、已有结果能否取回,以及再点一次会发生什么。

要给出可信回答,后端必须保留请求身份、执行阶段和实际结果之间的关系。提交成功说明意图已经留下;消息消费说明某次执行机会已经处理;图片成功需要可以核验的产物。每个状态只表达它能够证明的事实。

当结果不明时,停止自动重发会让体验暂时不够顺畅。但把不确定性显式留住,才有机会通过查询、对账或新的用户决定继续处理。对一次付费生成而言,这比在后台悄悄再试一次更容易让人信任。

延伸阅读

继续阅读

继续阅读

全部归档

评论