图片生成跑到一半“失踪”了:我重新设计了异步任务状态机

最初接入图片生成时,我写过一段非常直觉的代码:

ini 复制代码
response = requests.post(
    "/images/generations",
    json={"prompt": prompt},
    timeout=120
)

return response.json()

开发环境里一切正常。

直到任务时间从十几秒增加到一分钟,问题开始接连出现:

  • 网关先于生成任务超时;
  • 用户刷新页面后无法找回任务;
  • 客户端超时重试,创建了两张相同图片;
  • Worker 执行完成,但写入结果时数据库连接失败;
  • 同一个任务被两个 Worker 同时消费;
  • 页面轮询过于频繁,查询量远高于任务量。

真正麻烦的并不是"如何调用图片模型",而是:

如何管理一个持续几十秒、可能失败、可能重复执行,并且跨越多个进程的任务?

后来我把整个流程重新设计成显式状态机。本文记录其中几个最重要的取舍。

一、为什么不能一直保持 HTTP 连接?

同步接口的优点是简单:

复制代码
请求进入 → 执行业务 → 返回结果

但图片、音频和视频生成的执行时间通常不稳定。

某次任务可能只需要 8 秒,另一次可能需要 90 秒。中间还可能经历排队、限流和重试。

即使应用服务器允许请求保持两分钟,链路上的其他组件也不一定允许:

复制代码
浏览器
  ↓
CDN
  ↓
负载均衡
  ↓
API 网关
  ↓
应用服务器
  ↓
生成服务

其中任意一层提前断开,客户端都会认为请求失败。

但客户端看到的"失败",并不意味着服务端任务停止了。于是用户再次点击,系统又创建一个新任务。

最终表现就是:

复制代码
客户端收到一次超时
服务端实际生成了两张图片
用户可能被计算两次费用

所以,耗时任务更适合拆成两个阶段:

bash 复制代码
POST /tasks        创建任务,立即返回 task_id
GET  /tasks/{id}   查询状态和结果

HTTP 请求只负责"创建任务",不负责等待任务完成。

二、不要只用 success 和 failed

最开始,我给任务设计了三个状态:

ini 复制代码
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"

很快就发现不够用。

当一个任务处于 pending 时,它可能代表:

  • 刚写入数据库;
  • 正在等待队列;
  • 已经被 Worker 获取;
  • Worker 执行到一半;
  • Worker 已经崩溃;
  • 正在重试;
  • 永远不会再被执行。

这些情况对用户和运维人员的意义完全不同。

后来我将任务状态拆成:

ini 复制代码
from enum import StrEnum


class TaskStatus(StrEnum):
    QUEUED = "queued"
    RUNNING = "running"
    SUCCEEDED = "succeeded"
    FAILED = "failed"
    CANCELLED = "cancelled"

状态转换规则如下:

objectivec 复制代码
QUEUED ─────→ RUNNING ─────→ SUCCEEDED
   │              │
   │              ├────────→ FAILED
   │              │
   └──────────────┴────────→ CANCELLED

关键不是状态数量,而是限制状态之间如何转换。

例如:

  • 已成功任务不能再次进入运行状态;
  • 已取消任务不能再写入成功结果;
  • 只有排队中的任务才能被 Worker 领取;
  • 运行中的任务才允许上报进度。

三、把状态转换写成规则,而不是随处赋值

如果业务代码可以随时执行:

ini 复制代码
task.status = new_status

状态机实际上没有任何约束。

更稳妥的方式是集中定义合法转换:

css 复制代码
ALLOWED_TRANSITIONS = {
    TaskStatus.QUEUED: {
        TaskStatus.RUNNING,
        TaskStatus.CANCELLED
    },
    TaskStatus.RUNNING: {
        TaskStatus.SUCCEEDED,
        TaskStatus.FAILED,
        TaskStatus.CANCELLED
    },
    TaskStatus.SUCCEEDED: set(),
    TaskStatus.FAILED: set(),
    TaskStatus.CANCELLED: set()
}

然后通过统一方法修改:

python 复制代码
class InvalidTaskTransition(Exception):
    pass


