优雅地停止服务,也就是要保证把没有处理完的工作处理完成。比如停止一些依赖的服务,输出一些日志,发一些信号给其他的应用系统等,这个在保证系统的高可用中是非常有必要的。
尤其像长连接网关这种,管理了大量的TCP 连接,绝不能直接暴力关闭,必须要保证已有的任务全都处理完,并且在关闭的过程中不会有新的请求进来。
本文以开源项目SONA为例,详细解析网关服务优雅的原理和实现,帮助读者更好理解和实践。本文最后附上开源项目地址。
前言
Sona 平台是一个搭建语音房产品的全端解决方案,包含了房间管理、实时音视频、房间IM、长连接网关等能力。其中最基础核心的就是长连接网关。
我们很多时候都需要安全的将服务停止,也就是把没有处理完的工作继续处理完成。比如停止一些依赖的服务,输出一些日志,发一些信号给其他的应用系统等,这个在保证系统的高可用中是非常有必要的。
尤其像 长连接网关这种,管理了大量的TCP 连接,绝不能直接暴力关闭,必须要保证已有的任务全都处理完,并且在关闭的过程中不会有新的请求进来。
一、优雅关闭
服务的优雅关闭通常都是利用 JDK 里面提供的 Runtime.addShutDownHook(Thread hook)方法,JVM 提供一种 ShutdownHook(钩子)机制,当 JVM 接受到系统的关闭通知之后,会调用 ShutdownHook 内的方法,用以完成清理操作,从而平滑的退出应用。
- 程序正常退出
- 使用System.exit()
- 终端使用Ctrl+C触发的中断
- 系统关闭
- OutofMemory宕机
- 使用Kill pid杀死进程(使用kill -9是不会被调用的)
像Spring 、Dubbo 等都是基于这个实现的优雅关闭
二、Spring 优雅关闭
Spring 框架本身也依赖于 shutdown hook 执行优雅停机,通过调用 AbstractApplicationContext
的 registerShutdownHook 方法
在 doClose 方法里面,主要做了如下几件事:
- 发布 ContextClosedEvent 事件(Spring 里面默认使用同步方式执行事件发布)
- 执行
LifecycleProcessor
的 onClose 方法 (很多 spring 相关的jar里面会基于LifecycleProcessor 在容器关闭时做一些清理工作,比如 Kafka Listener) - 销毁所有的 bean
因为在Mercury 业务处理的 Handler 里面用到了 spring bean (主要是RocketMQ相关的) ,所以关闭连接、清理Netty 资源的时机一定要在 Spring 容器关闭之前才可以。
从上面 doClose 的流程可以看出,只要在 destroyBeans 之前执行 Netty 资源的关闭即可。
Spring 提供了 ApplicationListener
接口,开发者可以实现这个接口监听到 Spring 容器的 ContextClosedEvent 关闭事件。我选择的就是这种方式。因为没有修改Spring中默认的事件发布器SimpleApplicationEventMulticaster
,会同步的执行 onApplicationEvent 方法,这样就保证了在关闭Netty 相关资源之后才会去销毁 bean。
三、Netty 优雅关闭
Netty 相关资源优雅关闭的主要流程如下:
- 关闭 server channel
- server发送重连消息
- server主动close 未断开的连接
- 等待所有 channel 关闭
- 调用 Netty 的 shutdownGracefully
1.关闭 server channel
服务端关闭 NioServerSocketChannel,取消端口绑定,关闭服务
直接调用 channel.close() ;
2.发送重连消息
当 客户端收到 server 下发的 reconnect 消息之后,就会断开当前连接,然后重新建立连接。
长连接网关单台机器上会有数万连接,这里不会一次性给所有连接下发 reconnect 消息,不然可能会导致这数万客户端同时发起重新建连,会造成其他的网关机器 CPU使用率突增。所以这里最好做平滑处理。
我们每次只会同时给指定数量的连接下发 reconnect 消息 ,然后 等待几秒后再接着下发。
3.主动close 未断开的连接
虽然Server 下发了 重连消息,但有时候可能因为各种网络原因,客户端并没有收到,或者客户端收到了,但是由于某些原因没有断开连接,也就不会重新建立连接。
这时我们就需要在服务端主动 close 掉,这里也同样做了平滑处理,每次只close 指定数量的channel 。
后面就一直不停的检测是否还存在有效连接,如果有的话等待 250 ms 再重新检测,不过检测时间最多 3秒
4.Netty 的 shutdownGracefully
Netty 自身提供了优雅退出的方式,那就是 EventExecutorGroup 的 shutdownGracefully() 方法
NioEventLoopGroup 实际是 NioEventLoop 的线程组,它的优雅退出比较简单,直接遍历 EventLoop 数组,循环调用它们的 shutdownGracefully 方法。
这里再简单介绍下 netty shutdownGracefully 的实现原理:
最终调用的是 SingleThreadEventExecutor
里面的 shutdownGracefully
这里贴一下里面的核心代码
这段代码考虑了多线程同时调用关闭的情况,使用 自旋 + 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 中进行
在 NioEventLoop 里面最重要的就是 run 方法, 里面一直在不停的循环 select 、处理 IO 事件和 task。
在每次循环的最后,都会去 check 一下 线程的状态,如果是 ST_SHUTTING_DOWN ,就会执行 closeAll 方法
主要做的事是 把注册在 selector 上的所有 Channel 都关闭,循环调用 Channel Unsafe 的 close 方法,但是有些 Channel 正在发送消息,暂时还不能关,需要稍后再执行。
- 判断当前该链路是否有消息正在发送,如果有则将关闭操作封装成 Task 放到 eventLoop 中稍后再执行
- 将发送队列清空,不再允许发送新的消息
- 调用 SocketChannel 的 close 方法,关闭链路
- 调用 pipeline 的 fireChannelInactive,触发链路关闭通知事件
- 调用 deregister,从多路复用器上取消 SelectionKey
NioEventLoop 执行完 closeAll()操作之后,需要调用 confirmShutdown 看是否真的能够退出
- 取消所有的定时任务
- 执行 TaskQueue 中所有的 Task
- 执行注册到 NioEventLoop 中的 ShutdownHook
- 判断是否到达优雅退出的指定超时时间,如果达到或者过了超时时间,则立即退出
- 如果没到达指定的超时时间,暂时不退出,每隔 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 的线程才正式退出。
总结
本文详细介绍了SONA长连接网关中是如何实现服务优雅关闭的,在后续的系列文章中会对网关中的其他技术细节进行详细的介绍。
目前sona已经在比心的github仓库上开源,仓库地址:
欢迎你访问我们的项目,有任何想交流的想法可以留言联系我们。
往期阅读:
从0到1快速了解netty长连接网关协议_聊天室程序猿的博客-优快云博客
详解netty长连接网关请求处理模型_聊天室程序猿的博客-优快云博客