从能跑到可靠:一次把异步任务系统做稳的实践笔记

从能跑到可靠:一次把异步任务系统做稳的实践笔记

很多团队第一次上异步任务,往往是为了解耦接口耗时。接口里原本同步发短信、推消息、调三方接口、写审计日志,响应越来越慢,于是顺手把这些动作丢进队列,系统立刻“快了”。但再过一阵,新的问题就会冒出来:消息重复消费、任务堆积、重试把下游打挂、运营追问某条任务为什么没执行,你却只能去翻日志碰运气。

我最近重做了一次任务系统,体会很深:异步系统的难点从来不是“把任务发出去”,而是“出了问题以后还能不能解释、恢复、补偿”。下面把这次实践里几个最关键的点记一下。

一、先明确任务边界,不要什么都进队列

异步任务适合处理三类事情:

  1. 用户请求链路里非核心的动作,比如埋点、通知、邮件
  2. 明显耗时的动作,比如文件转码、报表生成、批量同步
  3. 可以接受最终一致性的动作,比如缓存预热、二级索引刷新

如果任务本身要求强事务一致,或者失败后没有补偿手段,就别急着异步化。很多“消息队列事故”的起点,是把本该在数据库事务里完成的核心写操作,拆成了多个最终一致步骤。

二、入队不是成功,落库才算成功

我比较推荐的做法是 Outbox 模式。业务事务提交时,除了更新主业务表,还把待执行事件写进 outbox 表。后台 worker 再从 outbox 表分发到消息队列。

这样做的好处是避免“数据库提交了,但消息没发出去”这种最难查的问题。

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
);

业务代码可以很直接:

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. 单独建消费记录表,先写入再执行
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 一起重试,把本来短暂抖动的问题放大成雪崩。

更稳的策略通常是分级设计:

  • 第 1 次失败:30 秒后重试
  • 第 2 次失败:2 分钟后重试
  • 第 3 次失败:10 分钟后重试
  • 第 4 次失败:1 小时后重试
  • 超过阈值:进入 dead letter queue

除了指数退避,还要区分错误类型:

  • 超时、429、503 这种临时错误,适合重试
  • 参数错误、数据缺失、状态非法,这种要尽快落到死信或人工处理

把“可重试错误”和“不可重试错误”分开,是任务系统从能跑走向可运营的分水岭。

五、可观测性比吞吐量更先救命

一开始大家最关心 TPS,真出故障时,最有用的反而是这些字段:

  • 当前队列长度
  • 任务平均等待时长
  • 最近 5 分钟失败率
  • 死信任务数
  • 单个任务完整链路日志

至少要给每条任务一个统一的 trace id,并且把状态流转打全:

created -> queued -> picked -> processing -> success
created -> queued -> picked -> processing -> retry_wait
created -> queued -> picked -> failed -> dead_letter

没有这套状态流转,你看到的只是“任务失败了”;有了这套流转,你才能回答“失败前卡在哪一跳、重试了几次、是谁处理的、能不能补”。

六、最后别忘了人工补偿入口

再完善的自动化系统,也会遇到脏数据、第三方异常、历史兼容问题。这个时候最重要的不是继续自动重试,而是给运营或开发一个可靠的人工入口:

  • 支持按任务 ID 重放
  • 支持修改参数后重试
  • 支持跳过某一步补执行后续动作
  • 所有人工操作有审计记录

我现在对异步任务系统的判断标准很简单:不是看它高峰期能吞多少,而是看一条任务失败后,团队能不能在 10 分钟内定位并决定下一步动作。

写在最后

同步系统的问题通常是慢,异步系统的问题通常是“不知道发生了什么”。所以做任务系统时,优先级应该是:幂等、可追踪、可补偿,然后才是吞吐量和花哨架构。

系统从“能跑”到“可靠”,靠的不是多上几台 worker,而是把失败当成常态去设计。只要这个思路立住了,后面不管你用 Redis Stream、RabbitMQ、Kafka,还是数据库轮询,系统都会稳很多。

posted @ 2026-07-20 22:21  fitch_liu  阅读(33)  评论(0)    收藏  举报