2026-10-27 · 深度笔记

AI 应用的后端队列与定时任务:长任务怎么不把 Web 进程拖垮

一次要跑 40 秒的生成任务挂在 Web 进程里会怎样。这篇写我用队列和 worker 的做法,以及长任务必须自己处理的超时、取消和幂等。

云主机AI工程队列架构

我的 AI 应用第一版,所有任务都这样处理:用户点"生成",前端发一个 HTTP 请求,后端在这个请求里同步跑完整个生成流程,然后返回结果。

上线第三天就开始出事。上午十点,一个用户提交了 200 页文档做批量生成,这个请求跑了 12 分钟还没结束。这 12 分钟里,整个 Web 服务一个新请求都接不住——所有 worker 都在处理这一个长任务。用户等不及刷新又提交了一次。又一个 12 分钟。

症状比原因更值得记

那天的故障现象是:CPU 没打满,但所有接口都超时;内存监控正常;队列里什么都没有——因为压根没有队列。

我一开始怀疑是数据库锁,后来发现就是最朴素的問題:处理线程被占住了。8 个 worker 线程,8 个长任务,就没有线程去接新请求。

这类故障不像崩溃那样有明显的起点。你会先看到"某个接口变慢",慢慢变成"全挂",中间夹杂着奇怪的 502,因为负载均衡器把超时连接重置了。

队列不是"更好的架构",是"必须的东西"

我换成了队列 + 独立 worker:

Web 服务 → 投递任务到队列 → 立刻返回 task_id
                          ↓
                     Worker 消费
                          ↓
                     写结果到存储 → Web 服务轮询/推送

第一个好处不是解耦,是Web 进程再也不会被长任务拖垮。哪怕 worker 全忙,Web 只负责收单。

第二个好处是重试变得可能:原来处理到一半失败,用户得手动重试;现在 worker 崩了,任务自动回队列。

第三个好处是能限流:同一用户最多 5 个任务在跑,这是队列的语义。

选哪种队列

我用过三种,各有各的位置:

方案适合坑
数据库表当队列任务量小(每天几百个)抢占和锁要自己处理
Redis List/Stream中等量,要快任务丢了就没了,除非开持久化
RabbitMQ / SQS 类任务量大,要可靠投递运维成本高一点

如果任务量不大,我建议别引入新组件,直接用数据库表:

