线上有个接口,原本 80ms 返回,改完 Spring Event 之后,偶发直接飙到 3 秒。开发同事第一反应是:“异步了啊,怎么还慢了?” 我看了下代码,问题就在那句 publishEvent()。
很多人第一次用 Spring Event,会觉得这个东西特别香。业务代码里不想塞一堆通知逻辑?丢个 Event。订单创建后要发短信、记日志、更新积分?丢个 Event。看起来代码干净了,耦合也降低了。但这里有个坑:Spring Event 默认不是异步。
你以为把代码扔出去让别人慢慢处理了,实际上它可能还在你的请求线程里同步执行。这类问题线上特别容易出现,因为测试环境数据少,接口调用频率低,根本看不出来。
我之前排一个订单接口慢的问题,链路大概这样:
POST /order/create
耗时:
Controller 20ms
保存订单 50ms
发送事件 5ms
监听库存事件 900ms
发送通知事件 1200ms
返回响应 100ms
最后发现所谓“异步通知”,全部在主线程里面跑。
Spring Event 默认机制其实很简单。
发布事件:
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public OrderService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void createOrder(Order order) {
orderRepository.save(order);
publisher.publishEvent(new OrderCreatedEvent(order.getId()));
}
}
监听:
@Component
public class OrderEventListener {
@EventListener
public void handle(OrderCreatedEvent event) {
// 发短信
smsService.send(event.getOrderId());
// 更新积分
pointService.add(event.getOrderId());
}
}
很多人看到这里,会下意识认为:“事件发布了,监听方法应该另外执行吧?”不是。默认情况下,publishEvent() 会直接调用监听器。调用链类似:
Controller线程
|
|
publishEvent()
|
|
OrderEventListener.handle()
|
|
短信接口
积分服务
数据库操作
|
|
返回Controller
所以如果监听里面有慢操作,接口一样被拖住。
这个问题我一般第一眼会看 trace。比如 SkyWalking 里面看到:
order-create
ApplicationEventPublisher.publishEvent
2ms
OrderCreatedListener.handle
1850ms
SmsClient.send
900ms
PointService.update
950ms
基本就定位了。
那是不是 Spring Event 不能用?也不是。它适合干一些轻量级通知,比如:
- 更新本地缓存
- 记录操作日志
- 发一些非核心埋点
- 同一个应用内部解耦
这些没问题。
但下面这些,我基本不会直接塞 Spring Event:
- 发短信
- 发邮件
- 调第三方接口
- 大量数据计算
- 文件处理
- MQ 同步
- 数据同步任务
因为这些事情有两个特点:第一,耗时不可控。今天第三方接口 200ms,明天可能 5 秒。第二,失败处理麻烦。事件监听里面异常了怎么办?默认情况下,如果监听抛异常,发布事件的线程也会受到影响。比如:
@EventListener
public void handle(OrderCreatedEvent event) {
remoteApi.call();
throw new RuntimeException("send failed");
}
这个异常可能直接影响你的业务流程。订单保存成功了,但是后面的通知失败,到底算成功还是失败?这时候很多系统开始补各种 try-catch,最后代码变成这样:
@EventListener
public void handle(OrderCreatedEvent event) {
try {
smsService.send(event.getOrderId());
} catch (Exception e) {
log.error("短信发送失败,id={}", event.getOrderId(), e);
retryQueue.add(event);
}
}
看到这里其实已经开始复杂了。事件通知慢慢变成了一个消息系统。那为什么不用 MQ?因为 Spring Event 和 MQ 解决的问题不一样。
Spring Event:
一个 JVM 内部
|
|
事件发布
|
|
监听处理
MQ:
订单服务
|
|
消息队列
|
|
短信服务
积分服务
库存服务
MQ 多了一层,但换来了:
当然,如果只是想让 Spring Event 真正异步,也可以配置线程池。比如:
@Configuration
@EnableAsync
public class EventConfig {
@Bean("eventExecutor")
public Executor eventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(12);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("event-worker-");
executor.initialize();
return executor;
}
}
监听:
@Component
public class OrderEventListener {
@Async("eventExecutor")
@EventListener
public void handle(OrderCreatedEvent event) {
smsService.send(event.getOrderId());
cacheService.refresh(event.getOrderId());
}
}
这样事件监听会进入线程池。但是这里还有一个坑。
异步之后,事务关系变了。比如:
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
publisher.publishEvent(new OrderCreatedEvent(order.getId()));
}
监听:
@Async
@EventListener
public void handle(OrderCreatedEvent event) {
Order order = orderRepository.findById(event.getOrderId());
}
这个时候监听线程已经不是原来的事务线程。所以可能出现:
主线程:
insert订单
事务未提交
publishEvent
异步线程:
查询订单
查不到
这类问题我见过不少。代码看起来没问题,测试也偶尔正常,上线压力一大就开始出奇怪问题。
如果你的事件必须等事务提交之后执行,应该考虑:
@Component
public class OrderListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommit(OrderCreatedEvent event) {
cacheService.refresh(event.getOrderId());
}
}
这个意思是:事务成功提交之后,再执行监听。如果事务回滚,事件不会触发。这个对于更新缓存、发送通知这种场景更安全。
不过还有一点要注意。Spring Event 没有消息队列那些能力。比如:服务重启了,事件还没执行,没了。线程池满了,任务拒绝,没了。所以别把它当 MQ 用。
我见过一些项目,把所有事情都包装成 Event。最后业务代码是干净了:
orderService.create();
但是后面藏了一堆:
OrderCreatedEvent
|
+-- 更新库存
+-- 发短信
+-- 计算佣金
+-- 同步ES
+-- 推送消息
+-- 生成报表
新人接手的时候,根本不知道一次创建订单会触发多少事情。这种“解耦”,有时候只是把复杂度藏起来。
我自己用 Spring Event,一般守几个边界:
- 简单通知,用。
- 跨服务调用,不用。
- 耗时任务,不用。
- 必须可靠执行,不用。
- 需要重试补偿,不用。
线上排查方便,比代码看起来优雅更重要。
Event 本身没有问题,问题在于很多人把它当成万能胶。刚开始觉得哪里都能粘,等系统复杂起来,才发现有些地方不是粘上就完了,后面拆的时候才真的疼。这类踩坑记录在云栈社区里也经常被翻出来讨论。