邯郸建立网站费用,ps制作网站导航图片,营销网络广告,淘宝网站可以做轮播吗1 前言一年之前#xff0c;我曾经写过一篇《研究优雅停机时的一点思考》#xff0c;主要介绍了 kill -9#xff0c;kill -15 两个 Linux 指令的含义#xff0c;并且针对性的聊到了 Spring Boot 应用如何正确的优雅停机#xff0c;算是本文的前置文章#xff0c;如果你对上… 1 前言一年之前我曾经写过一篇《研究优雅停机时的一点思考》主要介绍了 kill -9kill -15 两个 Linux 指令的含义并且针对性的聊到了 Spring Boot 应用如何正确的优雅停机算是本文的前置文章如果你对上述概念不甚了解建议先去浏览一遍再回头来看这篇文章。这篇文章将会以 Dubbo 为例既聊架构设计也聊源码聊聊服务治理框架要真正实现优雅停机需要注意哪些细节。本文的写作思路是从 Dubbo 2.5.x 开始围绕优雅停机这个优化点一直追溯到最新的 2.7.x。先对 Dubbo 版本做一个简单的科普2.7.x 和 2.6.x 是目前官方推荐使用的版本其中 2.7.x 是捐献给 Apache 的版本具备了很多新的特性目前最新的 release 版本是 2.7.4处于生产基本可用的状态2.6.x 处于维护态主要以 bugfix 为主但经过了很多公司线上环境的验证所以求稳的话可以使用 2.6.x 分支最新的版本。至于 2.5.x社区已经放弃了维护并且 2.5.x 存在一定数量的 bug本文介绍的 Dubbo 优雅停机特性便体现了这一点。2 优雅停机的意义优雅停机一直是一个非常严谨的话题但由于其仅仅存在于重启、下线这样的部署阶段导致很多人忽视了它的重要性但没有它你永远不能得到一个完整的应用生命周期永远会对系统的健壮性持怀疑态度。同时优雅停机又是一个庞大的话题操作系统层面提供了 kill -9 (SIGKILL)和 kill -15(SIGTERM) 两种停机策略语言层面Java 应用有 JVM shutdown hook 这样的概念框架层面Spring Boot 提供了 actuator 的下线 endpoint提供了 ContextClosedEvent 事件容器层面Docker 当执行 docker stop 命令时容器内的进程会收到 SIGTERM 信号那么 Docker Daemon 会在 10s 后发出 SIGKILL 信号K8S 在管理容器生命周期阶段中提供了 prestop 钩子方法应用架构层面不同架构存在不同的部署方案。单体式应用中一般依靠 nginx 这样的负载均衡组件进行手动切流逐步部署集群微服务架构中各个节点之间有复杂的调用关系上述这种方案就显得不可靠了需要有自动化的机制。为避免该话题过度发散本文的重点将会集中在框架和应用架构层面探讨以 Dubbo 为代表的微服务架构在优雅停机上的最佳实践。Dubbo 的优雅下线主要依赖于注册中心组件由其通知消费者摘除下线的节点如下图所示上述的操作旨在让服务消费者避开已经下线的机器但这样就算实现了优雅停机了吗似乎还漏掉了一步在应用停机时可能还存在执行到了一半的任务试想这样一个场景一个 Dubbo 请求刚到达提供者服务端正在处理请求收到停机指令后提供者直接停机留给消费者的只会是一个没有处理完毕的超时请求。结合上述的案例我们总结出 Dubbo 优雅停机需要满足两点基本诉求服务消费者不应该请求到已经下线的服务提供者在途请求需要处理完毕不能被停机指令中断优雅停机的意义应用的重启、停机等操作不影响业务的连续性。3 优雅停机初始方案 — 2.5.x为了让读者对 Dubbo 的优雅停机有一个最基础的理解我们首先研究下 Dubbo 2.5.x 的版本这个版本实现优雅停机的方案相对简单容易理解。3.1 入口类AbstractConfigpublic abstract class AbstractConfig implements Serializable { static { Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { public void run() { ProtocolConfig.destroyAll(); } }, DubboShutdownHook)); }}在 AbstractConfig 的静态块中Dubbo 注册了一个 shutdown hook用于执行 Dubbo 预设的一些停机逻辑继续跟进 ProtocolConfig.destroyAll() 。3.2 ProtocolConfigpublic static void destroyAll() { if (!destroyed.compareAndSet(false, true)) { return; } AbstractRegistryFactory.destroyAll(); // ①注册中心注销 // Wait for registry notification try { Thread.sleep(ConfigUtils.getServerShutdownTimeout()); // ② sleep 等待 } catch (InterruptedException e) { logger.warn(Interrupted unexpectedly when waiting for registry notification during shutdown process!); } ExtensionLoaderProtocol loader ExtensionLoader.getExtensionLoader(Protocol.class); for (String protocolName : loader.getLoadedExtensions()) { try { Protocol protocol loader.getLoadedExtension(protocolName); if (protocol ! null) { protocol.destroy(); // ③协议/流程注销 } } catch (Throwable t) { logger.warn(t.getMessage(), t); } }}Dubbo 中的 Protocol 这个词不太能望文生义它一般被翻译为协议但我更习惯将它理解为“流程”从 Protocol 接口的三个方法反而更加容易理解。public interface Protocol { T ExporterT export(InvokerT invoker) throws RpcException; T InvokerT refer(ClassT type, URL url) throws RpcException; void destroy();}它定义了暴露、订阅、注销这三个生命周期方法所以不难理解为什么 Dubbo 会把 shutdown hook 触发后的注销方法定义在 ProtocolConfig 中了。回到 ProtocolConfig 的源码中我把 ProtocolConfig 中执行的优雅停机逻辑分成了三部分其中第 12 部分和注册中心(Registry)相关第 3 部分和协议/流程(Protocol)相关分成下面的 3.3 和 3.4 两部分来介绍。3.3 注册中心注销逻辑public abstract class AbstractRegistryFactory implements RegistryFactory { public static void destroyAll() { LOCK.lock(); try { for (Registry registry : getRegistries()) { try { registry.destroy(); } catch (Throwable e) { LOGGER.error(e.getMessage(), e); } } REGISTRIES.clear(); } finally { // Release the lock LOCK.unlock(); } }}这段代码对应了 3.2 小节 ProtocolConfig 源码的第 1 部分代表了注册中心的注销逻辑更深一层的源码不需要 debug 进去了大致的逻辑就是删除掉注册中心中本节点对应的服务提供者地址。// Wait for registry notificationtry { Thread.sleep(ConfigUtils.getServerShutdownTimeout());} catch (InterruptedException e) { logger.warn(Interrupted unexpectedly when waiting for registry notification during shutdown process!);}这段代码对应了 3.2 小节 ProtocolConfig 源码的第 2 部分 ConfigUtils.getServerShutdownTimeout() 默认值是 10s为什么需要在 shutdown hook 中等待 10s 呢在注释中可以发现这段代码的端倪原来是为了给服务消费者一点时间确保等到注册中心的通知。10s 显然是一个经验值这里也不妨和大家探讨一下如何稳妥地设置这个值呢设置的过短。由于注册中心通知消费者取消订阅某个地址是异步通知过去的可能消费者还没收到通知提供者这边就停机了这就违背了我们的诉求 1服务消费者不应该请求到已经下线的服务提供者。设置的过长。这会导致发布时间变长带来不必要的等待。两个情况对比下起码可以得出一个实践经验如果拿捏不准等待时间尽量设置一个宽松的一点的等待时间。这个值主要取决三点因素集群规模的大小。如果只有几个服务每个服务只有几个实例那么再弱鸡的注册中心也能很快的下发通知。注册中心的选型。以 Naocs 和 Zookeeper 为例同等规模服务实例下 Nacos 在推送地址方面的能力远超 Zookeeper。网络状况。服务提供者和服务消费者与注册中心的交互逻辑走的 TCP 通信网络状况也会影响到推送时间。所以需要根据实际部署场景测量出最合适的值。3.4 协议/流程注销逻辑ExtensionLoaderProtocol loader ExtensionLoader.getExtensionLoader(Protocol.class);for (String protocolName : loader.getLoadedExtensions()) { try { Protocol protocol loader.getLoadedExtension(protocolName); if (protocol ! null) { protocol.destroy(); } } catch (Throwable t) { logger.warn(t.getMessage(), t); }}这段代码对应了 3.2 小节 ProtocolConfig 源码的第 3 部分在运行时 loader.getLoadedExtension(protocolName) 这段代码会加载到两个协议 DubboProtocol 和 Injvm 。后者 Injvm 实在没啥好讲的主要来分析一下 DubboProtocol 的逻辑。DubboProtocol 实现了我们前面提到的 Protocol 接口它的 destory 方法是我们重点要看的。public class DubboProtocol extends AbstractProtocol { public void destroy() { for (String key : new ArrayListString(serverMap.keySet())) { ExchangeServer server serverMap.remove(key); if (server ! null) { server.close(ConfigUtils.getServerShutdownTimeout()); } } for (String key : new ArrayListString(referenceClientMap.keySet())) { ExchangeClient client referenceClientMap.remove(key); if (client ! null) { client.close(ConfigUtils.getServerShutdownTimeout()); } } for (String key : new ArrayListString(ghostClientMap.keySet())) { ExchangeClient client ghostClientMap.remove(key); if (client ! null) { client.close(ConfigUtils.getServerShutdownTimeout()); } } stubServiceMethodsMap.clear(); super.destroy(); }}主要分成了两部分注销逻辑server 和 client注意这里是先注销了服务提供者后再注销了服务消费者这样做是有意为之。在 RPC 调用中经常是一个远程调用触发一个远程调用所以在关闭一个节点时应该先切断上游的流量所以这里是先注销了服务提供者这样从一定程度上降低了后面服务消费者被调用到的可能性(当然服务消费者也有可能被单独调用到)。由于 server 和 client 的流程类似所以我只选取了 server 部分来分析具体的注销逻辑。 public void close(final int timeout) { startClose(); if (timeout 0) { final long max (long) timeout; final long start System.currentTimeMillis(); if (getUrl().getParameter(Constants.CHANNEL_SEND_READONLYEVENT_KEY, true)) { // 如果注册中心有延迟会立即收到readonly事件下次不会再调用这台机器当前已经调用的会处理完 sendChannelReadOnlyEvent(); } while (HeaderExchangeServer.this.isRunning() // ① System.currentTimeMillis() - start max) { try { Thread.sleep(10); } catch (InterruptedException e) { logger.warn(e.getMessage(), e); } } } doClose(); // ② server.close(timeout); // ③ } private boolean isRunning() { CollectionChannel channels getChannels(); for (Channel channel : channels) { if (DefaultFuture.hasFuture(channel)) { return true; } } return false; } private void doClose() { if (!closed.compareAndSet(false, true)) { return; } stopHeartbeatTimer(); try { scheduled.shutdown(); } catch (Throwable t) { logger.warn(t.getMessage(), t); } }化繁为简这里只挑选上面代码中打标的两个地方进行分析判断服务端是否还在处理请求在超时时间内一直等待到所有任务处理完毕关闭心跳检测关闭 NettyServer特别需要关注第一点正符合我们在一开始提出的优雅停机的诉求 2“在途请求需要处理完毕不能被停机指令中断”。3.5 优雅停机初始方案总结上述介绍的几个类构成了 Dubbo 2.5.x 的优雅停机方案简单做一下总结Dubbo 的优雅停机逻辑时序如下Registry 注销等待 -Ddubbo.service.shutdown.wait 秒等待消费方收到下线通知Protocol 注销 DubboProtocol 注销 NettyServer 注销 等待处理中的请求完毕 停止发送心跳 关闭 Netty 相关资源 NettyClient 注销 停止发送心跳 等待处理中的请求完毕 关闭 Netty 相关资源Dubbo 2.5.3 优雅停机的缺陷如果你正在使用的 Dubbo 版本 2.5.3一些并发问题和代码缺陷会导致你的应用不能很好的实现优雅停机功能请尽快升级。详情可以参考该 pull request 的变更https://github.com/apache/dubbo/pull/5684 Spring 容器下 Dubbo 的优雅停机上述的方案在不使用 Spring 时的确是无懈可击的但由于现在大多数开发者选择使用 Spring 构建 Dubbo 应用上述的方案会存在一些缺陷。由于 Spring 框架本身也依赖于 shutdown hook 执行优雅停机并且与 Dubbo 的优雅停机会并发执行而 Dubbo 的一些 Bean 受 Spring 托管当 Spring 容器优先关闭时会导致 Dubbo 的优雅停机流程无法获取相关的 Bean从而优雅停机失效。Dubbo 开发者们迅速意识到了 shutdown hook 并发执行的问题开始了一系列的补救措施。4.1 增加 ShutdownHookListenerSpring 如此受欢迎的原因之一便是它的扩展点非常丰富例如它提供了 ApplicationListener 接口开发者可以实现这个接口监听到 Spring 容器的关闭事件为解决 shutdown hook 并发执行的问题在 Dubbo 2.6.3 中新增了 ShutdownHookListener 类用作 Spring 容器下的关闭 Dubbo 应用的钩子。 private static class ShutdownHookListener implements ApplicationListener { Override public void onApplicationEvent(ApplicationEvent event) { if (event instanceof ContextClosedEvent) { // we call it anyway since dubbo shutdown hook make sure its destroyAll() is re-entrant. // pls. note we should not remove dubbo shutdown hook when spring framework is present, this is because // its shutdown hook may not be installed. DubboShutdownHook shutdownHook DubboShutdownHook.getDubboShutdownHook(); shutdownHook.destroyAll(); } } }当服务提供者 ServiceBean 和服务消费者 ReferenceBean 被初始化时会触发该钩子被创建。再来看看 AbstractConfig 中的代码依旧保留了 JVM 的 shutdown hookpublic abstract class AbstractConfig implements Serializable { static { Runtime.getRuntime().addShutdownHook(DubboShutdownHook.getDubboShutdownHook()); }}也就是说在 Spring 环境下会注册两个钩子在 Non-Spring 环境下只会有一个钩子但看到 2.6.x 的实现大家是否意识到了两个问题呢两个钩子并发执行不会报错吗为什么在 Spring 下不取消 JVM 的钩子只保留 Spring 的钩子不就可以工作了吗先解释第一个问题这个按照我的理解这段代码的 Commiter 可能认为只需要有一个 Spring 的钩子能正常注销就完事了不需要考虑另外一个报不报错因为都是独立的线程不会有很大的影响。再解释第二个问题其实这个疑问的答案就藏在上面 ShutdownHookListener 代码的注释中这段注释的意思是说在 Spring 框架下不能直接移除原先的 JVM 钩子因为 Spring 框架可能没有注册 ContextClosed 事件。啥意思呢这里涉及到 Spring 框架生命周期的一个细节我打算单独介绍一下。4.2 Spring 的容器关闭事件详解在 Spring 中我们可以使用至少三种方式来注册容器关闭时一些收尾工作使用 DisposableBean 接口public class TestDisposableBean implements DisposableBean { Override public void destroy() throws Exception { System.out.println( invoke DisposableBean ); }}使用 PreDestroy 注解public class TestPreDestroy { PreDestroy public void preDestroy(){ System.out.println( invoke preDestroy ); }}使用 ApplicationListener 监听 ContextClosedEventapplicationContext.addApplicationListener(new ApplicationListener() { Override public void onApplicationEvent(ApplicationEvent applicationEvent) { if (applicationEvent instanceof ContextClosedEvent) { System.out.println( receive context closed event ); } }});但需要注意的是在使用 SpringBoot 内嵌 Tomcat 容器时容器关闭钩子是自动被注册但使用纯粹的 Spring 框架或者外部 Tomcat 容器需要显式的调用 context.registerShutdownHook(); 接口进行注册ClassPathXmlApplicationContext context new ClassPathXmlApplicationContext(spring/beans.xml);context.start();context.registerShutdownHook();context.addApplicationListener(new ApplicationListenerApplicationEvent() { Override public void onApplicationEvent(ApplicationEvent applicationEvent) { if (applicationEvent instanceof ContextClosedEvent) { System.out.println( receive context closed event ); } }});否则上述三种回收方法都无法工作。我们来看看 registerShutdownHook() 都干了啥public abstract class AbstractApplicationContext extends DefaultResourceLoader implements ConfigurableApplicationContext, DisposableBean{ Override public void registerShutdownHook() { if (this.shutdownHook null) { // No shutdown hook registered yet. this.shutdownHook new Thread() { Override public void run() { synchronized (startupShutdownMonitor) { doClose(); } } }; Runtime.getRuntime().addShutdownHook(this.shutdownHook); // 重点 } }}其实也就是显式注册了一个属于 Spring 的钩子。这也解释上了 4.1 小节中为什么有那段注释了注册了事件不一定管用还得保证 Spring 容器注册了它自己的钩子。4.3 Dubbo 优雅停机中级方案总结第 4 节主要介绍了 Dubbo 开发者们在 Spring 环境下解决 Dubbo 优雅停机并发执行 shutdown hook 时的缺陷问题但其实还不完善因为在 Spring 环境下如果没有显式注册 Spring 的 shutdown 还是会存在缺陷的准确的说Dubbo 2.6.x 版本可以很好的在 Non-Spring、Spring Boot、Spring ContextClosedEvent 环境下很好的工作。5 Dubbo 2.7 最终方案public class SpringExtensionFactory implements ExtensionFactory { public static void addApplicationContext(ApplicationContext context) { CONTEXTS.add(context); if (context instanceof ConfigurableApplicationContext) { ((ConfigurableApplicationContext) context).registerShutdownHook(); DubboShutdownHook.getDubboShutdownHook().unregister(); } BeanFactoryUtils.addApplicationListener(context, SHUTDOWN_HOOK_LISTENER); }}这段代码寥寥数行却是经过了深思熟虑之后的产物期间迭代了 3 个大版本真是不容易。这段代码很好地解决了第 4 节提出的两个问题担心两个钩子并发执行有问题那就在可以注册 Spring 钩子的时候取消掉 JVM 的钩子。担心当前 Spring 容器没有注册 Spring 钩子那就显示调用 registerShutdownHook 进行注册。其他细节方面的优化和 bugfix 我就不进行详细介绍了可以见得实现一个优雅停机需要考虑的点非常之多。6 总结优雅停机看似是一个不难的技术点但在一个通用框架中使用者的业务场景类型非常多这会大大加剧整个代码实现的复杂度。摸清楚整个 Dubbo 优雅停机演化的过程也着实花费了我一番功夫有很多实现需要 checkout 到非常古老的分支同时翻阅了很多 issue、pull request 的讨论最终才形成了这篇文章虽然研究的过程是困难的但获取到真相是让人喜悦的。在开源产品的研发过程中服务到每一个类型的用户真的是非常难的一件事能做的是满足大部分用户。例如 2.6.x 在大多数环境下其实已经没问题了在 2.7.x 中则是得到了更加的完善但是我相信在使用 Dubbo 的部分用户中可能还是会存在优雅停机的问题只不过还没有被发现。商业化的思考和开源产品一样商业化产品的研发也同样是一个逐渐迭代的过程需要数代开发者一起维护一份代码使用者发现问题开发者修复问题这样的正反馈可以形成一个正反馈促使产品更加优秀。相关 pull request:修复 2.5.3 bug 的 prhttps://github.com/apache/dubbo/pull/568 作者qinliujie2.6.x Spring Shutdown Hook Enhancement: https://github.com/apache/dubbo/pull/1763 , https://github.com/apache/dubbo/pull/1820 作者ralf01312.7.x Spring Shutdown Hook Enhancement: https://github.com/apache/dubbo/pull/3008/ 作者beiwei30「技术分享」某种程度上是让作者和读者不那么孤独的东西。欢迎关注我的微信公众号「Kirito的技术分享」