Spring Event 的执行顺序、事务与停机:沿着一次订单提交往下查

沿着一次订单提交,检查 Spring Event 的线程、事务与异常传播,区分提交后通知、异步任务和持久化补偿,再排查停机期间的事件问题。

把应用部署到土耳其|BRNCHOST · 土耳其 VDS
云服务器,积分可续期|雨云 · 国内外节点 · 积分兑换权益
低价年付,大流量 VPS|RackNerd · SSD 存储 · 1Gbps 端口
香港轻量,搭个小站|晚安云 · 香港云服务器
香港 VPS,大带宽可选|野草云 · BGP 直连
大陆访问,精品线路|搬瓦工 · CN2 GIA / CTGNet 套餐
资料归档,交给 AI 整理|WorkBuddy · 本地文件处理
建站起步,先看应用镜像|腾讯云 · 轻量应用服务器
CN2 GIA,中国方向优化|DMIT · Premium 网络
双 ISP 原生住宅 IP|丽萨主机 · 美国 9929 精品线路
高频 CPU,多地部署|Evoxt · 云服务器 · 每周异地备份
京东云轻量云主机:129元/年,新人专享,限购1台

在订单服务里加一个 publishEvent,再写几个监听器,代码会短一些。发邮件、清缓存、记录积分,各个模块不用挤在同一个方法里。麻烦通常出现在后面:邮件接口超时,为什么下单也变慢?监听器抛了异常,订单究竟提交没有?服务重启时,最后几条通知去了哪里?

这些问题需要沿着一次调用往下查。只看到 @EventListener,还不足以判断监听器在哪个线程执行、什么时候执行,以及异常会传给谁。

下面以传统的 Spring MVC、JDBC 本地事务为讨论范围。示例省略仓储实现,用于说明调用顺序。响应式事务另有上下文传递规则,不能套用文中的线程假设。

publishEvent 返回之前,谁已经做完了事情

设想订单创建成功后,需要更新积分。应用服务大致如下:

@Transactional
public long placeOrder(CreateOrder command) {
    long orderId = orders.insert(command);
    events.publishEvent(new OrderCreated(orderId));
    return orderId;
}

如果项目使用默认的 SimpleApplicationEventMulticaster,监听器没有添加 @Async,也没有另外配置异步执行器,监听方法就在发布事件的线程上运行。监听器花了两秒,调用 publishEvent 的方法就得等两秒。默认广播器没有设置错误处理器时,监听器抛出的异常会中止这次广播,并传回发布者。广播器的 API 文档把这两个默认行为写得很清楚。

据此看上面的代码:进入应用服务后,Spring 开启事务;插入订单;执行同步监听器;publishEvent 返回;方法结束时再尝试提交事务。假设积分监听器使用同一事务管理器、同一数据库,并加入当前事务,那么它的数据库写入可以和订单一起提交。不能仅凭使用了事件,就断言它无法参与事务。

异常也要连着事务配置看。默认声明式事务会因 RuntimeException 或 Error 回滚;受检异常需要结合 rollbackFor 等规则判断。若业务代码捕获异常后继续返回,或者错误处理器把异常记入日志后吞掉,结果又会不同。Spring 的事务回滚说明是核对这些行为的依据。

可以用一个反例检查自己的理解:同步监听器先成功调用外部短信接口,后一个监听器抛出运行时异常。本地订单记录可能回滚,但短信已经发出。数据库没有权限把运营商发出去的短信撤回来。

设计订单流程时,可以让库存校验、订单写入等决定下单成败的操作保留明确的调用关系。这样评审代码时能直接找到失败条件。同步事件是否参与事务,是框架行为;是否值得把关键业务藏在监听器中,是维护成本的选择,两件事应分开讨论。

订单提交后再发确认邮件

订单确认邮件有一个明确要求:数据库提交失败时,不应该告诉用户下单成功。普通同步监听器在事务提交前执行,满足不了这个要求。这里可以使用事务事件监听器:

@Component
class OrderMailListener {
    private final MailClient mail;

