后台任务消失的四种方式及解决方法

原文:https://dev.to/lovestaco/your-welcome-email-is-not-part-of-signup-designing-a-background-job-system-1lmo(作者 @lovestaco)

用户在你的应用上注册。

他们输入邮箱和密码,点击提交,然后期待几乎立刻进入产品。

在这个流程中的某个环节,需要发出一封欢迎邮件。

这简单的一句话——"顺便再发一封欢迎邮件"——是后端工程中最大的承重谎言之一。

它听起来像一条脚注。

实际上它是一个完整的子系统,如果你用最显而易见的方式去实现,它会用惨痛的方式教会你这一点。

所以我们先用最显而易见的方式构建它,然后不断破坏它,直到它不再坏。

版本 1:直接发送邮件

朴素版本在简洁性上堪称优美。一个函数,自上而下。

@app.post("/signup")
def signup(payload):
    user = db.insert_user(payload)          # 8毫秒
    email.send_welcome(user.email)          # 40毫秒?2秒?还是永远?
    return {"ok": True, "user_id": user.id} # 终于返回


再看一遍中间那行,因为你的可用性就是在这里死掉的。

后台任务,消息队列,双写问题,系统设计,异步处理

在你的笔记本电脑上,这一切完美无瑕。你的笔记本电脑从没见过限流。

在生产环境中,那个 email.send_welcome 调用是一次到你无法控制的公司的网络往返,而且可能正赶上他们状态不佳。

随之而来的是三件事:

慢。 你的注册现在和邮件服务商最差的百分位一样慢。你把 p99 交给了别人的值班团队。

会失败。 当服务商返回 500 时,你的处理器抛出异常,整个请求失败。用户看到一个"出了问题"的提示,而这个账户——取决于你的事务边界在哪里——现在可能存在,也可能不存在。

它将你的可用性与他们的可用性耦合在一起。 串行请求路径上的每一个额外依赖都会成倍增加你的故障概率。两个 99.9% 的服务串联在一起就是 99.8%。你不是在"使用"邮件服务商,而是在"继承"它的故障。

后台任务,消息队列,双写问题,系统设计,异步处理

这里真正的问题是概念层面的,而不是技术层面的。

创建账户和发送邮件是两个不同的工作,具有完全不同的紧急程度。

用户等待的是前者。在软件史上,从没有人坐着等一封欢迎邮件。

而你只是把它们硬粘在了一起。

版本 2:把工作放进队列

解决办法是在请求期间不再做第二件事。

保存用户,立即响应,然后在某个地方留一张纸条:"需要发一封邮件"。

一组独立的工作进程读取这些纸条,并按邮件服务商允许的速度实际发送。

这个纸条容器就是队列:SQS、RabbitMQ、用合适的库包装的 Redis,随你喜欢。

后台任务,消息队列,双写问题,系统设计,异步处理

响应时间从"邮件 API 今天心情好要多久就多久"降到大约 40 毫秒。

如果邮件服务商宕机一小时,任务会在队列中堆积,等服务恢复后排空。注册用户根本不会注意到。

这确实是一个巨大的胜利。

但大多数教程也在这里停下,而那个有趣的 bug 就住在这一层。

看下图的下半部分。

你的处理器现在会向两个不同的系统执行两次写入

它把用户插入 Postgres,同时向 SQS 发布一条消息。

这两个操作之间没有跨系统事务,因为不可能有。

它们是互不相识的不同厂商所拥有的不同数据库。

那么如果进程在两者之间崩溃会怎样?

账户存在,任务不存在。

这个用户现在永远收不到欢迎邮件了,而且没有任何错误、任何告警、任何失败的请求。

从每个系统的角度看,一切都很正常。

这就是双写问题。它比崩溃更讨厌,因为它是无声的。

颠倒顺序,你会得到镜像问题:一个任务要给一个不存在的用户发邮件。

Version 2.5:事务性发件箱

这个修复方案的名字听起来比实际吓人得多——它就是事务性发件箱(transactional outbox)

不要在处理器里直接写队列了。改成写一张表。同一套数据库,与用户行处于同一事务中。

BEGIN;
  INSERT INTO users (id, email)          VALUES ('u_881', 'ada@example.com');
  INSERT INTO outbox (id, type, payload) VALUES ('j_204', 'send_welcome', '{"user_id":"u_881"}');
COMMIT;


