最初接入图片生成时,我写过一段非常直觉的代码:
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": "生成任务超时,请稍后重试"
}
}
数据库堆栈、内部地址和上游原始响应不应直接暴露给客户端。
最后
异步图片生成的难点从来不是"多写一个查询接口"。
真正需要处理的是:
- 用显式状态机约束任务生命周期;
- 通过原子更新防止任务被重复领取;
- 使用幂等键避免客户端重复创建;
- 区分客户端重试和 Worker 重试;
- 将重试次数和执行状态持久化;
- 使用退避策略降低轮询压力;
- 正确处理超时、心跳和失联任务;
- 确保结果保存后再将任务标记成功。
如果任务规模很小,数据库加定时 Worker 已经足够,不必立即引入复杂消息系统。
但无论使用 Redis、数据库还是消息队列,有一点不会改变:
队列负责把任务送到 Worker,状态机才负责说明任务现在究竟发生了什么。