关闭应用服务器时如何优雅停止@Schedule标注的EJB定时任务
问题根因
你遇到的问题本质是EJB容器生命周期和组件特性的机制导致,和标志位逻辑本身无关:
- 无状态EJB(
@Stateless)采用实例池化管理,@Schedule标注的定时任务触发时,容器会从池中分配空闲实例执行方法。在定时方法执行返回之前,容器绝不会触发该实例的@PreDestroy回调,这就是你观察到服务器等schedule()完全跑完才调用stop()的核心原因。 - 无状态EJB不适合存储运行时状态:每次定时任务触发可能分配到池里不同的实例,你定义的
shutdown是实例级变量,就算回调触发,修改的也未必是正在执行定时任务的那个实例的标志位,逻辑天然不生效。 - 你的示例代码还有个低级逻辑错误:
@PreDestroy标注的stop()方法里把shutdown赋值为false,就算回调时机正确,也根本没法终止循环。 - WildFly/EAP的组件停止顺序是先标记组件为已停止状态、关闭事务上下文、拒绝新的组件调用请求,再执行实例的
@PreDestroy回调,所以在@PreDestroy里做任何事务操作,必然抛出ComponentIsStoppedException。
解决方案
不要在业务无状态EJB本身的@PreDestroy回调里实现优雅停机,按照以下步骤改造即可:
1. 替换为单例EJB托管定时任务和状态
无状态EJB的池化特性天然不适合做定时任务的状态控制,换成@Singleton单例EJB可以保证应用全局只有一个实例,定时任务执行、停机回调都作用在同一个实例上,从根本上解决实例不匹配、回调时机不对的问题。
2. 异步解耦定时任务执行逻辑
不要把业务逻辑直接跑在EJB定时器的线程上,把实际业务逻辑提交到独立的托管线程池执行,这样停机时可以主动触发中断、等待任务执行完成:
@Singleton @Startup // 应用启动时立即初始化实例,避免第一次定时触发才创建 public class TestService { private static final Logger logger = Logger.getLogger(TestService.class); private volatile boolean shutdown = false; private Future<?> runningTask; private ExecutorService taskExecutor; @Resource private TimerService timerService; @PostConstruct public void init() { // 初始化业务执行用的线程池,生产环境可根据实际并发调整线程数 taskExecutor = Executors.newFixedThreadPool(2); } @Schedule(hour = "*", minute = "*", second = "*/15", persistent = false) public void schedule() { if (shutdown) { return; } runningTask = taskExecutor.submit(() -> { logger.info("Start execution"); for (int i = 0; !shutdown && i < 10; ++i) { logger.info("Step " + i); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); logger.info("Task received interrupt signal"); break; } } logger.info("Stopped execution"); }); } @PreDestroy public void stop() { logger.info("Trigger stop"); shutdown = true; // 先取消所有已注册的定时器,避免停机过程中触发新任务 for (Timer timer : timerService.getTimers()) { timer.cancel(); } // 关闭线程池,等待正在运行的任务执行完成,最多等待15秒 taskExecutor.shutdown(); try { if (!taskExecutor.awaitTermination(15, TimeUnit.SECONDS)) { // 超时后强制中断未完成的任务 taskExecutor.shutdownNow(); } } catch (InterruptedException e) { taskExecutor.shutdownNow(); Thread.currentThread().interrupt(); } } // 停机前需要事务支持的清理逻辑放在这个方法里 @Transactional(Transactional.TxType.REQUIRES_NEW) public void cleanupBeforeStop() { // 在这里执行需要事务的清理操作,比如更新任务状态、释放锁等 } }
3. 提前执行带事务的停机清理逻辑
因为@PreDestroy执行时组件的事务上下文已经关闭,需要事务支持的清理操作不能放在@PreDestroy里,要通过CDI事件监听器,在应用停止、组件上下文还未销毁的时候执行:
@ApplicationScoped public class AppShutdownListener { @Inject private TestService testService; // 监听应用上下文销毁事件,此时事务上下文完全可用 public void onAppShutdown(@Observes @BeforeDestroy(ApplicationScoped.class) Object shutdownEvent) { testService.cleanupBeforeStop(); } }
如果不想依赖CDI的事件注解,也可以实现
javax.servlet.ServletContextListener接口,在contextDestroyed方法里执行清理逻辑,这个执行时机同样早于EJB组件销毁,不会抛出组件停止异常。
4. 调整容器停机超时配置
EAP 7.1/WildFly 11默认的EJB定时器停机等待时间很短,需要修改服务器配置给优雅停机留足时间,找到EJB3子系统的配置段,调整定时器服务的shutdown-timeout参数(单位毫秒):
<subsystem xmlns="urn:jboss:domain:ejb3:5.0"> <!-- 其他原有配置保持不变 --> <timer-service thread-pool-name="default" default-persistent-timer-object-store="true" shutdown-timeout="30000"> <!-- 停机时最多等待30秒让定时任务退出 --> <data-store path="timer-service-data" relative-to="jboss.server.data.dir"/> </timer-service> </subsystem>
注意:如果你的定时任务本身是长耗时任务,建议不要依赖EJB自带的@Schedule注解,直接用独立的线程池配合CDI启动事件触发调度,可控性会比EJB定时器高很多,停机逻辑也更灵活。
内容的提问来源于stack exchange,提问作者Martin
相关产品推荐
相关产品推荐

