从0到1:用 Akka 持久化 Actor + Outbox + RocketMQ 做到“订单-库存最终一致”

从0到1:用 Akka 持久化 Actor + Outbox + RocketMQ 做到"订单-库存最终一致"

这是一篇手把手文章:先讲清楚"为什么要这么做"(场景与痛点),再讲"怎么做"(方案与代码),最后教你"一键运行"。你不需要预先了解 Akka 或 RocketMQ,也能跟着走通全链路。


我们要解决什么问题?(先说人话场景)

想象一个电商系统:

  • 用户"下单"后,需要"预留库存"(避免超卖)。
  • 用户"支付"成功后,库存要"确认扣减"。
  • 用户"取消订单",库存要"释放回去"。

这看似简单,但跨两个服务(订单服务和库存服务),如果中途某一步挂了、网络抖动、消息重复,就会出现:

  • 订单已创建但库存没预留(超卖风险)
  • 订单已支付但库存未扣(账实不一致)
  • 订单已取消但库存未释放(库存被白占)

一句话:我们要在"分布式、会失败、会重复"的真实世界里,让订单和库存"最终一致"。


解决思路一张图(先有整体,再看细节)

  • 同步内:每个服务内部用 Akka 的"单线程 Actor"处理一个聚合(例如一个订单、一种商品的库存),把并发修改串行化,天然避免竞态。
  • 跨服务:用 Outbox + RocketMQ 保证"本地事务写成功后,再可靠地异步发布事件"。
  • 最终一致:库存服务订阅订单事件(创建/支付/取消),把这些事件"翻译"为库存的"预留/确认/释放"。
  • 可恢复:所有状态变更都写成"事件",崩溃后重放事件即可恢复到最新状态(这就叫事件溯源)。

你可以把它理解为:

  1. 订单服务是"权威来源",它产出的事件一定可靠地通过 Outbox 发到 RocketMQ;
  2. 库存服务只要把收到的事件按顺序处理,就能和订单保持一致;
  3. 任何时候宕机了,重放事件就能恢复现场。

用到哪些技术?(用通俗话解释)

  • Akka 持久化 Actor(经典 API:AbstractPersistentActor):

    • Actor = 单线程小服务员;一个 Actor 只处理一个聚合,顺序处理消息,不会抢同一份数据。
    • "持久化"意味着:我们不直接改状态,而是"写事件 → 用事件更新内存状态"。崩溃了就"重放事件"。
  • Outbox Pattern:

    • 把"要对外发布的消息"先写到自己数据库的 Outbox 表(和业务数据同一个本地事务里)。
    • 后台定时器把 Outbox 中的消息可靠地发到 RocketMQ,成功后再标记为已处理。
    • 好处:避免跨服务的分布式事务;保证"只要本地事务成功,消息一定最终发出去"。
  • RocketMQ(Topic + Tag):

    • 我们用 Topic ORDER_EVENTS,Tag 区分事件类型:ORDER_CREATEDORDER_PAIDORDER_CANCELLED
    • 库存服务按 Tag 分别消费,做不同动作(预留、确认、释放)。

端到端流程(一步步来看)

  1. 用户下单 → 订单服务生成 OrderCreated 事件,保存到事件存储;同时把这条事件写入 Outbox 表。
  2. Outbox 处理器(每5秒跑一次)扫描 Outbox,把事件发送到 ORDER_EVENTS:ORDER_CREATED
  3. 库存服务订阅到 ORDER_CREATED,将其"翻译"为库存 Actor 的"预留库存"命令。
  4. 用户支付 → 订单服务生成 OrderPaid,Outbox 发布 ORDER_EVENTS:ORDER_PAID,库存服务收到后"确认库存"。
  5. 用户取消 → 发布 ORDER_EVENTS:ORDER_CANCELLED,库存服务"释放库存"。
  6. 任意一步宕机 → 重启后通过"重放事件"恢复状态;Outbox 未发出的消息会继续重试发送;消费端若重复收到消息,通过当前状态判断来"幂等处理"。

一键运行(本地即可)

前置:安装 JDK 17、Maven、Docker & Docker Compose。

  1. 启动 RocketMQ(NameServer + Broker + Console):
bash 复制代码
# Windows
start-services.bat
# Mac/Linux
./start-services.sh
  1. 启动两个微服务(任一方式):
  • IDE 直接运行:order-serviceOrderServiceApplicationinventory-serviceInventoryServiceApplication
  • 或者使用根目录脚本:quick-test.bat
  1. 打开 RocketMQ Console:
arduino 复制代码
http://localhost:8180
  1. 调用 API(脚本现成):
bash 复制代码
# Windows
test-api.bat
# Mac/Linux
./test-api.sh

看几段关键代码(对照理解就行)

  1. 订单 Actor:把"命令"变成"事件",再用事件更新状态,并写入 Outbox。
12:120:order-service/src/main/java/com/example/orderservice/actor/PersistentOrderActor.java 复制代码
@Override
public Receive createReceive() {
    return receiveBuilder()
        .match(CreateOrder.class, this::handleCreateOrder)
        .match(PayOrder.class, this::handlePayOrder)
        .match(CancelOrder.class, this::handleCancelOrder)
        .build();
}

