ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot事件监听机制:从ApplicationListener到异步事务解耦实战

Spring Boot事件监听机制:从ApplicationListener到异步事务解耦实战 做后端时间久了几乎都会撞上这种场景用户注册成功后要发欢迎邮件要送新手积分要写操作日志还得顺手通知运营系统。以前我习惯在 Service 里一个方法接一个方法往下调等后续需求越加越多一个注册接口能牵扯出四五层调用链谁看了都头疼。后来我用 Spring 的事件监听机制重构了一把ApplicationListener 加自定义事件把业务之间的强耦合拆了个干净代码一下清爽了很多。这篇东西我从原理讲到实践把 Spring Boot 里的整套事件机制掰开揉碎讲清楚后端开发兄弟不管基础深浅都能跟着落地。1. 事件机制的整体设计思路为什么监听比直接调用更优雅1.1 传统同步调用的痛点到底在哪先说个最简单的例子。假设有个OrderService.createOrder()方法创建订单成功后需要扣库存、发短信、记录审计日志。初版代码多半长这样public void createOrder(Order order) { // 1. 保存订单 orderDao.save(order); // 2. 扣库存 stockService.deduct(order.getProductId()); // 3. 发短信 smsService.send(order.getUserId(), 您的订单已创建成功); // 4. 写审计日志 auditService.log(create_order, order.getId()); }这段代码的问题不在功能而在结构。扣库存、发短信、审计和创建订单本身并没有那么紧密的业务从属关系它们只是“订单创建成功”这个事实的后续反应。今天你加一个优惠券赠送明天加一个消息推送每次都要在这个方法里插一行代码然后重新编译、重新测试整个方法。更麻烦的是如果发短信接口恰好挂了订单创建整个流程跟着失败用户看到的是下单失败但实际订单已经写进库里了这种不一致很让人头大。事件机制换个思路订单只管自己持久化然后把“订单创建成功”这件事作为一个事件发布出去谁关心这件事谁自己监听。扣库存、短信、审计各写各的监听器订单服务对它们零依赖。1.2 Spring 事件三件套各自扮演什么角色Spring 的事件体系基于观察者模式一共就三个核心角色角色接口/类职责事件ApplicationEvent携带业务数据的消息体表示“发生了什么事”发布器ApplicationEventPublisher负责把事件广播出去不关心谁接收监听器ApplicationListener接收事件并执行后续逻辑不感知发布者是谁三者协作的流程就像微信公众号发文作者发布器写文章事件粉丝监听器收到推送后各自行动。作者不关心谁在看粉丝也不关心作者其他文章写了什么。第一版跑通最少只要三步继承ApplicationEvent定义事件类实现ApplicationListener写监听器然后注入ApplicationEventPublisher发布事件。1.3 什么情况下该考虑用事件机制事件机制不是银弹它解决的是同进程内、同步或异步的业务解耦。如果业务之间确实有主从关系、需要事务保持一致那还是老老实实写在一个事务里。如果业务之间只需要“知道结果后各自干活”事件机制就是很好的选择。我判断的依据通常有几条触发方不关心后续动作的执行结果比如下单后发短信短信失败不该影响下单。后续动作将来大概率会越加越多比如“注册成功”这个事件今天挂积分、明天挂活动、后天挂数据上报。希望代码具备可插拔能力某个监听器加个注解或删个 Bean 就能整体启用或移除。要注意的是事件机制和消息队列是两回事。Spring 事件默认在同一个 JVM 内存里跑没有持久化不支持跨系统。真正跨服务、跨团队的异步消息还是得交给 MQ 那套解决方案。2. ApplicationListener 核心原理从接口签名到事件分发链路2.1 接口定义和泛型事件类型匹配机制先看ApplicationListener的源码定义FunctionalInterface public interface ApplicationListenerE extends ApplicationEvent { void onApplicationEvent(E event); }一个函数式接口只有一个onApplicationEvent方法。但这一个泛型E是整套机制的关键。Spring 拿到一个事件后需要决定哪些监听器该对它有反应用的就是泛型解析。打个比方用户注册成功发了一个UserRegisteredEvent同时这个类继承自ApplicationEvent。Spring 内部会通过ResolvableType工具解析监听器声明的泛型类型只有当监听器的泛型类型和发布的事件类型“匹配”时才会把事件传进去。匹配规则我帮大家踩过的坑总结一下监听器声明的事件类型必须和发布的事件类型一致或者是它的父类型。实现ApplicationListener接口时泛型里写的是什么就匹配什么泛型写错了监听器不会报错但永远不触发。如果监听器对多种事件感兴趣要么写多个监听器要么用后面要讲的EventListener注解方式。2.2 publishEvent 发布一次事件内部走了几步从ApplicationEventPublisher.publishEvent()开始整个链路大致是这样的调用publishEvent(Object event)如果传入的不是ApplicationEventSpring 会自动包装成PayloadApplicationEvent。获取全局的ApplicationEventMulticaster默认实现是SimpleApplicationEventMulticaster。multicastEvent方法遍历注册表中的所有监听器逐个做类型匹配。匹配成功调用listener.onApplicationEvent(event)。这里有个重要的默认行为发布事件是同步的而且发布线程会阻塞到所有匹配的监听器执行完毕才返回。什么意思呢就是你调用publishEvent之后Spring 会按注册顺序把监听器列表过一遍每个监听器跑完了才轮到下一个。这个默认设计保证了监听器可以安全访问当前线程的上下文比如事务同步、ThreadLocal 里的用户信息。代价就是监听器里千万别写耗时操作否则你的主流程会卡在这里。后面讲异步方案时我会给线程池配置的具体方式。2.3 监听器的注册过程与排序规则监听器不是手动往 multicaster 里塞的Spring 启动时会自动扫。关键在ApplicationListenerDetector这个后置处理器它在 Bean 创建阶段检测到实现了ApplicationListener的实例就把注册进事件广播器。具体到 Spring Boot 项目里常见情况分两种用Component这种注解标记的监听器类会被扫描到。在Configuration配置类里通过Bean方法声明的监听器也会在 Bean 初始化阶段注册进去。多个监听器之间的执行顺序默认不稳定靠的是 bean 的实例化顺序。如果业务上要求先做什么后做什么不要赌这个默认顺序直接显式声明。做法有两个一是监听器类上标注Order(1)二是实现的 Bean 上用Order注解。数字越小优先级越高同一事件发生时数字小的监听器先执行。3. 实战在 Spring Boot 项目里从零搭建一套事件监听3.1 从定义事件类到发布先跑通最基础的一版我这里用一个贴近真实业务的场景订单支付成功之后要清空购物车、给用户加会员积分、记录支付渠道报表。三个动作和订单主流程都是弱相关适合用事件解耦。第一步定义事件类public class OrderPaidEvent extends ApplicationEvent { private final Long orderId; private final Long userId; private final BigDecimal paidAmount; public OrderPaidEvent(Object source, Long orderId, Long userId, BigDecimal paidAmount) { super(source); this.orderId orderId; this.userId userId; this.paidAmount paidAmount; } public Long getOrderId() { return orderId; } public Long getUserId() { return userId; } public BigDecimal getPaidAmount() { return paidAmount; } }有件事我不吐不快很多人图省事把一堆业务对象直接塞进事件里事件本身变成了二传手。我建议事件类只放必要字段因为监听器不一定需要整个订单实体而且把实体塞进事件容易在异步场景下引发序列化问题或数据一致性问题。存个 ID监听器需要完整数据时再查库这是最稳的姿势。第二步写监听器。先看实现ApplicationListener接口的写法Component public class CartCleanupListener implements ApplicationListenerOrderPaidEvent { Override public void onApplicationEvent(OrderPaidEvent event) { Long userId event.getUserId(); // 具体清空购物车逻辑 System.out.println(用户 userId 的购物车已清空); } }第三、四、五个监听器结构完全一致我就不展开了。第三步在业务代码里注入发布器并发布事件Service public class OrderService { private final ApplicationEventPublisher publisher; public OrderService(ApplicationEventPublisher publisher) { this.publisher publisher; } public void payOrder(Long orderId, Long userId, BigDecimal amount) { // 订单状态更新、支付流水记录等主流程逻辑 // ... 省略 ... // 主流程完成后发布事件 publisher.publishEvent(new OrderPaidEvent(this, orderId, userId, amount)); } }跑起来之后控制台会依次打印三个监听器的输出。注意那个this它是事件构造器里的 source 参数旧版本的 Spring 要求不能传 null新版本宽松了些但为了兼容性我还是习惯传个合法对象。3.2 更现代的写法EventListener 注解与条件过滤实现ApplicationListener接口比较正统但代码确实有点仪式感太重。实际项目里我更喜欢用EventListener注解Spring 会为每个注解方法自动生成对应的ApplicationListenerMethodAdapter内部再反射调用方法。同样是清空购物车的逻辑注解写法就清爽很多Component public class OrderEventListener { EventListener public void onOrderPaid(OrderPaidEvent event) { // 清空购物车逻辑 } }方法参数决定了它监听哪种事件这个方法也可以同时监听多个事件EventListener({OrderPaidEvent.class, OrderRefundEvent.class}) public void onOrderChanged(ApplicationEvent event) { // 处理订单状态变化当前事件的类型可以用 instanceof 判断 }EventListener还支持 SpEL 条件过滤同一个事件发布出来不同监听器可以按条件接走自己的那部分EventListener(condition #event.paidAmount.compareTo(1000) 0) public void onLargeAmountPaid(OrderPaidEvent event) { // 只处理支付金额大于等于1000的订单例如大额交易风控提醒 }这个#event就是方法入参名条件为 true 才执行方法体。有一点需要特别注意EventListener方法的访问权限最好是public原始属性必须是default。之前看到有人用了private方法Spring 版本不同表现不一样为了避免诡异问题还是统一用public。3.3 异步监听线程池配置和正确打开方式默认同步阻塞机制对耗时监听器不友好比如发邮件这个动作一个邮件要是跑两秒用户支付接口也得跟着等两秒。这种事在真实项目里肯定接受不了。异步方案其实就两个注解的事启动类加EnableAsync监听器方法加Async。但光这样能跑不一定跑得稳。Configuration EnableAsync public class AsyncConfig { Bean(orderAsyncExecutor) public Executor orderAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix(order-event-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }然后监听器这样写Component public class CouponListener { Async(orderAsyncExecutor) EventListener public void onOrderPaid(OrderPaidEvent event) { // 发券逻辑由独立线程池处理 } }这里的参数是线程池的 Bean 名称如果不指定Spring 会使用默认的SimpleAsyncTaskExecutor。这东西我没法推荐每次调用都新建线程没有复用高并发下会把自己拖垮。拒绝策略我习惯上CallerRunsPolicy这算是血泪教训换来的。异步监听意味着主流程不会等待监听器完成但如果线程池队列都满了直接抛弃这个事件可能造成业务数据不完整。CallerRunsPolicy会让发布事件的线程自己跑一遍监听逻辑虽然慢一点但事件至少不会丢。3.4 事务事件监听只在事务提交后干活怎么弄监听器里如果需要操作数据库或者需要外部系统看到一个已经提交的数据结果紧接着就有一个经典的坑你监听的事件触发时事务还没提交数据库里查不到刚才写入的数据。Spring 提供了TransactionalEventListener专门解决这个问题。它允许把监听器执行时机绑定到事务的某个阶段。Component public class OrderReportListener { TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT) public void onOrderPaid(OrderPaidEvent event) { // 事务提交之后执行这里查数据库一定能查到刚才的数据 } }phase可以选的值一共五个阶段行为BEFORE_COMMIT事务提交之前和普通事件差别不大AFTER_COMMIT事务成功提交之后最常用的场景AFTER_ROLLBACK事务回滚之后AFTER_COMPLETION事务无论提交还是回滚都执行BEFORE_COMMIT的变体语义同第一个还有一个fallbackExecution true参数它决定了如果没有事务存在时监听器是否仍然执行。默认情况下没有事务时TransactionalEventListener不会触发如果你希望无事务时也执行就设置TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT, fallbackExecution true)这个参数很容易被忽略之前我就因为忘加它在非事务方法里发布事件监听器死活没反应排查了半天。4. 高频问题排查与实战避坑清单4.1 监听器根本没执行先查这五个位置事件机制最常见的问题就是“我明明发布了事件监听器怎么没反应”。按下面的顺序排查基本能覆盖九成情况事件类是否继承了ApplicationEvent。如果你用publishEvent(Object event)发布一个普通对象Spring 会包成PayloadApplicationEvent实现ApplicationListenerOrderPaidEvent的老式监听器不会接住它。监听器泛型类型是否匹配。事件是UserRegisteredEvent监听器声明ApplicationListenerOrderPaidEvent当然永远不会触发。事件是否真的发布了。有些代码写了publisher.publishEvent但被 if 分支挡住没走到。监听器是否被 Spring 管理。漏了Component注解类没进容器自然没有监听行为。事务事件带了事务条件。用了TransactionalEventListener而没有事务fallbackExecution默认 false事件直接跳过。之前我接手过一个项目那边同事把监听器写在 test 包里部署时没被扫描到事件发布得挺欢实监听器原地沉默最后排查到这类问题真是欲哭无泪。4.2 异步监听不生效或线程池策略异常异步监听最常见的问题是加了Async但依然同步执行。原因九成出在EnableAsync没加。剩下的一成稍微隐蔽一点。如果你在同一个 Bean 内部直接调用自己的监听器方法Async是失效的。因为 Spring 的异步能力基于 AOP 代理从外部调用才会穿过代理内部this.method()调用不会经过代理对象。Component public class OrderEventListener { EventListener public void onOrderPaid(OrderPaidEvent event) { // 内部直接调用this 调用会绕过代理 this.sendEmail(event); } Async public void sendEmail(OrderPaidEvent event) { // 这个异步不会生效 } }解决办法是把异步方法拆到单独的 Bean 里或者自己注入代理对象。线程池配置上除了前面说的拒绝策略还有一个隐含参数容易被忽视setKeepAliveSeconds。默认情况下非核心线程空闲 60 秒后才回收如果你希望空闲就释放资源记得显式调整。另外线程池大小别拍脑袋定一次事件监听涉及硬件的 IO 密度不一样一般我先压测再定参数。4.3 监听器异常把主流程搞挂了到底要不要捕获先记住一个关键点同步监听器抛出的异常会一路抛到publishEvent调用处。这意味着监听器出异常你的主业务方法也会异常。这有时是好事比如某个监听器代表强一致性业务需求主流程必须失败但绝大多数弱相关业务场景下这反而是灾难。处理上有两种层次的经验第一层监听器内部自己做 try-catch保证任何单点异常不影响主流程Override public void onApplicationEvent(OrderPaidEvent event) { try { // 业务逻辑 } catch (Exception e) { log.error(监听器处理失败, orderId{}, event.getOrderId(), e); } }第二层优化全局配置。SimpleApplicationEventMulticaster有setErrorHandler方法可以设置统一的异常处理器捕获所有监听器抛出的异常而不中断发布流程。在配置类里可以这样Bean public SimpleApplicationEventMulticaster applicationEventMulticaster() { SimpleApplicationEventMulticaster multicaster new SimpleApplicationEventMulticaster(); multicaster.setErrorHandler(new SimpleErrorHandler() { Override public void handleError(Throwable t) { log.error(事件监听器执行异常, t); } }); return multicaster; }注意一旦自定义了ApplicationEventMulticasterSpring Boot 的自动配置就会让位给你的 Bean所以把该配置的都要配置好。异步监听器抛的异常不会被主流程看到也不会被同步错误处理机制捕获。异步线程异常要单独处理最稳妥的方式是给线程池自定义afterExecute或监听线程未捕获异常。实操中我通常把异步监听器内部也包 try-catch毕竟线上日志远比优雅设计更救命。4.4 事务事件没触发的高发原因用TransactionalEventListener的朋友基本都会遇到“事件没触发”的灵异事件。除了前面说的fallbackExecution还有几个高频原因事务代理没生效。事件发布方和事务在一个类里如果发布方法本身不带事务而内部调用了别的带事务方法尤其自调用场景事务可能根本就不存在。事务事件要求当前线程存在绑定的事务没有事务就默认跳过。事件发布在事务提交之前但监听器绑定的是AFTER_COMMIT也会出现延迟触发。要注意理解和接受这本来就是设计行为不是 bug。嵌套事务要特别小心。外层事务只是挂起而不是真正提交时AFTER_COMMIT触发的是最内层事务的提交事件和直觉可能不符。真实项目里如果逻辑比较复杂别迷信事务事件宁可手动在事务提交成功后显式调用事件发布。4.5 问题排查速查表症状可能原因解决方向监听器完全没反应泛型不匹配、未注册到 Spring、事务事件无事务对照 4.1 逐项检查监听器执行了但顺序不对没有声明 Order显式标注执行顺序异步不生效缺 EnableAsync、自调用补注解拆 Bean规避自调用主流程被监听器拖垮同步监听、异常冒泡内部捕获配全局 ErrorHandler事件内查不到刚写的数据事务未提交改用 TransactionalEventListener线程池满丢事件拒绝策略不合适配 CallerRunsPolicy自定义了 multicaster 但不生效Bean 名称不对确保 Bean 名称是 applicationEventMulticaster这套表格是排查的浓缩版实际项目里多数问题都能在这一层找到方向。事件监听机制用到现在我最大的体会是它真正解决的痛点不是性能而是代码结构。把“主流程”和“后续动作”分开业务逻辑的主线才能始终一眼看透。但我也不建议满项目地铺事件小功能直接调用比事件更直观监听器数量一旦失控又变成另一种混乱。每次加监听器前问一句自己“这个动作真的需要被解耦吗” 答案不确定的时候先别用事件。真要落地优先把监听器的异常处理和线程池配置想清楚这两样没做好任何事件机制都会被线上告警教育得明明白白。
返回列表