简单介绍
在长连接网关中,如何判断一个连接是否正常,是一个比较头疼的问题。连接的双方在连接空闲状态时,如果任意一方意外崩溃、宕机、网线断开或路由器故障,另一方无法得知TCP连接已经失效,除非继续在此连接上发送数据导致错误返回。很多时候,这不是我们需要的。我们希望服务器端和客户端都能够及时有效地检测到连接失效,然后优雅地完成一些清理工作。
如何及时有效地检测到一方的非正常断开,有两种技术可以运用。一种是由TCP协议层实现的Keepalive,另一种是由应用层自己实现的心跳包。
TCP Keepalive
TCP默认并不开启Keepalive功能,因为开启Keepalive功能需要消耗额外的宽带和流量,尽管这微不足道,但在按流量计费的环境下增加了费用,另一方面,Keepalive设置不合理时可能会因为短暂的网络波动而断开健康的TCP连接。并且,默认的Keepalive超时需要7200 秒,即2小时,探测次数为5次。
对于实用的程序来说,2小时的空闲时间太长。因此,我们需要手工开启Keepalive功能,设置SO_KEEPALIVE选项并设置相关参数,就可开启tcp协议的心跳机制
如果是基于 Netty 开发的话,使用如下方式即可
TCP Keepalive 虽然使用起来很方便,但是实际项目中一般都不会依靠它,而是业务心跳 + TCP KeepAlive 一起使用,互相作为补充。主要是因为:
-
KeepAlive 的开关是在应用层开启的,但是具体参数(如重试测试,重试间隔时间)的设置却是操作系统级别的,位于操作系统的
/etc/sysctl.conf
配置中,这对于应用来说不够灵活。 -
KeepAlive 的保活机制只在链路空闲的情况下才会起到作用,假如此时有数据发送,且物理链路已经不通,操作系统这边的链路状态还是 ESTABLISHED,这时会发生什么?自然会走 TCP 重传机制,要知道默认的 TCP 超时重传,指数退避算法也是一个相当长的过程。
-
KeepAlive 本身是面向网络的,并不是面向于应用的,当连接不可用时,可能是由于应用本身 GC 问题,系统 load 高等情况,但网络仍然是通的,此时,应用已经失去了活性,所以连接自然应该认为是不可用的。
应用层实现的心跳机制
如何理解应用层的心跳?简单来说,就是客户端会开启一个定时任务,定时对已经建立连接的对端应用发送请求(这里的请求是特殊的心跳请求),服务端则需要特殊处理该请求,返回响应。如果心跳持续多次没有收到响应,客户端会认为连接不可用,主动断开连接。不同的服务治理框架对心跳,建连,断连,拉黑的机制有不同的策略,但大多数的服务治理框架都会在应用层做心跳,比如说Dubbo 。
除了定时任务的设计,还需要在协议层面支持心跳。心跳请求应当和普通请求区别对待,如果将心跳请求识别为正常流量,会造成服务端的压力问题,干扰限流等诸多问题。
dubbo 协议如下:
在 Mercury 协议里面是通过 Flag 去区别是否 心跳包
正常消息Flag 是 245 ,而 心跳包是 0
IdleStateHandler
Netty 对空闲连接的检测提供了天然的支持,使用 IdleStateHandler
可以很方便的实现空闲检测逻辑。
-
readerIdleTime:读超时时间
-
writerIdleTime:写超时时间
-
allIdleTime:所有类型的超时时间
IdleStateHandler
这个类会根据设置的超时参数,循环检测 channelRead 和 write 方法多久没有被调用。当在 pipeline 中加入 IdleStateHandler
之后,可以在此 pipeline 的任意 Handler 的 userEventTriggered
方法之中检测 IdleStateEvent
事件
IdleStateHandler 内部使用了 eventLoop.schedule(task) 的方式来实现定时任务,使用 eventLoop 线程的好处是还同时保证了线程安全**。**
Dubbo 就是用的这种方式,在NettyServer
中添加 IdleStateHandler
当在指定时间内没有收到 心跳包,服务端就会 close 连接
RocketMQ 里面也是类似的
利用 IdleStateHandler
实现心跳机制可以说是十分优雅的,借助 Netty 提供的空闲检测机制,开发并不需要做太多工作。像 Dubbo、RocketMQ 里面也都是采用的这种方式
心跳检测
其实刚开始 Mercury 也是基于 IdleStateHandler
来实现的,但是后面我重新设计了方案。主要原因是当初压测的时候,单机连接数达到10w 以上之后,qps 一直上不去,CPU占用率很高。
上面也说过IdleStateHandler 内部使用了 eventLoop.schedule(task) 的方式来实现定时任务,这个操作是由 workerGroup 中的 eventloop 去执行的。当定时任务不多的时候,倒也没什么,毕竟这个任务执行是很快的,但是连接达到10w 这个数量级 就会出现问题了,大量的心跳检测任务会影响到正常 IO 事件处理,导致CPU 居高不下。
新的方案是使用 Netty 中的 HashedWheelTimer
去做心跳超时检测
-
每个channel 在执行操作的时候,都会记录对应的 readTimestamp 和 writeTimestamp,比如 receive 的时候 更新 readTimestamp ,send 的时候更新 writeTimestamp
-
HashedWheelTimer
会以指定的间隔去执行HeartbeatTimerTask
-
HeartbeatTimerTask 里面会拿到所有维护的channel,依次比较当前时间戳和channel 的readTimestamp、writeTimestamp,如果有任意一个差值超过了配置的心跳超时时间,就 close 掉当前 channel
其实大体逻辑和 IdleStateHandler 里面的做法类似,区别主要是:
IdleStateHandler 也是一个 ChannelHandler ,并且是有状态的,没有加上@io.netty.channel.ChannelHandler.Sharable
注解,不能被共享,所以每个 channel 都需要创建单独的定时任务。
如果有 10w 个连接,就会有 10w 个这样的定时任务 交给 workGroup 里面的 eventloop 去处理。而采用上面这种方式,不管有 10w 个还是 20w 个连接,永远都只会存在一个 HeartbeatTimerTask ,并不会因为连接数的增加而增加。
HashedWheelTimer
内部维护了一个单独的线程,所以不会影响到 eventloop 的执行,也就不会影响到正常的 IO 事件处理。
改造之后,qps 和 CPU占用率都有明显的改善,单机能支撑的连接数也提高了好几万。
这种方式也有个缺点,那就是 心跳检测的精确度没有 使用IdleStateHandler 高,不过问题不大。心跳超时时间其实不太好设置,太长的话无法及时清理无效连接,太短的话导致客户端经常重连 。业界一般的心跳超时时间都在 分钟级别以上,像 云信 就是 4分30秒,微信好像也是,Mercury 目前配置的时间也是这个。定时检测的间隔在三四十秒左右的话,在线上观察之后发现其实并没有多大误差,而且Mercury 还支持 主动消息探测。
如果想在单机达到数万乃至数十万连接的情况下,追求更高的 qps,推荐使用上述方式,否则可以直接基于Netty 的IdleStateHandler
去实现心跳检测,简单方便。
还是那句话,符合自己需要的才是最好的。
消息探测
为了能够更加及时的发现无效连接,Mercury 还做了主动消息探测。
客户端会将 App 是 前台还是后台的状态上报给 Mercury 网关,服务端维护的NettyChannel 会记录下这个状态。
处于前台状态的 channel,如果130秒内都没有读到过新数据,也没有收到任何心跳包,服务端就会主动下发 探测消息。这时客户端需要在4秒内给服务端返回一个响应消息,否则会 close 掉这个连接。
这里也涉及到了定时检测,为了避免在eventloop 里面做这些操作,依然还是使用上面说的 HashedWheelTimer
来实现,并且使用的是同一个时间轮。