从能跑到可靠:一次把异步任务系统做稳的实践笔记
# 从能跑到可靠:一次把异步任务系统做稳的实践笔记
很多团队第一次上异步任务,往往是为了解耦接口耗时。接口里原本同步发短信、推消息、调三方接口、写审计日志,响应越来越慢,于是顺手把这些动作丢进队列,系统立刻“快了”。但再过一阵,新的问题就会冒出来:消息重复消费、任务堆积、重试把下游打挂、运营追问某条任务为什么没执行,你却只能去翻日志碰运气。
我最近重做了一次任务系统,体会很深:异步系统的难点从来不是“把任务发出去”,而是“出了问题以后还能不能解释、恢复、补偿”。下面把这次实践里几个最关键的点记一下。
## 先明确任务边界,不要什么都进队列
异步任务适合处理三类事情:
1. 用户请求链路里非核心的动作,比如埋点、通知、邮件
2. 明显耗时的动作,比如文件转码、报表生成、批量同步
3. 可以接受最终一致性的动作,比如缓存预热、二级索引刷新
如果任务本身要求强事务一致,或者失败后没有补偿手段,就别急着异步化。很多“消息队列事故”的起点,是把本该在数据库事务里完成的核心写操作,拆成了多个最终一致步骤。
## 入队不是成功,落库才算成功
我比较推荐的做法是 Outbox 模式。业务事务提交时,除了更新主业务表,还把待执行事件写进 outbox 表。后台 worker 再从 outbox 表分发到消息队列。
这样做的好处是避免“数据库提交了,但消息没发出去”这种最难查的问题。
```sql
create table task_outbox (
id bigint primary key auto_increment,
event_type varchar(64) not null,
payload json not null,
status varchar(16) not null default 'pending',
retry_count int not null default 0,
next_run_at datetime not null default current_timestamp,
created_at datetime not null default current_timestamp,
updated_at datetime not null default current_timestamp on update current_timestamp
);
```
业务代码可以很直接:
```python
with db.transaction() as tx:
order_id = create_order(tx, user_id, items)
tx.execute(
"""
insert into task_outbox(event_type, payload)
values(%s, %s)
""",
("order_created", json.dumps({"order_id": order_id}))
)
```
这样即使 MQ 短暂不可用,至少任务还在库里,不会凭空消失。
## 消费端必须幂等
只要用了队列,就默认消息可能重复。不要幻想“broker 已经保证不会重复”。真正可靠的做法,是消费者把幂等设计成默认能力。
常见方法有两个:
1. 用业务唯一键去重,比如 `order_id + task_type`
2. 单独建消费记录表,先写入再执行
```python
def handle_order_created(event):
dedupe_key = f"order_created:{event['order_id']}"
if already_processed(dedupe_key):
return
mark_processing(dedupe_key)
try:
send_coupon(event["order_id"])
mark_done(dedupe_key)
except Exception:
mark_failed(dedupe_key)
raise
```
如果你的任务执行结果会修改余额、发券、发货、扣库存,这一步不能省。
## 重试要分级,不要无脑打满
很多系统的重试策略只有一句话:失败就 retry。结果一到下游故障时,几十个 worker 一起重试,把本来短暂抖动的问题放大成雪崩。
更稳的策略通常是:
```bash
# 示例策略
第1次失败: 30秒后重试
第2次失败: 2分钟后重试
第3次失败: 10分钟后重试
第4次失败: 1小时后重试
超过阈值: 进入 dead letter queue
```
除了指数退避,还要区分错误类型:
- 超时、429、503 这种临时错误,适合重试
- 参数错误、数据缺失、状态非法,这种要尽快落到死信或人工处理
把“可重试错误”和“不可重试错误”分开,是任务系统从能跑走向可运营的分水岭。
## 可观测性比吞吐量更先救命
一开始大家最关心 TPS,真出故障时,最有用的反而是这些字段:
- 当前队列长度
- 任务平均等待时长
- 最近 5 分钟失败率
- 死信任务数
- 单个任务完整链路日志
至少要给每条任务一个统一的 trace id,并且把状态流转打全:
```text
created -> queued -> picked -> processing -> success
created -> queued -> picked -> processing -> retry_wait
created -> queued -> picked -> failed -> dead_letter
```
没有这套状态流转,你看到的只是“任务失败了”;有了这套流转,你才能回答“失败前卡在哪一跳、重试了几次、是谁处理的、能不能补”。
## 最后别忘了人工补偿入口
再完善的自动化系统,也会遇到脏数据、第三方异常、历史兼容问题。这个时候最重要的不是继续自动重试,而是给运营或开发一个可靠的人工入口:
- 支持按任务 ID 重放
- 支持修改参数后重试
- 支持跳过某一步补执行后续动作
- 所有人工操作有审计记录
我现在对异步任务系统的判断标准很简单:不是看它高峰期能吞多少,而是看一条任务失败后,团队能不能在 10 分钟内定位并决定下一步动作。
## 写在最后
同步系统的问题通常是慢,异步系统的问题通常是“不知道发生了什么”。所以做任务系统时,优先级应该是:幂等、可追踪、可补偿,然后才是吞吐量和花哨架构。
系统从“能跑”到“可靠”,靠的不是多上几台 worker,而是把失败当成常态去设计。只要这个思路立住了,后面不管你用 Redis Stream、RabbitMQ、Kafka,还是数据库轮询,系统都会稳很多。

浙公网安备 33010602011771号