从0到1:用 Akka 持久化 Actor + Outbox + RocketMQ 做到"订单-库存最终一致"
这是一篇手把手文章:先讲清楚"为什么要这么做"(场景与痛点),再讲"怎么做"(方案与代码),最后教你"一键运行"。你不需要预先了解 Akka 或 RocketMQ,也能跟着走通全链路。
我们要解决什么问题?(先说人话场景)
想象一个电商系统:
- 用户"下单"后,需要"预留库存"(避免超卖)。
- 用户"支付"成功后,库存要"确认扣减"。
- 用户"取消订单",库存要"释放回去"。
这看似简单,但跨两个服务(订单服务和库存服务),如果中途某一步挂了、网络抖动、消息重复,就会出现:
- 订单已创建但库存没预留(超卖风险)
- 订单已支付但库存未扣(账实不一致)
- 订单已取消但库存未释放(库存被白占)
一句话:我们要在"分布式、会失败、会重复"的真实世界里,让订单和库存"最终一致"。
解决思路一张图(先有整体,再看细节)
- 同步内:每个服务内部用 Akka 的"单线程 Actor"处理一个聚合(例如一个订单、一种商品的库存),把并发修改串行化,天然避免竞态。
- 跨服务:用 Outbox + RocketMQ 保证"本地事务写成功后,再可靠地异步发布事件"。
- 最终一致:库存服务订阅订单事件(创建/支付/取消),把这些事件"翻译"为库存的"预留/确认/释放"。
- 可恢复:所有状态变更都写成"事件",崩溃后重放事件即可恢复到最新状态(这就叫事件溯源)。
你可以把它理解为:
- 订单服务是"权威来源",它产出的事件一定可靠地通过 Outbox 发到 RocketMQ;
- 库存服务只要把收到的事件按顺序处理,就能和订单保持一致;
- 任何时候宕机了,重放事件就能恢复现场。
用到哪些技术?(用通俗话解释)
-
Akka 持久化 Actor(经典 API:AbstractPersistentActor):
- Actor = 单线程小服务员;一个 Actor 只处理一个聚合,顺序处理消息,不会抢同一份数据。
- "持久化"意味着:我们不直接改状态,而是"写事件 → 用事件更新内存状态"。崩溃了就"重放事件"。
-
Outbox Pattern:
- 把"要对外发布的消息"先写到自己数据库的 Outbox 表(和业务数据同一个本地事务里)。
- 后台定时器把 Outbox 中的消息可靠地发到 RocketMQ,成功后再标记为已处理。
- 好处:避免跨服务的分布式事务;保证"只要本地事务成功,消息一定最终发出去"。
-
RocketMQ(Topic + Tag):
- 我们用 Topic
ORDER_EVENTS
,Tag 区分事件类型:ORDER_CREATED
、ORDER_PAID
、ORDER_CANCELLED
。 - 库存服务按 Tag 分别消费,做不同动作(预留、确认、释放)。
- 我们用 Topic
端到端流程(一步步来看)
- 用户下单 → 订单服务生成
OrderCreated
事件,保存到事件存储;同时把这条事件写入 Outbox 表。 - Outbox 处理器(每5秒跑一次)扫描 Outbox,把事件发送到
ORDER_EVENTS:ORDER_CREATED
。 - 库存服务订阅到
ORDER_CREATED
,将其"翻译"为库存 Actor 的"预留库存"命令。 - 用户支付 → 订单服务生成
OrderPaid
,Outbox 发布ORDER_EVENTS:ORDER_PAID
,库存服务收到后"确认库存"。 - 用户取消 → 发布
ORDER_EVENTS:ORDER_CANCELLED
,库存服务"释放库存"。 - 任意一步宕机 → 重启后通过"重放事件"恢复状态;Outbox 未发出的消息会继续重试发送;消费端若重复收到消息,通过当前状态判断来"幂等处理"。
一键运行(本地即可)
前置:安装 JDK 17、Maven、Docker & Docker Compose。
- 启动 RocketMQ(NameServer + Broker + Console):
bash
# Windows
start-services.bat
# Mac/Linux
./start-services.sh
- 启动两个微服务(任一方式):
- IDE 直接运行:
order-service
的OrderServiceApplication
和inventory-service
的InventoryServiceApplication
- 或者使用根目录脚本:
quick-test.bat
- 打开 RocketMQ Console:
arduino
http://localhost:8180
- 调用 API(脚本现成):
bash
# Windows
test-api.bat
# Mac/Linux
./test-api.sh
看几段关键代码(对照理解就行)
- 订单 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());
});
}
- 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);
}
- 库存消费者:按 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)、把事件投影到查询库、或加入完整的幂等与补偿策略,留言告诉我你的场景即可。