def ensure_transition(
    current: TaskStatus,
    target: TaskStatus
) -> None:
    allowed = ALLOWED_TRANSITIONS[current]

    if target not in allowed:
        raise InvalidTaskTransition(
            f"不允许从 {current} 转换到 {target}"
        )

更新前先验证:

ini 复制代码
def change_status(task, target: TaskStatus):
    ensure_transition(task.status, target)

    task.status = target
    task.updated_at = datetime.now()

这样至少可以避免应用层产生明显非法状态。

不过,这仍然没有解决并发问题。

四、两个 Worker 同时领取任务怎么办?

假设两个 Worker 几乎同时读取数据库:

ini 复制代码
Worker A 读取:status = queued
Worker B 读取:status = queued

两边都认为自己可以执行任务,于是同一张图片被生成两次。

仅在 Python 中检查状态是不够的,因为读取和更新之间存在竞争窗口。

更安全的做法是使用带条件的原子更新:

ini 复制代码
UPDATE generation_tasks
SET
    status = 'running',
    started_at = NOW(),
    worker_id = :worker_id
WHERE
    id = :task_id
    AND status = 'queued';

然后检查受影响的行数:

ini 复制代码
def claim_task(
    connection,
    task_id: str,
    worker_id: str
) -> bool:
    cursor = connection.execute(
        """
        UPDATE generation_tasks
        SET
            status = 'running',
            started_at = CURRENT_TIMESTAMP,
            worker_id = ?
        WHERE
            id = ?
            AND status = 'queued'
        """,
        (worker_id, task_id)
    )

    connection.commit()

    return cursor.rowcount == 1

只有一个 Worker 能成功把状态从 queued 改为 running

其他 Worker 会得到:

python 复制代码
False

然后放弃执行。

核心原则是:

不要先查询"能不能领取",再单独执行更新;让更新语句本身决定能否领取。

五、接口重试不等于任务重试

异步系统中至少存在两种不同的重试。

客户端重试提交接口

客户端可能没有收到创建任务的响应:

markdown 复制代码
任务已经创建
      ↓
响应返回途中网络断开
      ↓
客户端认为失败
      ↓
重新提交相同请求

如果服务端每次都生成新任务,就会产生重复任务。

解决办法是让客户端提供幂等键:

bash 复制代码
POST /v1/images/generations
Idempotency-Key: req_8f38d07c

服务端将用户与幂等键建立唯一约束:

scss 复制代码
CREATE UNIQUE INDEX uniq_task_idempotency
ON generation_tasks(user_id, idempotency_key);

创建任务时:

ini 复制代码
def create_task(
    user_id: str,
    idempotency_key: str,
    prompt: str
):
    existing = find_by_idempotency_key(
        user_id,
        idempotency_key
    )

    if existing:
        return existing

    return insert_task(
        user_id=user_id,
        idempotency_key=idempotency_key,
        prompt=prompt
    )

需要注意,"先查后插"依然可能发生并发竞争,最终仍应依靠数据库唯一约束兜底。

Worker 重试执行任务

Worker 调用生成服务失败,也可能需要重试。

但不是所有错误都值得重试:

复制代码
网络暂时失败       可以重试
上游返回 503       可以重试
请求被限流         可以延迟重试
Prompt 参数错误    不应重试
模型不存在         不应重试
内容不符合规则     通常不应原样重试

如果不区分错误类型,无效任务可能持续占用队列。

六、重试次数不能只存在 Worker 内存里

下面这种写法看似合理:

python 复制代码
for attempt in range(3):
    try:
        generate_image()
        break
    except Exception:
        time.sleep(2 ** attempt)

但如果 Worker 在第二次重试前重启,内存中的 attempt 会丢失。

任务可能又从第一次开始重试。

因此,重试状态应该持久化:

复制代码
attempt_count
max_attempts
next_retry_at
last_error_code
last_error_message

领取任务时加入条件:

ini 复制代码
WHERE
    status = 'queued'
    AND next_retry_at <= NOW()

任务失败后,如果仍可重试:

ini 复制代码
task.attempt_count += 1
task.status = TaskStatus.QUEUED
task.next_retry_at = calculate_retry_time(
    task.attempt_count
)

