关于长连接服务器和客户端之间要加入心跳的一些讨论

时间:2022-02-12 00:57:41

在之前的章节里深入浅出TCPIP之深入浅出TCPIP之TCP重传机制 我们都知道了TCPIP协议栈有个默认的TCP心跳机制,这个心跳机制是和socket绑定的,可以对指定的套接字开启协议栈的心跳检测机制。默认情况下,协议栈的心跳机制对socket套接字是关闭的,如果要使用需要人为开启的。

比如在windows中,默认每隔2个小时发一次心跳包,客户端程序将心跳包发给服务器后,接下来会有两种情况:

1)网络正常时:服务器收到心跳包,会立即回复ACK包,客户端收到ACK包后,再等2个小时发送下一个心跳包。其中,心跳包发送时间间隔时间keepalivetime,Windows系统中默认是2小时,可配置。如果在2个小时的时间间隔内,客户端和服务器有数据交互,客户端会收到服务器的ACK包,也算作心跳机制的心跳包,2个小时的时间间隔会重新计时。

2)网络异常时:服务器收不到客户端发过去的心跳包,没法回复ACK,Windows系统中默认的是1秒超时,1秒后会重发心跳包。如果还收不到心跳包的ACK,则1秒后重发心跳包,如果始终收不到心跳包,则在发出10个心跳包就达到了系统的上限,就认为网络出故障了,协议栈就会直接将连接断开了。其中,发出心跳包收不到ACK的超时时间称为keepaliveinterval,Windows系统中默认是1秒,可配置;收不到心跳包对应的ACK包的重发次数probe,Windows系统是固定的,是固定的10次,不可配置的。

所以TCPIP协议栈的心跳机制也能检测出网络异常,不过在默认配置下可能需要很久才能检测出来,除非网络异常出现在正在发送心跳包后等待对端的回应时,这种情况下如果多次重发心跳包都收不到ACK回应,协议栈就会判断网络出故障,主动将连接关闭掉。

关于物理层断开TCP的一些疑问

实际上,TCP 连接在 Linux 内核中是一个名为 struct socket 的结构体,该结构体的内容包含 TCP 连接的状态等信息。当拔掉网线的时候,操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变。

所以当你通过拔掉网线或者异常断电关机的方式来模拟物理层断开TCP的场景,此时查看 TCP 连接的状态没有发生变化,可能还是处于 ESTABLISHED 状态。

因此如果没有数据传输,不能被TCP感知到,TCP连接状态依旧保持;

如果有数据传输,重传次数超过一定值(Linux下默认15)或超过一定的超时时间则关掉本端的TCP连接,对端的TCP连接依旧保持;

因此这个时候我们就要借助心跳包了,如果你设置了socket的SO_KEEPALIVE选项,则如果没有数据发送时,会发送心跳包,如果网不通,则收不到对心跳包的应答,则关闭本端,对端仍旧保持连接;如果本段阻塞等待键盘输入,则依旧发动心跳包,而且即使联网着,由于键盘输入优先级更高,不可被中断,所以如果键盘长时间不输 入keepalive心跳包发不出去依旧会关闭本端连接。

一端关闭连接时会4次挥手,如果网通,则对端收到4次挥手后也会关闭连接, 如果网不通,则对端连接依旧保持。

如果是kill 或kill -9,则系统会释放TCP连接;

如果直接关闭电脑或程序崩溃或电脑宕机,则不会释放连接。也就是说

本段连接关闭,对端依旧存在。

那么在实际开发中,我们需要处理下面两种情形中遇到的问题:

情形一: 一个TCP客户端和服务器建立建立连接之后,可能触发某个场景case被防火墙程序关闭连接,但我们并不想要被关闭连接,此时一旦客户端或者服务器有新消息来时,另外一端就再也没法收到了,这就违背了“即时通讯”的设计要求。这种场景下就要求必须保持客户端与服务器之间的连接正常,就是我们通常所说的“保活“。如上文所述,当服务器与客户端一定时间内没有有效业务数据来往时,我们只需要给对端发送心跳包即可实现保活。

