AI 生图重试为何会导致文件被误删
文章分析多个生图任务复用对象存储键时,重试覆盖与后续清理如何共同造成图片丢失。建议每次尝试使用唯一键,在数据库记录任务状态,并于事务提交后将胜出结果复制到固定键。
AI 深度解读
AI 图片上传成功,稍后读取却变成 404,问题可能出在重试覆盖和清理任务的配合上。原文讨论的是游戏里的 AI 生图链路:多个生成尝试共用文件名,数据库记录与实际对象逐渐错位,最终把仍在使用的图片删掉。
作者给出的办法是:每次生成使用独立对象键,由数据库决定采用哪一次结果,再发布到固定展示路径。这套思路值得后端和全栈开发者借鉴,但示例还没有解决发布乱序、提交后复制失败等问题,不能直接当成完整方案。
上传返回 200,为什么最后还是 404
假设任务一直写入 player-42/loading-screen.png。第一次生成图片 A,上传已经完成,但 worker 在确认结果时超时,于是重新执行任务,生成图片 B,并写入同一个位置。
对象存储只看到两次写入同一个名字,第二次覆盖第一次。数据库却可能仍保留 A 的生成记录。之后,清理任务认定 A 是废弃尝试,删除它对应的对象键,实际删掉的已经是 B。
作者称自己见过这类事故:上传路径返回正常的 200,后续读取却得到令人困惑的 404。原文没有提供复现日志或发生频率,这是作者报告的经历。
这里容易混淆的是幂等性(idempotency)。固定文件名只能保证写到同一个位置,不能保证重复执行得到同一个结果。 AI 生图重跑可能产生不同内容,把路径固定下来,只是让不同结果互相覆盖。
因此要区分两个身份:任务是一次业务请求,尝试是其中一次执行。例如 users/42/jobs/8f31/attempts/0002.png 标识第二次尝试,数据库另外保存任务、提示词哈希、模型结果和当前选中的尝试。
客户端收到 504 或连接中断,也不能据此认定 PUT 没完成。上传前就应生成对象键并持久化;只有明确要恢复同一次尝试时,才复用它。重新生成的图片应该获得新身份。
数据库选中结果后,图片才进入展示路径
作者提出两阶段发布:
先上传到独立、保持不可变的尝试路径;再通过数据库事务接受某次尝试,事务提交后,将该对象复制到固定路径,例如 users/42/latest.png。
下面这段代码展示了这个边界。原文标题写 Node.js,实际示例是 Python,接入 Node 服务时需要自行实现相同流程。
def publish_render(image_bytes, user_id, job_id, attempt_number):
attempt_key = f"users/{user_id}/jobs/{job_id}/attempts/{attempt_number:04d}-{uuid.uuid4().hex}.png"
request_with_backoff(
"PUT",
f"/storage/object/put/{BUCKET}/{attempt_key}",
data=image_bytes,
headers={"Content-Type": "image/png"},
)
# 在同一个数据库事务中提交被接受的尝试及固定目标路径。
# 如果比较并交换竞争失败,保留这次尝试供审计或清理。
accepted = mark_attempt_accepted(job_id, attempt_key)
if not accepted:
return attempt_key
canonical_key = f"users/{user_id}/latest.png"
request_with_backoff(
"POST",
"/storage/object/copy",
json={"source_bucket": BUCKET, "source_key": attempt_key,
"destination_bucket": BUCKET, "destination_key": canonical_key},
)
return canonical_key
重点是 accepted = mark_attempt_accepted(job_id, attempt_key):它必须通过原子事务或比较并交换(compare-and-set)决定谁胜出。原文的占位实现直接 return True,没有实现互斥,照抄无法阻止多个 worker 同时发布。
完整示例从 STORAGE_API_BASE_URL、INFRAI_API_KEY 读取配置,使用 Bearer 认证,桶名为 game-renders,上传的 Content-Type 为 image/png。请求封装最多尝试 5 次,收到 429 时优先读取 Retry-After,否则等待 2 ** attempt 秒;其他响应通过 raise_for_status() 检查。
这个退避只处理 429,并未实现网络超时恢复。另一个细节也要分清:函数内部重试会沿用已经生成的键,但重新调用整个函数会生成新的 UUID。它没有完整展示「先持久化尝试,再跨进程恢复」的机制。
存储选型还要看恢复和交付能力
原文针对的存储模型没有对象版本控制,也没有条件写入 If-Match。因此,并发互斥要放到队列或数据库里;意外覆盖后,也无法指望存储自行找回旧内容。
对象元数据可以携带提示词哈希、审核状态,但列表查询只支持按前缀过滤。需要检索的任务状态应保存在 Postgres 或业务数据库,不能把 metadata 当查询索引。
作者列出的替代方案各有侧重:AWS S3 提供版本控制和条件请求模式,Cloudflare R2 的吸引力在流出流量成本及 S3 兼容性,Google Cloud Storage 提供对象代次和保留控制。Infrai 这类统一 API 可以减少 AI 生成与存储的接入界面,但统一入口不会补上缺失的版本能力。
交付限制同样具体:文中服务没有 public 或 public-read ACL,不适合直接充当永久公开图片地址;浏览器直传需要 CORS 配置,但没有独立开放的 CORS 设置路由。私有后端通过签名或代理提供读取可以考虑,公开画廊则需要重新评估。
清理也有边界。这里生命周期过期按天计算,不能按小时控制,多段上传残片没有自动清理规则。原文没有给出统一保留天数,而是要求结合隐私、审核和支持需求制定策略,并提醒 GDPR Article 17 可能要求删除固定副本及全部尝试对象。
我的判断
我认可「尝试对象独立保存,数据库决定采用结果」这个方向。它把重试、审计和清理的关系讲清楚了,也比反复覆盖同一文件名更容易排查问题。
但「事务提交后复制」只解决了发布顺序的一部分。数据库提交成功、复制失败时,任务已经被接受,展示路径却还是旧图,需要补偿机制。两个任务先后被接受时,较早任务的复制若最后完成,也可能覆盖较新的结果。示例没有处理这些情况。
数据库事务提交,不等于数据库与对象存储已经共同完成一次原子发布。
工程上可以用事务消息表记录待发布操作,再由 worker 重试并校验顺序;也可以让数据库直接指向胜出的不可变对象,减少固定路径复制。这些是进一步的设计选择,原文没有给出对照实验,不能据此断言哪种方案成本更低。
存储产品的比较也只是能力取舍,没有性能、费用或可靠性的实测数据。若硬性要求 WORM 保留、跨区域复制、任意来源浏览器上传或即时回滚,作者也明确承认当前选择无法独立满足。
能拿来做什么
- 给 Node 生图任务分别建立 job 和 attempt 记录,上传前保存对象键,并明确区分「恢复上传」与「重新生成」,避免一次重试悄悄替换图片内容。
- 给每次上传记录 job ID、attempt key、响应状态和 request ID,再主动制造客户端超时,验证存储已写入但客户端未确认时的恢复行为。
- 如果保留
latest.png这样的展示路径,就补上数据库提交后的复制重试和发布顺序检查,并测试旧任务延迟完成时会不会覆盖新结果。 - 清理时先根据数据库判断对象是否仍被引用,再按保留窗口处理尝试目录;同时覆盖固定副本、失败尝试和多段上传残片,验证隐私删除流程。