超过最大次数才进入最终失败状态:

ini 复制代码
if task.attempt_count >= task.max_attempts:
    task.status = TaskStatus.FAILED

七、任务超时后,Worker 可能仍在运行

客户端等待一分钟后超时,并不代表 Worker 停止执行。

即使服务端把任务标记为失败,上游模型仍可能继续生成,并在稍后返回结果。

这里需要区分三个概念:

  • 客户端等待超时;
  • 平台任务执行超时;
  • 上游请求实际终止。

客户端超时通常只意味着:

复制代码
客户端暂时不再等待

它不应该自动把任务标记为失败。

更合理的行为是让客户端保存 task_id,稍后继续查询。

平台执行超时则需要通过 Worker 控制,例如:

ini 复制代码
result = await asyncio.wait_for(
    generate_image(prompt),
    timeout=180
)

但即使本地协程被取消,也要确认底层 HTTP 客户端是否真正关闭了连接,以及上游是否支持取消任务。

"本地不再等待"和"上游停止计算"不是同一件事。

八、页面轮询也需要退避

我最初让前端每秒查询一次任务:

javascript 复制代码
setInterval(() => {
  fetch(`/tasks/${taskId}`)
}, 1000)

如果有 1000 个正在生成的任务,就会产生大约每秒 1000 次查询。

而图片任务的状态可能十几秒才变化一次。

更适合的策略是逐步延长轮询间隔:

ini 复制代码
const intervals = [1000, 1500, 2500, 4000, 6000, 10000];

async function waitForTask(taskId) {
  let attempt = 0;

  while (true) {
    const response = await fetch(`/tasks/${taskId}`);
    const task = await response.json();

    if (task.status === "succeeded") {
      return task.result;
    }

    if (
      task.status === "failed" ||
      task.status === "cancelled"
    ) {
      throw new Error(task.error || "任务未完成");
    }

    const index = Math.min(
      attempt,
      intervals.length - 1
    );

    await new Promise(resolve => {
      setTimeout(resolve, intervals[index]);
    });

    attempt += 1;
  }
}

如果服务端返回 Retry-After,客户端还可以优先采用服务端建议:

makefile 复制代码
Retry-After: 5

对于时间更长、并发更高的任务,可以考虑:

  • Server-Sent Events;
  • WebSocket;
  • Webhook;
  • 消息推送。

不过,轮询仍然是最简单、兼容性最好的方案。系统规模不大时,没有必要为了"实时"过早引入长连接。

九、进度值不一定是真实进度

很多生成服务只能告诉我们:

复制代码
任务仍在运行

并不能给出真实百分比。

这时,如果页面显示:

shell 复制代码
73%

可能只是一个根据时间估算出来的数字。

问题在于,它可能很快到达 99%,然后停在那里一分钟,反而让用户觉得系统卡住了。

如果没有真实进度,宁可展示阶段:

复制代码
等待处理
正在生成
正在保存结果
已完成

或者显示已经等待的时间:

复制代码
已生成 24 秒,通常需要 30~60 秒

不要把不确定的估算伪装成精确进度。

十、任务完成和结果写入必须考虑一致性

假设 Worker 按以下顺序执行:

scss 复制代码
task.status = "succeeded"
save_task(task)

upload_result(image)

数据库已经显示成功,但图片可能还没有上传完成。

用户查询任务后会得到一个不存在的结果。

顺序应该调整为:

复制代码
生成图片
   ↓
上传并验证结果
   ↓
保存结果信息
   ↓
将状态改为 succeeded

数据库更新最好放在同一个事务中:

ini 复制代码
with database.transaction():
    save_result(
        task_id=task_id,
        result_url=result_url
    )

    mark_task_succeeded(task_id)

外部文件上传无法与数据库组成普通事务,因此还要考虑:

  • 文件上传成功,但数据库提交失败;
  • 数据库保存成功,但返回响应前进程退出;
  • 重试时重复上传相同文件。

一种可行方案是使用确定性的对象存储路径:

bash 复制代码
generated/{task_id}/result.png

即使 Worker 重试,也覆盖或复用同一个任务结果,而不是不断产生新文件。

