多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Spring Boot优雅关闭全解析:从kill -9到容器销毁的底层机制

Spring Boot优雅关闭全解析:从kill -9到容器销毁的底层机制 1. 关闭流程的底层机制1.1 从 kill 命令到 Spring 容器销毁的完整链路很多同学对应用关闭的理解停留在“进程死了就完事”的层面但真实情况远比这复杂。以最常见的kill -9和kill默认 SIGTERM为例这两者在 Spring Boot 应用身上引发的连锁反应完全不同。先说kill -9。这个信号 JVM 无法拦截进程会直接终止Spring 容器的销毁方法、Bean 的PreDestroy、DisposableBean 的 destroy 方法统统不会执行。如果你在生产环境用过这招大概率遇见过日志里的“未完成请求”“数据库连接泄露”这类诡异问题。原因很简单连接池里的连接还没来得及归还下一批请求就被新实例接管而旧进程的 TCP 连接直接断了。再说正常的kill即 SIGTERM。JVM 收到后会先触发已注册的 shutdown hook关闭钩子。Spring Boot 启动时SpringApplication内部会通过Runtime.addShutdownHook注册一个钩子这个钩子的核心逻辑是调用SpringApplication.doClose最终把ConfigurableApplicationContext整个容器关掉。而这个关容器动作具体包含几件大事触发ContextClosedEvent、调用各 Bean 的PreDestroy回调、销毁DisposableBean、清理单例池、关闭底层 Web 容器。这个过程听起来顺理成章但里面有个关键时序问题Spring Boot 在不同版本里对 Web 容器的关闭是放在容器自身停止逻辑中的而不是简单线性执行。比如 Tomcat 的关闭逻辑里又分“先停止接收新请求”和“再缓慢处理已经接收的请求”两段。如果这两段之间协调不好就会出现“应用还活着但新请求已经进不来、旧请求却卡住”的场景。1.2 Shutdown Hook 与 ContextClosedEvent 的触发顺序在实际排查中很多人搞不清楚PreDestroy、SmartLifecycle.stop、监听器里的ContextClosedEvent三者到底谁先谁后。这直接关系到你在关闭时做清理工作的代码写在哪儿才靠谱。我把顺序讲透ContextClosedEvent的发布发生在容器关闭的最早期准确说是AbstractApplicationContext.doClose方法首先publishEvent(new ContextClosedEvent(this))。而PreDestroy的触发时间在后续的destroyBeans阶段。SmartLifecycle.stop则更早——容器关闭流程里第一步就是调lifecycleProcessor.onClose()这个阶段会执行所有 SmartLifecycle Bean 的stop回调。所以如果你在做异步任务队列清理想“在容器关掉之前把剩余任务处理完”用SmartLifecycle接口的stop方法配合phase数值来控制先后顺序比单纯监听ContextClosedEvent更可靠。因为ContextClosedEvent触发时Web 容器可能已经开始拒绝新请求了。而SmartLifecycle.stop里你还可以做阻塞等待只要设置合适的phase可以确保它比 Web 容器的 stop 更早执行。注意phase值越小越先启动但停止的顺序相反——phase值越大越先停止。别记反了。2. 优雅关闭的实现方案选择2.1 Spring Boot 2.3 原生优雅关闭配置从 Spring Boot 2.3 开始官方终于把“优雅关闭”做成了内置能力不再需要额外引包。配置方式非常简洁server: shutdown: graceful配合这个配置的还有超时时间默认是 30 秒spring: lifecycle: timeout-per-shutdown-phase: 30s这里有个容易踩的坑很多人以为server.shutdowngraceful配完就万事大吉但其实它只在处理“新请求不再接收、旧请求等待完成”这一层起作用。对于 Tomcat 而言意味着 Connector 会先停止接受新连接但已经 keep-alive 的连接上发来的新请求Tomcat 内部会判断如果还在 graceful 窗口内仍然会处理掉之后才逐步关闭。timeout-per-shutdown-phase这个参数很有意思它控制的是每个关闭阶段的最大等待时间而不是整体时间。关闭流程被划分成多个 phase每个 phase 超时后继续进下一个 phase。实际项目中我建议把超时配置成你线上最长请求耗时的 1.5 到 2 倍。如果业务 SLA 要求在 10 秒内返回那我一般配 15 秒左右。不要一味贪大超时太长发布时流量切走旧实例迟迟不退会占用资源太短又可能出现大量“处理了一半”的请求被杀掉。2.2 自定义 GracefulShutdown 实现方案如果你的项目还在用 Spring Boot 2.2 或更早版本那就只能自己实现 Tomcat 的优雅关闭了。网上流传的TomcatGracefulShutdown方案核心代码大致如下Component public class TomcatGracefulShutdown implements SmartLifecycle { Autowired private WebServerStartStopLifecycle lifecycle; Override public void stop(Runnable callback) { // 获取Tomcat的Connector停止接收新请求 // 等待活跃请求数归零 } }这个方案的实际效果取决于你对 Tomcat 内部类Tomcat、Connector的访问方式。在 Spring Boot 2.x 不同小版本里TomcatWebServer的内部结构有些微变化如果你直接强转或者反射取内部字段升级一个小版本就可能编译报错。更稳的做法是直接用WebServerFactoryCustomizer定制Tomcat的ProtocolHandler。Tomcat 8.5 之后AbstractProtocol里有个pause方法调用它可以让 Connector 不再接收新连接注意是“不再接收新连接”但已经建立的 keep-alive 长连接上Tomcat 仍然会处理传入的新请求直到连接被客户端关闭或超时。所以一个完整的手写优雅关闭方案至少要做三件事调用 Connector 的pause暂停接收新连接。等待或限制活跃请求完成。再执行容器真正的 stop。这也是为什么 Spring Boot 官方在 2.3 里直接内置优雅关闭后我强烈建议你升级版本而不是自己维护这套逻辑。因为内部细节太多稍不注意就有请求漏掉或卡死。2.3 结合 Actuator 实现远程关闭Spring Boot Actuator 里有一个shutdown端点默认是关闭的。当你需要从运维平台远程触发应用关闭时开启它很实用management: endpoint: shutdown: enabled: true endpoints: web: exposure: include: health,info,shutdown但我要泼一盆冷水如果直接在生产环境暴露这个端点风险极大。任何人只要知道路径就能 POST 一个请求把你的服务关掉。我一般建议通过 Spring Boot Admin 集成管理端或者配合网关做内部网络隔离并且在开启后把端口绑定到内网 IP而不是对外暴露。还要做鉴权哪怕是 Spring Security 的简单 Basic Auth 也好过裸奔。使用姿势上有一点要注意shutdown端点的操作是异步的发起 POST 请求后HTTP 连接在进程退出前可能无法正常返回响应。所以运维脚本里不能等这个接口返回“success”再继续否则会卡到超时。3. 关闭过程中的资源清理与数据一致性3.1 数据库连接池与线程池的销毁顺序先问一个问题HikariDataSource.close()其实发生在什么阶段如果你在PreDestroy方法里有事务提交后同步数据的需求而连接池已经先关闭了那代码里拿不到连接事务提交必然报错。Spring 容器对 Bean 的销毁顺序遵循一个基本规则PreDestroy回调的执行顺序和 Bean 的依赖关系、depends-on声明、order属性相关。HikariDataSource 通常被自动配置为一个单例 Bean它在容器里的销毁时序相对靠后但如果你自定义的 Bean 在PreDestroy里直接向数据库写数据依然可能遇到“连接池已关闭”的并发问题。我踩过的坑是这样的项目里有一个缓存同步任务在PreDestroy里把内存中最新数据刷到 MySQL。本地测试一切正常但容器关闭时报了HikariDataSource is closed。排查发现close()逻辑里虽然 Hikari 池主线程会先停止分配连接但由于容器是多线程并发销毁 Bean我的同步 Bean 和 HikariDataSource 的销毁同时进行导致拿到已关闭的连接。解决方案是在自己的清理 Bean 上使用DependsOn(hikariDataSource)强制保证连接池在这个 Bean 之前存活。这是个很冷门但极其有效的技巧。3.2 异步任务与消息队列的补偿策略优雅关闭最怕什么不是新请求进不来而是“已经拿到的消息还没处理完”。比如 Kafka 消费者线程正在处理一条消息此时进程退出消息虽然已经拉取到内存但因为 offset 还没提交重启后会重新消费造成重复。更麻烦的是如果处理逻辑里写了一半数据库重启后重复执行可能出现脏数据或幂等性破功。针对这种情况我一般用三层策略第一层在SmartLifecycle.stop()里给固定的缓冲时间比如 10 秒让正在执行的消费者线程处理完手头这一条消息。这个时间要足够长但又不能太长否则发布窗口期被拉长。第二层手动暂停消息监听容器。如果你的消费者用的是KafkaListenerConcurrentKafkaListenerContainerFactory关闭前先调用KafkaMessageListenerContainer.stop()它能确保已拉取的消息在pause后被消费完。注意stop()默认也是优雅的它会等待当前在途记录处理完再返回。第三层兜底补偿。在数据库层面做幂等键或者在消息体里带上业务幂等 ID。这样即使重启后重复消费最多是多执行一次而不是产生脏数据。3.3 缓存组件与外部资源释放Redis、Redis Cluster、Elasticsearch 客户端这些组件的清理在实际关闭过程中容易被忽略。以 Spring Data Redis 为例默认的JedisConnectionFactory或LettuceConnectionFactory在容器关闭时会释放底层连接资源。但如果你在代码里手动创建了RedisTemplate的线程池比如某些RedisExecutor这部分资源不会被容器自动感知需要你自己在PreDestroy里调用关闭方法。这块我见过的低级错误是工程师在 Spring 容器里手动创建了一个Executors.newFixedThreadPool用来并发写 Redis但从没关闭过。应用每次发布后就有一个线程池的资源没释放。虽然 JVM 退出后这些线程自然没了但在同一个 JVM 里跑多个 Spring 应用比如通过 Spring Cloud 的进程内多容器模式时线程泄漏会逐步吃光内存。经验之谈容器关闭日志里如果出现jvm 1 | Exception in thread pool-8-thread-1 java.lang.InterruptedException多半是某个自定义线程池在关闭时没有正确处理中断优先检查自定义线程池的 shutdown 与 awaitTermination。4. 生产环境关闭排查与监控要点4.1 如何从关闭日志中判断是否优雅应用关闭是否成功、是否优雅不是看进程退没退。我会先看日志里有没有这几类关键信息“Commencing graceful shutdown. Waiting for active requests to complete” —— Spring Boot 2.3 开始优雅关闭时会打印这条。“Tomcat started on port(s)” 之后的Shutdown相关日志能看到Destroying ProtocolHandler或Pausing ProtocolHandler。“Closing JPA EntityManagerFactory” / “Closing HikariCP” 这类资源关闭日志。如果看到的是直接进程消失、没有任何日志输出那基本可以断定是 SIGKILL 或System.exit强行终止。后者其实也走关闭钩子所以有日志前者则没有。另外关闭日志里如果出现大量InterruptedException或者RejectedExecutionException说明你在优雅关闭期间还给工作线程派了新任务或者线程被强制中断。这属于关闭逻辑的设计问题不是是非对错但通常说明需要重新编排关闭阶段。4.2 线程 Dump 定位关闭卡死问题生产环境遇到最多、也最难查的问题是关闭特别慢甚至十几分钟都退不出去。这种状况极大概率是有线程在优雅关闭阶段阻塞住了。排查方法很简单在关闭期间连续抓多次线程 Dump对比关闭前后线程状态变化找出始终停留在WAITING或BLOCKED状态的线程。具体操作可以用jstack直接抓jstack pid thread_dump_1.txt sleep 5 jstack pid thread_dump_2.txt重点看两个地方Tomcat的 worker 线程是否卡在第三方接口调用上比如某个 HTTP 调用没有设置超时时间外部服务又不响应导致线程一直在等。自定义Excutor线程池中awaitTermination是否在等待一个永远不会结束的任务比如一个循环里没有判断中断标志位。针对这类问题我的修复经验是在所有外部调用的规范里强制加连接超时和读取超时。很多团队只在开发阶段测试正常到关闭时就卡死就是因为在关闭窗口期外部依赖服务的超时时间默认 30 秒而你的优雅关闭超时只有 10 秒线程来不及结束就被容器等待最后进程迟迟退不掉。4.3 关闭阶段的多实例协调与流量摘除单实例优雅关闭只是基础。真正生产环境里服务基本都是多实例部署这时候“关闭一个实例”就涉及流量摘除和负载均衡器协调。以 Nginx 上游和 Spring Cloud Gateway 为例。如果你用的是 Nginx我一般建议发布前先从 upstream 节点列表里做down操作等 Nginx 把流量切走后再发 SIGTERM。Spring Cloud Gateway 则可以利用DiscoveryClient主动将自身注册状态改为OUT_OF_SERVICE。Spring Boot 2.3 之后graceful关闭虽然会让 Web 容器不再接受新请求但前提是负载均衡器已经把流量调度走否则在关闭窗口内负载均衡器仍然可能把新请求打过来造成短暂 5xx。这里有个很实用的组合在优雅关闭前先调actuator/health探针让自己处于 DOWN 状态。Kubernetes 的readinessProbe和 Spring Boot 自带的health端点天然搭配。先让探针失败再等几个探针周期确保所有流量的路由表更新完毕最后再关闭 JVM。实战心得在 K8s 环境里不要只依赖preStop钩子做优雅关闭。preStop是阻塞执行的K8s 在 preStop 完成后才发 SIGTERM。如果你在 preStop 里做长耗时操作会延长整个 Pod 终止时间而且 K8s 的 terminationGracePeriodSeconds 一到直接 SIGKILL你的优雅关闭根本没机会执行完。4.4 关闭超时参数与 K8s 生命周期对齐容器化部署是当下主流参数对齐这事踩过的坑太多了。Kubernetes 默认的terminationGracePeriodSeconds是 30 秒如果你在 Spring Boot 里配的spring.lifecycle.timeout-per-shutdown-phase是 45 秒那么即使应用还在优雅关闭Pod 到 30 秒也会被强制杀掉。发布前必须把两边的超时时间对齐好的。我给一个典型配置样例# application.yml spring: lifecycle: timeout-per-shutdown-phase: 25s server: shutdown: graceful# K8s deployment 部分 terminationGracePeriodSeconds: 30再配合探针readinessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 10 periodSeconds: 10 failureThreshold: 3这样调配的逻辑是探针在 3 个周期内失败通常意味着 30 秒左右已经把该实例从 Service 的端点列表里摘除之后 K8s 发 SIGTERMSpring Boot 开始优雅关闭。总体 25 秒的关闭超时覆盖请求处理30 秒的 K8s 宽限期兜底两边都有余量不会出现一端先杀进程的尴尬。5. 常见关闭异常案例速查这里把我在不同项目里遇到并处理过的高频异常整理成一张表方便你对照排查。现象可能原因处理策略关闭日志出现 “waiting for N active requests to complete” 后迟迟不退有请求一直未结束超出timeout-per-shutdown-phase查看线程 Dump重点排查外部调用超时设置加大 graceful 超时时间PreDestroy 中写数据库报HikariDataSource is closedBean 销毁顺序不对连接池先于业务 Bean 关闭给业务 Bean 加DependsOn(hikariDataSource)Kafka 消费者重启后重复消费消息优雅关闭未处理在途消息offset 未提交在SmartLifecycle.stop里先暂停并等待消息消费完成关闭后端口还在监听进程无法退出非 Spring 管理的原生线程池或外部连接未释放排查自定义线程池、Netty 组、ZooKeeper 客户端等显式调用 closekill -9后重启出现连接被拒的雪崩旧实例未摘除即被杀流量还没切走调整负载均衡健康检查时间协调流量摘除shutdown 端点 POST 请求响应超时异步关闭导致响应无法在进程退出前返回运维脚本不等待响应改为轮询端口的探活方式判断进程退出5.1 超过关闭等待时间却没有报错有一种特别容易忽视的情况Spring Boot 的优雅关闭超时到了以后Tomcat 并不会把还在执行的请求强制中断而是继续等待余下的请求。也就是说超时后只是“不再保证优雅”进程可能继续等那些永远不结束的请求。所以日志里哪怕没有报超时错误你的应用也可能僵死在关闭流程里。这类问题我碰上过一次排查到最后是一个老项目里的 RestTemplate 没设置连接池超时对端服务挂了一个端口TCP 连接一直挂着。优雅关闭时 Tomcat 的两个 worker 线程卡在ResponseBodyHandler的读取上。解决办法是给 RestTemplate 添加connectTimeout和readTimeoutHttpComponentsClientHttpRequestFactory factory new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(5000);同时在超时策略上关注两个参数的区别。连接超时管的是建立连接读取超时管的是等待响应。对于依赖下游很重的服务读取超时尤其重要否则你的线程会一直傻等。5.2 Nacos / Consul 注册中心在关闭时的反注册服务注册中心的节点摘除也是一个很容易被忽略的细节。Spring Cloud 项目中服务关闭时如果没来得及主动反注册网关或调用方会持续把流量路由到已挂实例上直到下次心跳失联被注册中心剔除。这个窗口期通常有 15 秒到 60 秒不等期间请求会间歇性失败。Spring Cloud 本身在 WebServer 停止后会自动执行反注册逻辑。但这里有个隐藏顺序问题如果server.shutdowngraceful的等待时间很长比如 30 秒而注册中心的心跳剔除逻辑认为你的节点失联需要 60 秒那么在这个 30 秒内注册中心里的节点状态依然是“在线”流量会源源不断进来。这就会造成一个奇怪的现象应用已经进入关闭流程但负载均衡器还在往它发送新请求而优雅关闭期间 Tomcat 已经不再接受新连接于是新请求直接被拒绝。解决思路是在流量摘除阶段就把实例标记为不健康。让/actuator/health返回 DOWN注册中心探活失败后会主动将节点剔除。也就是我之前说的把健康检查、探针、优雅关闭三件事联动起来比单纯依赖哪个组件的默认行为可靠得多。6. 实际操作中的心得体会说实话优雅关闭这件事在业务开发阶段几乎没人会花时间考虑。我第一次碰到还是因为一次半夜发布事故上线脚本直接kill -9第二天一早收到监控报警数据库连接数暴涨消息队列积压了几十万条。当时怎么也想不通代码里明明每一步都盯着怎么会出这种问题。后来仔细看了生产日志才知道kill -9不仅让 Spring 容器来不及关连 TCP 连接都直接断了数据库服务端的连接还没感知要等 tcp keepalive 超时才会回收期间连接池越积越多。那次之后我给自己定了一条生产发布铁律任何环境不允许用kill -9默认一律kill并且用脚本校验进程退出时间超过阈值就报警。对于 K8s 环境不允许改terminationGracePeriodSeconds默认值而忽略 Spring Boot 侧的超时配置。如果你的发布平台只给一个“停止服务”按钮那么请务必确认这个按钮背后是 SIGTERM 而不是 SIGKILL很多云厂商的“强制停止”选项就是直接 SIGKILL这个选项在生产环境必须禁用。另外我还特别想提一点Spring Boot 的优雅关闭参数并不复杂但把它和负载均衡摘流、注册中心摘除、消息队列暂停、连接池关闭、自定义线程池回收这几个阶段串联起来才是一个完整的关闭方案。单纯配一个server.shutdowngraceful只能保证 Web 容器这层优雅背后的线程池、消息消费者、外部依赖清理仍需要你针对业务去定制。最后分享一个我常用的关闭阶段编排思路按phase大小规划phase最大的最先停止最先停止的是消息消费者这样在途消息有足够时间消化然后是业务线程池确保任务能提交到队列但不再新增再是 Web 容器这个由 Spring Boot 控制最后是连接池和外部客户端。每个阶段之间的等待时间至少留 3 秒余量防止依赖组件的内部异步回调还没结束就进入下一阶段。这套思路在多个项目里验证下来发布过程基本能做到零异常零告警。等你照着这个思路把自己的应用关闭流程捋过一遍你会发现之前随手用的kill -9有多么“暴力”。
返回列表