现在只有一次写入,针对唯一一个系统,由唯一一次提交保证。要么用户和任务同时存在,要么两者都不存在。裂缝消失了,因为中间不再有可供崩溃发生的间隙。

后台任务,消息队列,双写问题,系统设计,异步处理

一个独立的转发(relay)进程随后读取未发送的 outbox 行,并将其发布到真实的队列——可以通过轮询该表,也可以通过类似 Debezium 的工具追踪预写日志(WAL)来实现。

这里有一个大家容易略过的点,我想坦率地说清楚。

outbox 并不会带给你精确一次(exactly-once)语义。 转发进程可能已经发布了消息,但在标记该行为已发送之前崩溃了。

重启之后,它会再次发布同一条消息。

你只是把故障从“静默丢失任务”变成了“偶尔重复执行任务”,而这是一笔非常划算的交易——因为前者无法修复,后者至少看得见。

先记住这一点,十段之后我们会再回到这个话题。

版本 3:不要删除尚未完成的任务

新的故障出现在管道更下游。

一个 worker 从队列里取出任务,开始发送。

刚做到一半,Pod 被驱逐、触发滚动发布、或者竞价实例被回收——worker 没了。

任务去哪了?

如果你的队列在消息被取走的那一刻就删除它,那么答案就是:哪儿都没有。消息已经被消费掉了。

它不在队列里,也没有被执行完,更不会有人去找它。

后台任务,消息队列,双写问题,系统设计,异步处理

真正的队列不会这样工作。

它们使用可见性超时(visibility timeout)。

当 worker 接收到任务时,任务会被隐藏,而不是删除。它会在一段时间窗口内保持不可见,比如 30 秒。可能出现两种情况:

  • worker 完成任务,并显式删除消息。任务结束,永久消失。
  • worker 没有完成,因为它崩溃了。计时器到期,任务重新可见,下一个轮询的 worker 会再次取走它。

不会有任何消息丢失。永远不会。

删除操作是“已完成”的回执,而不是“已取走”的凭证。

一个实用提醒:把超时时间设置得比你实际最慢的任务还要长,否则你会遇到这种有趣场景——一个需要 45 秒的任务在第 30 秒被重新投递,于是两个 worker 并行处理同一个任务,而且都以为只有自己在做。

账单来了:至少一次投递(at-least-once)

看看你刚刚做出了什么保证:任务永远不会丢失。

注意,这比“任务恰好运行一次”要弱得多,队列甚至都不假装自己能做成后者。

worker 发送了邮件,但在调用删除的前一微秒崩溃了。计时器到期,第二个 worker 又把邮件发了一遍。

后台任务,消息队列,双写问题,系统设计,异步处理

所有值得使用的分布式队列都是至少一次投递(at-least-once),因为跨网络的恰好一次投递并不是你能买到的东西。

你只能在至少一次投递之上构建“恰好一次”的效果,而且这要在 worker 里实现,不是在队列里实现。

这意味着你的 worker 必须幂等。

在创建任务时,为每个任务生成一个稳定的 id,写在 outbox 记录里。worker 在完成工作时记录这个 id,并在开始处理前检查它:

def handle(job):
    # 唯一索引替我们做仲裁
    inserted = db.execute(
        "INSERT INTO processed_jobs (job_id) VALUES (%s) ON CONFLICT DO NOTHING",
        job.id,
    )
    if inserted.rowcount == 0:
        return  # 这个任务已经有人处理过了,回家吧

    email.send_welcome(job.payload["user_id"])


这里真正起作用的是唯一约束。对于这个问题来说,这是恰到好处的聪明做法。

如果两个 worker 竞争,数据库会选出一个赢家。这正是数据库的用途。

人们常搞错两点:

标记写在哪里很关键。 如果标记所在存储与副作用所在存储不同,你只是把双写问题又制造到了下一层——套娃。

不是每个任务都需要这套机制。 SET last_login = now() 天然就是幂等的,执行两次不会改变任何结果。

INCREMENT credits BY 10 显然不是。你要清楚自己的任务属于哪一类,因为不需要的幂等机制只会白白增加延迟。

第 4 版:永远不会成功的任务

有些任务不是运气不好,而是注定失败。

邮件地址是 bob@@gmial.con。账户已被删除。负载引用的行已不存在。

你可以每 30 秒重试一次那个任务,直到宇宙热寂,它会每次都失败,而且失败得很快乐,永远如此,同时消耗配额、填满日志。