十一、如何处理"运行中但 Worker 已经死了"的任务?

如果 Worker 领取任务后突然退出,状态可能永远停在:

arduino 复制代码
running

可以给任务增加租约信息:

复制代码
worker_id
heartbeat_at
lease_expires_at

Worker 执行时定期更新心跳。

后台扫描任务:

ini 复制代码
SELECT id
FROM generation_tasks
WHERE
    status = 'running'
    AND lease_expires_at < NOW();

对于租约已经过期的任务,可以根据策略:

  • 重新放入队列;
  • 标记失败;
  • 等待人工检查。

但重新执行前必须确认任务是否具备幂等性,否则原 Worker 可能只是暂时失去数据库连接,实际仍在调用上游服务。

十二、一张任务表至少需要哪些字段?

一个相对完整的异步生成任务可以包含:

bash 复制代码
id
user_id
idempotency_key
status
progress
prompt
request_payload
result_payload
attempt_count
max_attempts
next_retry_at
worker_id
heartbeat_at
created_at
started_at
finished_at
updated_at
error_code
error_message

不一定要一开始就加入全部字段。

但以下信息最好尽早保留:

  • 当前状态;
  • 创建和更新时间;
  • 尝试次数;
  • 最后一次错误;
  • 最终结果;
  • 幂等键。

这些字段会极大降低线上问题的排查难度。

十三、最终接口应该向客户端暴露什么?

任务详情接口不应该直接返回数据库全部字段。

可以提供稳定的公共结构:

json 复制代码
{
  "id": "task_123",
  "status": "running",
  "progress": null,
  "created_at": "2026-08-14T10:00:00Z",
  "result": null,
  "error": null
}

成功时:

json 复制代码
{
  "id": "task_123",
  "status": "succeeded",
  "progress": 100,
  "created_at": "2026-08-14T10:00:00Z",
  "result": {
    "url": "https://example.com/result.png",
    "width": 1024,
    "height": 1024
  },
  "error": null
}

失败时只返回可以公开的信息:

json 复制代码
{
  "id": "task_123",
  "status": "failed",
  "result": null,
  "error": {
    "code": "GENERATION_TIMEOUT",
    "message": "生成任务超时,请稍后重试"
  }
}

数据库堆栈、内部地址和上游原始响应不应直接暴露给客户端。

最后

异步图片生成的难点从来不是"多写一个查询接口"。

真正需要处理的是:

  1. 用显式状态机约束任务生命周期;
  2. 通过原子更新防止任务被重复领取;
  3. 使用幂等键避免客户端重复创建;
  4. 区分客户端重试和 Worker 重试;
  5. 将重试次数和执行状态持久化;
  6. 使用退避策略降低轮询压力;
  7. 正确处理超时、心跳和失联任务;
  8. 确保结果保存后再将任务标记成功。

如果任务规模很小,数据库加定时 Worker 已经足够,不必立即引入复杂消息系统。

但无论使用 Redis、数据库还是消息队列,有一点不会改变:

队列负责把任务送到 Worker,状态机才负责说明任务现在究竟发生了什么。

相关推荐
小白勇闯网安圈1 小时前
Django 模板复用、ORM 查询与多对多关系
数据库·python·django
TheBestRucy1 小时前
基于Dify的旅游攻略&王者荣耀攻略智能助手项目
服务器·开发语言·人工智能·python·算法·旅游
天才少女爱迪生1 小时前
KIMI-K3技术博客写作思路分析
python
丨白色风车丨2 小时前
MCP 入门指南:大模型时代的“USB-C”接口
python·mcp
EXI-小洲2 小时前
Web Spider 某渣渣企业平台 表单参数逆向 Webpack
python·webpack·js逆向·spider
玫幽倩3 小时前
2026聚合獬豸杯决赛wp(手机取证)
python·电子取证·misc·取证·聚合·手机取证·獬豸杯
FlyWIHTSKY3 小时前
在智能体系统中,什么是多模态,举例详细说明
人工智能·python·langchain
zzzll11114 小时前
Loop Engineering:循环工程的原理、实践与应用
java·数据库·python