Netty 中的心跳检测机制

时间:2024-01-11 21:59:14

心跳检测一般存在于建立长连接 或者 需要保活的场景。

心跳的使用场景

长连接的应用场景非常的广泛,比如监控系统,IM系统,即时报价系统,推送服务等等。像这些场景都是比较注重实时性,如果每次发送数据都要进行一次DNS解析,建立连接的过程肯定是极其影响体验。

而长连接的维护必然需要一套机制来控制。比如 HTTP/1.0 通过在 header 头中添加 Connection:Keep-Alive参数,如果当前请求需要保活则添加该参数作为标识,否则服务端就不会保持该连接的状态,发送完数据之后就关闭连接。HTTP/1.1以后 Keep-Alive 是默认打开的。

Netty 是 基于 TCP 协议开发的,在四层协议 TCP 协议的实现中也提供了 keepalive 报文用来探测对端是否可用。TCP 层将在定时时间到后发送相应的 KeepAlive 探针以确定连接可用性。

tcp-keepalive,操作系统内核支持,但是不默认开启,应用需要自行开启,开启之后有三个参数会生效,来决定一个 keepalive 的行为。

net.ipv4.tcp_keepalive_time = 7200
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_intvl = 75

tcp_keepalive_time: 在 TCP 保活打开的情况下,最后一次数据交换到 TCP 发送第一个保活探测包的间隔,即允许的持续空闲时长,或者说每次正常发送心跳的周期,默认值为7200s(2h);

tcp_keepalive_probes: 在 tcp_keepalive_time 之后,没有接收到对方确认,继续发送保活探测包次数,默认值为9(次);

tcp_keepalive_intvl:在 tcp_keepalive_time 之后,没有接收到对方确认,继续发送保活探测包的发送频率,默认值为75s。

TCP KeepAlive 是用于检测连接的死活,而心跳机制则附带一个额外的功能:检测通讯双方的存活状态。两者听起来似乎是一个意思,但实际上却大相径庭。

考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态一直向当前服务器发送些必然会失败的请求。

可以通过如下命令查看系统tcp-keepalive参数配置:

sysctl -a | grep keepalive

cat /proc/sys/net/ipv4/tcp_keepalive_time

sysctl net.ipv4.tcp_keepalive_time

Netty 中也提供了设置 tcp-keepalive 的设置:

Netty 中的心跳检测机制

设置:ChannelOption.SO_KEEPALIVE, true 表示打开 TCP 的 keepAlive 设置。

所以基础协议对应用来说不是那么尽善尽美,一个 Netty 服务端可能会面临上万个连接,如何去维护这些连接是应用应该去处理的事情。在 Netty 中提供了 IdleStateHandler 类专门用于处理心跳。

IdleStateHandler 的构造函数如下:

public IdleStateHandler(long readerIdleTime, long writerIdleTime,
long allIdleTime,TimeUnit unit){ }

第一个参数是隔多久检查一下读事件是否发生,如果 channelRead() 方法超过 readerIdleTime 时间未被调用则会触发超时事件调用 userEventTrigger() 方法;

第二个参数是隔多久检查一下写事件是否发生,writerIdleTime 写空闲超时时间设定,如果 write() 方法超过 writerIdleTime 时间未被调用则会触发超时事件调用 userEventTrigger() 方法;

第三个参数是全能型参数,隔多久检查读写事件;

第四个参数表示当前的时间单位。

所以这里可以分别控制读,写,读写超时的时间,单位为秒,如果是0表示不检测,所以如果全是0,则相当于没添加这个 IdleStateHandler,连接是个普通的短连接。

Netty 中的心跳逻辑

下面演示一下在 Netty 中如果使用 IdleStateHandler, 整体代码流程请见 :

gitHub

先上代码:

Server端:

package com.rickiyang.learn.keepAlive;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.extern.slf4j.Slf4j; /**
* @author: rickiyang
* @date: 2020/3/15
* @description: server 端
*/
@Slf4j
public class KpServer { private int port; public KpServer(int port) {
this.port = port;
} public void start(){
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workGroup = new NioEventLoopGroup(); ServerBootstrap server = new ServerBootstrap().group(bossGroup,workGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ServerChannelInitializer()); try {
ChannelFuture future = server.bind(port).sync();
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("server start fail",e);
}finally {
bossGroup.shutdownGracefully();
workGroup.shutdownGracefully();
}
} public static void main(String[] args) {
KpServer server = new KpServer(7788);
server.start();
}
}

