前提:
本文會直接使用netty的LineBasedFrameDecoder和StringDecoder作爲例子,介紹heatbeat機制,因此讀者需要具備一定netty編程基礎。
問題由來
所謂心跳, 就在在 TCP長連接中, 客戶端和服務器之間定期發送的一種特殊數據包, 通知對方自己還在線, 以確保 TCP 連接的有效性。
因爲網絡的不可靠性, 有可能在 TCP 保持長連接的過程中, 由於某些突發情況, 例如網線被拔出, 突然掉電等, 會造成服務器和客戶端的連接中斷. 在這些突發情況下, 如果恰好服務器和客戶端之間沒有交互的話, 那麼它們是不能在短時間內發現對方已經掉線的. 爲了解決這個問題, 我們就需要引入心跳機制.
心跳機制的工作原理是: 在服務器和客戶端之間一定時間內沒有數據交互時, 即處於 idle 狀態時, 客戶端或服務器會發送一個特殊的數據包給對方, 當接收方收到這個數據報文後, 也立即發送一個特殊的數據報文, 迴應發送方, 此即一個 PING-PONG 交互. 自然地, 當某一端收到心跳消息後, 就知道了對方仍然在線, 這就確保 TCP 連接的有效性.
如何實現心跳
兩種方式實現心跳機制:
使用 TCP 協議層面的 keepalive 機制.
在應用層上實現自定義的心跳機制.
雖然在 TCP 協議層面上, 提供了 keepalive 保活機制, 但是使用它有幾個缺點:
它不是 TCP 的標準協議, 並且是默認關閉的.
TCP keepalive 機制依賴於操作系統的實現, 默認的 keepalive 心跳時間是 兩個小時, 並且對 keepalive 的修改需要系統調用(或者修改系統配置), 靈活性不夠.
TCP keepalive 與 TCP 協議綁定, 因此如果需要更換爲 UDP 協議時, keepalive 機制就失效了.
基於以上缺點, 一般實踐中, 人們大多數都是選擇在應用層上實現自定義的心跳.
解決辦法
Netty已經爲我們提供瞭如何實現心跳功能的辦法, 這就是IdleStateHandler。 在 Netty 中, 實現心跳機制的關鍵是 IdleStateHandler, 它可以對一個 Channel 的 讀/寫設置定時器, 當 Channel 在一定事件間隔內沒有數據交互時(即處於 idle 狀態), 就會觸發指定的事件.
最終實現
我的例子比較簡單,就是服務器端如果發現某個客戶端連續3次(idle時間第5秒)都沒有發送數據,就斷開連接。 也就是如果某個連接已經超過15秒都不想服務器上傳消息,服務器就認爲該客戶端異常
通常空閒時間以及異常是根據業務定義的。 我們的業務很簡單,服務器給讓客戶端執行job, 客戶端邊執行邊發送結果。如果客戶端沒有job執行,也有需要不斷向服務器發送ping,表示自己還在active狀態,這樣服務器端就修改該客戶端的狀態爲ready,一旦條件符合就選擇該客戶端執行任務。(現在的示例中,客戶端沒有檢測服務器端是否正常,只是保證自己(client端),如果沒有job日誌需要發送時,定期(也就是寫空閒時)向服務器端發送心跳)。 示例比較簡單,讀者需要根據自己的業務進行判斷和修改,但基本用法是一致。 本文示例直接使用行分隔符作爲消息解碼器,實際當中我們使用的protobuf相關的decoder和encoder。爲了示例簡單易懂,就使用最簡單的LineBasedFrameDecoder
完整代碼在這裏, 歡迎fork, 加星。 謝謝!
import com.yq.uitl.SocketUtils;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@NoArgsConstructor
public class ServerSideHandler extends SimpleChannelInboundHandler<String> {
private int idleCounter = 0;
@Override
public void channelActive(final ChannelHandlerContext ctx) {
log.info("---Connection Created from {}", ctx.channel().remoteAddress());
SocketUtils.sendHello(ctx, "server", false);
}
@Override
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
// Send the received message to all channels but the current one.
log.info("ip:{}--- msg:{}", ctx.channel().remoteAddress(), msg);
idleCounter = 0;;
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.warn("Unexpected exception from downstream.", cause);
ctx.close();
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt)
throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state().equals(IdleState.READER_IDLE)) {
log.warn("第" + idleCounter +"次沒收到客戶端信息了。ip={}", ctx.channel().remoteAddress());
if (idleCounter > 3) {
// 超時關閉channel
log.warn("已經連續三次沒收到客戶端信息了, 關閉不活躍的連接={}", ctx);
ctx.close();
} else {
idleCounter++;
}
} else if (event.state().equals(IdleState.WRITER_IDLE)) {
log.info("寫空閒");
} else if (event.state().equals(IdleState.ALL_IDLE)) {
log.info("ALL_IDLE");
// 發送心跳
ctx.channel().write("ping\n");
}
}
super.userEventTriggered(ctx, evt);
}
}
客戶端檢測寫空閒,發現自己已經8秒沒有寫消息給服務器,就發送一個ping消息到服務器。
package com.yq.client;
import com.yq.uitl.SocketUtils;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@NoArgsConstructor
public class ClientSideHandler extends SimpleChannelInboundHandler<String> {
private int idleCounter = 0;
@Override
public void channelActive(final ChannelHandlerContext ctx) {
System.out.println("connected");
log.info("---Connection Created from {}", ctx.channel().remoteAddress());
//SocketUtils.sendHello(ctx,"Client", false);
String str20 = "012345678901234567890123456789";
SocketUtils.sendLineBaseText(ctx, str20);
}
@Override
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
// Send the received message to all channels but the current one.
log.info("ip={}--- msg={}", ctx.channel().remoteAddress(), msg);
idleCounter = 0;
String str20 = "01234567890123456789";
SocketUtils.sendLineBaseText(ctx, str20);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.warn("Unexpected exception from downstream.", cause);
ctx.close();
}
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt)
throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state().equals(IdleState.READER_IDLE)) {
log.warn("5秒沒收到服務器端信息了.");
} else if (event.state().equals(IdleState.WRITER_IDLE)) {
log.warn("第" + idleCounter +"次沒向服務器端發送信息了。ip={}", ctx.channel().remoteAddress());
if (idleCounter > 1) {
log.warn("向服務器端發送一次心跳");
// 發送心跳
SocketUtils.sendLineBaseText(ctx, "ping");
idleCounter = 0;
} else {
idleCounter++;
}
} else if (event.state().equals(IdleState.ALL_IDLE)) {
log.info("ALL_IDLE");
// 發送心跳
SocketUtils.sendLineBaseText(ctx, "ping");
}
}
super.userEventTriggered(ctx, evt);
}
}
執行結果
服務器端日誌
[INFO ] 2019-09-01 12:51:30,732 [ nioEventLoopGroup-3-1:4299 ] method:com.yq.server.ServerSideHandler.channelRead0(ServerSideHandler.java:27)
ip:/192.168.1.104:60581--- msg:012345678901234567890123456789
[INFO ] 2019-09-01 12:51:30,735 [ nioEventLoopGroup-3-1:4302 ] method:com.yq.server.ServerSideHandler.channelRead0(ServerSideHandler.java:27)
ip:/192.168.1.104:60581--- msg:01234567890123456789
[WARN ] 2019-09-01 12:51:35,738 [ nioEventLoopGroup-3-1:9305 ] method:com.yq.server.ServerSideHandler.userEventTriggered(ServerSideHandler.java:43)
第0次沒收到客戶端信息了。ip=/192.168.1.104:60581
[WARN ] 2019-09-01 12:51:40,737 [ nioEventLoopGroup-3-1:14304 ] method:com.yq.server.ServerSideHandler.userEventTriggered(ServerSideHandler.java:43)
第1次沒收到客戶端信息了。ip=/192.168.1.104:60581
[WARN ] 2019-09-01 12:51:45,740 [ nioEventLoopGroup-3-1:19307 ] method:com.yq.server.ServerSideHandler.userEventTriggered(ServerSideHandler.java:43)
第2次沒收到客戶端信息了。ip=/192.168.1.104:60581
[INFO ] 2019-09-01 12:51:45,741 [ nioEventLoopGroup-3-1:19308 ] method:com.yq.server.ServerSideHandler.channelRead0(ServerSideHandler.java:27)
ip:/192.168.1.104:60581--- msg:ping
[WARN ] 2019-09-01 12:51:50,742 [ nioEventLoopGroup-3-1:24309 ] method:com.yq.server.ServerSideHandler.userEventTriggered(ServerSideHandler.java:43)
第0次沒收到客戶端信息了。ip=/192.168.1.104:60581
[WARN ] 2019-09-01 12:51:55,742 [ nioEventLoopGroup-3-1:29309 ] method:com.yq.server.ServerSideHandler.userEventTriggered(ServerSideHandler.java:43)
第1次沒收到客戶端信息了。ip=/192.168.1.104:60581
[WARN ] 2019-09-01 12:52:00,743 [ nioEventLoopGroup-3-1:34310 ] method:com.yq.server.ServerSideHandler.userEventTriggered(ServerSideHandler.java:43)
第2次沒收到客戶端信息了。ip=/192.168.1.104:60581
[INFO ] 2019-09-01 12:52:00,744 [ nioEventLoopGroup-3-1:34311 ] method:com.yq.server.ServerSideHandler.channelRead0(ServerSideHandler.java:27)
ip:/192.168.1.104:60581--- msg:ping
[WARN ] 2019-09-01 12:52:05,745 [ nioEventLoopGroup-3-1:39312 ] method:com.yq.server.ServerSideHandler.userEventTriggered(ServerSideHandler.java:43)
第0次沒收到客戶端信息了。ip=/192.168.1.104:60581
客戶端日誌
[INFO ] 2019-09-01 12:51:30,734 [ nioEventLoopGroup-2-1:1335 ] method:com.yq.client.ClientSideHandler.channelRead0(ClientSideHandler.java:31)
ip=/192.168.1.104:5566--- msg=HELLO from HeatBeatDemo server
[INFO ] 2019-09-01 12:51:30,734 [ nioEventLoopGroup-2-1:1335 ] method:io.netty.util.internal.logging.Slf4JLogger.info(Slf4JLogger.java:101)
[id: 0xe8f2e2a1, L:/192.168.1.104:60581 - R:/192.168.1.104:5566] WRITE: 22B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 30 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 |0123456789012345|
|00000010| 36 37 38 39 0d 0a |6789.. |
+--------+-------------------------------------------------+----------------+
[INFO ] 2019-09-01 12:51:30,735 [ nioEventLoopGroup-2-1:1336 ] method:io.netty.util.internal.logging.Slf4JLogger.info(Slf4JLogger.java:101)
[id: 0xe8f2e2a1, L:/192.168.1.104:60581 - R:/192.168.1.104:5566] FLUSH
[INFO ] 2019-09-01 12:51:30,735 [ nioEventLoopGroup-2-1:1336 ] method:io.netty.util.internal.logging.Slf4JLogger.info(Slf4JLogger.java:101)
[id: 0xe8f2e2a1, L:/192.168.1.104:60581 - R:/192.168.1.104:5566] READ COMPLETE
[WARN ] 2019-09-01 12:51:35,737 [ nioEventLoopGroup-2-1:6338 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:52)
第0次沒向服務器端發送信息了。ip=/192.168.1.104:5566
[WARN ] 2019-09-01 12:51:40,736 [ nioEventLoopGroup-2-1:11337 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:52)
第1次沒向服務器端發送信息了。ip=/192.168.1.104:5566
[WARN ] 2019-09-01 12:51:45,736 [ nioEventLoopGroup-2-1:16337 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:52)
第2次沒向服務器端發送信息了。ip=/192.168.1.104:5566
[WARN ] 2019-09-01 12:51:45,737 [ nioEventLoopGroup-2-1:16338 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:54)
向服務器端發送一次心跳
[INFO ] 2019-09-01 12:51:45,737 [ nioEventLoopGroup-2-1:16338 ] method:io.netty.util.internal.logging.Slf4JLogger.info(Slf4JLogger.java:101)
[id: 0xe8f2e2a1, L:/192.168.1.104:60581 - R:/192.168.1.104:5566] WRITE: 6B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 70 69 6e 67 0d 0a |ping.. |
+--------+-------------------------------------------------+----------------+
[INFO ] 2019-09-01 12:51:45,738 [ nioEventLoopGroup-2-1:16339 ] method:io.netty.util.internal.logging.Slf4JLogger.info(Slf4JLogger.java:101)
[id: 0xe8f2e2a1, L:/192.168.1.104:60581 - R:/192.168.1.104:5566] FLUSH
[WARN ] 2019-09-01 12:51:50,739 [ nioEventLoopGroup-2-1:21340 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:52)
第0次沒向服務器端發送信息了。ip=/192.168.1.104:5566
[WARN ] 2019-09-01 12:51:55,739 [ nioEventLoopGroup-2-1:26340 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:52)
第1次沒向服務器端發送信息了。ip=/192.168.1.104:5566
[WARN ] 2019-09-01 12:52:00,740 [ nioEventLoopGroup-2-1:31341 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:52)
第2次沒向服務器端發送信息了。ip=/192.168.1.104:5566
[WARN ] 2019-09-01 12:52:00,741 [ nioEventLoopGroup-2-1:31342 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:54)
向服務器端發送一次心跳
[INFO ] 2019-09-01 12:52:00,742 [ nioEventLoopGroup-2-1:31343 ] method:io.netty.util.internal.logging.Slf4JLogger.info(Slf4JLogger.java:101)
[id: 0xe8f2e2a1, L:/192.168.1.104:60581 - R:/192.168.1.104:5566] WRITE: 6B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 70 69 6e 67 0d 0a |ping.. |
+--------+-------------------------------------------------+----------------+
[INFO ] 2019-09-01 12:52:00,742 [ nioEventLoopGroup-2-1:31343 ] method:io.netty.util.internal.logging.Slf4JLogger.info(Slf4JLogger.java:101)
[id: 0xe8f2e2a1, L:/192.168.1.104:60581 - R:/192.168.1.104:5566] FLUSH
[WARN ] 2019-09-01 12:52:05,743 [ nioEventLoopGroup-2-1:36344 ] method:com.yq.client.ClientSideHandler.userEventTriggered(ClientSideHandler.java:52)
第0次沒向服務器端發送信息了。ip=/192.168.1.104:5566
如果我們使用telnet連接服務器,會發現因爲我們在telent中有超過15秒不發消息,telent被自動中斷