| 技术 | 适合场景 | 特点 |
|---|---|---|
| Redis Queue | 简单任务队列 | 轻量、接入快,但可靠性一般 |
| RabbitMQ | 传统业务消息队列 | 路由能力强,适合订单、通知、任务分发 |
| Kafka | 高吞吐事件流 | 适合日志、埋点、数据同步、消息回放 |
| Celery | Python 后台任务框架 | 适合 Django / FastAPI 异步任务 |
做后端一段时间后会发现,很多业务其实根本不适合同步做完。
比如资讯抓取、内容清洗、工单流转、通知分发、离线索引构建,这些任务要么耗时长,要么依赖第三方服务,要么链路太长。如果硬塞进一个接口里同步执行,结果往往就是:
- 请求超时
- 用户体验差
- 系统耦合严重
- 某个下游挂了,整条链路一起受影响
- 出问题后还很难恢复
所以,异步化几乎是后端系统走向工程化的必经之路。
这篇文章我想把这条链路完整梳理一遍:
从为什么要异步,到 MQ、Kafka、Celery,再到任务状态表、幂等、重试、死信、Outbox 和最终一致性,最后再说说这些东西怎么落到真实项目里。
1. 为什么很多后端任务必须异步化?
先不讲技术,先看业务。
1.1 有些任务本来就不该卡在请求里
比如一个海外资讯聚合系统,用户点一下"开始抓取",后端实际要做的事情可能有:
- 抓网页
- 清洗正文
- 去重
- 翻译
- 入库
- 建索引
- 更新缓存
这些动作很明显不是几十毫秒能完成的。如果把这整条链路都塞进一个 HTTP 请求里,请求大概率会很慢,甚至超时。
再比如智能工单系统,监控发现机器异常后,后端可能要:
- 创建工单
- 写审计日志
- 通知负责人
- 调用 Agent 分析原因
- 触发工具执行诊断命令
- 回填执行结果
这也不适合在一个接口里同步串行执行。
1.2 真正需要的不是"更快",而是"解耦"
异步化的核心价值不只是提升接口响应速度,更重要的是把"提交任务"和"执行任务"拆开。
同步链路是这样:
用户请求
↓
接口服务直接执行所有逻辑
↓
执行很久 / 中途失败
↓
返回结果
异步链路通常会变成这样:
用户请求
↓
接口服务创建任务
↓
消息进入队列
↓
Worker 后台消费执行
↓
任务状态更新
前者是"请求绑死执行",后者是"请求只负责提交,执行交给后台"。
这两种思路的系统稳定性,差别非常大。
2. MQ 到底解决了什么问题?
很多人刚学消息队列时,容易把重点放在"怎么发消息"。但 MQ 真正解决的是几个更底层的问题。
2.1 解耦
没有 MQ 的时候,上游服务经常要直接调用下游:
A 服务 -> B 服务 -> C 服务 -> D 服务
任何一个环节出问题,前面都会被拖住。
有了 MQ 之后,会变成:
A 服务 -> MQ
↓
B 服务消费
C 服务消费
D 服务消费
A 只管发消息,不关心谁来处理、什么时候处理。这就是解耦。
2.2 异步
用户请求不需要等待所有耗时动作做完,只要先把任务投递出去,立即返回即可。
这对接口 RT 改善非常明显。
2.3 削峰
流量高峰来的时候,数据库和下游服务扛不住,最怕请求直接打穿。
MQ 相当于加了一个缓冲层:
高峰请求 -> 先进队列 -> Worker 按能力慢慢消费
这就是削峰填谷。
2.4 重试和补偿
真实系统里,失败很常见:
- 网络超时
- 第三方服务抖动
- 数据库短暂不可用
- 某个 Worker 崩了
MQ 不会让失败消失,但能让你有机会把失败的任务重新拉起来做重试和补偿。
2.5 最终一致性
很多链路不一定要求"立刻全部成功",但要求"最终要成功"。
比如工单已经创建好了,但通知消息暂时没发出去,这种情况一般不需要回滚工单,而是通过重试补发通知,最后达到一致。
这就是最终一致性思路。
3. Producer、Broker、Consumer 是怎么协作的?
消息队列这套东西,最核心的角色就三个:
Producer -> Broker -> Consumer
3.1 Producer:生产消息的人
Producer 不是凭空发消息,它通常来自某个业务动作。
比如:
- 用户点击"开始抓取资讯"
- 定时任务触发"刷新数据字典"
- 监控系统发现服务器异常
- BI 系统创建了一次新的 query run
这时候,Producer 会把业务动作封装成一条结构化消息。
比如一条资讯抓取任务,消息体可以长这样:
{
"event_id": "evt_001",
"event_type": "news.crawl.requested",
"task_id": "task_10001",
"source": "bbc",
"url": "https://example.com/news/xxx",
"trace_id": "trace_abc123",
"created_at": "2026-05-06T10:00:00+08:00"
}
这里几个字段很重要:
event_id:事件唯一 IDevent_type:事件类型task_id:任务 IDtrace_id:链路追踪 IDcreated_at:时间戳
消息一定要结构化,否则后面排查和幂等会非常难做。
3.2 Broker:接收、存储、分发消息的人
Broker 就是消息中间件服务端。
常见的 Broker 有:
- Redis
- RabbitMQ
- Kafka
它负责三件事:
- 接收 Producer 发来的消息
- 存储消息
- 分发给 Consumer
3.3 Consumer:真正执行任务的人
Consumer 才是异步任务真正的执行者。
比如:
- Celery Worker 消费资讯抓取任务
- Worker 消费工单工具执行任务
- BI 系统异步消费索引构建任务
真正的难点,不在 Producer 能不能发出去,而在 Consumer 能不能正确处理:
- 什么时候 ack
- 失败后怎么重试
- 重复消费怎么办
- 卡死的任务怎么恢复
这也是后面最值得展开的部分。
4. Kafka 和普通 MQ 有什么区别?
这一块非常容易混。
很多人会说 Kafka 也是消息队列,RabbitMQ 也是消息队列,Redis 也能做队列,所以它们都差不多。
其实不太一样。
4.1 Queue 和 Topic 的区别
先把两个基础模型分清。
Queue:点对点模型
一条消息通常只会被一个消费者处理。
适合场景:
- 资讯抓取任务
- 清洗任务
- 发邮件任务
- 工具执行任务
特点是"这件事只要一个人做就行"。
Topic:发布订阅模型
一条消息发出去后,可以被多个消费者组分别消费。
适合场景:
- 工单创建事件
- 用户注册事件
- 数据同步事件
- 审计日志事件
特点是"这件事发生了,谁关心谁订阅"。
4.2 Kafka 更像事件流平台
Kafka 最核心的几个概念是:
- Topic
- Partition
- Offset
- Consumer Group
Topic
消息主题,类似分类。
Partition
一个 Topic 可以拆成多个 Partition,用来提升吞吐和并发消费能力。
Offset
消息在 Partition 里的位置,也是 Consumer 的消费进度。
Kafka 和很多传统 MQ 最大的不同是:
Kafka 消费消息后,不会立刻把消息删掉,而是保留日志,Consumer 通过 offset 记录自己读到哪里。
这意味着 Kafka 天然支持:
- 断点续传
- 消息回放
- 历史重放
比如资讯清洗逻辑升级后,可以从旧 offset 重新消费历史原始数据,重新跑一遍。
4.3 Kafka 为什么吞吐高?
Kafka 高吞吐一般来自几个点:
- 顺序写磁盘
- 批量发送
- Page Cache
- Partition 并行消费
所以 Kafka 更适合:
- 高吞吐事件流
- 日志采集
- 埋点数据
- 数据同步
- 需要回放的业务链路
4.4 Celery 和 Kafka 不是一类东西
这个很重要。
Kafka 更偏消息流平台。
Celery 更偏 Python 任务执行框架。
一句话理解:
- Kafka:更适合"事件流"
- Celery:更适合"后台任务执行"
所以在 Django / FastAPI 业务里,中小规模异步任务很多时候用 Celery + Redis 已经够了,不一定非要一上来就 Kafka。
5. Celery 在 Django / FastAPI 里怎么落地?
Celery 是 Python 生态里最常见的异步任务框架之一。
它最适合做的事其实很简单:
把一个 Python 函数丢到后台 Worker 去执行。
5.1 Celery 解决的是什么问题?
在 Python Web 项目里,很多事情你不想同步做:
- 发通知
- 抓数据
- 导出报表
- 生成 embedding
- 清理缓存
- 调用第三方接口
Celery 的价值就是把这些任务从请求线程里拿出去。
典型结构大概是这样:
Django / FastAPI
↓
Celery Task
↓
Redis / RabbitMQ Broker
↓
Celery Worker
↓
执行任务
5.2 Web 服务应该怎么提交任务?
很多人刚用 Celery 时会直接在接口里这样写:
crawl_news_task.delay(url)
这当然能跑,但工程上还不够。
更稳的方式通常是:
- 先创建业务任务记录
- 生成
task_id - 把任务状态写进数据库
- 再投递 Celery 消息
- Celery 消息里只传
task_id
也就是说,消息只做"触发器",真正的任务参数和状态都在数据库里。
这样 Consumer 收到消息后,不是直接相信消息内容,而是先根据 task_id 去查数据库状态。
这会让幂等、重试、恢复都简单很多。
5.3 Redis Broker 和业务状态不是一回事
很多人刚学 Celery 时还容易混一点:
- Redis Broker:负责存放待执行任务消息
- 业务状态表:负责记录任务现在执行到哪里了
Redis 只是中转层,不应该成为业务任务状态的最终事实来源。
真正能告诉你:
- 任务是否成功
- 失败几次
- 是否已经重试
- 是否需要人工介入
这些信息的,还是你自己的任务状态表。
5.4 Worker 怎么拆更合理?
在项目里,最好不要把所有任务都塞进一个队列。
比如资讯中台里,可以拆成:
crawl_queue
clean_queue
index_queue
notify_queue
这样做的好处很明显:
- 抓取慢,不会拖垮清洗
- 入库慢,不会影响通知
- 每类任务可以单独扩容
- 出问题更容易定位
5.5 定时任务怎么做?
Celery Beat 可以负责定时投递任务。
比如:
- 每小时生成一批资讯抓取任务
- 每分钟扫描超时任务
- 每分钟扫描 retrying 到期任务
- 每天夜里做归档和清理
这就把"业务定时任务"和"系统补偿任务"都串起来了。
6. 为什么任务状态表才是异步系统的核心?
这一节我觉得是整套异步链路里最重要的部分。
因为很多人以为有了 MQ、Celery,异步系统就完整了。其实不是。
MQ 只能告诉你:
有任务要做。
但它回答不了这些更关键的问题:
- 任务当前执行到哪一步了?
- 有没有执行过?
- 失败几次了?
- 下次什么时候重试?
- 是不是已经卡死了?
- 能不能人工重新执行?
这些都需要任务状态表。
6.1 一个典型的任务状态表长什么样?
核心字段一般包括:
task_idtask_typebiz_ididempotency_keystatusretry_countmax_retry_countnext_retry_atworker_idlease_untilpayloadresulterror_msg
6.2 状态怎么设计?
我比较推荐这几个基础状态:
pending
running
success
failed
retrying
dead_letter
含义比较直观:
pending:等待执行running:正在执行success:执行成功failed:这次执行失败retrying:等待下次重试dead_letter:超过次数或不可恢复,转人工处理
6.3 状态机怎么走?
可以抽象成这样:
pending -> running -> success
running -> failed -> retrying -> pending
failed -> dead_letter
关键点是:
状态不能乱跳,必须按规则流转。
比如已经 success 的任务,就不应该被再次执行。
已经 dead_letter 的任务,也不应该继续自动重试。
6.4 为什么说任务状态表才是事实来源?
因为 MQ 消息可能:
- 重复
- 延迟
- 乱序
- 被重投
所以 Consumer 不应该看到消息就直接执行,而应该:
收到 task_id
↓
查任务状态表
↓
确认状态允许执行
↓
再执行
这句话很值得记:
MQ 负责触发执行,任务状态表负责描述任务生命周期。
7. Ack、幂等、重试、死信:异步系统最容易翻车的地方
这一部分基本就是异步系统稳定性的核心。
7.1 Ack 应该什么时候做?
最安全的原则通常是:
业务处理成功后,再 ack。
如果一拿到消息就先 ack,再执行业务,一旦 Worker 中途挂了,这条消息就彻底丢了。
当然,业务成功后再 ack 也会有另一个问题:
- 业务已经做完了
- 但 ack 之前进程挂了
- Broker 以为消息没处理成功
- 又重新投了一次
这就是为什么重复消费几乎不可避免。
7.2 为什么重复消费一定会发生?
常见原因很多:
- ack 没成功
- offset 提交前宕机
- Producer 重试导致重复发送
- Broker 重投
- Consumer rebalance
所以异步系统里一定要接受一件事:
重复消费不是异常,而是常态。
真正要做的是幂等。
7.3 幂等怎么做?
幂等就是同一个操作做一次和做多次,结果一样。
常见做法有几种。
业务唯一键
资讯系统里可以用:
source_urlnews_id
工单系统里可以用:
ticket_id + step_id + tool_name + action
任务状态表
执行前先看这个任务是不是已经 success。如果已经成功,直接跳过。
执行记录表
对于工具执行这种有副作用的任务,最好单独做执行记录表,防止危险动作被重复执行。
数据库唯一索引
这是最后一层兜底。
单靠代码判断幂等不够,并发下还是可能翻车。最好配上唯一索引。
7.4 哪些失败该重试?
可以重试的一般是临时错误:
- 网络超时
- 连接失败
- 第三方 5xx
- 429 限流
- 数据库短暂抖动
不应该重试的一般是确定性错误:
- 参数错误
- 权限不足
- URL 非法
- 业务状态不允许
- 工具不存在
7.5 为什么要有死信队列?
坏消息不能无限重试。
如果一条消息本身就有问题,还一直重试,只会:
- 占用 Worker
- 拖慢主队列
- 对下游形成重试风暴
所以超过最大重试次数后,应该进入 dead_letter 或死信队列,转人工处理。
7.6 消息积压怎么排查?
消息积压本质上就是:
生产速度 > 消费速度
排查思路通常是:
- Consumer 是否还活着
- 失败率是不是变高了
- 单任务耗时是不是变长了
- 数据库 / 第三方接口是不是变慢了
- Worker 数是不是不够
- Kafka 的 Partition 是否成了并发瓶颈
不要一上来就说"加机器",先定位瓶颈才更像工程师。
8. Worker 抢占、超时恢复和重试补偿怎么设计?
这部分是把状态表真正用起来的关键。
8.1 为什么要抢占任务?
假设有多个 Worker 同时看到一条 pending 任务,如果没有控制,很可能会被执行多次。
所以需要"抢占"。
正确做法不是:
先 select 看状态是不是 pending
再 update 改成 running
因为并发下多个 Worker 可能同时查到 pending。
更稳的方式是:
直接用带条件的 update 抢占。
比如只有 status in ('pending', 'retrying') 时,才能改成 running。
影响行数为 1,说明抢占成功;为 0,说明已经被别人抢了。
8.2 running 卡死怎么办?
最常见的事故之一就是任务一直卡在 running。
原因可能是:
- Worker 宕机
- 代码死循环
- 第三方接口一直不返回
- 进程被杀
- 机器重启
所以 running 状态不能无限挂着。
8.3 lease_until 很关键
Worker 抢占任务时,可以给它一个租约:
worker_idlease_until
意思是:
这个任务在 lease_until 之前归当前 Worker 处理。
如果 Worker 还活着,可以续租。
如果 Worker 挂了,就没人续租。
后台扫描器发现 lease_until 过期的 running 任务,就可以判定它可能已经卡死了,然后把任务恢复成 retrying,或者直接进 dead_letter。
8.4 重试补偿不是 while 死循环
错误做法是:
失败了 -> while 一直重试
这样会把 Worker 占死。
正确做法应该是:
- 失败后更新任务状态
- 设置
next_retry_at - 释放 Worker
- 等后台扫描器到时间再重新投递
这才是工程上的重试补偿。
9. 数据库和 MQ 怎么保证一致?为什么需要 Outbox?
这一块是异步系统再往前走一步,必须面对的问题。
9.1 最经典的问题:库写成功了,但消息没发出去
比如创建工单时:
ticket 表写成功
↓
准备发 ticket.created
↓
进程突然挂了
结果就是:
- 工单在数据库里已经存在
- 但通知服务没收到
- 审计服务没收到
- Agent 也没收到
整个异步链路断了。
9.2 反过来也有问题:消息发出去了,但数据库回滚了
如果你先发消息再写数据库,也可能出现:
- 消息已经被下游消费
- 但数据库事务失败回滚
- 下游拿到的是假事件
9.3 为什么不能简单"先写库再发 MQ"?
因为数据库事务提交和消息发送,本来就是两个独立动作,不是天然原子操作。
中间这段空隙就是最危险的:
数据库提交成功
↓
消息还没发
↓
进程挂了
9.4 Outbox 的思路是什么?
核心思路其实很朴素:
不要业务一成功就立刻发 MQ,而是先把"待发送消息"也落到数据库里。
也就是说,在同一个本地事务里同时做两件事:
- 写业务表
- 写 outbox_event 表
事务提交后,再由后台 dispatcher 去扫描 outbox_event 并投递 MQ。
流程大概是这样:
业务操作
↓
同一事务里:
写业务表 + 写 outbox_event
↓
事务提交
↓
后台 dispatcher 扫描 outbox_event
↓
发送 MQ
↓
成功后标记 sent
9.5 Outbox 解决了什么?
它解决的是:
消息不会无记录地丢失。
即使服务在事务提交后、消息发送前挂了,也没关系,因为 outbox_event 还在数据库里,后面扫描器还能补发。
9.6 dispatcher 也要有状态机
Outbox 表我一般会设计这些状态:
pendingsendingsentretryingdead_letter
dispatcher 工作逻辑一般是:
- 扫描
pending / retrying - 抢占改成
sending - 真正发送 MQ
- 成功改
sent - 临时失败改
retrying - 永久失败或超过次数改
dead_letter
9.7 Outbox 不解决重复,要靠幂等
这一点一定要说清楚:
Outbox 解决的是"不丢",不是"绝不重复"。
比如 MQ 明明发成功了,但 dispatcher 还没来得及把 outbox 状态改成 sent 就挂了,后面还是可能再发一遍。
所以消费端依然要做幂等。
9.8 什么叫最终一致性?
最终一致性不是"永远一致",而是:
允许短时间不一致,但系统会通过补偿机制,最终达到一致。
比如工单创建好了,通知稍后几秒补发成功,这在很多业务里都是完全可以接受的。
10. 这些方案在我的项目里怎么落地?
技术写到这里,如果不落项目,很容易变成"知识点罗列"。
下面说说我觉得比较自然的几个落地场景。
10.1 海外资讯聚合中台
这个项目里,抓取、清洗、入库本质上都是异步任务。
业务问题
- 抓取链路耗时长
- 资讯源质量不稳定
- 网络抖动比较多
- 不能同步卡在请求里
技术方案
- Django + Celery + Redis
- 抓取、清洗、入库拆成不同队列
- 任务状态表记录
pending / running / success / retrying / dead_letter source_url / news_id做幂等- 入库层加唯一索引防止重复写入
- Beat 定时扫描超时任务和待重试任务
更进一步
如果后期量更大、链路更复杂,其实可以继续升级成:
- 抓取事件
- 清洗事件
- 索引事件
用 Kafka 作为事件流承载,做可回放的数据链路。
10.2 智能工单与自动化运维 Agent 系统
这个场景特别适合讲状态表、幂等和 Outbox。
业务问题
- 告警触发后会有多步异步动作
- 工具调用有副作用,不能重复执行
- 通知、审计、Agent 分析不应该强耦合同步调用
技术方案
- 告警事件进入 MQ
- 用 run / step / attempt 管理任务链路
ticket_id + step_id + tool_name + action生成幂等键- Worker 条件 update 抢占任务
lease_until做超时恢复- 临时失败进
retrying - 超过次数进
dead_letter ticket.created等事件通过 Outbox 保证不丢
这个项目里最关键的一点其实是:
工具执行类任务不能只想着"跑通",必须先解决"不能乱跑、不能重复跑、跑挂了能恢复"。
10.3 智能 BI 项目
BI 场景里,主查询链路很多时候是同步 + SSE 返回进度,但离线步骤非常适合异步化。
比如:
- schema 刷新
- 字段 embedding 构建
- 历史 query 向量化
- 索引重建
这些任务可以:
- 用 Celery 异步跑
- 用 run / step / attempt 记录状态
- 用
datasource_id + schema_version + task_type做幂等键
这样后面做观测、错误恢复、重建索引都会轻松很多。
11. 这套方案最容易踩的坑
最后再集中说一下几个最常见的坑。
11.1 以为用了 MQ 就天然可靠
不是。
MQ 只是传消息,可靠性还要靠:
- 状态表
- 幂等
- 重试
- 死信
- 超时恢复
11.2 只用 Celery,不做状态表
这样系统表面上能跑,但出了问题很难排查:
- 任务到底成功没
- 重试几次了
- 卡在哪一步
- 是否需要人工介入
全都不清楚。
11.3 幂等只靠代码判断,不加唯一索引
并发场景下很容易翻车。
最好数据库层也有唯一索引做最终兜底。
11.4 失败后无限重试
这会导致重试风暴。
正确做法是:
- 分类错误
- 有限次数
- 指数退避
- 超过次数转死信
11.5 没有超时恢复
没有 lease_until 和扫描器,任务就会永远卡在 running。
11.6 没有 Outbox
数据库和消息不一致是非常典型的事故来源。
业务只要开始依赖异步事件,Outbox 基本就绕不开了。
12. 还能怎么继续优化?
异步任务系统真正成熟之后,优化点其实很多。
比如:
- 队列按任务类型拆分
- Worker 水平扩容
- Kafka 增加 Partition 提升并发
- 批量发送、批量消费
- 指数退避避免重试风暴
- 增加监控指标:
-
- 队列长度
- lag
- retry_count
- dead_letter 数量
- 成功率
- P95 执行耗时
- 状态表归档
- Outbox 用 CDC 替代纯轮询
- 链路追踪接 OpenTelemetry
这些优化的方向其实可以浓缩成一句话:
异步系统不只是"能跑",还要做到低延迟、可观测、可恢复、可扩展。
13. 总结
回头看这整条链路,其实会发现:
- MQ 解决的是异步解耦和削峰
- Kafka 更适合高吞吐事件流和可回放链路
- Celery 更适合 Python 业务里的后台任务执行
- 任务状态表决定了系统能不能恢复、能不能排障
- 幂等、重试、死信决定了系统能不能稳定运行
- Outbox 解决的是生产端消息不丢
- 最终一致性是大多数业务系统更现实的选择
真正难的,从来不是把消息发出去。
真正难的是:
在重复、失败、超时、宕机这些现实问题下,任务依然能被正确地执行完。
这也是为什么我现在越来越觉得,异步任务系统的重点从来不在"会不会用队列",而在"能不能把这条链路做成一个可恢复、可追踪、可补偿的系统"。