ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot应用优雅关闭实战:从信号机制到资源释放全解析

Spring Boot应用优雅关闭实战:从信号机制到资源释放全解析 1. 为什么我盯着Spring Boot应用关闭不放先说一件让我印象特别深的事。有次线上版本发布我用的是常规滚动发布脚本结果刚把旧实例摘流量准备替换监控面板一下子全红了。拉日志一看旧实例收到SIGTERM信号之后不到两秒就被系统直接回收可当时仍有近四十个正在处理的请求挂着数据库事务、第三方回调全被拦腰截断。后来我才意识到问题根本不在这批请求本身而在于Spring Boot应用在关闭这件事上几乎没有被认真对待过。其实Spring Boot应用关闭是一个比很多人想象中复杂得多的话题。它既涉及操作系统的信号机制又涉及JVM的ShutdownHook还要牵扯Spring容器的Bean销毁、Web容器优雅停机、线程池和连接池的资源释放。很多后端开发对启动流程如数家珍但一聊到关闭阶段往往只知道kill一下完事结果上线、扩容、缩容的时候频频踩坑。这篇文章想做的就是一次比较完整的Spring Boot应用关闭分析把我自己实测过的东西、踩过的坑、排查过的现场都摊开来说。无论你是负责发布运维的同事还是日常写接口的Java开发这篇文章应该都能给你一些能直接落地的经验。2. 关闭过程拆解从 kill 到 Spring 容器销毁2.1 信号进来之后发生了什么如果你在Linux服务器上停止一个Java进程通常会执行kill pid这发给进程的是一个SIGTERM信号编号是15。这是一个请你自己清理一下再退出的信号接收方可以选择处理它也可以忽略它。JVM对SIGTERM的处理方式就是触发所有已注册的ShutdownHook等这些Hook执行完毕后再真正退出进程。Spring Boot启动时SpringApplication会在SpringApplicationRunListeners准备完成后调用registerShutdownHook()把context.close()挂到JVM的ShutdownHook上。也就是说在没有特殊改动的情况下你的Spring Boot应用收到SIGTERM后会触发一次完整的ApplicationContext关闭流程而不是被系统直接砍掉。这也是优雅关闭能成立的前提。kill -9发送的是SIGKILL编号9。这个信号不能捕获、不能忽略、不能注册任何处理器内核直接把进程了结。所以kill -9之后JVM不会执行任何ShutdownHookSpring容器不会关闭连接池不会释放已提交的任务直接丢掉。在生产环境这是最后的手段不是常规手段。另外还有个SIGINT也就是你按CtrlC时发出的信号。对JVM而言它同样会触发ShutdownHook所以本地开发时你用CtrlC停掉Spring Boot应用理论上也会走一次容器关闭流程。2.2 从关闭钩子到 ApplicationContext当JVM的ShutdownHook被触发Spring的SpringApplicationShutdownHook会调用context.close()。这一步之后Spring容器会做一系列事情顺序大致是发布ContextClosedEvent事件所有监听这个事件的组件先收到通知。暂停并关闭Web服务器根据server.shutdown配置决定是立即停还是等活跃请求结束。依序销毁所有Bean调用实现了DisposableBean接口的destroy()方法触发标了PreDestroy的方法。停止容器中实现了SmartLifecycle的生命周期组件例如各类后台任务、消息监听容器。释放资源如数据源、Redis连接工厂、HTTP客户端连接池。这个流程看起来有章可循但真正出问题的往往就在细节里。比如某些第三方客户端的连接池没有实现DisposableBean你不手动管理它它就变成野资源又比如自定义线程池是通过Executors.newFixedThreadPool()创建的Spring容器根本不知道它的存在自然也不会在关闭时帮你清理。这类问题如果不专门做关闭分析光看运行期日志根本发现不了。2.3 三种关闭类型对照我把日常在生产里遇到的关闭情况归纳成三类这样后续讨论会清晰很多强制关闭kill -9进程被内核直接终结不存在任何回调数据与外部资源的一致性完全靠业务侧补偿。立即关闭Spring Boot默认行为配置server.shutdownimmediate。收到SIGTERM后Spring容器会执行关闭流程但Web服务器只会等很短的内建时间不会专门等待仍在处理的HTTP请求完成。正在执行的请求大概率会被中断。优雅关闭配置server.shutdowngracefulSpring Boot 2.3开始支持。容器关闭前先主动停止接收新请求同时给正在处理的活跃请求一段宽限期让它们自然结束之后再执行Bean销毁和资源释放。三者差异可以用一个比喻强制关闭像是拔电源立即关闭像是按了机箱上的关机键但不关窗口进程优雅关闭则是先拦住门口不再放人进来等店里的客人都出门之后再熄灯。3. Spring Boot 官方优雅关闭怎么配3.1 server.shutdowngraceful 的正确姿势Spring Boot 2.3之后优雅关闭已经变成了一个官方配置项不再需要自己写一堆SmartLifecycle或者拦截线程。以常见的application.yml为例server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s第一个配置把Web服务器的关闭策略切到优雅模式。第二个配置则是每个关闭阶段的最大等待时间到了30秒还没结束的话对应阶段会被强制收尾。在Servlet容器Tomcat、Jetty、Undertow下graceful会让容器在收到关闭请求之后停止接受新连接已经进入处理流程的请求会继续执行直到完成或者超时。在WebFlux这种Reactive场景下也有对应支持。实测下来配合Nginx或网关层先摘流量后端再优雅关闭基本能做到用户无感发布。这里有个很多人忽略的地方timeout-per-shutdown-phase的单位是每个阶段。Spring Boot关闭过程是分阶段的Web服务器优雅停机一个阶段生命周期组件停止一个阶段Bean销毁又是一个阶段。如果你遇到关闭总时长超过30秒的情况不要惊讶因为这30秒不是整个关闭流程的总预算而是单阶段预算。3.2 用Actuator的shutdown端点做接口级关闭除了依靠操作系统信号Spring Boot还提供了Actuator的/actuator/shutdown端点允许通过POST请求触发关闭前提是先手动开启management: endpoint: shutdown: enabled: true endpoints: web: exposure: include: shutdown开启之后发送一个POST请求给应用curl -X POST http://localhost:8080/actuator/shutdown会有类似下面的响应{ message: Shutting down, bye... }然后应用进入关闭流程。这个端点的好处是可以在内网环境中通过网关、堡垒机或自动化平台远程触发关闭尤其适合那些不能直接操作宿主机的场景。坏处也明显一旦暴露到外网等于给攻击者留了一个秒杀服务的后门。所以生产环境我只建议在连接内网的管理端口上启用并且要加权限认证绝对不能原样开放给公网。我自己的实践是如果没有特别强需求优先用kill -TERM而不是依赖这个HTTP端点。一方面它多出来一层HTTP链路在应用假死、线程池满的时候根本访问不到另一方面多一个入口就多一份安全审查工作。Actuator的shutdown更适合做平台化发布系统里的远程停止通道。3.3 自定义关闭回调PreDestroy、DisposableBean、ContextClosedEvent配置文件能解决Web容器优雅停机的问题但应用内部的资源清理还得靠自己。Spring提供了一套清晰的销毁回调机制优先级大致是PreDestroy和DisposableBean在Bean销毁阶段执行ContextClosedEvent在容器关闭初期就发布。实际应该用哪个取决于你想让清理动作发生在什么时机。如果只是关闭单个Bean持有的资源比如关闭一个自定义的HTTP客户端连接池用PreDestroy最直接Component public class InternalApiClient { private final CloseableHttpClient httpClient; public InternalApiClient() { this.httpClient HttpClients.custom() .setMaxConnTotal(200) .build(); } PreDestroy public void closeHttpClient() { if (httpClient ! null) { try { httpClient.close(); log.info(Internal http client closed.); } catch (IOException e) { log.error(Failed to close internal http client, e); } } } }如果你想在容器开始关闭时先广播一个全局信号通知各个模块提前做收尾可以用事件监听Component public class ShutdownContextListener { EventListener(ContextClosedEvent.class) public void onContextClosed(ContextClosedEvent event) { log.info(ApplicationContext is closing, do some global cleanup...); // 比如通知任务调度器暂停调度、标记服务状态为STOPPING } }这里有一个我在代码里见过无数次的坑某些团队在PreDestroy里调用了依赖的另一个Bean方法但容器关闭时Bean的销毁顺序并不完全可控。依赖关系虽然能在一定程度上决定销毁顺序但复杂的场景下A依赖BA反而可能先被销毁。如果A的PreDestroy需要B还活着大概率会拿到一个半关闭状态的B。稳妥做法是不要在销毁阶段做跨Bean的业务调用所有资源各关各的。4. 关闭分析的关键路径与工具4.1 日志怎么读做关闭分析第一步不是看代码而是看日志。Spring Boot在关闭时正常情况下会有一段比较明显的日志变化。以默认Tomcat为例打开INFO级别日志你会发现关闭阶段出现了好几个标志性输出应用启动时会打印Started Application in x.xxx seconds这个是起点。收到关闭信号后如果配置了graceful会看到类似Commencing graceful shutdown. Waiting for active requests to complete...的日志不同版本措辞略有差异。容器资源释放完毕日志中出现Shutting down ExecutorService applicationTaskExecutor这类线程池关闭信息。最终如果一切正常进程静默退出。阅读日志时要特别留意节点之间的时间差。如果从触发关闭到进程退出只有几毫秒基本可以断定没有走优雅关闭流程如果卡在某一步迟迟不退就说明这一步有线程在阻塞或等待。我习惯在发布前特意打开调试级日志然后手动kill -TERM一次把整个关闭日志留档。这些日志就是之后做线上故障排查的对照样板。没有这个标准答案线上出问题的时候你连正常应该长什么样都不清楚。4.2 实操手动复现一次优雅关闭要验证你的Spring Boot应用关闭行为是否符合预期最好的方式就是本地复现。我建议你准备一个Docker容器或者一台独立的测试机按下面几步来做启动一个带外部访问的Spring Boot应用接口里故意模拟一个需要5秒才能返回的业务逻辑。用压测工具或者脚本并发发起请求保证有请求正处于执行中。执行kill -TERM pid。观察请求是否全部正常返回而不是被Connection reset打断。查看应用日志确认graceful shutdown的日志出现并且等待时间在配置的宽限期之内。我实际跑过一次印象很深。配置server.shutdowngraceful之后模拟5秒请求的应用到了3秒时收到SIGTERM那个请求依然老老实实返回了200完全没断。而同一套代码切回默认的immediate请求瞬间被掐断日志里全是ClientAbortException。这已经很能说明问题。如果你希望更细粒度地观察关闭过程可以在关闭期间执行jstack pid抓几次线程栈。你会看到Tomcat的工作线程还在执行业务代码Spring的关闭线程正在等待这些任务结束。这个现场信息非常宝贵。4.3 检查线程池和连接池是否真的关闭很多Spring Boot应用关闭之后进程却迟迟不退或者重启时报端口被占用多半就是资源没有真正释放干净。这里最常见的就是线程池和连接池。先看线程池。如果线程池是通过Executors直接创建的比如ExecutorService executor Executors.newFixedThreadPool(16);这个线程池建立的线程是非守护线程JVM退出前会等所有非守护线程结束。如果你的业务线程一直在空闲存活JVM可能会一直卡在关闭流程里。更糟糕的情况是Spring容器关闭时完全不知道这个线程池存在自然也不会触发shutdown()。要解决它要么把它声明成Spring Bean要么在PreDestroy里显式关闭要么用ThreadFactory把线程设置成daemon线程。再来看连接池。Spring Boot默认的HikariCP数据源本身实现了标准关闭逻辑。但你如果用了某些自研的HTTP连接池、Redis连接工厂或者在代码里用单例方式持有连接资源一定要确认它们也在关闭链路里。一个排查技巧是关闭应用之后立刻执行lsof -i :8080看看端口上还有没有LISTEN或者一堆残余连接。如果还有大量ESTABLISHED状态的连接挂着十有八九是连接池没有随容器正确关闭。5. 关闭过程的疑难问题排查实录5.1 进程迟迟不退一查是有非守护线程有一段时间我负责的一个服务在发布时总是出现旧进程不退干净的现象。运维脚本发完SIGTERM等30秒发现进程还活着最后只能强制kill -9。后来线程栈抓下来一看有个第三方SDK在内部开了一个名不见经传的ScheduleThreadPool它把所有线程都设置成了非守护线程且正常情况下永远不会退出。Spring容器都关完了JVM却因为这几个线程一直挂在那里不肯走。这种问题排查起来第一步就是jstack pid把可疑的线程名字捞出来看它们的daemon属性。如果发现线程栈里有一堆RUNNABLE或者TIMED_WAITING状态的业务线程在容器关闭后还活着就要去代码里找创建线程池的地方显式按优雅方式shutdown()并等待终止。我个人的习惯是在应用内部维护一个线程池统一管理清单关闭时按序释放避免第三方SDK把线程池藏得太深。5.2 请求处理一半被强杀数据状态怎么对有些业务一定要保证数据一致性比如订单创建、支付回调、消息发版。这类场景下如果服务在请求执行途中被强杀Redis缓存里可能已经写了一半数据库事务可能还没提交MQ消息可能已经消费却没更新状态。事后对账通常很痛苦。针对这个问题优雅关闭只能解决不杀正在处理的请求这个环节但无法解决杀之前已经发生的数据不一致。你必须在业务设计上留后手。比如写数据库的操作要保证幂等调第三方服务的动作要有状态机能补偿消费MQ消息一定要等事务提交之后再确认不能先确认后处理。这些内容虽然不属于Spring Boot关闭分析的范畴但在排查关闭问题时往往会同时暴露出来。每个负责发布的人心里都该有一根弦优雅关闭是降低风险的手段不是数据一致性的兜底方案。5.3 端口没释放重启报端口占用重启应用时报BindException: Address already in use一度是我被问得最多的问题。原因是上一次关闭时某些连接池或网络资源没有及时释放端口所在的socket还处于TIME_WAIT或者被某个残余进程占着。排查思路其实很清晰。先执行lsof -i :8080看占用端口的是哪个进程再执行netstat -tulnp | grep 8080看具体状态。如果占用者是上一个Java进程那就是没被完全杀死如果是TIME_WAIT状态一般不会阻止重新监听可以配合netstat -an | grep 8080 | wc -l看连接积压情况。真正要根治的还是回到应用关闭流程本身确认所有网络资源都随容器关闭被释放。另外记得在服务启动时允许地址复用Tomcat内建通常会做处理但如果自己实现SocketServer就得把SO_REUSEADDR打开。5.4 常见问题速查表把平时遇到的关闭阶段问题整理成一个速查表方便你遇到类似情况时快速定位问题现象常见原因排查命令解决方向请求被截断日志出现ClientAbort未开启优雅关闭查看server.shutdown配置设置graceful调大超时时间进程久不退发布卡住非daemon线程未释放jstack pid关注线程栈显式关闭自定义线程池重启报端口占用连接池未释放或上轮进程未退lsof -i :8080、netstat -tulnp修复关闭流程等待前次释放关闭日志很少几乎秒退ShutdownHook未被触发核对发送的信号类型改用SIGTERM检查自定义HookMQ消息重复消费消费后未等事务提交就确认检查消息确认时机调整消费事务边界设计幂等优雅关闭超时被强杀单个阶段超时设置不合理查看timeout-per-shutdown-phase调大超时时间优化任务时长5.5 实战中容易忽略的关闭细节这里有几个我在实测中反复验证过的细节单独拿出来说因为它们实在太容易踩了。第一kill -TERM和kill -15是一回事但有些发布脚本习惯了kill -9只用前者才是触发优雅关闭的前提。如果你的发布系统默认发的是强杀信号那你在应用层做的所有优雅关闭配置全是白费。第二多应用共享资源时关闭顺序很关键。比如订单服务和库存服务都连同一个MQ如果订单服务先关库存服务还没关消费者容易积压告警。这时更合理的方案是先在网关层摘流量再按依赖层次依次关闭服务而不是同时发信号。第三Spring Boot的优雅关闭只保证Web请求的完整性对Async异步方法来说如果线程池没有设置waitForTasksToCompleteOnShutdown部分已提交任务可能被拒绝或者还没跑完就被打断。这个点我踩过一次明明配置了graceful但异步任务还是丢了最后排查发现是自定义ThreadPoolTaskExecutor缺少等待配置。使用Spring的ThreadPoolTaskExecutor时可以显式声明Bean public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(300); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.setThreadNamePrefix(async-task-); executor.initialize(); return executor; }setWaitForTasksToCompleteOnShutdown(true)表示容器关闭时等待已提交任务完成setAwaitTerminationSeconds(30)控制等待上限。没有这两项异步任务在关闭时会有相当高的丢失风险。6. 从一次线上事故中总结的关闭经验回到文章开头的那个线上事故。当时我把发布脚本从kill -9改成先发SIGTERM给应用30秒的优雅关闭窗口同时把server.shutdown设成graceful再配合Nginx摘流量。随后同样规模的一次发布监控面板再也没有出现过那样的红色告警用户侧也反馈完全无感。这个事情给我留下的印象特别深很多时候差的不是代码能力而是对关闭这个动作缺少最基本的敬畏。我个人现在做发布前检查一定会盯着这三件事第一应用和网关都要感知关闭流程先摘流量再关服务第二关闭信号用SIGTERM并配置合理的优雅关闭超时时间不轻易动用kill -9第三在测试环境手动复现一次完整关闭记录日志并检查连接池、线程池是否都干净释放。最后再分享一个小习惯。我会在每个Spring Boot应用的启动脚本里加一个关闭演练命令允许运维在非生产环境一键发送SIGTERM并收集关闭日志。这样每次发布前都能先跑一遍把关闭流程里的问题提前暴露出来。关闭分析这件事实在不起眼但它决定了你每一次发布是丝滑无感还是事故警戒线上下蹦跶。先把这层基本功打好比研究再多高级特性都值。
返回列表