后台任务,消息队列,双写问题,系统设计,异步处理

因此,你需要针对两种不同类型的失败,采用两种不同的行为。

瞬时失败采用指数退避加抖动进行重试。比如 429、超时、503。

服务商只是暂时出了点问题,很快会恢复。

退避是为了让你不成为它出问题的原因之一,抖动则是为了避免一万个排队任务在同一秒重试,把它再次压垮。

AWS 就此写过一篇权威文章

永久性失败根本不应该重试。一个格式错误的地址不会在第 4 次尝试时变成正确的。

在有限次数的尝试后,通常大约 5 次,任务就会停止。

后台任务,消息队列,双写问题,系统设计,异步处理

它不会永远重试,也绝不会被悄悄丢弃。它会进入死信队列

DLQ 是停车场,不是坟墓。任务带着完整的负载和失败历史停在那里,以便人工查看、找出问题、修复原因并重放它们。

我要大声说这个重点,因为我看过太多团队在这里犯错:没有人被呼叫的死信队列,只是另一种更慢、更贵的数据丢失方式。 当 DLQ 深度大于零时就要报警。

如果从来没有什么呼叫我,要么是你的系统完美无缺,要么是你的报警坏了——我知道我会赌哪一个。

下面用一张图展示整个生命周期:

stateDiagram-v2
    [*] --> Pending: written to outbox in the txn
    Pending --> Queued: relay publishes
    Queued --> InFlight: worker receives, hidden 30s
    InFlight --> Done: sent, then deleted
    InFlight --> Queued: worker crashed, timer expired
    InFlight --> Retrying: transient failure
    Retrying --> Queued: after backoff
    Retrying --> DLQ: 5 attempts used up
    InFlight --> DLQ: permanent failure, fail fast
    DLQ --> Pending: a human replays it
    Done --> [*]


注意 InFlight 有三个出口,其中只有一个是成功。

这个比例就是后台系统的全部工作。

整合起来

经过四个版本迭代,下面是你最终交付的东西:

后台任务,消息队列,双写问题,系统设计,异步处理

当你面对一项工作,不确定它该放在哪里时,决策路径如下:

flowchart TD
    A[a piece of work] --> B{is the user waiting<br/>for the result?}
    B -->|yes| C[do it in the request]
    B -->|no| D{does it touch<br/>something you do not own?}
    D -->|no| E{is it slow<br/>or bursty?}
    D -->|yes| F[background job]
    E -->|no| C
    E -->|yes| F
    F --> G{is running it twice<br/>harmful?}
    G -->|yes| H[background job<br/>+ idempotency key]
    G -->|no| I[background job<br/>ship it]

    classDef decision fill:#f4d35e,stroke:#b8991f,color:#1a1a1a
    classDef start fill:#e9ecef,stroke:#6c757d,color:#1a1a1a
    classDef sync fill:#6ea8ff,stroke:#3b6dcc,color:#1a1a1a
    classDef async fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a

    class B,D,E,G decision
    class A start
    class C sync
    class F,H,I async


这实际给你带来了什么

退一步看这些组件,因为很容易看着这条流水线,觉得你为了发一封邮件搭建了一个庞然大物。

其实并没有。你买到的是四个具体的特性,每一个都直接对应一个曾经崩坏过的版本:

  • 响应永远不等待第三方。这是版本 1。
  • 任务和账户要么一起提交,要么都不提交。这是版本 2。
  • 崩溃的 worker 不会丢失任何东西,它只会重试,幂等性处理重复。这是版本 3。
  • 永远不可能成功的工作最终会出现在人类能看到的地方。这是版本 4。

📌 Image description(图,点击查看)

这些都不会让邮件投递变得可靠。邮件从来就不可靠,以后也不会。它做的是让你的注册流程与邮件解耦,而那是这句话里你唯一能控制的部分。

这个通用教训比示例本身更长久。任何时候你发现自己在请求处理器里写“顺便”,顺便发邮件,顺便更新搜索索引,顺便通知 CRM,你其实就是在描述一个后台任务。“顺便”就是信号。

把它移出请求流程,用事务方式写下来,让它可重试,并给它一个能大声失败的地方。

原文:https://dev.to/lovestaco/your-welcome-email-is-not-part-of-signup-designing-a-background-job-system-1lmo(作者 @lovestaco)

发布评论
全部评论(0)