持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情
为什么要有心跳机制
日常工作中TCP长连接在一些优秀的中间件或开源项目中得到大量的使用。 比如zookeeper的订阅和监听、日常使用的各种数据库连接池、redis连接池、常用的RPC框架dubbo等等。 使用TCP长连接的优势在于: 1、有效避免频繁的三次握手、四次挥手开销; 2、避免TCP滑动窗口冷启动的低效问题 3、能极大的提升网络通信的效率。 缺点也比较明显,当客户端因为断电、网线被拔除等原因突然断开时,服务端没办法及时知道客户端已经断线,不能及时回收socket占用的系统资源。
为了解决TCP长连接的缺点,基本上所有使用了TCP长连接的优秀开源项目都会自定义一套心跳保持和心跳消息发送机制,保证在客户端异常断开时服务端能及时的收到通知。 而netty通过IdleStateHandler处理器,能极为方便的通过参数配置来实现心跳处理策略。
Tcp的长连接
首先,我们来看看netty关于长连接的使用:
//服务端
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.childOption(ChannelOption.SO_KEEPALIVE, true);
//客户端
Bootstrap bootstrap = new Bootstrap();
bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
最终我们进入的java.net.Socket.setKeepAlive
public void setKeepAlive(boolean on) throws SocketException {
if (isClosed())
throw new SocketException("Socket is closed");
getImpl().setOption(SocketOptions.SO_KEEPALIVE, Boolean.valueOf(on));
}
心跳机制
心跳机制
心跳是在TCP长连接中,客户端和服务端定时向对方发送数据包通知对方自己还在线,保证连接的有效性的一种机制
在服务器和客户端之间一定时间内没有数据交互时, 即处于 idle 状态时, 客户端或服务器会发送一个特殊的数据包给对方, 当接收方收到这个数据报文后, 也立即发送一个特殊的数据报文, 回应发送方, 此即一个 PING-PONG 交互. 自然地, 当某一端收到心跳消息后, 就知道了对方仍然在线, 这就确保 TCP 连接的有效性.
心跳实现
使用TCP协议层的Keeplive机制,但是该机制默认的心跳时间是2小时,依赖操作系统实现不够灵活;
应用层实现自定义心跳机制,比如Netty实现心跳机制
常见的心跳模式
1. 双向
客户端定时向服务端发送约定的心跳包,服务端收到客户端的心跳包后回复一个心跳响应包
如: Netty中websocket中的PingWebSocketFrame(ping消息),PongWebSocketFrame(pong消息)
2. 单向
客户端定时向服务端发送心跳包,服务端不回复心跳响应包,但检测到一定时间内没有收到客户端的心跳包时断开与客户端的连接(当客户端有几十万时,服务端光回复心跳就会占用大量资源)
我们看看jdk关于SocketOptions.SO_KEEPALIVE的注释: 注释大意:当2个小时没有发生数据交换时,TCP会发送一个探针给对方,如果收到的是ACK标记的应答,则连接保持,否则关闭连接。
Netty中的心跳机制
Netty 提供了 IdleStateHandler ,ReadTimeoutHandler,WriteTimeoutHandler 检测连接的有效性。我们也可以自己写个任务。
ReadTimeout 事件和 WriteTimeout 事件都会自动关闭连接,而且属于异常处理,所以我们重点看 IdleStateHandler
当连接的空闲时间(读或者写)太长时,将会触发一个 IdleStateEvent 事件。然后,你可以通过你的 ChannelInboundHandler 中重写 userEventTrigged 方法来处理该事件。
如何使用呢?
IdleStateHandler 既是出站处理器也是入站处理器,继承了 ChannelDuplexHandler 。通常在 initChannel 方法中将 IdleStateHandler 添加到 pipeline 中。然后在自己的 handler 中重写 userEventTriggered 方法,当发生空闲事件(读或者写),就会触发这个方法并传入具体事件。
这时,你可以通过 Context 对象尝试向目标 Socekt 写入数据,并设置一个 监听器,如果发送失败就关闭 Socket (Netty 准备了一个 ChannelFutureListener.CLOSE_ON_FAILURE 监听器用来实现关闭 Socket 逻辑)。这样,就实现了一个简单的心跳服务。
IdleStateHandler的类继承关系
Netty中IdleStateHandler源码分析-初始化
initOutputChanged方法(初始化 “监控出站数据属性”)这个 observeOutput “监控出站数据属性” 的作用。本来是没有这个参数的。为什么需要呢?
假设:当你的客户端应用每次接收数据是30秒,而你的写空闲时间是 25 秒,那么,当你数据还没有写出的时候,写空闲时间触发了。实际上是不合乎逻辑的。因为你的应用根本不空闲。
怎么解决呢?
Netty 的解决方案是:记录最后一次输出消息的相关信息,并使用一个值 firstXXXXIdleEvent 表示是否再次活动过,每次读写活动都会将对应的 first 值更新为 true,如果是 false,说明这段时间没有发生过读写事件。同时如果第一次记录出站的相关数据和第二次得到的出站相关数据不同,则说明数据在缓慢的出站,就不用触发空闲事件。
总的来说,这个字段就是用来对付 “客户端接收数据奇慢无比,慢到比空闲时间还多” 的极端情况。所以Netty 默认是关闭这个字段的。
读事件的 run 方法
该方法处理逻辑:
得到用户设置的超时时间。
如果读取操作结束了(执行了 channelReadComplete 方法设置) ,就用当前时间减去给定时间和最后一次读操作的时间,如果小于0,就触发事件。反之,继续放入队列。间隔时间是新的计算时间。
触发的逻辑是:首先将任务再次放到队列,时间是刚开始设置的时间,返回一个 promise 对象,用于做取消操作。然后,设置 first 属性为 false ,表示,下一次读取不再是第一次了,这个属性在 channelRead 方法会被改成 true。
创建一个 IdleStateEvent 类型的写事件对象,将此对象传递给用户的 UserEventTriggered 方法。完成触发事件的操作。
总的来说,每次读取操作都会记录一个时间,定时任务时间到了,会计算当前时间和最后一次读的时间的间隔,如果间隔超过了设置的时间就触发userEventTriggered 方法。
读取数据超时时间计算
写事件的 run 方法
所有事件的 run 方法
IdleStateHandler使用注意事项
1. IdleStateHandler在ChannelPipeline中的顺序
将IdleStateHandler放在入站的开头,并且将重写userEventTriggered这个方法的handler必须在其后面。否则当出现超时事件时,将无法传递到处理超时事件的handler中,因为IdleStateHandler只会将超时事件传递给它的next handler处理。
当没有自定义的handler时,userEventTriggered事件将按以下顺序被TailContext 处理掉
DefaultChannelPipeline-->TailContext -->userEventTriggered() -->onUnhandledInboundUserEventTriggered()-->ReferenceCountUtil.release(evt)
2. 读写事件在ChannelPipeline中的传递
3. handler链的传播顺序
ChannelHandlerContext.fireChannelRead() 是从调用当前节点往下传播
ChannelHandlerContext.pipeline().fireChannelRead()是从HeadContext开始传播
4. 有了ChannelHandler为何还需要ChannelHandlerContext
个人理解是为了使ChannelHandler的作用更单纯、更单一、更简单。如果没有ChannelHandlerContext则ChannelHandler中需要维护prev、next、isInbound、isOutbound以及与Channle及pipeline的关系,使ChannelHandler类变得复杂