情形二:通常情况下,服务器与某个客户端一般不是位于同一个网络,其之间可能经过数个路由器和交换机,如果其中某个必经路由器或者交换器出现了故障,并且一段时间内没有恢复,导致这之间的链路不再畅通,而此时服务器与客户端之间也没有数据进行交换,由于 TCP 连接是状态机,对于这种情况,无论是客户端或者服务器都无法感知与对方的连接是否正常,这类连接我们一般称之为“死链”。只要我们此时任意一端给对端发送一个数据包即可检测链路是否正常,这类数据包我们也称之为”心跳包”,这种操作我们称之为“心跳检测”。顾名思义,如果一个人没有心跳了,可能已经死亡了;一个连接长时间没有正常数据来往,也没有心跳包来往,就可以认为这个连接已经不存在,为了节约服务器连接资源,我们可以通过关闭 socket,回收连接资源。

因此根据上面的分析,让我再强调一下,心跳检测一般有两个作用:

保活

检测死链

TCP keepalive 选项

操作系统的 TCP/IP 协议栈其实提供了这个的功能,即 keepalive 选项。在 Linux 操作系统中,我们可以通过代码启用一个 socket 的心跳检测(即每隔一定时间间隔发送一个心跳检测包给对端),代码如下:

//on 是 1 表示打开 keepalive 选项,为 0 表示关闭,0 是默认值
int on = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));

但是,即使开启了这个选项,这个选项默认发送心跳检测数据包的时间间隔是 7200 秒(2 小时),这时间间隔实在是太长了,不具有实用性。

当然,我们可以通过继续设置 keepalive 相关的三个选项来改变这个时间间隔,它们分别是 TCP_KEEPIDLE、TCP_KEEPINTVL 和 TCP_KEEPCNT,示例代码如下:

//发送 keepalive 报文的时间间隔
int val = 7200;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &val, sizeof(val));

//两次重试报文的时间间隔
int interval = 75;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));

int cnt = 9;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));

TCP_KEEPIDLE 选项设置了发送 keepalive 报文的时间间隔,发送时如果对端回复 ACK。则本端 TCP 协议栈认为该连接依然存活,继续等 7200 秒后再发送 keepalive 报文;如果对端回复 RESET,说明对端进程已经重启,本端的应用程序应该关闭该连接。如果对端没有任何回复,则本端做重试,如果重试 9 次(TCP_KEEPCNT 值)(前后重试间隔为 75 秒(TCP_KEEPINTVL 值))仍然不可达,则向应用程序返回 ETIMEOUT(无任何应答)或 EHOST 错误信息。

TCP的Keepalive使用的是SO_KEEPALIVE参数,它对应三个参数,通过setsocketopt()可以配置socket的这个属性,客户端和服务端都可以设置,两者是独立的,互不依赖,但在一方设置就可以了。

TCP的检测是应用层感知不到的,是操作系统在TCP层完成的。

TCP Keepalive在实际中用得并不多,因为它只能检测到TCP层,无法检测应用层,像应用层因阻塞或负载高导致的服务不可用,它是检测不出来的。另外,我们很多时候需要在应用层实现心跳检测,

它可以是客户端发起,也可以是服务端发起,比如Zookeeper是由客户端发起的,阿里的Tengine是由服务端发起的,数据库连接池都是客户端发起的。我目前已知的的服务或客户端中,只有Redis服务端

可以在配置文件中配置tcp_keepalive,即使用TCP层的心跳检测,默认是关闭的。

我们可以使用如下命令查看 Linux 系统上的上述三个值的设置情况:

[root]# sysctl -a | grep keepalive
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200

在 Windows 系统设置 keepalive 及对应选项的代码略有不同:

//开启 keepalive 选项
const char on = 1;
setsockopt(socket, SOL_SOCKET, SO_KEEPALIVE, (char *)&on, sizeof(on);

// 设置超时详细信息
DWORD cbBytesReturned;
tcp_keepalive klive;
// 启用保活
klive.onoff = 1;
klive.keepalivetime = 7200;
// 重试间隔为10秒
klive.keepaliveinterval = 1000 * 10;
WSAIoctl(socket, SIO_KEEPALIVE_VALS, &klive, sizeof(tcp_keepalive), NULL, 0, &cbBytesReturned, NULL, NULL);

应用层的心跳包机制设计

由于 keepalive 选项需要为每个连接中的 socket 开启,由于这不一定是必须的,可能会产生大量无意义的带宽浪费,且 keepalive 选项不能与应用层很好地交互,因此一般实际的服务开发中,还是建议读者在应用层设计自己的心跳包机制。那么如何设计呢?

从技术来讲,心跳包其实就是一个预先规定好格式的数据包,在程序中启动一个定时器,定时发送即可,这是最简单的实现思路。但是,如果通信的两端有频繁的数据来往,此时到了下一个发心跳包的时间点了,此时发送一个心跳包。这其实是一个流量的浪费,既然通信双方不断有正常的业务数据包来往,这些数据包本身就可以起到保活作用,为什么还要浪费流量去发送这些心跳包呢?所以,对于用于保活的心跳包,我们最佳做法是,设置一个上次包时间,每次收数据和发数据时,都更新一下这个包时间,而心跳检测计时器每次检测时,将这个包时间与当前系统时间做一个对比,如果时间间隔大于允许的最大时间间隔(实际开发中根据需求设置成 15 ~ 45 秒不等),则发送一次心跳包。总而言之,就是在与对端之间,没有数据来往达到一定时间间隔时才发送一次心跳包。

同理,发送心跳包的一端,应该是在与对端没有数据来往达到一定时间间隔时才做一次心跳检测。检测心跳包的伪码:

void GameServer::onNetData(Net_Event* net_event) { 
//更新当前客户端网络连接的心跳时间
updateCurrentTick(net_event->id, time(NULL));
//TODO 处理当前网络事件Net_Event的相关协议
//....
}
//游戏服务器的定时帧 100ms一次
void GameServer::onFrameTick() {
//1. 处理过了心跳超时时间的连接, 2.需要再次发送心跳的则发一次心跳包
procHeartBeat();
//TODO 处理其他定时业务逻辑
//....
}

void GameServer::procHeartBeat() {
auto now = time(NULL);
foreach(auto net in net_evs) {
if(net->lastTickTime_ + 30 >= now){
netManager_->delete_packet(net->id);
}
}
}

需要注意的是:一般是客户端主动给服务器端发送心跳包,服务器端做心跳检测决定是否断开连接。而不是反过来,从客户端的角度来说,客户端为了让自己得到服务器端的正常服务有必要主动和服务器保持连接状态正常,而服务器端不会局限于某个特定的客户端,如果客户端不能主动和其保持连接,那么就会主动回收与该客户端的连接。当然,服务器端在收到客户端的心跳包时应该给客户端一个心跳应答。

带业务数据的心跳包

上面介绍的心跳包是从纯技术的角度来说的,在实际应用中,有时候我们需要定时或者不定时从服务器端更新一些数据,我们可以把这类数据放在心跳包中,定时或者不定时更新。

这类带业务数据的心跳包,就不再是纯粹技术上的作用了(这里说的技术的作用指的上文中介绍的心跳包起保活和检测死链作用)。

这类心跳包实现也很容易,即在心跳包数据结构里面加上需要的业务字段信息,然后在定时器中定时发送,客户端发给服务器,服务器在应答心跳包中填上约定的业务数据信息即可。

心跳包与流量

通常情况下,多数应用场景下,与服务器端保持连接的多个客户端中,同一时间段活跃用户(这里指的是与服务器有频繁数据来往的客户端)一般不会太多。当连接数较多时,进出服务器程序的数据包通常都是心跳包(为了保活)。所以为了减轻网络代码压力,节省流量,尤其是针对一些 3/4 G 手机应用,我们在设计心跳包数据格式时应该尽量减小心跳包的数据大小。

心跳包与调试

如前文所述,对于心跳包,服务器端的逻辑一般是在一定时间间隔内没有收到客户端心跳包时会主动断开连接。在我们开发调试程序过程中,我们可能需要将程序通过断点中断下来,这个过程可能是几秒到几十秒不等。等程序恢复执行时,连接可能因为心跳检测逻辑已经被断开。

调试过程中,我们更多的关注的是业务数据处理的逻辑是否正确,不想被一堆无意义的心跳包数据干扰实线。

鉴于以上两点原因,我们一般在调试模式下关闭或者禁用心跳包检测机制。代码大致如下:

ChatSession::ChatSession(const std::shared_ptr<TcpConnection>& conn, int sessionid) :
TcpSession(conn),
m_id(sessionid),
m_seq(0),
m_isLogin(false)
{
m_userinfo.userid = 0;
m_lastPackageTime = time(NULL);

//这里设置了非调试模式下才开启心跳包检测功能
#ifndef _DEBUG
EnableHearbeatCheck();
#endif
}