Spring Boot异步线程池重启引发数据丢失?优雅停机与任务补偿实战
先说个最近遇到的问题吧。项目用的是 RuoYi-Vue 前后端分离版Spring Boot 2.5 那一支为了方便给上游系统回传数据、处理批量导入我在 Service 里用 Async ThreadPoolTaskExecutor 把一些耗时操作扔到异步线程池里。一开始跑得好好的直到一次例行发布改完代码在开发工具里点重启控制台卡住不动几秒后开始抛各种异常端口还被占用。等杀掉残留进程重新启动发现刚重启完那会儿异步任务把旧数据重新写了一遍新数据被覆盖了。那天下午我基本就在和异步线程池 项目重启这个问题死磕。后来我把这个问题在内部也复盘了好几次发现这不只是 RuoYi 的问题只要是 Spring Boot 项目用异步线程池重启时都容易踩类似的坑。但 RuoYi 作为集成度比较高的后台管理框架又有自己的定时任务、缓存、Redis 等组件坑位更多互相干扰起来更难排查。这篇文章就把我排查、修复、验证的完整过程整理出来从原理到代码、从配置到排查命令一次性说清楚。后面内容基于我自己在模拟项目X中的实践如果你正好也遇到类似问题应该能少走不少弯路。1. 先还原现场异步线程池在重启时到底会出哪些幺蛾子1.1 我实际遇到的四类故障现象先把我当时遇到的、以及身边同事遇到的典型情况列出来方便你对照自己是不是同一类问题。第一类关闭时报错、进程卡住。在 IDE 里点 Stop 或者执行 shutdown 脚本应用没有立刻退出控制台还在打印日志或者干脆卡住不动再点启动直接提示端口被占用。这个属于线程池线程没有随容器一起销毁的典型表现。第二类任务中断导致数据不完整。异步任务正在批量写入数据库重启的瞬间连接池断开、事务回滚任务写到一半就没了。尤其是那种循环里逐条 insert 的逻辑写了一半的数据既没有完整提交也没有打日志重启后对账才发现缺口。第三类重启后旧任务把新数据覆盖。这是最隐蔽的一种。旧的任务线程没有被 JVM 退出机制拦下来在容器销毁和新容器启动的间隙还在执行。如果任务里有先查后写这种逻辑用的还是旧的事务和旧的数据上下文很容易把新版本刚写入的数据覆盖掉出现灵异现象。第四类重启后异步能力直接失效。Async 方法不报错但也不异步或者容器起来之后首次调用异步方法报No qualifying bean of type java.util.concurrent.Executor。这类看起来和重启关系不大实际是线程池 Bean 被重复初始化或者初始化顺序被打乱导致的。1.2 为什么问题会集中在重启这个动作上先说结论大多数情况下问题不是线程池配置本身写错了而是线程池的销毁时机和 Spring 容器的销毁时机没有对齐再加上 JVM 对非守护线程的退出规则导致重启这个动作变成了事故触发器。正常开发时你的进程启动一次跑很久线程池创建之后一直用着不会暴露问题。但重启意味着旧的 JVM 要退出、容器要销毁、Bean 要销毁、线程池要关闭同时新的 JVM 又要启动、容器初始化、线程池重新创建。两个过程有交叉特别是如果旧线程还在执行任务、持有数据库连接、引用着旧容器的 Bean问题就全冒出来了。这也能解释为什么很多人说开发环境没问题重启就出问题。开发环境你可能是直接 stop没注意线程生产环境用的是 kill -9 强杀完全不给收尾动作机会问题更明显。后面第三、第四节会分别给出不同场景的解法。2. 根因剖析Spring、线程池和 JVM 退出三方是怎么互相拉扯的2.1 Async 默认线程池和自定义线程池的关闭路径Spring 的 Async 在没有显式指定 executor 时会去找容器里唯一的 Executor Bean。RuoYi 框架里如果你没有自己定义Spring Boot 会默认提供一个SimpleAsyncTaskExecutor这个家伙是每次都 new 线程的谈不上线程池也没有优雅关闭的概念重启时线程是随 JVM 强杀直接没的反而不会卡住。真正容易出问题的是我们自己定义的ThreadPoolTaskExecutor。它继承自ExecutorConfigurationSupport这个父类在 Bean 销毁时会执行shutdown()。关键就在这里Spring 容器销毁时确实会调用线程池的 shutdown但 shutdown 并不意味着立刻终止任务。shutdown()只是停止接收新任务已经在执行的任务会继续跑阻塞队列里的任务也会继续被取走执行直到所有任务完成。所以如果你只是自定义了一个线程池没有设置setWaitForTasksToCompleteOnShutdown(true)默认情况下 Spring 容器销毁时线程池执行完 shutdown 后并不会等待任务结束。但线程是非守护线程JVM 退出时发现还有存活线程只能一直等或者被强制杀掉这就出现了关闭卡住或者关闭后线程还在跑的矛盾现象。2.2 非守护线程和 JVM 退出的关系这里有个基础知识点值得展开一下。Java 里的线程分两种守护线程daemon和非守护线程。JVM 退出时不会管守护线程但只要还有一个非守护线程存活JVM 就不会退出。ThreadPoolTaskExecutor内部创建的 worker 线程默认是非守护的。所以当你在一个 Spring Boot 应用里用线程池提交了一个长任务即使所有业务代码都执行完了、main 方法也返回了JVM 仍然会因为线程池里的 worker 线程还活着而不退出。这就是重启时进程残留、端口占用最直接的原因。有些同学会想那把线程设成 daemon 不就行了我建议不要这么干。你的业务异步任务可能正在写数据库、调接口如果直接设成守护线程JVM 一退出线程就没了任务丢得更彻底而且后果不可控。正确做法是让线程池感知容器关闭给足时间把手头的活干完再优雅退出。2.3 kill -9 和 kill -15同一个内核两种结局另一个容易踩的坑是部署脚本里的杀进程方式。kill -9SIGKILL是操作系统直接强制回收进程Java 应用的 shutdown hook 不执行Spring 容器的销毁回调全部失效线程池当然也不会收到任何通知。kill -15SIGTERM则会触发 JVM 的 shutdown hookSpring Boot 会走优雅停机流程。生产环境最好把重启脚本里的 kill -9 改成先 kill -15等个几十秒不行再 kill -9 兜底。RuoYi 的发布脚本很多默认就是找端口、kill -9、重启这个习惯遇到异步任务会被放得很大。你可以做一个简单实验往线程池里丢一个 sleep 60 秒的任务分别用 kill -9 和 kill -15 杀进程观察任务日志是否完整、应用退出时间差多少。我实测下来 kill -15 配合后面的优雅关闭配置应用可以在 30 秒内干净退出任务也完整跑完。2.4 为什么重启后还会出现旧线程池 新容器的混乱如果是在同一台机器上停止、启动两个动作间隔很短可能出现这种情况旧 JVM 的进程没有完全退出因为非守护线程还在但你已经启动了新的 JVM两个进程同时监听同一个端口。更隐蔽的是旧线程持有的数据库连接、Redis 连接还活着重启瞬间新容器也在创建连接连接池数量被撑大数据库端可能报连接数超限。还有一类问题是 classloader 层面用 IDE 热部署或者 devtools 时旧 classloader 被新 classloader 替换但旧线程池里的线程还持有旧 classloader 的对象。这时任务里如果用反射、枚举、静态缓存容易抛出莫名其妙的NoClassDefFoundError或者类转换错误。这类问题在 RuoYi 里尤其容易诱发因为框架里静态工具类、缓存类用得特别多旧线程引用旧类、新容器加载新类版本错位。3. 落地方案一把线程池的关闭动作从听天由命变成可控3.1 两个必须配置的参数waitForTasksToCompleteOnShutdown 和 awaitTerminationSeconds自定义ThreadPoolTaskExecutor的时候除了核心线程数、最大线程数、队列容量这些常规参数有两个参数一定要配并且大多数人都不配setWaitForTasksToCompleteOnShutdown(true)容器关闭时让线程池等待已经提交的任务全部执行完再真正关闭。setAwaitTerminationSeconds(30)最多等待多少秒。超过这个时间放弃等待强制关闭。这两个参数配合 Spring 容器销毁流程能保证业务线程把手头的活干完再下班。如果不设置第一个参数shutdown 之后 worker 线程空闲下来后线程池就直接终止正在跑的任务虽然会继续跑但队列里的任务会被丢弃而且容器销毁过程中连接已经关了任务跑到一半依赖的资源也没了。给个我一直在用的配置模板Configuration public class AsyncConfig implements AsyncConfigurer { Bean(name taskExecutor) public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数按 CPU 核数 * 2 估算再结合异步任务的 IO 耗时适当放大 executor.setCorePoolSize(8); // 最大线程数不是越大越好要考虑数据库连接池和下游接口的承受能力 executor.setMaxPoolSize(16); // 队列容量如果任务积压说明线程数不够或者任务本身太慢先给 200 试试 executor.setQueueCapacity(200); // 线程名要带业务前缀后面 jstack 排查全靠它认人 executor.setThreadNamePrefix(async-biz-); // 重点优雅关闭 executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); // 拒绝策略用 CallerRunsPolicy尽量不丢任务 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } Override public Executor getAsyncExecutor() { return taskExecutor(); } }这里有几个细节要注意。waitForTasksToCompleteOnShutdown和awaitTerminationSeconds是ThreadPoolTaskExecutor自己的方法如果你直接用java.util.concurrent.ThreadPoolExecutor那就没有这两个参数需要自己重写shutdown()逻辑不要搞混。另外executor.initialize()要不要调用要看场景如果你是作为 Bean 交给 Spring 管理Spring 容器启动时会自动调用 afterPropertiesSet如果你在 getAsyncExecutor 里手动 new 出来建议在确定不再改参数之后调用一次 initialize避免重复初始化。CallerRunsPolicy的作用是线程池已经满了并且开始走拒绝策略时不丢任务而是让提交任务的调用方线程自己执行这个任务。这个策略在发布瞬间大量异步任务涌入时会增加调用方线程的压力但是比丢弃任务好得多。RuoYi 的后台管理场景里异步任务大多是导入、导出、通知这类允许阻塞一会儿换取不丢数据是划算的。3.2 Spring Boot 优雅停机全局配置除了线程池自身的关闭设置Spring Boot2.3 及以上版本还提供了应用级别的优雅停机配置。这里的思路和线程池是一脉相承的收到停机信号后先停止接收新请求再处理完正在处理的请求最后关闭容器。server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s在 RuoYi 项目中这个配置放在application.yml里。需要注意server.shutdown默认是immediate不设置的话走的是老逻辑容器一关直接断请求异步线程池的优雅关闭效果也会被削弱。spring.lifecycle.timeout-per-shutdown-phase控制每个关闭阶段的最大等待时间。这里建议和线程池的awaitTerminationSeconds保持一致或者略大避免容器关闭阶段把线程池的等待时间截断。如果 RuoYi 版本用的 Spring Boot 是 2.3 以下这个配置不生效要用下面的 ShutdownHook 方式兜底。3.3 兼容旧版本和外部 Tomcat 的 ShutdownHook 兜底RuoYi 有些部署方式是把 war 丢到独立 Tomcat 里。Tomcat 本身有自己的关闭机制但和 Spring Boot 内置容器的优雅停机又不完全一样。如果你确认自己的 RuoYi 版本不支持server.shutdown: graceful或者部署在外部容器可以手动注册一个 ShutdownHook兜底保证线程池能收尾。Configuration public class GracefulShutdownConfig { Bean public ApplicationRunner registerShutdownHook(ThreadPoolTaskExecutor taskExecutor) { return args - Runtime.getRuntime().addShutdownHook(new Thread(() - { System.out.println(JVM shutdown hook triggered); taskExecutor.shutdown(); try { // 此处不用太长时间主要给数据库、Redis等资源释放留出缓冲 if (!taskExecutor.getThreadPoolExecutor().awaitTermination(30, TimeUnit.SECONDS)) { System.out.println(Async tasks did not finish in 30s, forcing shutdown); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } })); } }不过说实话这个兜底方案有点重复。正常情况下 Spring Boot 内置容器的优雅停机 线程池自身的两个参数已经够了ShutdownHook 更适合外部 Tomcat kill -15这种没法全局优雅停机的场景。你要是两个都配了注意不要让线程池被 shutdown 两次shutdown()重复调用本身是安全的最多日志里多点冗余信息问题不大。3.4 RuoYi 项目里具体怎么落地不打架RuoYi 框架里本身已经有一套 TaskExecutor 相关的配置定时任务那块你在加自己的异步线程池时最容易踩的坑是Bean 冲突和覆盖了框架默认配置。我的建议是新建一个独立的配置类比如AsyncConfig只定义你自己的ThreadPoolTaskExecutor不要动 RuoYi 原来的定时任务配置类。Async(taskExecutor)显式指定线程池名称让 Spring 按名字注入避免和框架默认的 executor 混淆。在 Service 里使用时Async(taskExecutor) public void sendNotifyAsync(NotifyReq req) { // 异步通知逻辑 }如果你的业务中还有事务需求注意Async和Transactional同时注解同一个方法时事务不生效的问题。因为异步调用会把调用方的事务上下文截断正确做法是拆两个方法一个异步入口一个事务方法事务方法在异步线程里自己开启事务。4. 落地方案二任务可恢复重启不再是事故现场4.1 为什么重启后任务恢复不能只靠线程池说句实在话线程池优雅关闭能解决正在运行的任务被强杀这个问题但解决不了两个更现实的问题。第一如果你当时的停机方式就是不干净比如还没来得及配优雅关闭kill -9 已经下去了任务丢了就是丢了线程池配置救不回来。第二即使任务完整执行了如果执行结果是写库写到一半或者调了别人的接口但对方没返回你从业务数据层面仍然不知道这个任务到底算成功还是失败。所以对于真正重要的异步任务我的建议是线程池解决执行层面的优雅收尾任务表解决结果层面的可追踪、可补偿两条腿走路。4.2 任务表 状态机的设计在 RuoYi 的业务库中新建一张异步任务日志表字段大致如下CREATE TABLE async_task_log ( id bigint NOT NULL AUTO_INCREMENT, biz_key varchar(64) NOT NULL COMMENT 业务唯一键防止重复提交, task_type varchar(32) NOT NULL COMMENT 任务类型如 IMPORT、NOTIFY、EXPORT, task_status tinyint NOT NULL DEFAULT 0 COMMENT 0-待执行 1-执行中 2-成功 3-失败 4-待补偿, retry_count int NOT NULL DEFAULT 0, next_retry_time datetime DEFAULT NULL, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_biz_key (biz_key) ) ENGINEInnoDB COMMENT异步任务日志表;状态流转逻辑我简化成四步提交任务前先插一条记录状态为待执行biz_key 用业务数据的自然键比如订单号、导入批次号数据库唯一索引兜底。任务线程真正开始执行前把状态更新为执行中update_time 也刷新。执行成功状态更新为成功。执行失败或异常状态更新为失败记录重试次数和下次重试时间达到最大重试次数就置为终态。这套设计最大的好处是不管 JVM 怎么死任务表中的数据是持久的重启后一查便知哪些任务是执行中但没结果的。4.3 启动补偿重新捞起中断的任务写一个ApplicationRunner在应用启动完成时扫描任务表把上一次执行中且超过一定时间没有更新的任务捞出来重新置为待执行或直接重新提交给线程池。Component public class AsyncTaskRecoverRunner implements ApplicationRunner { Autowired private AsyncTaskService asyncTaskService; Override public void run(ApplicationArguments args) { // 取 30 秒前还在执行中的任务视为被中断 ListAsyncTaskLog pendingList asyncTaskService.listInterruptedTasks(30); for (AsyncTaskLog task : pendingList) { // 置为待执行 asyncTaskService.resetToPending(task); // 重新投递到线程池 asyncTaskService.dispatch(task); } } }这里有个细节为什么是30 秒前还在执行中因为任务刚提交时可能还没更新状态且正常短任务几秒内就结束了。如果把执行中还不到 30 秒的任务也捞起来重新执行就可能和正在跑的旧线程重复执行同一个任务。所以要给个时间缓冲。具体的缓冲时间看你的任务最长耗时比如任务最长 60 秒那就取超过 60 秒还没结束的判定为中断。4.4 幂等消费才是最后的兜底补偿机制看着简单但有一个隐含风险你重新执行的任务可能上一次真的已经跑完了只是状态没来得及更新比如刚好在执行完业务逻辑和更新任务状态之间断电了。这种情况下去重新执行任务就会产生重复的副作用。解决办法是让任务消费具备幂等性。结合 RuoYi 框架自带的 Redis可以在执行任务前加一个分布式锁锁的 key 就用任务的 biz_keypublic boolean tryLock(String bizKey) { // 这里用 Redis 的 SETNX 过期时间语义 // 如果用 Spring Data Redis就是 // return stringRedisTemplate.opsForValue().setIfAbsent(ASYNC_TASK_LOCK: bizKey, 1, 60, TimeUnit.SECONDS); // RuoYi 的 RedisCache 没有直接暴露 setIfAbsent可以自行封装一层 }拿到锁再执行执行完释放锁。如果拿不到锁说明同一业务键的任务正在执行不管在哪个 JVM 里直接跳过。对于数据库写入类任务除了 Redis 锁还可以利用数据库唯一索引做最后防线比如任务要把结果同步到明细表明细表对业务键建唯一索引重复写入就会被数据库拦截。注意 Redis 锁的过期时间必须大于任务最大执行时间否则任务没跑完锁就自动释放另一个节点又能拿到锁重复执行就防不住了。如果任务可能跑很久建议考虑基于数据库表的乐观锁或者续期机制不要只在 Redis 上赌一把。4.5 重试参数给一组可参考的初始值任务重试不是越密越好发布期间服务都在重启太密集的重试反而加剧故障。我给个初始值参考重试间隔第一次 30 秒第二次 2 分钟第三次 10 分钟之后按 30 分钟封顶用指数退避加一点随机抖动。最大重试次数5 次。超过就转人工处理不要无限重试。补偿扫描频率启动补偿跑一次运行时可以加一个定时任务每 5 分钟扫一次失败任务但注意别和 RuoYi 自带的定时任务模块混淆建议单独标记任务分组。5. 排查与验证下次再出问题怎么快速定位5.1 端口占用和进程残留三分钟查明白重启失败最常见的就是端口被占用。先确认是谁占着端口# 查 8080 端口的占用进程 PID lsof -i :8080 # 如果上面查不到换 netstat 再试一次 netstat -anp | grep 8080 # 看这个 PID 是什么进程确认是不是残留的 Java 进程 ps -ef | grep pid拿到 PID 之后如果确实是旧的 Java 进程用 jstack 看一眼它的线程状态判断是有任务在跑还是线程池空转jstack pid | grep async-biz- -A 5如果你看到的线程状态是WAITINGpark说明线程池里的 worker 线程在等任务线程池没有收到关闭信号这是典型的没有配优雅关闭参数的表现。如果看到RUNNABLE那说明真有任务在执行这时候要么等它做完要么确认这个任务可以放弃后再 kill。记住一个原则先看线程状态再决定要不要 kill。不要一上来就 kill -9否则又把任务丢一遍。5.2 优雅停机没有生效怎么确认配置了server.shutdown: graceful之后重启时观察控制台日志。正常走优雅停机时应用会先停止接收新请求日志里能看到类似 Commencing graceful shutdown 的提示。如果你的日志里没有这句话说明配置没生效检查 Spring Boot 版本是否支持以及是不是配置放到了错误的 profile 文件里。再配合线程池日志。我给线程池配过自定义的线程工厂在线程初始化和终止时打日志executor.setThreadFactory(new ThreadFactory() { Override public Thread newThread(Runnable r) { Thread t new Thread(r, async-biz- r.hashCode()); t.setDaemon(false); t.setUncaughtExceptionHandler((thread, e) - log.error(异步线程异常, e)); return t; } });这样重启时能看到每个线程的退出顺序如果优雅关闭生效线程池线程会在任务完成后挨个退出如果直接消失就是被强杀了。这个细节排查起来非常有用。5.3 重启后任务重复执行的排查思路如果你怀疑任务重复执行先从逻辑上判断是哪种重复同一进程内重复执行多半是补偿扫描的时间窗口太短和正在执行的任务重叠了。跨进程重复执行多半是同一个任务被两个 JVM 的补偿机制同时捞起需要检查 Redis 锁或者数据库唯一约束。重启瞬间重复旧线程还没死新线程已经开始跑同一个任务。排查时先把任务表的状态流转记录打开看同一个 biz_key 的历史记录是否多次进入执行中、每次执行的时间戳是否跨了重启时间点。如果跨了基本可以确定是旧线程和新线程重叠执行优先修优雅关闭如果没跨那就是补偿逻辑或锁的问题。5.4 常见问题速查表现象可能原因优先处理方式重启时端口被占用旧线程池线程未退出进程残留配置 waitForTasksToCompleteOnShutdown awaitTerminationSeconds不要用 kill -9关闭时应用长时间卡住等待任务完成时间设置过长或任务本身跑不完检查任务耗时缩短 awaitTerminationSeconds给任务加超时控制重启后数据缺了一部分任务被强杀状态无记录引入任务表 启动补偿重启后数据被旧任务覆盖新旧进程重叠执行先保证旧进程完全退出再启动新进程发布脚本加等待Async 方法变成同步执行executor Bean 冲突或未显式指定Async 指定线程池名称检查是否有多个 Executor Bean异步线程里事务不生效Async 和 Transactional 同时标注拆分异步入口和事务方法kill -9 后连接池报错数据库连接未释放服务强杀重启脚本先 kill -15等 30 秒再兜底 kill -9热部署后异步任务类转换错误旧线程引用旧 classloader 的类用完整重启代替热部署5.5 一次标准的上线验证流程如果你把上面这些方案都落地了上线前建议走一遍验证流程提交一个耗时 60 秒的异步任务到线程池任务里每 5 秒打一行日志。执行 stop 或者 kill -15观察日志任务是否完整跑完。观察应用退出的时间是否在 30 秒左右退出端口是否释放干净。重启应用观察启动日志里补偿任务是否被捞起。造一个执行中但状态未更新的任务手动改任务表的 update_time 为 10 分钟前重启确认补偿机制能把它捞起来重新执行。连续两次重启同一批任务确认 Redis 锁能挡住重复消费。这套流程我每次发布前都会走一遍尤其是第 1、2 步基本能根治发布即事故的体感。6. 最后再分享一点经验写完这些回头看那次事故其实根子不在异步线程池而在我把异步任务当成同步任务写却忘了给异步执行环境配一个下班流程。技术人员最容易犯的错就是把重点放在如何让任务更快执行忽略了任务在什么时候会被打断、打断之后如何恢复。我在这个项目上最终落地的组合是ThreadPoolTaskExecutor 优雅关闭参数 Spring Boot 优雅停机配置 任务表状态机 启动补偿 Redis 幂等锁。这套组合下来后续又做了几次发布再也没出现端口占用、任务丢失、数据覆盖的问题。代价无非是多一张表、多一个扫描任务投产比很高。另外想提醒一句如果你用的是 RuoYi 这种高集成框架改动前先看看框架自带的定时任务、异步配置有没有占用同名 Bean别一上来就自定义一个taskExecutor把框架的覆盖了排查起来会非常痛苦。改一个东西之前先打印一下当前容器里已有的 Executor Bean做到心里有数。异步线程池和项目重启这个问题本质上就是生命周期管理问题。线程池有自己的生命周期Spring 容器有自己的生命周期JVM 还有自己的退出规则把这三个生命周期串到一起去思考就不会再被各种灵异现象带偏。希望这篇记录能帮到你少熬夜排查问题。