别急着上微服务,先把异步边界设计清楚

先别急着上微服务,先把异步边界设计清楚

这两年做系统改造,我越来越少听到“我们要不要拆成微服务”,反而更常问一句:这条业务链路里,哪些步骤必须同步完成,哪些步骤应该异步化?很多系统的问题,表面上看像是架构不够先进,实际上是同步边界画错了。

最典型的例子是订单系统。用户点击“提交订单”之后,常见动作包括:写订单、扣减库存、计算优惠、写支付单、发短信、推送物流、同步 CRM、写审计日志。如果把这些动作全部塞进一个同步事务,接口延迟会被拉长,任意一个下游抖动都可能把主流程拖死。最后你会发现,系统不是不能用,而是高峰期特别脆。

一、先把主链路压缩到最小闭环

我的经验是,先把主链路压缩到“用户真正关心的最小闭环”。以电商下单为例,真正必须同步保证的,通常只有三件事:

  1. 订单记录成功落库。
  2. 库存预占成功,避免超卖。
  3. 返回一个明确、可追踪的订单号。

其他动作,比如发站内信、写经营分析报表、同步会员积分、触发推荐系统更新,都可以异步处理。这样做的价值不是“为了看起来更像分布式架构”,而是为了让系统在不稳定环境里还能维持核心可用性。

二、异步边界的第一个坑:本地事务和消息投递分离

这里最容易踩坑的点,不是消息队列怎么选,而是“本地事务提交了,消息没发出去怎么办”。如果直接在代码里这么写:

order_repo.create(order)
inventory.reserve(order.items)
kafka.send("order_created", order.to_dict())

那你迟早会碰到一种情况:数据库提交成功了,进程在 kafka.send 前挂掉,结果订单已经存在,但下游完全不知道。库存回滚、营销发券、履约建单全都不会发生。这个问题不是靠重试 HTTP 调用能解决的,而是要在架构上补一层 Outbox。

更稳妥的写法通常是把业务数据和待发送事件放进同一个本地事务:

with db.transaction() as tx:
    order_repo.create(tx, order)
    inventory_repo.reserve(tx, order.items)
    outbox_repo.insert(tx, {
        "event_type": "order_created",
        "aggregate_id": order.id,
        "payload": order.to_dict(),
        "status": "pending"
    })

然后由独立的投递程序扫描 outbox 表,把事件投递到 Kafka、RabbitMQ 或者其他消息系统。这样即使业务进程在事务提交后崩掉,事件也还在库里,补偿程序可以继续发。

三、怎么排查事件投递是否卡住

如果用 PostgreSQL,排查某个事件迟迟没被投递,我一般先看这条 SQL:

select id, event_type, aggregate_id, status, retry_count, created_at
from event_outbox
where status <> 'sent'
order by created_at asc
limit 20;

如果是在线上机器做快速检查,命令也很直接:

psql "$DATABASE_URL" -c "select count(*) from event_outbox where status='pending';"

四、有异步边界,还必须有幂等

有了异步边界,还不够。第二个必须提前设计的是幂等。因为只要你依赖消息队列,就要接受“至少一次投递”这个现实。消费者收到重复消息,不是异常场景,而是正常场景。

比如履约服务收到两次 order_created,如果代码没有幂等保护,就可能重复建运单、重复扣库存或者重复发券。

比较简单的处理方式,是给每个业务事件一个稳定的 event_id,消费者在本地落一张处理记录表。伪代码大概是这样:

func HandleOrderCreated(evt Event) error {
    if processedRepo.Exists(evt.ID) {
        return nil
    }

    tx := db.Begin()
    defer tx.Rollback()

    if err := shipmentRepo.CreateIfAbsent(tx, evt.OrderID); err != nil {
        return err
    }
    if err := processedRepo.MarkDone(tx, evt.ID); err != nil {
        return err
    }

    return tx.Commit()
}

这里的关键不是 Exists 这个判断本身,而是“业务执行”和“幂等标记”要放在同一事务里。否则两台消费者并发处理时,还是可能穿透。

五、失败处理策略比“无限重试”更重要

另一个经常被低估的问题,是失败处理策略。很多团队的默认做法是消费者报错就无限重试,听起来很保险,实际上可能把故障放大。比如下游接口参数格式已经变了,无限重试只会把队列堆满,连正常消息都跟着延迟。

更合理的方式通常是分层处理:临时性错误做指数退避重试,数据错误直接入死信队列,人工或脚本修复后再回灌。

六、监控要盯住链路结果,而不是单点资源

如果是 Kafka,我会把下面几个指标长期挂到监控里:

  • 消费组 lag
  • 死信队列堆积量
  • Outbox 未发送数量
  • 单个事件平均重试次数
  • 从订单创建到履约建单的端到端延迟

这些指标比“服务 CPU 60%”更能说明你的架构有没有真正扛住业务流量。因为用户感知的是链路结果,不是某个 Pod 的资源曲线。

结语

回过头看,很多所谓架构升级,真正有效的不是引入了多少新组件,而是把系统拆成了几个清晰的承诺:什么要同步完成,什么允许延后;什么必须强一致,什么接受最终一致;失败后谁负责补偿,重复到达时谁负责兜底。边界画清楚了,后面无论你是单体、模块化单体还是微服务,都能稳很多。

所以如果你正准备做系统重构,我建议先别急着讨论“要不要全面服务化”。先拿一条最关键的业务链路出来,画清楚同步步骤、异步事件、幂等键和补偿路径。这个动作做扎实了,架构才算真正开始。

posted @ 2026-08-03 09:03  fitch_liu  阅读(11)  评论(0)    收藏  举报