下单接口越来越慢
createOrder 接口 RT 一度到了 800ms,点进去一看,主链路里塞了发短信、记风控、推积分三件"顺带"的事。它们慢一点,用户下单就卡一下。最糟的是,积分服务抖动时连下单都失败了,核心链路被旁路拖死。监控里下单 RT 的 P99 曲线和积分服务的 RT 曲线几乎同涨同落,一眼就知道是牵连的。
当时这段代码是这样的:
public Order create(OrderCmd cmd) {
Order o = repo.save(build(cmd));
smsService.send(o); // 同步
riskService.record(o); // 同步
pointsService.add(o); // 同步,这里抖一下下单就挂
return o;
}
三件事都是"最好能做、不做也不影响下单"的旁路,却被写成了强依赖。这就是典型的上帝方法把不该同步的东西同步了。
用事件把长链路解耦
我把非核心动作从同步调用改成发应用事件,Spring 的 ApplicationEventPublisher 直接能用,不用引额外中间件:
@Service
public class OrderService {
@Autowired ApplicationEventPublisher publisher;
@Transactional
public Order create(OrderCmd cmd) {
Order o = orderRepository.save(...);
publisher.publishEvent(new OrderCreatedEvent(o.getId()));
return o;
}
}
改完主链路只剩"建单 + 发事件"两步,RT 立刻下来。下游各自订阅事件异步处理。
事务边界是个坑
第一版我监听直接用 @EventListener,结果事件在事务提交前就发出去了,下游查库查不到刚建的订单,空指针一片。根因是:publishEvent 在主事务内就触发了监听器,而监听器去查库时,下单事务还没提交,隔离级别下读不到。
改用 @TransactionalEventListener 并指定阶段才对:
@Component
public class PointsListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCreatedEvent e) {
pointsService.add(e.getOrderId());
}
}
这样保证只有下单事务真正提交后,积分才加。事务回滚则事件不触发,数据自然一致。phase 还有 AFTER_ROLLBACK、BEFORE_COMMIT 等可选,按需取。
异步化与顺序问题
默认监听器是同步执行的,还是在下单线程里跑,没真正解耦。要异步,得给监听方法加 @Async,并配线程池:
@Async("bizExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCreatedEvent e) { ... }
但要注意:事件丢了怎么办?@Async 加了之后,如果进程在事务提交后、监听器执行前挂了,这条事件就丢了。我们的做法是关键旁路(如积分)仍然同步 AFTER_COMMIT 保可靠,非关键的(如发短信)才用 @Async。别一股脑全异步。
排查记录
改造后 createOrder 主链路 RT 从 800ms 降到 120ms;三个下游故障期间,下单成功率仍保持 100%。监控上用 SkyWalking 能清晰看到事件消费是独立 span,不再阻塞主链路。一次积分服务 full GC 40 秒,下单完全无感。
什么时候不该用事件
事件驱动不是银弹。如果下游结果会影响主流程的返回(比如风控不通过就拒绝下单),那必须同步调用,不能丢给事件。我们只在"做了更好、不做也不影响主流程"的旁路动作上用事件解耦。
事件溯源的延伸想法
解耦之后我进一步想:既然下单已经发事件了,能不能把关键业务动作都变成事件,做成事件溯源(Event Sourcing)?这样订单的每次状态变更都是一条不可变事件,查订单历史就是重放事件。我们没全量上,但在退款域试了:退款的"申请—审核—打款—完成"每一步都是事件,出账对不上时直接重放就能定位哪步丢了。这比在表里 UPDATE 状态好排查太多。
和 MQ 的界限
有人问:ApplicationEvent 和 Kafka 什么区别?我的经验:进程内、不需要跨服务、丢了能接受同步重做的,用 ApplicationEvent 足够,零依赖;需要跨服务、要持久化、要保证不丢的,走 Kafka。我们下单解耦先用 ApplicationEvent 把三个下游摘出来,其中"风控"因为要跨服务且不能丢,后续单独改成发 Kafka 事件,其余两个留在进程内。按可靠性要求分层,别一上来就上 MQ。
监控怎么看
解耦后主链路 RT 下来,但旁路有没有正常执行得有监控。我们给每个监听器加了计数和耗时指标,发到 Prometheus:积分加成功/失败数、短信发送耗时。哪天积分加失败率突增,告警直接找过来,而不是等用户发现积分没到账。解耦不是甩锅,可观测性得跟上,否则旁路静默失败更可怕。
事务消息的另一种思路
如果下游必须"不丢",用 ApplicationEvent 的 AFTER_COMMIT 还不够——进程提交后挂了,事件没发出去。这种场景该用事务消息(RocketMQ/Kafka 事务性生产者):本地事务和发消息在一个事务里,保证"下单成功则消息必发"。我们积分这种可丢的用事件解耦,风控这种不可丢的走事务消息。解耦方式按可靠性分三档:进程内事件(可丢)、MQ 异步(可能丢需补偿)、事务消息(不丢),按业务选。
先到这
《Spring 事件驱动改造同步调用链》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。