Initializer:

package com.rickiyang.learn.keepAlive;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.timeout.IdleStateHandler; import java.util.concurrent.TimeUnit; /**
* @author: rickiyang
* @date: 2020/3/15
* @description:
*/
public class ServerChannelInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline(); pipeline.addLast(new IdleStateHandler(5, 0, 0, TimeUnit.SECONDS)); // 字符串解码 和 编码
pipeline.addLast("decoder", new StringDecoder());
pipeline.addLast("encoder", new StringEncoder()); // 自己的逻辑Handler
pipeline.addLast("handler", new KpServerHandler());
}
}

Handler:

package com.rickiyang.learn.keepAlive;

import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j; /**
* @author: rickiyang
* @date: 2020/3/15
* @description:
*/
@Slf4j
public class KpServerHandler extends SimpleChannelInboundHandler { @Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("server channelActive");
} @Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
String message = (String) msg;
if ("heartbeat".equals(message)) {
log.info(ctx.channel().remoteAddress() + "===>server: " + message);
ctx.write("heartbeat");
ctx.flush();
}
} /**
* 如果5s没有读请求,则向客户端发送心跳
* @param ctx
* @param evt
* @throws Exception
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (IdleState.READER_IDLE.equals((event.state()))) {
ctx.writeAndFlush("heartbeat").addListener(ChannelFutureListener.CLOSE_ON_FAILURE) ;
}
}
super.userEventTriggered(ctx, evt);
} @Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
super.exceptionCaught(ctx, cause);
ctx.close();
} }

客户端代码:

Client:

package com.rickiyang.learn.keepAlive;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import lombok.extern.slf4j.Slf4j; /**
* @author: rickiyang
* @date: 2020/3/15
* @description:
*/
@Slf4j
public class KpClient { private int port;
private String address; public KpClient(int port, String address) {
this.port = port;
this.address = address;
} public void start(){
EventLoopGroup group = new NioEventLoopGroup(); Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ClientChannelInitializer());
try {
ChannelFuture future = bootstrap.connect(address,port).sync();
future.channel().writeAndFlush("Hello world, i'm online");
future.channel().closeFuture().sync();
} catch (Exception e) {
log.error("client start fail",e);
}finally {
group.shutdownGracefully();
} } public static void main(String[] args) {
KpClient client = new KpClient(7788,"127.0.0.1");
client.start();
}
}

Initializer:

package com.rickiyang.learn.keepAlive;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.timeout.IdleStateHandler; import java.util.concurrent.TimeUnit; public class ClientChannelInitializer extends ChannelInitializer<SocketChannel> { @Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline(); pipeline.addLast(new IdleStateHandler(0, 4, 0, TimeUnit.SECONDS));
pipeline.addLast("decoder", new StringDecoder());
pipeline.addLast("encoder", new StringEncoder()); // 客户端的逻辑
pipeline.addLast("handler", new KpClientHandler());
}
}

Handler:

package com.rickiyang.learn.keepAlive;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.util.CharsetUtil;
import lombok.extern.slf4j.Slf4j; /**
* @author: rickiyang
* @date: 2020/3/15
* @description:
*/
@Slf4j
public class KpClientHandler extends SimpleChannelInboundHandler { /** 客户端请求的心跳命令 */
private static final ByteBuf HEARTBEAT_SEQUENCE =
Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("heartbeat", CharsetUtil.UTF_8)); @Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
String message = (String)msg;
if("heartbeat".equals(message)) {
log.info(ctx.channel().remoteAddress() + "===>client: " + msg);
}
} /**
* 如果4s没有收到写请求,则向服务端发送心跳请求
* @param ctx
* @param evt
* @throws Exception
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if(evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if(IdleState.WRITER_IDLE.equals(event.state())) {
ctx.writeAndFlush(HEARTBEAT_SEQUENCE.duplicate()).addListener(ChannelFutureListener.CLOSE_ON_FAILURE) ;
}
}
super.userEventTriggered(ctx, evt);
} @Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.info("client channelActive");
ctx.fireChannelActive();
} @Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("Client is close");
} }

解释一下代码的逻辑:

服务端添加了:

pipeline.addLast(new IdleStateHandler(5, 0, 0, TimeUnit.SECONDS));

每隔5s检查一下是否有读事件发生,如果没有就处罚 handler 中的 userEventTriggered(ChannelHandlerContext ctx, Object evt)逻辑。

客户端添加了:

new IdleStateHandler(0, 4, 0, TimeUnit.SECONDS)

每隔4s检查一下是否有写事件,如果没有就触发 handler 中的 userEventTriggered(ChannelHandlerContext ctx, Object evt)逻辑。

大家可以再本地启动工程,看一下触发的逻辑。

IdleStateHandler逻辑分析

心跳检测也是一种 Handler,在启动时添加到 ChannelPipeline 管道中,当有读写操作时消息在其中传递。首先我们看到 IdleStateHandler 继承了 ChannelDuplexHandler:

public class IdleStateHandler extends ChannelDuplexHandler {

  ...
}

表明 IdleStateHandler 也可以同时处理入站和出站事件,所以可以同时监控读事件和写事件。

IdleStateHandler 的 channelActive() 方法在 socket 通道建立时被触发:

@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
initialize(ctx);
super.channelActive(ctx);
}

其中 channelActive() 方法调用 Initialize() 方法,根据配置的 readerIdleTime、writeIdleTIme 等超时事件参数往任务队列 taskQueue 中添加定时任务 task:

private void initialize(ChannelHandlerContext ctx) {
// Avoid the case where destroy() is called before scheduling timeouts.
// See: https://github.com/netty/netty/issues/143
//这里判断状态,避免重复初始化
switch (state) {
case 1:
case 2:
return;
} state = 1; EventExecutor loop = ctx.executor();
//初始化最后一次读写时间
lastReadTime = lastWriteTime = System.nanoTime();
// 根据用户设置的读空闲时间启动一个定时任务,读空闲时间为频率执行
// 这里的 schedule 方法会调用 eventLoop 的 schedule 方法,将定时任务添加进队列中
if (readerIdleTimeNanos > 0) {
readerIdleTimeout = loop.schedule(
new ReaderIdleTimeoutTask(ctx),
readerIdleTimeNanos, TimeUnit.NANOSECONDS);
}
// 根据用户设置的写空闲时间启动一个定时任务,写空闲时间为频率执行
if (writerIdleTimeNanos > 0) {
writerIdleTimeout = loop.schedule(
new WriterIdleTimeoutTask(ctx),
writerIdleTimeNanos, TimeUnit.NANOSECONDS);
}
// 根据用户设置的读写空闲时间启动一个定时任务,读写空闲时间为频率执行
if (allIdleTimeNanos > 0) {
allIdleTimeout = loop.schedule(
new AllIdleTimeoutTask(ctx),
allIdleTimeNanos, TimeUnit.NANOSECONDS);
}
}

看到这里或者没看这里你也应该能想到,这种监控性的任务肯定是使用定时任务类似这种机制来进行。

上面有一个 state 字段:

private byte state;
0:初始状态,1:已经初始化, 2: 已经销毁。

上面的 switch 判断只有当前状态为 0 即初始化状态的时候才执行下面的操作,避免多次提交定时任务。

定时任务添加到对应线程 EventLoopExecutor 对应的任务队列 taskQueue 中,在对应线程的 run() 方法中循环执行:

  • 用当前时间减去最后一次 channelRead 方法调用的时间判断是否空闲超时;
  • 如果空闲超时则创建空闲超时事件并传递到 channelPipeline 中。

只要给定的参数大于0,就创建一个定时任务,每个事件都创建。同时,将 state 状态设置为 1,防止重复初始化。

读事件处理:ReaderIdleTimeoutTask

来看读事件是如何处理的, ReaderIdleTimeoutTask:

private final class ReaderIdleTimeoutTask implements Runnable {

  private final ChannelHandlerContext ctx;

  ReaderIdleTimeoutTask(ChannelHandlerContext ctx) {
this.ctx = ctx;
} @Override
public void run() {
if (!ctx.channel().isOpen()) {
return;
}
// nextDelay = 当前时间-最后一次时间
long nextDelay = readerIdleTimeNanos;
if (!reading) {
nextDelay -= System.nanoTime() - lastReadTime;
} if (nextDelay <= 0) {
// 重新定义readerIdleTimeout schedule,与initialize方法设置的相同,继续执行定时任务
readerIdleTimeout =
ctx.executor().schedule(this, readerIdleTimeNanos, TimeUnit.NANOSECONDS);
try {
// event = new IdleStateEvent(IdleState.READER_IDLE, true),将event设置为读空闲
IdleStateEvent event = newIdleStateEvent(IdleState.READER_IDLE, firstReaderIdleEvent);
if (firstReaderIdleEvent) {
firstReaderIdleEvent = false;
}
//channelIdle的主要工作就是将evt传输给下一个Handler
channelIdle(ctx, event);
} catch (Throwable t) {
ctx.fireExceptionCaught(t);
}
} else {
// 如果nextDelay>0,则说明客户端在规定时间内已经写入数据了
// 重新定义readerIdleTimeout schedule,以nextDelay为执行频率
readerIdleTimeout = ctx.executor().schedule(this, nextDelay, TimeUnit.NANOSECONDS);
}
}
}

nextDelay的初始化值为超时秒数readerIdleTimeNanos,如果检测的时候没有正在读,就计算多久没读了:

nextDelay = nextDelay - 当前时间 - 上次读取时间

如果小于0,说明左边的 readerIdleTimeNanos 小于空闲时间(当前时间 - 上次读取时间),表示已经超时,

创建 IdleStateEvent 事件,IdleState 枚举值为 READER_IDLE,然后调用 channelIdle(ctx, event) 方法分发给下一个 ChannelInboundHandler。

总的来说,每次读取操作都会记录一个时间,定时任务时间到了,会计算当前时间和最后一次读的时间的间隔,如果间隔超过了设置的时间,就触发 UserEventTriggered() 方法。

写事件处理:WriterIdleTimeoutTask

写事件,WriterIdleTimeoutTask:

private final class WriterIdleTimeoutTask implements Runnable {

  private final ChannelHandlerContext ctx;

  WriterIdleTimeoutTask(ChannelHandlerContext ctx) {
this.ctx = ctx;
} @Override
public void run() {
if (!ctx.channel().isOpen()) {
return;
} long lastWriteTime = IdleStateHandler.this.lastWriteTime;
long nextDelay = writerIdleTimeNanos - (System.nanoTime() - lastWriteTime);
if (nextDelay <= 0) {
// Writer is idle - set a new timeout and notify the callback.
writerIdleTimeout = ctx.executor().schedule(
this, writerIdleTimeNanos, TimeUnit.NANOSECONDS);
try {
IdleStateEvent event = newIdleStateEvent(IdleState.WRITER_IDLE, firstWriterIdleEvent);
if (firstWriterIdleEvent) {
firstWriterIdleEvent = false;
} channelIdle(ctx, event);
} catch (Throwable t) {
ctx.fireExceptionCaught(t);
}
} else {
// Write occurred before the timeout - set a new timeout with shorter delay.
writerIdleTimeout = ctx.executor().schedule(this, nextDelay, TimeUnit.NANOSECONDS);
}
}
}

写超时时间:

nextDelay = writerIdleTimeNanos - (System.nanoTime() - lastWriteTime)

写超时也是跟读超时同理,每次写操作都记录操作时间。

IdleStateHandler 心跳检测主要是通过向线程任务队列中添加定时任务,判断 channelRead() 方法或 write() 方法是否调用空闲超时,如果超时则触发超时事件执行自定义 userEventTrigger() 方法。

Netty 通过 IdleStateHandler 实现最常见的心跳机制不是一种双向心跳的 PING-PONG 模式,而是客户端发送心跳数据包,服务端接收心跳但不回复,因为如果服务端同时有上千个连接,心跳的回复需要消耗大量网络资源。

如果服务端一段时间内一直收到客户端的心跳数据包则认为客户端已经下线,将通道关闭避免资源的浪费。在这种心跳模式下服务端可以感知客户端的存活情况,无论是宕机的正常下线还是网络问题的非正常下线,服务端都能感知到,而客户端不能感知到服务端的非正常下线。

要想实现客户端感知服务端的存活情况,需要进行双向的心跳;Netty 中的 channelInactive() 方法是通过 Socket 连接关闭时挥手数据包触发的,因此可以通过 channelInactive() 方法感知正常的下线情况,但是因为网络异常等非正常下线则无法感知。上面的示例只做了客户端和服务端双向心跳测试,大家可以补充一下如果一段时间内都收到的是客户端的心跳包则判定连接无效关闭连接的逻辑。