图像生成的规模化:队列、幂等与重试
图像生成打破了大多数 Web 架构赖以成立的假设。请求以数十秒而非毫秒计,容量是真的有限因为它是物理的,而一次失败的请求已经花掉了真金白银。让这一切保持正确的模式——队列、幂等、重试——都是众所周知的,只不过典型的 CRUD 后端里并不现成具备。
队列就是设计本身
第一直觉是在请求处理函数里直接调用 API。它在开发环境能跑通,在真实流量到来的第一刻就会失败,因为需求和容量之间什么都没有:一个尖峰就变成一堵超时之墙,而且进程重启时没有任何在途任务的记录。
在中间放一个持久化队列。「持久化」是关键词——内存里的列表会在每次发布时丢掉全部在途任务,而发布恰恰发生在有流量的时候。在这个量级上,一张数据库表完全够用,还顺带给了你一份可恢复的状态记录。
背压是一项功能
有了队列,你就能决定同时允许多少任务在途,而这个上限是产品决策而非技术细节。设得太高,所有人都慢;设得太低,容量闲置而队列却在堆积。真正重要的是这个上限存在,并且在同一个地方被强制执行,这样流量尖峰只会让队列变长,而不是让每个请求都劣化。
幂等,以及那种含糊的失败
真正花钱的失败模式不是错误响应——错误好办。是那种含糊的情况:请求已经离开你的进程,连接断了,你无法判断任务是否已被创建。重试,你可能付了两次;不重试,用户可能付了钱却什么都没得到。
幂等键通过让重试变安全来解决这个问题:同一个键返回原任务,而不是创建第二个。规则很简单,也很容易做错——在用户操作时生成键,而不是在发起 HTTP 调用时;把它和任务记录一起存下来;该操作的每次重试都复用它。
# 一次用户操作的所有重试都使用同一个键 KEY=$(uuidgen) curl -X POST https://api.example/api/v1/jobs \ -H "X-API-Key: $API_KEY" -H "Idempotency-Key: $KEY" \ -F image=@photo.jpg -F feature=undress -F consent=confirmed
哪些情况不该自动重试
有一类失败,安全的做法是停下来。如果你的进程在发出提交和记录响应之间崩溃了,你是真的不知道发生了什么——而在没有键可用于去重的情况下,自动重试就是一次可能导致重复计费的抛硬币。把这类任务标记为需要人工介入,而不是盲目重试。一个看得见的卡住任务,好过一次无声的重复扣费。
对账:救你一命的那个循环
实时事件很方便,但不可靠。socket 会断、进程会重启,而那条唯一重要的消息偏偏在没人监听时到达。解法是一个后台循环,定期就数据库中仍视为活跃的每个任务向 API 查询,并据此更新状态。
这一个循环就吸收掉了一整类 bug。丢失事件不再是问题,生成过程中重启也不再是问题。无论中间连接发生了什么,每个任务都会在一个对账周期内收敛到它的真实状态。
既有进度,又不把数据库打爆
生成会频繁发出进度——每个任务每秒可能好几次。把每一次更新都写进数据库,只会成倍放大写入负载,却不带来任何额外信息。把持久化限流到大约每秒一次,并且始终写入最终状态,然后把实时数值直接广播给已连接的客户端,完全不碰存储。
一份靠得住的检查清单
- 用户与 API 之间有持久化队列,重启后可恢复。
- 有明确的在途上限,并在同一处强制执行。
- 幂等键按用户操作生成、存储,并在重试时复用。
- 含糊的投递失败作标记,而不是自动重试。
- 有对账循环,无论是否漏掉事件都能让状态收敛。
- 进度写入限流,实时进度走 socket。
这些都不是图像生成特有的——它就是普通的分布式系统卫生习惯。只不过在这里变得显眼,因为每一个工作单元都足够慢、足够贵,让平时那些偷工减料再也藏不住。
常见问题
我需要消息中间件吗?
在这个量级通常不需要。一张数据库表加上一条抢占查询,就能给你持久性和可恢复的状态审计,还少一个运维组件。
应该允许多少任务同时在途?
先设低,测量队列等待时间与完成时间的关系,然后逐步调高,直到延迟不再改善为止。具体数字不如「有一个被强制执行的上限」重要。
失败任务应该自动重试吗?
明确的错误,应该。而在无法判断任务是否已创建的含糊投递失败上,不应该——标记出来,因为盲目重试可能重复一次已付费的生成。