Administrator
发布于 2022-11-19 / 6958 阅读
56

Spring 事件驱动改造同步调用链

下单接口越来越慢

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 事件驱动改造同步调用链》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考