IM长连接网关:服务优雅关闭

avatar
研发 @比心APP

简单介绍

我们很多时候都需要安全的将服务停止,也就是把没有处理完的工作继续处理完成。比如停止一些依赖的服务,输出一些日志,发一些信号给其他的应用系统等,这个在保证系统的高可用中是非常有必要的。

尤其像 Mercury 网关这种,管理了大量的TCP 连接,绝不能直接暴力关闭,必须要保证已有的任务全都处理完,并且在关闭的过程中不会有新的请求进来。

优雅关闭

服务的优雅关闭通常都是利用 JDK 里面提供的 Runtime.addShutDownHook(Thread hook)方法,JVM 提供一种 ShutdownHook(钩子)机制,当 JVM 接受到系统的关闭通知之后,会调用 ShutdownHook 内的方法,用以完成清理操作,从而平滑的退出应用。

  1. 程序正常退出
  2. 使用System.exit()
  3. 终端使用Ctrl+C触发的中断
  4. 系统关闭
  5. OutofMemory宕机
  6. 使用Kill pid杀死进程(使用kill -9是不会被调用的)

像Spring 、Dubbo 等都是基于这个实现的优雅关闭

Spring 优雅关闭

Spring 框架本身也依赖于 shutdown hook 执行优雅停机,通过调用 AbstractApplicationContext的 registerShutdownHook 方法

image.png

在 doClose 方法里面,主要做了如下几件事:

  1. 发布 ContextClosedEvent 事件(Spring 里面默认使用同步方式执行事件发布)
  2. 执行 LifecycleProcessor的 onClose 方法 (很多 spring 相关的jar里面会基于LifecycleProcessor 在容器关闭时做一些清理工作,比如 Kafka Listener)
  3. 销毁所有的 bean

image.png

因为在Mercury 业务处理的 Handler 里面用到了 spring bean (主要是RocketMQ相关的) ,所以关闭连接、清理Netty 资源的时机一定要在 Spring 容器关闭之前才可以。

从上面 doClose 的流程可以看出,只要在 destroyBeans 之前执行 Netty 资源的关闭即可。

Spring 提供了 ApplicationListener 接口,开发者可以实现这个接口监听到 Spring 容器的 ContextClosedEvent 关闭事件。我选择的就是这种方式。因为没有修改Spring中默认的事件发布器SimpleApplicationEventMulticaster,会同步的执行 onApplicationEvent 方法,这样就保证了在关闭Netty 相关资源之后才会去销毁 bean。

image.png

Netty 优雅关闭


Netty 相关资源优雅关闭的主要流程如下:

  1. 关闭 server channel
  2. server发送重连消息
  3. server主动close 未断开的连接
  4. 等待所有 channel 关闭
  5. 调用 Netty 的 shutdownGracefully

关闭 server channel


服务端关闭 NioServerSocketChannel,取消端口绑定,关闭服务

直接调用 channel.close() ;

这里的 close() 方法实际是在 AbstractChannel 实现的。

在方法内部,会调用对应的 ChannelPipeline的 close() 方法,将 close 事件在 pipeline 上传播。而 close 事件属于 Outbound 事件,所以会从 tail 节点开始,最终传播到 head 节点,使用 Unsafe 进行关闭:

image.png

在 unsafe 内部最终会执行 Java 原生 NIO ServerSocketChannel 关闭

image.png

发送重连消息


当 客户端收到 server 下发的 reconnect 消息之后,就会断开当前连接,然后重新建立连接。

Mercury 网关单台机器上会有数万连接,这里不会一次性给所有连接下发 reconnect 消息,不然可能会导致这数万客户端同时发起重新建连,会造成其他的网关机器 CPU使用率突增。所以这里最好做平滑处理。

我们每次只会同时给指定数量的连接下发 reconnect 消息 ,然后 等待几秒后再接着下发。

主动close 未断开的连接


