观察者模式如何处理事务提交后的事件一致性?
简化版
事务场景下发布事件要关注“事件何时可见”和“监听器读到的数据是否已提交”。如果在事务未提交前同步通知,监听器可能读到未提交数据,甚至外部动作已经执行但主事务随后回滚。Spring 中可用 @TransactionalEventListener(phase = AFTER_COMMIT) 在提交后处理;跨进程可靠通知则通常使用本地消息表、Outbox 或消息队列。
详细版
订单创建成功后发布 OrderCreatedEvent,监听器可能发送短信、发放积分、通知仓库。如果事件在数据库事务提交前就被处理,会出现危险:
事务开始
保存订单
发布事件 -> 短信已发送
事务回滚
数据库里没有订单,但用户收到了短信
所以事务事件要先回答 3 个问题:
- 监听器是否需要看到已提交数据;
- 外部副作用能否跟随事务回滚;
- 事件是否需要可靠投递到其他服务。
应用内场景可用事务提交后监听;分布式场景要用可靠事件机制,而不是只靠本地观察者。
记忆钩子:事务内发布事件,最怕“外部世界已经响应,数据库最后回滚”。
完整版教学
面试提示:事务一致性题要先区分“事务内同步观察者”和“事务后异步观察者”,再谈 outbox、重试和补偿。
一、事务内同步事件有什么风险
看一个常见代码:
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = orderRepository.save(command);
eventPublisher.publishEvent(new OrderCreatedEvent(order.id()));
}
如果默认同步监听,监听器会在事务方法返回前执行。此时数据库事务可能还没提交。
风险包括:
- 监听器查询订单,可能受隔离级别影响读不到;
- 监听器调用外部系统,外部动作无法随数据库回滚;
- 监听器抛异常,可能让主事务回滚;
- 主事务最终失败,但事件副作用已经发生。
二、提交后监听适合应用内事件
Spring 提供 @TransactionalEventListener:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
smsService.send(event.orderId());
}
AFTER_COMMIT 表示只有当前事务成功提交后,监听器才执行。这能避免“事务回滚但短信已发”的问题。
常见 phase:
| phase | 含义 | 场景 |
|---|---|---|
| BEFORE_COMMIT | 提交前 | 校验、准备数据 |
| AFTER_COMMIT | 提交后 | 发送通知、异步任务 |
| AFTER_ROLLBACK | 回滚后 | 清理、补偿 |
| AFTER_COMPLETION | 完成后 | 统计、释放资源 |
三、提交后监听也不是可靠消息
AFTER_COMMIT 只解决事务时机,不保证进程崩溃时事件一定送达。
例如:
事务提交成功
进程还没执行监听器就宕机
短信没有发送
如果业务要求可靠投递,就不能只靠本地内存事件。需要本地消息表、Outbox 或消息队列。
四、本地消息表如何保证可靠性
常见方案是业务数据和事件记录写在同一个事务里:
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = orderRepository.save(command);
eventLogRepository.save(new EventLog("OrderCreated", order.id()));
}
事务提交后,后台任务扫描未发送事件,投递到 MQ 或调用下游:
业务表 Order
事件表 EventLog(status=NEW)
-> relay task
-> MQ / downstream
-> status=SENT
这样即使进程崩溃,事件表里仍然有待发送记录。
五、Outbox 模式适合跨服务事件
Outbox 和本地消息表思想相近:业务服务在同一数据库事务内写业务表和 outbox 表,再由 relay 组件发布消息。
优点:
- 避免数据库提交成功但消息发送失败;
- 可重试;
- 可追踪;
- 可做幂等消费。
代价是增加事件表、扫描任务、消息状态和清理机制。
六、监听器读数据要注意时机
如果事件只带 orderId,监听器通常要查数据库。提交前监听可能查不到或查到旧数据;提交后监听更稳。
如果事件携带完整快照,也要注意快照是否与最终提交一致。高频面试回答可以这样说:事件里带最小必要字段和版本号,监听器在提交后按 id 读取权威数据,必要时使用事件快照做审计。
七、事务事件要考虑幂等
可靠事件通常意味着可能重复投递。监听器必须幂等:
if (processedEventRepository.exists(event.id(), listenerName)) {
return;
}
sendSms(event.orderId());
processedEventRepository.save(event.id(), listenerName);
尤其是发券、加积分、扣库存、发送外部请求,都要有业务唯一键或处理记录。
八、常见误区与追问
- 误区:在事务方法里 publishEvent 就等于事务提交后发布。 默认同步事件通常会在事务提交前执行监听器。
- 误区:
AFTER_COMMIT就能保证消息不丢。 它只保证执行时机,不能抵抗进程崩溃。 - 误区:事件监听器读不到数据就是数据库坏了。 可能是事务还没提交,或者隔离级别导致不可见。
- 误区:本地观察者可以替代消息队列。 跨进程可靠投递要用可靠事件表、Outbox 或 MQ。
- 追问:什么时候用
@TransactionalEventListener? 应用内、同进程、需要事务提交后执行的非核心副作用。 - 追问:事务回滚后要做动作怎么办? 可以使用
AFTER_ROLLBACK,但要控制副作用和补偿边界。 - 追问:可靠事件为什么需要幂等? 失败重试、网络抖动、消费者重启都可能导致重复处理。
九、加强记忆
- 事务内同步事件可能早于提交执行。
- 外部副作用无法随数据库事务自动回滚。
- Spring 可用
@TransactionalEventListener(AFTER_COMMIT)控制时机。 - 提交后监听不等于可靠消息。
- 跨服务可靠事件用本地消息表、Outbox 或 MQ。
- 可靠投递一定要配幂等消费。