    OrderMailListener(MailClient mail) {
        this.mail = mail;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCreated(OrderCreated event) {
        mail.sendOrderConfirmation(event.orderId());
    }
}

AFTER_COMMIT 也是该注解的默认阶段。它还支持提交前、回滚后以及完成后的回调。如果发布事件时没有正在运行的事务,监听器默认不执行;设置 fallbackExecution = true 后,才允许无事务场景执行。事务事件文档介绍了各阶段的含义。

这个差异会影响测试。测试方法自带事务,而且结束时回滚,提交后监听器就不会按成功提交的路径执行。此时先检查测试有没有真正提交,再怀疑事件是否注册成功。

还要留意时延。提交后执行不等于异步执行。邮件接口如果仍在原线程中调用,接口响应可能继续等待它完成,只是订单事务已经提交。邮件发送失败也不能使已提交的订单重新回滚。应用此时需要分别表达订单状态和通知状态;把两者折成一个笼统的“下单失败”,可能诱使用户重复提交。

提交后再写数据库,应该有自己的事务

另一个容易漏掉的细节是:事务完成时,某些连接或会话资源尚未解除绑定。监听器可能仍能访问数据,却不能据此认定后续修改还会得到一次提交。@TransactionalEventListener 的 Javadoc 警告专门说明了这件事。

假设发送邮件后要登记通知结果,可以让监听器调用另一个受 Spring 管理的服务,由那个服务用 REQUIRES_NEW 开启独立事务。调用必须经过事务代理;同一个对象内部直接调用自己的注解方法,不能想当然地认为代理会介入。

不过,新事务只是让“登记结果”拥有一次独立提交。它不能把邮件接口和数据库合成一个原子操作:邮件发送成功后,进程仍可能在写入结果前退出。恢复后再次发送,也就可能出现重复邮件。

REQUIRES_NEW 还会改变连接资源需求。在外层事务占用连接的场景中,内层独立事务需要另一个连接,连接池大小必须考虑这种并发。相关行为可查事务传播文档。为了登记一条无关紧要的日志而层层开新事务,通常不值得。

异步之后,调用者看到的成功变少了

给监听方法加上 @Async,并正确启用异步支持后,发布者通常只等到任务交给执行器。业务方法返回时,邮件可能尚未发出,甚至还没开始连接邮件服务。

实际配置中至少要回答两个问题。队列满了怎么办?已经接收的任务执行失败,谁知道?前一个问题涉及拒绝策略,后一个涉及异常处理和业务记录。不能把“线程池没抛异常”当作“客户收到了通知”。

同样不要依赖原线程的事务自动跨线程传播。把实体对象直接塞进异步事件,还可能让监听器在另一个线程访问尚未初始化的关联,或者读到已经变化的对象。事件携带订单 ID、事件 ID 等稳定字段,监听器按需要重新查询,通常更容易说明数据来自哪个时点。如果业务必须保留发生当时的价格与商品名称,就应在事件中保存那份快照,而不能拿后来的查询结果替代历史事实。

这些是应用设计建议,需要结合自己的 ORM、事务和线程池配置验证。框架不会替订单系统决定一次通知失败应不应该影响履约。

真正会丢通知的那个间隙

考虑这条时间线:

订单事务提交
    ↓
准备运行提交后监听器
    ↓
进程被终止
    ↓
新实例启动

如果订单已经保存,而通知任务只存在于旧进程内存,新实例没有任务记录可找。多写几次 retry 也没有用,因为负责重试的代码根本没有重新获得这条任务。

对业务必须补发的通知,可以在订单所在的数据库事务里同时写入一条待发送记录。后台任务再读取它、发送、登记结果。这样订单和发送意图一起提交。至于外部接收方是否只收到一次,需要单独处理:发送成功后、登记成功前仍可能崩溃,重试会重复发送。

以订单确认邮件为例,可以为每次应发送的通知生成稳定标识,重试沿用这个标识。如果邮件供应商支持幂等请求,就把它作为幂等键;如果供应商不支持,系统应接受并记录这种重复可能性,或者增加人工处理。仅在本地加一张“已处理”表,并不能消除两个系统之间的所有失败间隙。

Spring Modulith 的 Event Publication Registry 提供了另一条现成路径:记录发给事务监听器的事件发布情况,并跟踪处理完成状态。它有相应持久化实现与恢复能力,适合进一步评估;具体重发策略仍应按所用版本配置。官方事件章节给出了工作方式。采用框架前,先用自己的失败场景验收,比比较注解数量更有用。

停机异常:先找出谁还在发布事件

如果日志出现 BeanCreationNotAllowedException,能直接得出的结论是:代码在不允许创建 Bean 的阶段请求了创建,销毁期间是其中一种情况。这个异常本身不能证明根因一定是某个事件监听器。需要完整堆栈、Bean 名称和生命周期日志。异常定义并没有把它限定为事件问题。

排查时先按时间列出四个点:实例何时收到停机信号,何时停止接收任务,最后一次事件在哪个线程发布,以及目标 Bean 何时开始销毁。如果发布者来自自建消费线程,就检查它有没有接入 Spring 的停止流程;如果来自 HTTP 请求,就核对服务器是否在给在途请求留时间;如果堆栈从销毁回调开始,则检查回调是否又触发了新的业务工作。

Spring Boot 的优雅停机属于关闭应用上下文的一部分。当前官方文档说明了内嵌 Web 服务器对在途请求的等待,并提供 spring.lifecycle.timeout-per-shutdown-phase 配置。它是每个停止阶段的时间预算,不能直接当作整个进程的退出总时长。文档也提醒,IDE 的停止方式可能与发送正常 SIGTERM 不同。Graceful Shutdown

HTTP 停止接客,不代表自己创建的线程、定时任务和消息消费者都停止工作。上线前可以让一个请求停在业务中间,再发送退出信号,观察它是否完成;也可以让异步任务排队,检查关闭之后哪些任务有持久记录、哪些任务仅靠内存保存。用这些结果设定容器退出时间和补偿策略。

评审一处 publishEvent 时,可以沿着监听器把线程、事务和失败处理标在旁边。哪一步必须一起提交,哪一步允许以后补做,哪一步已经影响外部系统,写清楚后再决定用普通监听器、事务监听器还是持久任务。