不为人知的网络编程 (十二):彻底搞懂 TCP 协议层的 KeepAlive 保活机制
文中引用了参考资料中的部分内容,本文参考资料详见文末“参考资料”一节,感谢资料分享者。
1、引言
对于 IM 开发者而言,网络保活这件事再熟悉不过了,比如这是我最近一篇有关网络保活话题文章《一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等》,以及我分享的大量代码实战编码中也都必须要考虑这个问题的实现,比如最近的这篇《跟着源码学 IM(五):正确理解 IM 长连接、心跳及重连机制,并动手实现》。
对于 IM 这种应用而言,应用层的网络保活的最直接办法就是心跳机制,比如主流的 IM 里有微信、QQ、钉钉、易信等等,可能代码实现细节有所差异,但理论上无一例外都是这样实现。(PS:没错,当初微信跟运营商间的“信令危机”就是跟这个有关)
所谓的网络心跳,通常是客户端每隔一小段时间向服务器发送一个数据包(即心跳包),通知服务器自己仍然在线(心跳包中同时可能传输一些必要的数据)。发送心跳包,从通信层面来说就是为了保持长连接,至于这个包的内容,是没有什么特别规定的,但在移动端 IM 中为了省流量,一般都是很小的包(比如某些第 3 方的 IM 云为了说明心跳不费流量,号称 1 字节的心跳包)。
但经常有人会问到,既然 TCP 协议本身有 KeepAlive 保活这个东西(见:《TCP/IP 详解 卷 1 - 第 23 章·TCP 的保活定时器》),为什么还要自已在应用层去实现网络保活/心跳机制呢?
没错,通常面视即时通讯/IM 方面的程序员时,这几乎是必提问题!
要解答这个问题,我通常建议看看《为什么说基于 TCP 的移动端 IM 仍然需要心跳保活?》这篇。但限于篇幅,该篇并没有深入探讨 TCP 协议本身的 KeepAlive 机制,所以这次借本文想把 TCP 协议的 KeepAlive 保活机制给详细的整理出来,以便大家能深入其中一窥究竟。
学习交流:开源 IM 框架源码:https://github.com/JackJiang2011/MobileIMSDK
(本文已同步发布于:http://www.52im.net/thread-3506-1-1.html)
2、系列文章
本文是系列文章中的第 12 篇,本系列文章的大纲如下:
《不为人知的网络编程(一):浅析 TCP 协议中的疑难杂症(上篇)》《不为人知的网络编程(二):浅析 TCP 协议中的疑难杂症(下篇)》《不为人知的网络编程(三):关闭 TCP 连接时为什么会 TIME_WAIT、CLOSE_WAIT》《不为人知的网络编程(四):深入研究分析 TCP 的异常关闭》《不为人知的网络编程(五):UDP 的连接性和负载均衡》《不为人知的网络编程(六):深入地理解 UDP 协议并用好它》《不为人知的网络编程(七):如何让不可靠的 UDP 变的可靠?》《不为人知的网络编程(八):从数据传输层深度解密 HTTP》《不为人知的网络编程(九):理论联系实际,全方位深入理解 DNS》《不为人知的网络编程(十):深入操作系统,从内核理解网络包的接收过程(Linux 篇)》《不为人知的网络编程(十一):从底层入手,深度分析 TCP 连接耗时的秘密》《不为人知的网络编程(十二):彻底搞懂 TCP 协议层的 KeepAlive 保活机制》(* 本文)
3、TCP KeepAlive 的初衷
采用 TCP 连接的 C/S 模式应用中,当连接的双方在连接空闲状态时,如果任意一方意外崩溃、当机、网线断开或路由器故障,另一方无法得知 TCP 连接已经失效。
那么,连接的另一方并不知道对端的情况,它会一直维护这个连接。而作为“服务端”来说,长时间的积累会导致非常多的半打开连接,造成端系统资源的消耗和浪费,且有可能导致在一个无效的数据链路层面发送业务数据,结果就是发送失败。
所以各端要做到快速感知失败,减少无效链接操作,这就有了 TCP 的 KeepAlive 保活探测机制。
PS:这样宽泛的说 TCP 的 KeepAlive 机制的必要性,貌似还不是很有说服力,下节将带着具体的例子深入分析。
4、从 NAT 角度更具体地理解 TCP KeepAlive 的必要性
讲到 TCP 的 KeepAlive 的必要性,多数文章都是像上节这样比较笼统的进行说明,但对于爱刨根问底的开发者来说,这还远远不够。
本节将以路由器的 NAT 机制这个角度来具体分析 TCP 协议的造物主们设计 KeepAlive 机制的必要性。
4.1 从 NAT 原理讲起狭义上,NAT 分为 SNAT(原地址转换)和 DNAT(目标地址转换),关于 DNAT,有兴趣的同学可以自行查阅,这里只讨论 SNAT。
我们都知道,路由器的最基本功能是对第三层(网络层)上的 IP 报文进行转发。实际上,路由器还有很关键的一个功能,这便是 NAT。特别是对于 ISP 对普通用户链路上的路由器,NAT 功能尤为重要。
为什么要使用 NAT?
原因很简单:IPv4 地址非常稀缺。上网需求庞大,这使得 ISP 不可能为每一个入网用户都提供一个独立的公网 IP,因此通常情况下,ISP 会把用户接入局域网,使得多个用户共享同一个公网 IP,而每一个用户各分得一个局域网内网 IP。而连接公网和局域网的这台路由器,称之为网关(gateway),NAT 的过程就发生在这台网关路由器上。
PS:《P2P 技术详解(一):NAT 详解——详细原理、P2P 简介》这篇文章有助于更深入的理解 NAT 原理。
4.2 三层地址转换局域网内的主机向公网发出的网络层 IP 报文,将经由网关被转发至公网,而在该转发过程中发生了地址转换。网关将该 IP 报文中的 源 IP 地址 从”该主机的内网 IP”修改为”网关的公网 IP”。
比如:局域网主机获得的内网 IP 为 192.168.1.100,网关的公网 IP 为 210.177.63.2,局域网主机向公网目标主机发出的 IP 报文中,源 IP 字段数据为 192.168.1.100,在经过网关时,该字段数据将被修改为 210.177.63.2。
为什么要这么做,相信大家已经猜到了:公网上的目标主机在收到这个 IP 报文后,需要知道这个 IP 报文的来源地址,并向该来源地址发送响应报文,但如果不经过 NAT,目标主机拿到的来源地址是 192.168.1.100,这显然是一个公网上不可访问到的私有地址,目标主机无法将响应报文发送到正确的来源主机上。开启了 NAT 之后,IP 报文的来源地址被网关修改为 210.177.63.2,这是一个公网地址,目标主机将向这个地址(即网关路由器的公网地址)发送响应报文。
但是请注意:如果这个 IP 报文的数据段不含传输层协议报文,而是一个 pure 的网络层 packet,来自目标主机的响应报文是不能被网关准确转发到多台局域网主机中的其中一台的。
PS:ICMP 报文除外,其报头中有 Identifier 字段用于标识不同的主机或进程,网关在处理 Identifier 时类似于下面提到的运输层端口。
4.3 传输层端口转换表在三层地址转换中,我们可以保证局域网内主机向公网发出的 IP 报文能顺利到达目的主机,但是从目的主机返回的 IP 报文却不能准确送至指定局域网主机(我们不能让网关把 IP 报文广播至全部局域网主机,因为这样必然会带来安全和性能问题)。
为了解决这个问题,网关路由器需要借助传输层端口,通常情况下是 TCP 或 UDP 端口,由此来生成一张端口转换表。
让我们通过一个实例来说明端口转换表如何运作:
假设局域网主机 A192.168.1.100 需要与公网上的目标主机 B210.199.38.2:80 进行一次 TCP 通信。其中 A 所在局域网的网关 C 的公网 IP 地址为 210.177.63.2。
步骤如下:
1)局域网主机 A192.168.1.100 发出 TCP 连接请求,A 上的 TCP 端口为系统分配的 53600。该 TCP 握手包中,包含源地址和端口 192.168.1.100:53600,目的地址和端口 210.199.38.2:80。
2)网关 C 将该包的原地址和端口修改为 210.177.63.2:63000,其中 63000 是网关分配的临时端口。
3)网关 C 在端口转换表中增加一条记录:
4)网关 C 将修改后的 TCP 包发送至目的主机 B。
5)目的主机 B 收到后,发送响应 TCP 包。该响应 TCP 包含有以下信息:源地址和端口 210.199.38.2:80,目的地址和端口 210.177.63.2:63000。
6)网关 C 收到这个来自 B 的响应包后,随即在端口转换表中查找记录。该记录须符合以下条件:目的主机 IP==210.199.38.2,目的主机端口==80,网关端口==63000。
7)网关 C 搜索到这条记录,记录显示内网主机 IP 为 192.168.1.100,内网主机端口为 53600。
8)网关 C 将该包的目的地址和端口修改为 192.168.1.100:53600。
9)网关 C 随即将该修改后的 TCP 包转发至 192.168.1.100:53600,即局域网主机 A。此时运输层数据的一次交换已完成。
4.4 问题来了在网关 C 上,由于端口数量有限(0~65535),端口转换表的维护占用系统资源,因此不能无休止地向端口转换表中增加记录。对于过期的记录,网关需要将其删除。
如何判断哪些是过期记录?
网关认为:一段时间内无活动的连接是过期的,应定时检测转换表中的非活动连接,并将之丢弃。而这个丢弃的过程,网关不会以任何的方式通告该连接的任何一端。
通过下图可以更直观的理解这个过程:
▲ 上图引用自《TCP 保活(TCP keepalive)》
那么问题就来了:如果一个客户端应用程序由于业务需要,需要与服务端维持长连接(例如基于 TCP 的 IM 聊天应用),而如果在特别长的时间内这个连接没有任何的数据交换,网关会认为这个连接过期并将这个连接从端口转换表中丢弃。该连接被丢弃时,客户端和服务端对此是完全无感知的。在连接被丢弃后,客户端将收不到服务端的数据推送,客户端发送的数据包也不能到达服务端。
一个具体的例子来感受一下这个问题的严重性:
某财务应用,在客户端需要填写大量的表单数据,在客户端与服务器端建立 TCP 连接后,客户端终端使用者将花费几分钟甚至几十分钟填写表单相关信息,终端使用者终于填好表单所需信息后,点击“提交”按钮。
结果,这个时候由于中间设备早已经将这个 TCP 连接从连接表中删除了,其将直接丢弃这个报文或者给客户端发送 RST 报文,应用故障产生,这将导致客户端终端使用者所有的工作将需要重新来过,给使用者带来极大的不便和损失。
4.5 解决方法针对上述问题,TCP 协议这一层的解决方法就是利用 KeepAlive 机制维持长连接,让网关认为我们的 TCP 连接是活动的,从而避免网关“干掉”我们的长连接。
通过 NAT 这个具体的例子,相信你已经能更具体地理解 TCP 协议中 KeepAlive 保活机制的必要性了。
5、TCP Keepalive 工作原理
5.1 技术原理当一个 TCP 连接建立之后,启用 TCP Keepalive 的一端便会启动一个计时器,当这个计时器数值到达 0 之后(也就是经过 tcp_keep-alive_time 时间后,这个参数之后会讲到),一个 TCP 探测包便会被发出。这个 TCP 探测包是一个纯 ACK 包(RFC1122#TCP Keep-Alives 规范建议:不应该包含任何数据,但也可以包含 1 个无意义的字节,比如 0x0),其 Seq 号 与上一个包是重复的,所以其实探测保活报文不在窗口控制范围内。
如果一个给定的连接在两小时内(默认时长)没有任何的动作,则服务器就向客户发一个探测报文段,客户主机必须处于下表中的 4 个状态之一。
详细解释一下就是:
1)客户主机依然正常运行,并从服务器可达。客户的 TCP 响应正常,而服务器也知道对方是正常的,服务器在两小时后将保活定时器复位。
2)客户主机已经崩溃,并且关闭或者正在重新启动。在任何一种情况下,客户的 TCP 都没有响应。服务端将不能收到对探测的响应,并在 75 秒后超时。服务器总共发送 10 个这样的探测 ,每个间隔 75 秒。如果服务器没有收到一个响应,它就认为客户主机已经关闭并终止连接。
3)客户主机崩溃并已经重新启动。服务器将收到一个对其保活探测的响应,这个响应是一个复位,使得服务器终止这个连接。
4)客户机正常运行,但是服务器不可达,这种情况与 2 类似,TCP 能发现的就是没有收到探测的响应。
直观来说,TCP KeepAlive 的交互过程大致如下图所示:
▲ 上图引用自《TCP 保活(TCP keepalive)》
5.2 具体使用举例以 linux 内核为例,应用程序若想使用 TCP Keepalive,需要设置 SO_KEEPALIVE 套接字选项才能生效。
对应的,有三个重要的参数:
1)tcp_keepalive_time,在 TCP 保活打开的情况下,最后一次数据交换到 TCP 发送第一个保活探测包的间隔,即允许的持续空闲时长,或者说每次正常发送心跳的周期,默认值为 7200s(2h);2)tcp_keepalive_probes 在 tcp_keepalive_time 之后,没有接收到对方确认,继续发送保活探测包次数,默认值为 9(次);3)tcp_keepalive_intvl,在 tcp_keepalive_time 之后,没有接收到对方确认,继续发送保活探测包的发送频率,默认值为 75s。上面谈的是 linux 内核参数的配置,实际上其他编程语言有相应的设置方法。
例如,Java 的 Netty 服务器框架中也提供了相关接口:
ServerBootstrap b = new ServerBootstrap();
PS:Java 程序只能做到设置 SO_KEEPALIVE 选项,至于 TCP_KEEPCNT,TCP_KEEPIDLE,TCP_KEEPINTVL 等参数配置,应用层面是没法设置的。
6、TCP KeepAlive
可能导致的问题 Keepalive 技术只是 TCP 协议中的一个可选项。因为不当的配置可能会引起一些问题,所以默认是关闭的。
具体来说,可能导致下列问题:
1)在短暂的故障期间,Keepalive 设置不合理时可能会因为短暂的网络波动而断开健康的 TCP 连接;
2)需要消耗额外的宽带和流量(对于现在这个时代来说,这貌似已经不是问题了);
3)在以流量计费的互联网环境中增加了费用开销。
7、TCP KeepAlive 在移动网络时代的局限性
不可否认,TCP 协议作为 TCP/IP 协议族中最重要部分,对互联的发展确实功不可没(见:《技术往事:改变世界的 TCP/IP 协议(珍贵多图、手机慎点)》)。
但如今移动网络时代,无线通信越来越普及,作为上个世纪中期发明的 TCP 协议来说,客观的讲,在某些场景下确实有先天不足(见:《5G 时代已经到来,TCP/IP 老矣,尚能饭否?》)。
那么,又回到了本文开头的问题——“既然 TCP 协议本身有 KeepAlive,为什么还要自已在应用层实现网络保活/心跳机制?”。
以移动端 IM 应用为例:
1)一方面,运营商 ISP 的网络资源更为稀缺,TCP 协议默认 2 小时的 KeepAlive 基本不可能实现 IM 长连接“保活”(为了提升无线网络资源的利用率,运营商长则几分钟,短则数十秒就有可能回收空闲的网络连接)。2)另一面,无线网络本身存在弱网问题,即使 TCP 连接是“好的”,但实际上处于“假死”状态,也无法起到长连接该有的作用。所以说,IM 应用层自已做网络保活(心跳机制)是不可避免的。
有关这方面的更多资料,有兴趣,可以深入阅读下面这几篇:
《为何基于 TCP 协议的移动端 IM 仍然需要心跳保活机制?》
《移动端 IM 开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》
《移动端 IM 开发者必读(二):史上最全移动弱网络优化方法总结》
《IM 开发者的零基础通信技术入门(十三):为什么手机信号差?一文即懂!》
《IM 开发者的零基础通信技术入门(十四):高铁上无线上网有多难?一文即懂!》
8、知识拓展:TCP Keepalive 和 HTTP Keep-Alive 有什么区别?
很多人会把 TCP Keepalive 和 HTTP Keep-Alive 这两个概念搞混淆。
这里简单介绍下 HTTP Keep-Alive 。
在 HTTP/1.0 中,默认使用的是短连接。也就是说,浏览器和服务器每进行一次 HTTP 操作,就建立一次连接,但任务结束就中断连接。如果客户端浏览器访问的某个 HTML 或其他类型的 Web 页中包含有其他的 Web 资源,如 JavaScript 文件、图像文件、CSS 文件等;当浏览器每遇到这样一个 Web 资源,就会建立一个 HTTP 会话。
但从 HTTP/1.1 起,默认使用长连接,用以保持连接特性。使用长连接的 HTTP 协议,会在响应头加上 Connection、Keep-Alive 字段。
如下图所示:
HTTP 1.0 和 1.1 在 TCP 连接使用方面的差异如下图所示:
通俗地总结一下:
1)HTTP 的 Keep-Alive 是为了让 TCP 连接活得更久一点,在发起多个 http 请求时能复用同一个连接,提高通信效率;2)TCP 的 KeepAlive 机制意图在于探测连接的对端是否存活,是一种检测 TCP 连接状况的保鲜机制。
9、参考资料
[1] TCP 保活(TCP keepalive)
[2] TCP 协议的 KeepAlive 机制与 HeartBeat 心跳包
[3] HTTP keep-alive 和 TCP keepalive 的区别,你了解吗?
[4] TCP KeepAlive 与 HTTP Keep-Alive 区别
[5] tcp 连接探测 Keepalive 和心跳包
[6] TCP keepalive 的探究 (1) : NAT 和保活机制
[7] 理解 TCP 长连接(Keepalive)
[8] 为何基于 TCP 协议的移动端 IM 仍然需要心跳保活机制?
[9] 移动端 IM 开发者必读(二):史上最全移动弱网络优化方法总结
[10] IM 开发者的零基础通信技术入门(十三):为什么手机信号差?一文即懂!
附录:更多网络编程精华文章
[1] 网络编程(基础)资料:
《网络编程懒人入门(一):快速理解网络通信协议(上篇)》
《网络编程懒人入门(二):快速理解网络通信协议(下篇)》
《网络编程懒人入门(三):快速理解 TCP 协议一篇就够》
《网络编程懒人入门(四):快速理解 TCP 和 UDP 的差异》
《网络编程懒人入门(五):快速理解为什么说 UDP 有时比 TCP 更有优势》
《网络编程懒人入门(六):史上最通俗的集线器、交换机、路由器功能原理入门》
《网络编程懒人入门(七):深入浅出,全面理解 HTTP 协议》
《网络编程懒人入门(八):手把手教你写基于 TCP 的 Socket 长连接》
《网络编程懒人入门(九):通俗讲解,有了 IP 地址,为何还要用 MAC 地址?》
《网络编程懒人入门(十):一泡尿的时间,快速读懂 QUIC 协议》
《网络编程懒人入门(十一):一文读懂什么是 IPv6》
《网络编程懒人入门(十二):快速读懂 Http/3 协议,一篇就够!》
《脑残式网络编程入门(一):跟着动画来学 TCP 三次握手和四次挥手》
《脑残式网络编程入门(二):我们在读写 Socket 时,究竟在读写什么?》
《脑残式网络编程入门(三):HTTP 协议必知必会的一些知识》
《脑残式网络编程入门(四):快速理解 HTTP/2 的服务器推送(Server Push)》
《脑残式网络编程入门(五):每天都在用的 Ping 命令,它到底是什么?》
《脑残式网络编程入门(六):什么是公网 IP 和内网 IP?NAT 转换又是什么鬼?》
《脑残式网络编程入门(七):面视必备,史上最通俗计算机网络分层详解》
《脑残式网络编程入门(八):你真的了解 127.0.0.1 和 0.0.0.0 的区别?》
《脑残式网络编程入门(九):面试必考,史上最通俗大小端字节序详解》
《网络编程入门从未如此简单(一):假如你来设计网络,会怎么做?》
《网络编程入门从未如此简单(二):假如你来设计 TCP 协议,会怎么做?》
[2] 网络编程(高阶)资料:
《高性能网络编程(一):单台服务器并发 TCP 连接数到底可以有多少》
《高性能网络编程(二):上一个 10 年,著名的 C10K 并发连接问题》
《高性能网络编程(三):下一个 10 年,是时候考虑 C10M 并发问题了》
《高性能网络编程(四):从 C10K 到 C10M 高性能网络应用的理论探索》
《高性能网络编程(五):一文读懂高性能网络编程中的 I/O 模型》
《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》
《高性能网络编程(七):到底什么是高并发?一文即懂!》
《不为人知的网络编程(一):浅析 TCP 协议中的疑难杂症(上篇)》
《不为人知的网络编程(二):浅析 TCP 协议中的疑难杂症(下篇)》
《不为人知的网络编程(三):关闭 TCP 连接时为什么会 TIME_WAIT、CLOSE_WAIT》
《不为人知的网络编程(四):深入研究分析 TCP 的异常关闭》
《不为人知的网络编程(五):UDP 的连接性和负载均衡》
《不为人知的网络编程(六):深入地理解 UDP 协议并用好它》
《不为人知的网络编程(七):如何让不可靠的 UDP 变的可靠?》
《不为人知的网络编程(八):从数据传输层深度解密 HTTP》
《不为人知的网络编程(九):理论联系实际,全方位深入理解 DNS》
《不为人知的网络编程(十):深入操作系统,从内核理解网络包的接收过程(Linux 篇)》
《不为人知的网络编程(十一):从底层入手,深度分析 TCP 连接耗时的秘密》
《不为人知的网络编程(十二):彻底搞懂 TCP 协议层的 KeepAlive 保活机制》
《IM 开发者的零基础通信技术入门(十一):为什么 WiFi 信号差?一文即懂!》
《IM 开发者的零基础通信技术入门(十二):上网卡顿?网络掉线?一文即懂!》
《IM 开发者的零基础通信技术入门(十三):为什么手机信号差?一文即懂!》
《IM 开发者的零基础通信技术入门(十四):高铁上无线上网有多难?一文即懂!》
《IM 开发者的零基础通信技术入门(十五):理解定位技术,一篇就够》
本文同步发布链接是:http://www.52im.net/thread-3506-1-1.html
评论