Netty-14-心跳与空闲检测

连接假死现象: 在某一端(服务端或者客户端)看来底层TCP连接已经断开,但是应用程序并没有捕获到,因此认为这条连接仍然是存在的,从TCP层面来说,只有收到四次握手数据包或者一个RST数据包,连接的状态表示已断开.

引发的问题: 对于服务端来说,因为每条连接都耗费CPU和内存资源,大量假死的连接逐渐耗光服务器的资源,最终导致性能逐渐下降,程序奔溃;对于客户端来说,连接假死造成发送数据超时,影响用户体验.

出现假死的原因可能一以下几种:

  • 应用程序出现线程堵塞,无法进行数据读写
  • 客户端或者服务端网络相关设备出现故障,比如网卡,机房故障
  • 公网丢包,公网环境相对内网而言容易出现丢包、网络抖动等现象,如果在一段时间内用户接入网络连续出现丢包现象,则对客户端来说数据一直发送不出去,服务端接收不到客户端的数据,连接一直耗着.

下面来看一下如何解决这个问题

服务端空闲检测

空闲检测就是服务端每隔一段时间,就去检测这段时间内是否有数据读写,简单来说,就是检测一下有没有收到客户端发过来的数据

使用 Netty 自带的 IdleStateHandler 就可以实现这个功能,下面看下具体如何实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Slf4j
public class IMIdleStateHandler extends IdleStateHandler {

private static final int READER_IDLE_TIME = 15;

public IMIdleStateHandler(){
super(READER_IDLE_TIME, 0, 0, TimeUnit.SECONDS);
}

@Override
protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception {
log.info("{}秒内未读到数据,连接关闭", READER_IDLE_TIME);
ctx.channel().close();
}

}

新建一个类,然后继承IdleStateHandler类, 构造函数调用父类,这里传入了4个参数:

  • 第一个: 读空闲时间,在这段时间内如果没有数据读到,就表示连接假死
  • 第二个: 写空闲时间,在这段时间内如果没有写数据,就表示连接假死
  • 第三个: 读写空闲时间,在这段时间内如果没有产生数据读或写,就表示连接假死
  • 第四个: 时间单位

我们上面的代码就表示,15秒内如果没有读到数据,就表示连接假死
连接假死之后会回调下面的channelIdle(),这里就是打印数据,然后关闭channel

最后把这个handler添加到服务端的第一个

1
2
3
4
5
6
7
8
9
10
11
12
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) {
log.info("取出childAttr属性:{}", ch.attr(CommonConfig.CLIENT_KEY).get());

ch.pipeline()
.addLast(new IMIdleStateHandler())
.addLast(new Spliter())
.addLast(PacketCodecHandler.INSTANCE)
// ....
}
});

这里放到第一个是因为,如果这个handler在后面,那前面的handler处理数据完毕,或者出错了就不会再往后传递了,那我们的IMIdleStateHandler最终就收不到数据了,就会误判

客户端发送心跳

上面我们实现了服务端的空闲检测,就是每隔一段时间读取channel里面的数据,读不到就是连接假死

这里有个问题就是,客户端在这段时间确实是没有发送数据,但是客户端是正常状态,并没有假死,那这种情况就需要客户端每隔一段时间去给服务端发一个心跳数据包

这里也是新建一个handler,给服务端发送心跳的数据包,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class HeartBeatTimerHandler extends ChannelInboundHandlerAdapter {

private static final int HEARTBEAT_INTERVAL = 5;

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

private void scheduleSendHeartBeat(ChannelHandlerContext ctx){
ctx.executor().schedule(() -> {
if (ctx.channel().isActive()) {
ctx.writeAndFlush(new HeartBeatRequestPacket());
scheduleSendHeartBeat(ctx);
}
}, HEARTBEAT_INTERVAL, TimeUnit.SECONDS);
}
}

这里也就是一个定时任务,隔五秒给服务端发送一个心跳数据包,通常空闲检测时间要比发送心跳时间的两倍要长一些,排除偶发的公网抖动防止误判

服务端回复心跳与客户端空闲检测

上面的操作做完之后是解决了服务端的空闲检测问题,服务端这个时候是能够在一定时间段之内关掉假死的连接,释放连接的资源了,但是对于客户端来说,我们也需要检测到假死的连接

方法和服务端是一样的,也是在客户端pipeline的最前面,加入IMIdleStateHandler

1
2
3
4
5
6
7
8
9
10
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline()
.addLast(new IMIdleStateHandler())
.addLast(new Spliter())
// ...
;
}
});

然后服务端也需要定时的给客户端发送心跳数据, 然后服务端再添加一个HeartBeatRequestHandler,放在AuthHandler前面,因为这个是可以不用登录的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) {
log.info("取出childAttr属性:{}", ch.attr(CommonConfig.CLIENT_KEY).get());

ch.pipeline()
.addLast(new IMIdleStateHandler())
.addLast(new Spliter())
.addLast(PacketCodecHandler.INSTANCE)
.addLast(LoginRequestHandler.INSTANCE)
.addLast(HeartBeatRequestHandler.INSTANCE)
.addLast(AuthHandler.INSTANCE)
.addLast(IMHandler.INSTANCE);
}
});

HeartBeatRequestHandler的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@ChannelHandler.Sharable
public class HeartBeatRequestHandler extends SimpleChannelInboundHandler<HeartBeatRequestPacket> {
public static final HeartBeatRequestHandler INSTANCE = new HeartBeatRequestHandler();

private HeartBeatRequestHandler() {

}

@Override
protected void channelRead0(ChannelHandlerContext ctx, HeartBeatRequestPacket requestPacket) {
ctx.writeAndFlush(new HeartBeatResponsePacket());
}
}

这里就是简单的回复一个响应的数据包

完整代码已上传github,传送门