在互联网领域,客户端和服务端之间通常需要建立和保持TCP长连接。所谓长连接,就是通信双方在建立TCP连接后进行数据通信,一次或若干次通信交互完成之后,不主动断开连接,而是保持TCP连接不释放,在随时需要通信的时候,不再需要重新建立连接。长连接可以提高通信速度、确保实时性、避免短时间内重复连接所造成的网络资源浪费,例如:即时通信,物联网等应用场景。对于服务器来说,接入和保持海量的客户端长连接,需要付出大量的服务器资源(网络、内存、CPU、文件句柄等)。由于很多客观原因(例如网络环境、客户端本身出现故障等),双方会建立一些无效的连接,既没有有效的数据通信,又不会主动关闭,称之为“死”连接。为了提高服务器资源的利用率,需要将“死”连接主动关闭释放资源,这就是心跳保活机制。所谓心跳保活,就是在通信过程中,通信双方定期给对方发送心跳包(一种特殊的数据报文),表示发送方还存活着。服务器收到客户端定期发送的心跳包之后,就认为客户端还活着,反之,如果超过规定时间内没有收到心跳包,则认为客户端已“死”,需要将TCP连接关闭。因此,服务端需要管理所有客户端连接会话,记录所有会话的超时时间,定期把超时的会话连接进行清除。
如何定期把超时的会话清除,如何将有数据通信的活跃连接的会话进行保持?
一种简单和常用的实现方法是在每次客户端连接建立的时候,就设置一个定时器和该TCP连接关联,该定时器在指定超时时间到达之后会关闭该关联的TCP连接。如果在该定时器超时时间到达之前,关联的TCP连接链路有数据通信,则重置定时器的超时时间。这种方法可以非常精确设置每一个TCP连接保活的超时时间。但是当客户端接入数量达到海量的时候,该方法会产生大量的定时器任务,对于每一次客户端连接,都要产生一个对应的定时任务,定时任务的数量等于客户端连接数,定时任务的维护将耗费比较多的计算资源。
那么,在海量的场景,我们真的需要非常精确的超时保活吗?当然实际场景对于超时,我们不需要那么精确。因此,有一种更加高效的处理会话保活的算法,叫做时间分桶算法,这种算法可以极大减少了定时任务的数量,只使用一个定时线程就可以处理,极大降低海量连接情况下的计算资源的占用。
时间分桶算法,采用批量处理和近似超时的思想,提升对于客户端超时判断的执行效率。服务器有两类线程,第一类线程是IO处理线程,处理客户端连接后的IO事件,例如连接建立、报文读取、连接关闭等事件;第二类线程是会话清理线程,定期清除超时时间桶中的批量超时会话。具体两种线程的执行过程如下:
第一类IO线程的处理逻辑:
1. 首先,将连续的时间按照固定间隔DT切割成片段,每一个片段就是一个时间桶。建立从任意时刻t到时间桶的映射关系B(t),如图1所示,B(t1)->B0, B(t2)->B1。
图1
2. 连接初始化:当客户端C新建连接的时候,计算出超时的时刻t’=t+DT(假设连接超时时间为DT),计算出B(t’)=B1,因此将连接C1的会话句柄保存在编号为B1的时间桶中,如下图所示:
图2
3. 连接保活:当收到客户端C的数据报文的时候,刷新客户端新的超时时刻t’<-now+DT,如果得到的时间桶比C原来所在的时间桶更新,即:B(t’)>B(t),则将会话C转移到B(t’)的时间桶中去。如下图所示:
图3
4. 连接删除:服务端探测到客户端C的连接发生关闭事件之后,直接从C所在时间桶B(t)中删除该会话。
第二类清理线程的处理逻辑:
1. 初始化:t为当前系统毫秒时间,to为离当前时间最近一个时间桶的超时时刻(时间桶的上界即为时间桶的整体超时时刻)to=UP(B(t))。
2. 更新系统当前时刻t<-now()。
3. 如果t<to(t所在的时间桶还未到超时时刻),则线程睡眠(to-t)毫秒,并且跳转到步骤2;
4. 否则(t所在时间桶已经到超时时刻),将B(t)中的所有会话批量删除和关闭连接。超时的时间桶中没有被转移走的会话全是超时的。
5. 更新离当前时间最近一个时间桶的的超时时刻to<-to+DT,并跳转到步骤2。
如下图所示,随着时间流逝,当前时刻达到B2的上界UP(B2)时,由于客户端C1、C7、C8没有及时转移,则清理线程会将C1、C7、C8全部清除掉。
图4
在JAVA开源领域,著名的zookeeper就是使用该算法进行海量连接心跳保活,大家感兴趣可以去阅读一下zookeeper源码。