private void handleCreateOrder(CreateOrder cmd) {
    OrderCreatedEvent event = new OrderCreatedEvent(
        cmd.getOrderId(), cmd.getCustomerId(), cmd.getItems(), cmd.getTotalAmount()
    );
    persist(event, evt -> {
        handleOrderCreatedEvent(evt);   // 用事件更新内存状态
        saveToOutbox(evt);              // 写出 Outbox,稍后异步发 MQ
        if (lastSequenceNr() % 10 == 0) saveSnapshot(state); // 定期快照
        getSender().tell(new OrderCreated(cmd.getOrderId(), "SUCCESS"), getSelf());
    });
}
  1. Outbox 处理器:每5秒把未发送的事件发到 RocketMQ;成功后标记为已处理。
22:76:order-service/src/main/java/com/example/orderservice/service/OutboxEventProcessor.java 复制代码
@Scheduled(fixedDelay = 5000)
@Async
public void processUnprocessedEvents() {
    List<OutboxEvent> unprocessed = outboxEventRepository.findUnprocessedEvents();
    for (OutboxEvent event : unprocessed) {
        processEvent(event);
    }
}

@Transactional
public void processEvent(OutboxEvent event) {
    rocketMQTemplate.syncSend(
        "ORDER_EVENTS:" + event.getEventType(),
        MessageBuilder.withPayload(event.getEventData()).build()
    );
    event.setProcessed(true);
    event.setProcessedAt(LocalDateTime.now());
    outboxEventRepository.save(event);
}
  1. 库存消费者:按 Tag 消费订单事件,并把它翻译成库存命令。
36:84:inventory-service/src/main/java/com/example/inventoryservice/service/OrderEventConsumer.java 复制代码
@RocketMQMessageListener(
  topic = "ORDER_EVENTS",
  selectorExpression = "ORDER_CREATED",
  consumerGroup = "inventory-consumer-group"
)
public class OrderCreatedEventConsumer implements RocketMQListener<String> {
  @Override
  public void onMessage(String message) {
    OrderCreatedEvent event = objectMapper.readValue(message, OrderCreatedEvent.class);
    ActorRef inventoryActor = actorSystem.actorOf(
      PersistentInventoryActor.props(), "inventory-" + event.getOrderId()
    );
    PersistentInventoryActor.ReserveInventory cmd =
      new PersistentInventoryActor.ReserveInventory(event.getOrderId(), event.getItems());
    Patterns.ask(inventoryActor, cmd, timeout);
  }
}

你现在应该已经理解了什么?(复盘)

  • 为什么要这么做:分布式下"会失败、会重复",必须有可靠的事件发布与幂等处理,才能做到最终一致。
  • 我们怎么做的:
    • 服务内用 Akka 持久化 Actor(单线程顺序处理 + 事件溯源 + 快照/重放)。
    • 跨服务用 Outbox + RocketMQ(本地事务成功 → 异步可靠发布)。
    • 库存侧订阅订单事件并执行预留/确认/释放。
  • 出了问题怎么办:
    • Actor 崩溃 → 重放事件恢复。
    • 消息重复 → 按当前状态幂等处理(例如已确认就忽略再次确认)。
    • 消息发送失败 → Outbox 会重试,直到标记为处理完成或超过最大重试。

常见坑与解决建议(真正在一线会遇到的)

  • 消息重复/乱序:消费者用"状态机 + 业务键"做幂等;必要时引入去重表。
  • 大量事件查询慢:把事件投影(Projection)到查询表或 ES,读写分离。
  • Actor 粒度:按聚合(如 orderId / productId)建 Actor;高并发时可做 Sharding。
  • 运维可观测:接入日志、指标、链路追踪(Prometheus / OpenTelemetry)。

总结(一句话版本)

用"Akka 持久化 Actor + Outbox + RocketMQ"的组合,可以在复杂、不稳定的真实分布式环境里,把订单与库存做成"服务内强一致 + 跨服务最终一致",而且能追溯、能恢复、能扩展。

如果你想进一步:我可以把 Actor 做成分片(Sharding)、把事件投影到查询库、或加入完整的幂等与补偿策略,留言告诉我你的场景即可。

代码

相关推荐
我不只是切图仔5 小时前
我只是想给网站加个注册验证码,咋就那么难!
前端·后端
专注VB编程开发20年5 小时前
CSS 的命名方式像是 PowerShell 的动词-名词结构,缺乏面向对象的层级关系
开发语言·后端·rust
野犬寒鸦5 小时前
力扣hot100:相交链表与反转链表详细思路讲解(160,206)
java·数据结构·后端·算法·leetcode
爱吃烤鸡翅的酸菜鱼6 小时前
【Spring】原理:Bean的作用域与生命周期
后端·spring
JohnYan6 小时前
工作笔记 - 微信消息发送和处理
javascript·后端·微信
该用户已不存在6 小时前
macOS是开发的终极进化版吗?
前端·后端
计算机毕业设计木哥6 小时前
计算机毕设选题:基于Python+Django的B站数据分析系统的设计与实现【源码+文档+调试】
java·开发语言·后端·python·spark·django·课程设计
歪歪1006 小时前
qt creator新手入门以及结合sql server数据库开发
c语言·开发语言·后端·qt·数据库开发
布列瑟农的星空6 小时前
大话设计模式——观察者模式和发布/订阅模式的区别
前端·后端·架构