CREATE TABLE tasks (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    kind        TEXT NOT NULL,
    payload     JSONB NOT NULL,
    status      TEXT NOT NULL DEFAULT 'pending',
    attempts    INT NOT NULL DEFAULT 0,
    max_attempts INT NOT NULL DEFAULT 3,
    available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    locked_at   TIMESTAMPTZ,
    worker_id   TEXT,
    error       TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_tasks_claim ON tasks (status, available_at)
    WHERE status = 'pending';

worker 抢占任务用一条 UPDATE 就够,不需要额外的分布式锁:

UPDATE tasks
SET status = 'running',
    locked_at = now(),
    worker_id = %s,
    attempts = attempts + 1
WHERE id = (
    SELECT id FROM tasks
    WHERE status = 'pending' AND available_at <= now()
    ORDER BY available_at
    LIMIT 1
    FOR UPDATE SKIP LOCKED
)
RETURNING *;

FOR UPDATE SKIP LOCKED 是关键。多个 worker 同时执行这条语句时,各自拿到不同的行,不会互相阻塞。我早期用 SELECT 然后 UPDATE,结果多个 worker 抢到同一个任务,跑了三遍。

长任务必须自己处理的四件事

队列只负责"让任务被跑起来",语义还得自己写。这四件事漏一个都出事。

超时

GPU 推理卡住是常事(显存竞争、CUDA 死锁)。没有超时,一个卡住的任务会把 worker 占到天荒地老。 我给每个任务设了三层超时:单次执行的墙钟超时(超过就杀掉进程,任务回队列)、单次模型调用的超时(HTTP 客户端层设)、以及整个任务的 deadline(超了就标记失败,不再重试)。

TASK_TIMEOUT_SEC = 300          # 单次执行上限
DEADLINE_SEC = 1800             # 任务总时长上限

取消

用户点"停止",任务得真的停。实现上有个坑:取消请求会发到 Web 服务,而任务在 worker 里。

我的做法是 Web 服务写一个 cancelled_at 字段,worker 在每个可中断的阶段(切分文档、每生成一张图)检查一次。

worker 不能直接被 kill——进程里可能有正在写的文件、正在开的连接,硬杀会留半成品。

幂等

这是最重要的一条,也是我最后才补上的。

我遇到过的情况:用户提交批量生成,worker 处理到第 30 张图时因下游超时失败。任务重试,从第 1 张重跑,前 29 张的结果被覆盖,产生了 30 次计费调用。

修复方式是让每一步都有幂等键:

def process_batch(task_id):
    items = get_items(task_id)
    for idx, item in enumerate(items):
        if check_cancelled(task_id):
            return
        # 幂等键由 task_id + 序号 + 内容哈希决定
        key = f"{task_id}:{idx}:{hash_item(item)}"
        result = cache.get(key)
        if result is None:
            result = do_generate(item)
            cache.set(key, result, ttl=86400)
        save_step_result(task_id, idx, result)

save_step_result 用 (task_id, idx) 做唯一键,重复写入不会产生两份。

更彻底的做法是把任务拆成一堆子任务,每个独立可重试。

重试的分类

不是所有失败都该重试。

def should_retry(exc, attempts):
    if attempts >= 3:
        return False
    if isinstance(exc, ValidationError):
        return False          # 参数错了,重试一百次也没用
    if isinstance(exc, RateLimitError):
        return True           # 该退避后重试
    if isinstance(exc, TimeoutError):
        return True
    return False

指数退避 + 抖动必须一起用。纯指数退避在集体故障恢复那刻会惊群——所有任务同时重试,把刚恢复的下游再打死。

delay = min(base * (2 ** attempts), max_delay) * random.uniform(0.8, 1.2)

定时任务:多副本是最容易翻车的地方

定时任务比队列任务更容易出问题,因为它"看起来总能跑"。

我第一次部署时在应用里加了个定时器,每小时跑一次数据同步。开发环境单副本一切正常,上了生产三副本,第二天早上收到三份一样的邮件。

原因是每个副本都跑了自己的定时器。这不是 bug,是设计如此——应用副本不该假设自己是唯一的。解决办法是加一把分布式锁:

def try_acquire_lock(name: str, ttl: int = 3600) -> bool:
    key = f"cron_lock:{name}"
    return redis.set(key, instance_id, nx=True, ex=ttl)

nx=True 保证只有第一个能拿到。ex 保证进程挂了锁能自动释放,不然一次崩溃就让定时任务永久停摆。

另一个更省事的做法是把定时任务跟 Web 服务分开部署,单独进程跑定时器。我后来两个都做了——定时器独立 + 锁。

队列深度是个必须监控的指标

我给自己定的一条线是队列深度超过 500 就告警。

为什么是 500?因为它跟处理速度有关:worker 平均每分钟处理 20 个任务时,500 个任务就是 25 分钟积压,体验崩了。

所以告警规则不该只写"队列 > 500",而应该写成"按当前处理速率,预计消化时间超过 5 分钟"。

def estimated_drain_minutes(queue_depth, completed_last_10min):
    rate = completed_last_10min / 10
    if rate <= 0:
        return float('inf')
    return queue_depth / rate

最后几点

worker 的资源限制要写死。 跑 GPU 推理的 worker 和跑数据库查询的 worker 内存画像完全不同,混在一个池子里,一个内存吃多了会把别人挤死。

worker 要能被安全重启。 部署新版本时先让 worker 处理完手上的任务再退出,然后起新的。直接重启的结果是正在跑的任务全丢。

Web 服务只返回 task_id,不返回结果,结果通过轮询或 WebSocket 推。这是从"同步请求"到"异步任务"最核心的改变。 具体用什么队列取决于任务量和可靠性要求;换成哪种队列,上面这些语义都得自己写。

接着读