虽然Server 下发了 重连消息,但有时候可能因为各种网络原因,客户端并没有收到,或者客户端收到了,但是由于某些原因没有断开连接,也就不会重新建立连接。

这时我们就需要在服务端主动 close 掉,这里也同样做了平滑处理,每次只close 指定数量的channel 。

后面就一直不停的检测是否还存在有效连接,如果有的话等待 250 ms 再重新检测,不过检测时间最多 3秒

Netty 的 shutdownGracefully


Netty 自身提供了优雅退出的方式,那就是 EventExecutorGroup 的 shutdownGracefully() 方法

NioEventLoopGroup 实际是 NioEventLoop 的线程组,它的优雅退出比较简单,直接遍历 EventLoop 数组,循环调用它们的 shutdownGracefully 方法。

最终调用的是 SingleThreadEventExecutor里面的 shutdownGracefully

这里贴一下里面的核心代码

image.png

这段代码考虑了多线程同时调用关闭的情况,使用 自旋 + CAS 的方式修改当前NioEventLoop所关联的线程的状态(volatile修饰的成员变量state)。

这里并没有执行具体的关闭操作。其中的关键点,就是将线程状态修改为ST_SHUTTING_DOWN。

NioEventLoop所关联的线程总共有5个状态 :


private static final int ST_NOT_STARTED = 1// 线程还未启动

private static final int ST_STARTED = 2// 线程已经启动

private static final int ST_SHUTTING_DOWN = 3// 线程正在关闭

private static final int ST_SHUTDOWN = 4// 线程已经关闭

private static final int ST_TERMINATED = 5// 线程已经终止

完成状态修改之后,剩下的操作主要在 NioEventLoop 中进行

image.png

在 NioEventLoop 里面最重要的就是 run 方法, 里面一直在不停的循环 select 、处理 IO 事件和 task。

在每次循环的最后,都会去 check 一下 线程的状态,如果是 ST_SHUTTING_DOWN ,就会执行 closeAll 方法

主要做的事是 把注册在 selector 上的所有 Channel 都关闭,循环调用 Channel Unsafe 的 close 方法,但是有些 Channel 正在发送消息,暂时还不能关,需要稍后再执行。

image.png

  1. 判断当前该链路是否有消息正在发送,如果有则将关闭操作封装成 Task 放到 eventLoop 中稍后再执行
  2. 将发送队列清空,不再允许发送新的消息
  3. 调用 SocketChannel 的 close 方法,关闭链路
  4. 调用 pipeline 的 fireChannelInactive,触发链路关闭通知事件
  5. 调用 deregister,从多路复用器上取消 SelectionKey

NioEventLoop 执行完 closeAll()操作之后,需要调用 confirmShutdown 看是否真的能够退出

image.png

  1. 取消所有的定时任务
  2. 执行 TaskQueue 中所有的 Task
  3. 执行注册到 NioEventLoop 中的 ShutdownHook
  4. 判断是否到达优雅退出的指定超时时间,如果达到或者过了超时时间,则立即退出
  5. 如果没到达指定的超时时间,暂时不退出,每隔 100 ms 检测下是否有新的任务加入,有则继续执行

在 NioEventLoop 的 run 方法中,已经调用了 runAllTasks 方法,随后在 confirmShutdown 中又再次调用了 runAllTasks 方法。

这是因为 为了防止schedule task 或者用户自定义的 task 执行过多占用了 NioEventLoop 线程的调度资源,Netty 里面有个 IO ratio ,默认是 50,表示 NioEventLoop 线程 I/O 操作和非 I/O 操作时间的比例。有了执行时间限制,因此可能会导致已经到期的定时任务、普通任务没有执行完,需要等待下次 Selector 轮询继续执行。在线程退出之前,需要对本该执行但是没有执行完成的 Task 进行扫尾处理,所以在 confirmShutdown 中再次调用了 runAllTasks 方法。

至此,Netty 的线程才正式退出。