支持优雅停机的底层技术基石--内核中的信号量
从内核层我们接着会聊到 JVM 层,在 JVM 层一探优雅停机底层的技术玄机。
随后我们会从 JVM 层一路奔袭到 Spring 然后到 Dubbo。在这个过程中,笔者还会带大家一起 Shooting Dubbo 在优雅停机下的一个 Bug,并为大家详细介绍修复过程。
最后由 Dubbo 层的优雅停机,引出我们的主角--Netty 优雅停机的设计与实现:
下面我们来正式开始本文的内容~~
在我们的日常开发工作中,业务需求的迭代和优化伴随着我们整个开发周期,当我们加班加点完成了业务需求的开发,然后又历经各种艰难险阻通过了测试的验证,最后经过和产品经理的各种纠缠相爱相杀之后,终于到了最最激动人心的时刻程序要部署上线了。
上线时的情绪波动.png
那么在程序部署上线的过程中势必会涉及到线上服务的关闭和重启,关于对线上服务的启停这里面有很多的讲究,万万不能简单粗暴的进行关闭和重启,因为此时线上服务可能承载着生产的流量,可能正在进行重要的业务处理流程。
比如:用户正在购买商品,钱已经付了,恰好这时赶上程序上线,如果我们这时简单粗暴地对服务进行关闭,重启,可能就会导致用户付了钱,但是订单未创建或者商品未出现在用户的购物清单中,给用户造成了实质的损失,这是非常严重的后果。
为了保证能在程序上线的过程中做到业务无损,所以线上服务的 优雅关闭 和 优雅启动 显得就非常非常重要了。
保持优雅很重要.png
在 Java 程序的运行过程中,程序的运行速度一般会随着程序的运行慢慢地提高,所以从线上表现上来看 Java 程序在运行一段时间后往往会比程序刚启动的时候会快很多。
这是因为 Java 程序在运行过程中,JVM 会不断收集到程序运行时的动态数据,这样可以将高频执行代码通过即时编译成机器码,随后程序运行就直接执行机器码,运行速度完全不输 C 或者 C++ 程序。
同时在程序执行过程中,用到的类型会被加载到 JVM 中缓存,这样当程序再次使用到的时候不会触发临时加载,影响程序执行性能。
我们可以将以上几点当做 JVM 带给我们的性能红利, 而当应用程序重新启动之后,这些性能红利也就消失了 ,如果我们让新启动的程序继续承担之前的流量规模,那么就会导致程序在刚启动的时候在没有这些性能红利的加持下直接进入高负荷的运转状态,这就可能导致线上请求大面积超时,对业务造成影响。
所以说优雅地启动一个程序是非常重要的,优雅启动的核心思想就是让程序在刚启动的时候不要承担太大的流量,让程序在低负荷的状态下运行一段时间,使其提升到最佳的运行状态时,在逐步的让程序承担更大的流量处理。
下面我们就来看下常用于优雅启动场景的两个技术方案:
启动预热就是让刚刚上线的应用程序不要一下就承担之前的全部流量,而是在一个时间窗口内慢慢地将流量打到刚上线的应用程序上,目的是让 JVM 先缓慢地收集程序运行时的一些动态数据,将高频代码即时编译为机器码。
这个技术方案在众多 RPC 框架的实现中我们都可以看到,服务调用方会从注册中心拿到所有服务提供方的地址,然后从这些地址中通过特定的负载均衡算法从中选取一个服务提供方的发送请求。
为了能够使刚刚上线的服务提供方有时间去预热,所以我们就要从源头上控制服务调用方发送的流量,服务调用方在发起 RPC 调用时应该尽量少地去负载均衡到刚刚启动的服务提供方实例。
服务提供方在启动成功后会向注册中心注册自己的服务信息,我们可以将服务提供方的真实启动时间包含在服务信息中一起向注册中心注册,这样注册中心就会通知服务调用方有新的服务提供方实例上线并告知其启动时间。
服务调用方可以根据这个启动时间,慢慢地将负载权重增加到这个刚启动的服务提供方实例上。这样就可以解决服务提供方冷启动的问题,调用方通过在一个时间窗口内将请求慢慢地打到提供方实例上,这样就可以让刚刚启动的提供方实例有时间去预热,达到平滑上线的效果。
启动预热更多的是从服务调用方的角度通过降低刚刚启动的服务提供方实例的负载均衡权重来实现优雅启动。
而延迟暴露则是从服务提供方的角度,延迟暴露服务时间,利用延迟的这段时间,服务提供方可以预先加载依赖的一些资源,比如:缓存数据,spring 容器中的 bean 。等到这些资源全部加载完毕就位之后,我们在将服务提供方实例暴露出去。这样可以有效降低启动前期请求处理出错的概率。
比如我们可以在 dubbo 应用中可以配置服务的延迟暴露时间:
优雅关闭需要考虑的问题和处理的场景要比优雅启动要复杂得多,因为一个正常在线上运行的服务程序正在承担着生产的流量,同时也正在进行业务流程的处理。
要对这样的一个服务程序进行优雅关闭保证业务无损还是非常有挑战的,一个好的关闭流程,可以确保我们业务实现平滑的上下线,避免上线之后增加很多不必要的额外运维工作。
下面我们就来讨论下具体应该从哪几个角度着手考虑实现优雅关闭:
image.png
第一步肯定是要将程序承担的现有流量全部切走,告诉服务调用方,我要进行关闭了,请不要再给我发送请求。那么如果进行切流呢??
在 RPC 的场景中,服务调用方通过服务发现的方式从注册中心中动态感知服务提供者的上下线变化。在服务提供方关闭之前,首先就要自己从注册中心中取消注册,随后注册中心会通知服务调用方,有服务提供者实例下线,请将其从本地缓存列表中剔除。这样就可以使得服务调用方之后的 RPC 调用不在请求到下线的服务提供方实例上。
但是这里会有一个问题,就是通常我们的注册中心都是 AP 类型的,它只会保证最终一致性,并不会保证实时一致性,基于这个原因,服务调用方感知到服务提供者下线的事件可能是延后的,那么在这个延迟时间内,服务调用方极有可能会向正在下线的服务发起 RPC 请求。
因为服务提供方已经开始进入关闭流程,那么很多对象在这时可能已经被销毁了,这时如果再收到请求过来,肯定是无法处理的,甚至可能还会抛出一个莫名其妙的异常出来,对业务造成一定的影响。
那么既然这个问题是由于注册中心可能存在的延迟通知引起的,那么我们就很自然的想到了让准备下线的服务提供方主动去通知它的服务调用方。
这种服务提供方 主动通知 再加上注册中心 被动通知 的两个方案结合在一起应该就能确保万无一失了吧。
事实上,在大部分场景下这个方案是可行的,但是还有一种极端的情况需要应对,就是当服务提供方通知调用方自己下线的网络请求在到达服务调用方之前的很极限的一个时间内,服务调用者向正在下线的服务提供方发起了 RPC 请求,这种极端的情况,就需要服务提供方和调用方一起配合来应对了。
首先服务提供方在准备关闭的时候,就把自己设置为正在关闭状态,在这个状态下不会接受任何请求,如果这时遇到了上边这种极端情况下的请求,那么就抛出一个 CloseException (这个异常是提供方和调用方提前约定好的),调用方收到这个 CloseException ,则将该服务提供方的节点剔除,并从剩余节点中通过负载均衡选取一个节点进行重试,通过让这个请求快速失败从而保证业务无损。
这三种方案结合在一起,笔者认为就是一个比较完美的切流方案了。
当把流量全部切走后,可能此时将要关闭的服务程序中还有正在处理的部分业务请求,那么我们就必须得等到这些业务处理请求全部处理完毕,并将业务结果响应给客户端后,在对服务进行关闭。
当然为了保证关闭流程的可控,我们需要引入关闭超时时间限制,当剩下的业务请求处理超时,那么就强制关闭。
为了保证关闭流程的可控,我们只能做到尽可能的保证业务无损而不是百分之百保证。所以在程序上线之后,我们应该对业务异常数据进行监控并及时修复。
通过以上介绍的优雅关闭方案我们知道,当我们将要优雅关闭一个应用程序时,我们需要做好以下两项工作:
我们首先要做的就是将当前将要关闭的应用程序上承载的生产流量全部切走,保证不会有新的流量打到将要关闭的应用程序实例上。
当所有的生产流量切走之后,我们还需要保证当前将要关闭的应用程序实例正在处理的业务请求要使其处理完毕,并将业务处理结果响应给客户端。以保证业务无损。当然为了使关闭流程变得可控,我们需要引入关闭超时时间。
以上两项工作就是我们在应用程序将要被关闭时需要做的, 那么问题是我们如何才能知道应用程序要被关闭呢 ?换句话说,我们在应用程序里怎么才能感知到程序进程的关闭事件从而触发上述两项优雅关闭的操作执行呢?
既然我们有这样的需求,那么操作系统内核肯定会给我们提供这样的机制,事实上我们可以通过捕获操作系统给进程发送的信号来获取关闭进程通知,并在相应信号回调中触发优雅关闭的操作。
接下来让我们来看一下操作系统内核提供的信号机制:
信号是操作系统内核为我们提供用于在进程间通信的机制,内核可以利用信号来通知进程,当前系统所发生的的事件(包括关闭进程事件)。
信号在内核中并没有用特别复杂的数据结构来表示,只是用一个代号一样的数字来标识不同的信号。Linux 提供了几十种信号,分别代表不同的意义。信号之间依靠它们的值来区分
信号可以在任何时候发送给进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行对应的信号处理函数就可以了。这就相当于一个操作系统的应急手册,事先定义好遇到什么情况,做什么事情,提前准备好,出了事情照着做就可以了。
内核发出的信号就代表当前系统遇到了某种情况,我们需要应对的步骤就封装在对应信号的回调函数中。
信号机制引入的目的就在于:
让应用进程知道当前已经发生了某个特定的事件(比如进程的关闭事件)。
强制进程执行我们事先设定好的信号处理函数(比如封装优雅关闭逻辑)。
通常来说程序一旦启动就会一直运行下去,除非遇到 OOM 或者我们需要重新发布程序时会在运维脚本中调用 kill 命令关闭程序。Kill 命令从字面意思上来说是杀死进程,但是其本质是向进程发送信号,从而关闭进程。
下面我们使用 kill -l 命令查看下 kill 命令可以向进程发送哪些信号:
笔者这里提取几个常见的信号来简要说明下:
SIGINT: 信号代号为 2 。比如我们在终端以非后台模式运行一个进程实例时,要想关闭它,我们可以通过 Ctrl+C 来关闭这个前台程序。这个 Ctrl+C 向进程发送的正是 SIGINT 信号。
SIGQUIT: 信号代号为 3 。比如我们使用 Ctrl+\ 来关闭一个前台进程,此时会向进程发送 SIGQUIT 信号, 与 SIGINT 信号不同的是 ,通过 SIGQUIT 信号终止的进程会在退出时,通过 Core Dump 将当前进程的运行状态保存在 core dump 文件里面,方便后续查看。
SIGKILL: 信号代号为 9 。通过 kill -9 pid 命令结束进程是非常非常危险的动作, 我们应该坚决制止这种关闭进程的行为 ,因为 SIGKILL 信号是不能被进程捕获和忽略的,只能执行内核定义的默认操作直接关闭进程。 而我们的优雅关闭操作是需要通过捕获操作系统信号,从而可以在对应的信号处理函数中执行优雅关闭的动作 。由于 SIGKILL 信号不能被捕获,所以优雅关闭也就无法实现。现在大家就赶快检查下自己公司生产环境的运维脚本是否是通过 kill -9 pid 命令来结束进程的,一定要避免用这种方式,因为这种方式是极其无情并且略带残忍的关闭进程行为。
image.png
SIGSTOP : 信号代号为 19 。该信号和 SIGKILL 信号一样都是无法被应用程序忽略和捕获的。向进程发送 SIGSTOP 信号也是无法实现优雅关闭的。通过 Ctrl+Z 来关闭一个前台进程,发送的信号就是 SIGSTOP 信号。
SIGTERM: 信号代号为 15 。我们通常会使用 kill 命令来关闭一个后台运行的进程,kill 命令发送的默认信号就是 SIGTERM , 该信号也是本文要讨论的优雅关闭的基础 ,我们通常会使用 kill pid 或者 kill -15 pid 来向后台进程发送 SIGTERM 信号用以实现进程的优雅