Day21-Spring事件机制:观察者模式在企业级解耦中的应用
专栏:《Java后端工程师进阶之路》从CRUD到AI工程师的完整跃迁路径(Day 21/ 90):好的架构不是把代码写得复杂,而是让复杂的需求改动起来简单。
一、一个真实的故事
一个电商项目,用户下单后要做三件事:发短信、扣积分、记日志。原来的代码是这样的——OrderService里直接注入了SmsService、PointService、LogService,三个方法串行调用,一个下单接口拉到 3 秒。更要命的是,后来产品说下单后还要同步到 BI 系统、触发风控检查……每次加需求都得改OrderService。
"为什么不考虑用事件机制解耦?"
很多写了三五年 Spring Boot 的兄弟,天天用@Autowired、@Transactional、@Async,却从来没碰过 Spring 自带的事件机制。这个从 Spring 1.0(2004年)就存在的功能,堪称观察者模式的教科书级实现。
二、Spring事件机制的核心角色(30秒速通)
Spring 事件机制一共就三个角色,理解起来不超过 30 秒:
[事件发布者] --发布--> [ApplicationEvent] --被--> [事件监听器] 接收| 角色 | Spring 中的体现 | 职责 |
|---|---|---|
| 事件(Event) | 继承ApplicationEvent | 携带事件数据,是"消息体" |
| 发布者(Publisher) | ApplicationEventPublisher | 发布事件,谁都可以是发布者 |
| 监听器(Listener) | @EventListener注解 | 接收并处理事件 |
核心流程一句话:发布者把事件扔给 Spring 容器,Spring 找到所有对这个事件感兴趣的监听器,逐个调用。发布者不需要知道谁是监听者,监听者也不需要知道谁发的。这就是解耦的本质。
// 极简版演示 @Component public class OrderService { @Autowired private ApplicationEventPublisher publisher; @Transactional public void placeOrder(Order order) { // 核心业务:保存订单 orderRepository.save(order); // 发布事件:谁关心谁自己来拿 publisher.publishEvent(new OrderPlacedEvent(order)); // OrderService 不需要知道短信怎么发、积分怎么扣 } }三、从零搭建 Spring 事件体系(完整代码)
环境:Spring Boot 3.2+ | JDK 17+ | Maven 3.9+
3.1 自定义事件类
Spring 4.2 之后,你不需要继承ApplicationEvent,随便一个 POJO 就能当事件用。但我建议仍然继承——它能强迫你显式定义事件源(source),让事件有意义。
import lombok.Getter; import org.springframework.context.ApplicationEvent; /** * 订单已支付事件 * 携带事件源(订单实体)和事件时间戳 */ @Getter public class OrderPaidEvent extends ApplicationEvent { private final Long orderId; // 订单ID private final Long userId; // 用户ID private final BigDecimal amount; // 支付金额 private final LocalDateTime paidAt; // 支付时间 public OrderPaidEvent(Object source, Long orderId, Long userId, BigDecimal amount) { super(source); this.orderId = orderId; this.userId = userId; this.amount = amount; this.paidAt = LocalDateTime.now(); } }要点:事件类应该是不可变的——所有字段用final,通过构造器注入。事件一旦发布就不该被修改,这是事件溯源(Event Sourcing)的基本纪律。
3.2 编写监听器
import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; @Slf4j @Component public class OrderEventListener { /** * 发送支付成功短信 * 默认是同步执行的——会阻塞发布者线程 */ @EventListener public void handleSendSms(OrderPaidEvent event) { log.info("发送支付成功短信:用户[{}] 订单[{}] 金额[{}]", event.getUserId(), event.getOrderId(), event.getAmount()); // 实际调用短信服务 smsService.sendPaymentSuccess(event.getUserId(), event.getOrderId()); } /** * 扣减积分 */ @EventListener public void handleDeductPoints(OrderPaidEvent event) { log.info("扣减积分:用户[{}] 消费[{}],积分+{}", event.getUserId(), event.getAmount(), event.getAmount().multiply(new BigDecimal("0.1"))); pointService.addPoints(event.getUserId(), event.getAmount()); } /** * 记录操作日志(条件监听:只处理金额大于100的大额订单) */ @EventListener(condition = "#event.amount > 100") public void handleLogForLargeOrder(OrderPaidEvent event) { log.warn("大额订单记录:订单[{}] 用户[{}] 金额[{}]", event.getOrderId(), event.getUserId(), event.getAmount()); auditLogService.record(event); } }这里有个细节值得说一下:@EventListener(condition = "#event.amount > 100"),这是 Spring 通过SpEL 表达式实现的条件过滤。事件会被发布给所有监听器,但只有满足条件的方法才会真正执行。它比在方法体内写if更干净——因为职责分离了:条件是事件的属性,不是监听器的逻辑。
3.3 发布事件
@Service public class PaymentService { @Autowired private ApplicationEventPublisher publisher; // 如果不想依赖 Spring 接口,实现 ApplicationEventPublisherAware 也行 // 但 ApplicationEventPublisherAware 已经过时了,直接用注入就好 @Transactional public void pay(Long orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); order.setStatus(OrderStatus.PAID); orderRepository.save(order); // 构造事件并发布 OrderPaidEvent event = new OrderPaidEvent( this, orderId, order.getUserId(), order.getAmount() ); publisher.publishEvent(event); log.info("订单[{}]支付完成,事件已发布", orderId); } }执行流程如下:
PaymentService.pay(orderId) ↓ orderRepository.save(order) ← 落库 ↓ publisher.publishEvent(event) ← 发布事件 ↓ Spring 遍历所有 @EventListener ↓ handleSendSms(event) ← 同步执行 handleDeductPoints(event) ← 同步执行 handleLogForLargeOrder(event) ← 条件满足才执行 ↓ pay() 方法返回默认情况下,事件处理是同步的。这意味着上面三个监听器会顺序执行,pay()方法要等所有监听器执行完才返回。如果发短信花了 2 秒,扣积分花了 1 秒,整个支付接口就要 3 秒+。
这就是为什么要引入异步事件。
四、异步事件:让下单回到毫秒级
4.1 开启异步支持
@Configuration @EnableAsync // ← 这一步很多人忘 public class AsyncConfig { /** * 自定义事件线程池 * 不配置的话 Spring 会用 SimpleAsyncTaskExecutor * 那玩意每来一个任务就 new 一个线程——在生产环境会炸 */ @Bean(name = "eventTaskExecutor") public Executor eventTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); // 核心线程数 executor.setMaxPoolSize(10); // 最大线程数 executor.setQueueCapacity(100); // 队列容量 executor.setThreadNamePrefix("event-"); // 线程名前缀(排查问题时救命) executor.setRejectedExecutionHandler( new ThreadPoolExecutor.CallerRunsPolicy() ); // 拒绝策略:交给调用线程执行,不会丢任务 executor.initialize(); return executor; } }4.2 监听器加 @Async
@Slf4j @Component public class OrderEventListener { @Async("eventTaskExecutor") // 指定线程池 @EventListener public void handleSendSms(OrderPaidEvent event) { // 现在这个方法会在独立线程中执行 // pay() 方法不用等它完成就能返回 smsService.sendPaymentSuccess(event.getUserId(), event.getOrderId()); } @Async("eventTaskExecutor") @EventListener @Order(2) // ← 控制执行顺序:数字越小越先执行 public void handleDeductPoints(OrderPaidEvent event) { pointService.addPoints(event.getUserId(), event.getAmount()); } @Async("eventTaskExecutor") @EventListener @Order(1) public void handleLog(OrderPaidEvent event) { auditLogService.record(event); // 日志先记,积分别丢 } }加上@Async后,完整的执行时序变成了这样:
pay() event-1 线程 event-2 线程 │ ├─ save(order) ├─ publishEvent(event) ──┬──────────────────────┬──────────────── │ │ handleLog() │ handleSms() │ │ handlePoints() │ ├─ return "支付成功" │ │ │ ↓ (异步继续) ↓ (异步继续) ▼ HTTP 响应 < 100ms 记录日志... 发送短信...pay()方法把事件"扔出去"就返回了,具体的事情由线程池慢慢做。支付接口的响应时间从「下单+短信+积分+日志」降到了「下单+发布事件」,用户体验立竿见影。
但是,异步有一个坑——如果支付事务回滚了怎么办?
五、事务绑定事件:@TransactionalEventListener 的正确姿势
这是 Spring 4.2 引入的杀手级特性。普通@EventListener在publishEvent()调用时立即执行,而@TransactionalEventListener可以指定在事务的哪个阶段执行:
5.1 四种事务阶段
@Slf4j @Component public class TransactionalOrderListener { /** * AFTER_COMMIT(默认):事务提交后执行 * 适用场景:事务性操作完成后的通知 * 如果事务回滚,这个监听器根本不会触发 */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void afterCommit(OrderPaidEvent event) { log.info("事务已提交,开始发送短信通知"); smsService.sendPaymentSuccess(event.getUserId(), event.getOrderId()); // 此时能保证数据库里已经有这条订单了 } /** * AFTER_ROLLBACK:事务回滚后执行 * 适用场景:失败补偿、告警 */ @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) public void afterRollback(OrderPaidEvent event) { log.error("订单[{}]支付回滚,触发告警", event.getOrderId()); alertService.sendAlert("订单支付异常", event.getOrderId()); } /** * AFTER_COMPLETION:不管成功还是失败都执行 * 适用场景:清理资源、取消锁定 */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) public void afterCompletion(OrderPaidEvent event) { log.info("支付事务完成(成功或失败),释放库存锁定"); inventoryService.unlock(event.getOrderId()); } /** * BEFORE_COMMIT:事务提交前执行 * 适用场景:最后的校验 * 注意:这里抛异常会导致事务回滚 */ @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void beforeCommit(OrderPaidEvent event) { // 提交前再次校验库存 if (!inventoryService.check(event.getOrderId())) { throw new InsufficientInventoryException("库存不足"); } } }5.2 完整示例:支付业务全流程
结合事务绑定事件 + 异步,支付业务的完整流程应该是这样的:
@Service public class PaymentService { @Autowired private ApplicationEventPublisher publisher; /** * 支付方法 * 核心思路:事务内只管「数据一致性」,事务外的事交给事件 */ @Transactional(rollbackFor = Exception.class) public PaymentResult pay(Long orderId) { // 1. 事务内:确保数据一致性 Order order = orderRepository.findByIdForUpdate(orderId); // SELECT FOR UPDATE if (order.getStatus() != OrderStatus.PENDING) { throw new OrderStatusException("订单状态异常"); } order.setStatus(OrderStatus.PAID); order.setPaidAt(LocalDateTime.now()); orderRepository.save(order); // 2. 发布事件(注意:@TransactionalEventListener 的事件 // 只有事务提交后才会被消费,这里只是"登记") publisher.publishEvent(new OrderPaidEvent( this, orderId, order.getUserId(), order.getAmount() )); // 3. 快速返回 return PaymentResult.success(orderId); } // 事务在此提交 // ↓ // @TransactionalEventListener(AFTER_COMMIT) 开始执行 // → 异步线程池发送短信、扣积分、记日志、同步BI... }对比改造前后的区别:
| 维度 | 改造前(紧耦合) | 改造后(事件驱动) |
|---|---|---|
| 支付接口耗时 | ~3000ms | ~100ms |
| 新增下游逻辑 | 修改 PaymentService | 新增 Listener(不改现有代码) |
| 事务一致性 | 短信失败但支付成功? | 事务提交后才发通知 |
| 异常隔离 | 短信挂了支付也挂 | 短信挂了不影响支付 |
| 可测试性 | 需要 mock 所有依赖 | 只需要验证事件是否发布 |
六、三个实战建议
建议一:事件命名体现业务语义
不要叫DataChangedEvent或者UpdateEvent,这种名字三个月后你自己也看不懂。用业务语言命名:OrderPaidEvent、UserRegisteredEvent、GoodsShippedEvent。事件是业务动作的投影,不是技术动作的描述。
建议二:给事件加上 traceId
在分布式系统中,一个请求的链路可能跨越多个服务。事件机制让这个链路更复杂了。在构造事件时把 MDC 中的traceId塞进去:
public OrderPaidEvent(Object source, Long orderId) { super(source); this.orderId = orderId; this.traceId = MDC.get("traceId"); // 关键:传递追踪ID }在监听器里把 traceId 放回 MDC,这样日志就能串联起来了。
建议三:事件数量不是越多越好
我见过有项目里一个下单操作发布了 23 个事件——每个事件对应一个微小的监听逻辑。这不叫解耦,这叫隐藏的意大利面条。一个好的判断标准是:如果没有这个事件,下游能不能正常工作?如果答案是「能」,那这个事件就是多余的。事件应该承载必要的业务副作用,而不是替代方法调用。
Spring 事件机制不是什么新概念——2004 年就有了。但用好它,能让你的代码在业务复杂度增长时保持足够的内聚性。
好的架构不是把代码写得复杂,而是让复杂的需求改动起来简单。
下篇预告(Day 22):我们将进入持久层实战,聊聊MyBatis-Plus 的深度实践——动态 SQL、逻辑删除、多租户插件,以及那些你在官方文档里看不到的生产坑。点关注不迷路。
