Whoosy's Blog

藏巧于拙 用晦而明 寓清于浊 以屈为伸

0%

采坑之 TCP 长连接和 KeepAlive 心跳机制

TCP 长连接和 KeepAlive 心跳机制

[danger] “踩坑”背景:公司因为需要,计划将内部一些生产服务迁移至云主机进行长期维护。云服务商是国内某主流电信运营商旗下的虚拟化云平台,因为性价比高且在我们当地部署有云节点,成为了我们创业小团队的首选。

说实话,在使用该平台订购主机的时候,就已经感受到其易用性和整体 Web 系统的用户体验上都无法和“老马家”的虚拟化云平台相媲美。算了,这都无所谓,只要云主机和整体网络环境足够稳定,其他都无法比“省钱”来得更实在。多余的话不说了,云主机和网络环境是否足够稳定,需要我们长期观察,这里主要还是记录下由于我们在云主机环境下搭建生产环境的经验较少,导致诸多异常情况无法及时处理的经验、教训和总结。所谓“打铁还需自身硬”,各领域知识的积累是我们解决问题的关键,有时候会成为思路上的启发,进而成为领域认知的问题,比如对“TCP 长连接和 KeepAlive 心跳机制”的准确理解,是解决我们云主机部署过程中遇到问题的关键。

1. 问题:“中心数据库服务器连接异常挂起?”

架构示意图

以上图为例,我们在云服务提供的资源中构建了由 5 台云主机的组成的生产环境。其中 sijia-db 为中心数据库服务器:主机操作系统为 Ubuntu Server 18.04,数据库为 PostgreSQL 11。其他四台机器可以理解为应用服务器(连接数据库的客户端)。

异常情况表现为:abcd 中任意一台机器,正常连接数据库并执行查询或者更新等操作后,闲置一段时间(1 小时左右),再次执行数据库操作均出现“假死”或者“挂起”的情况。应用层服务接口会报出 500 OperationalError 的错误,如下图所示:

image-20200422171347347

通过 PSQL 客户库远程连接数据库并执行操作会出现如下情况:

image-20200422171347347

通过数据库服务器主机直接操作测试未出现该问题,基本可以确定是网络层面导致的异常现象。

考虑数据库客户端和服务器之间建立的有 TCP 长连接的情况,决定从 “TCP 连接维持与阻断策略”开始入手,于是长达一天的 Google 之路开启。最终原因和经验积累通过下文进行描述,阅读结束相信大家会对出现以上情况的原因有一个清晰的认识。

2. 长连接与短连接

TCP 本身并没有长短连接的区别 ,长短与否,完全取决于我们怎么用它。

  • 短连接:每次通信时,创建 Socket;一次通信结束,调用 socket.close()。这就是一般意义上的短连接,短连接的好处是管理起来比较简单,存在的连接都是可用的连接,不需要额外的控制手段。
  • 长连接:每次通信完毕后,不会关闭连接,这样可以做到连接的复用。 长连接的好处是省去了创建连接的耗时

短连接和长连接的优势,分别是对方的劣势。想要图简单,不追求高性能,使用短连接合适,这样我们就不需要操心连接状态的管理;想要追求性能,使用长连接,我们就需要担心各种问题:比如 端对端连接的维护,连接的保活

长连接还常常被用来做数据的推送,我们大多数时候对通信的认知还是 request/response 模型,但 TCP 双工通信的性质决定了它还可以被用来做双向通信。在长连接之下,可以很方便的实现 push 模型,长连接的这一特性在本文并不会进行探讨,有兴趣的同学可以专门去搜索相关的文章。

3. 长连接的维护

因为客户端请求的服务可能分布在多个服务器上,客户端自然需要跟对端创建多条长连接,我们遇到的第一个问题就是如何维护长连接。

这里插一句,解释下为什么我认为客户端的连接集合要重要一点。TCP 是一个双向通信的协议,任一方都可以是发送者,接受者,那为什么还抽象了 Client 和 Server 呢?因为 建立连接这件事就跟谈念爱一样,必须要有主动的一方,你主动我们就会有故事 。Client 可以理解为主动建立连接的一方,实际上两端的地位可以理解为是对等的。

4. 连接的保活

这个话题就有的聊了,会牵扯到比较多的知识点。首先需要明确一点,为什么需要连接的保活?当双方已经建立了连接,但因为网络问题,链路不通,这样长连接就不能使用了。需要明确的一点是,通过 netstat,lsof 等指令查看到连接的状态处于 ESTABLISHED 状态并不是一件非常靠谱的事,因为连接可能已死,但没有被系统感知到,更不用提假死这种疑难杂症了。如何保证长连接可用是一件技术活。

5. 连接的保活:KeepAlive

保活”:使 TCP 保持活动状态,保证长连接可用。

首先想到的是 TCP 中的 KeepAlive 机制。

5.1 什么是 TCP KeepAlive

KeepAlive 并不是 TCP 协议的一部分,但是大多数操作系统都实现了这个机制(所以需要在操作系统层面设置 KeepAlive 的相关参数)。

保持连接的概念非常简单:建立TCP连接时,需要关联一组计时器。其中一些计时器处理 KeepAlive 过程。当 KeepAlive 计时器达到零时,Client 端会向其对等方(Server 端)发送一个其中没有数据且 ACK 标志已打开的 KeepAlive 探测数据包。

You can do this because of the TCP/IP specifications, as a sort of duplicate ACK, and the remote endpoint will have no arguments, as TCP is a stream-oriented protocol.

另一方面,您将收到来自远程主机的答复(该主机根本不需要支持keepalive,仅支持TCP / IP),并且没有数据和ACK。

KeepAlive 是非侵入性的。在大多数情况下,客户端可以毫无顾虑的将其打开,并不用担心会有什么异常情况发生。但是请记住,它会产生额外的网络流量,这可能会对路由器和防火墙产生影响。

5.2 区分 KeepAlive 的两个目标任务

  • 检测对等端的健康状况【Checking for dead peers】

    通常,正常的 TCP 操作对于检查网络状态根本没有用。可以分别用于检查对等主机的健康状况和两者之间网络的健康状况。

    image-20200422192703936

  • 防止由于网络不活动而断开连接【Preventing disconnection due to network inactivity】

    当服务在 NAT 代理(CGN)或防火墙后面时(也可以理解为两者之间),无缘无故地断开连接是一个非常常见的问题。此行为是由 NAT 代理或防火墙中实施的连接跟踪过程引起的,该过程会跟踪通过它们的所有连接。由于这些网络设备物理硬件条件的限制,它们只能在其内存中保留有限数量的连接。其最常见和合乎逻辑的策略是保留最新的连接,并首先丢弃旧的和不活动的连接。

    image-20200422192729658

    返回刚才的例子,对等方 A 和 B,重新连接它们。通道打开后,请等待事件发生,然后将此事件传达给另一个对等方。如果一个事件要经过很长的时间才进行验证怎么办?我们的连接有其范围,但是代理不知道。因此,当我们最终发送数据时,代理无法正确处理数据,并且连接中断,导致服务报错或者挂起的情况出现。

    通常为了减小被代理删除网络连接的风险,我们最直接想到的实现方式是:在其中一个数据包到达时将连接放在列表队列(或者缓存)的顶部,并在需要消除连接条目时选择队列中的最后一个连接。因此定期通过网络发送数据包是保持连接有效并始终处于网络中的一种好方法。

5.3 操作系统设置

KeepAlive 机制开启后,在一定时间内(一般时间为 7200s,参数 tcp_keepalive_time)在链路上没有数据传送的情况下,TCP 层将发送相应的 KeepAlive 探针以确定连接可用性,探测失败后重试 10(参数 tcp_keepalive_probes)次,每次间隔时间 75s(参数 tcp_keepalive_intvl),所有探测失败后,才认为当前连接已经不可用。

Linux 操作系统中设置 KeepAlive 相关参数,修改 /etc/sysctl.conf 文件:

image-20200422171347347

5.4 不允许操作系统设置怎么办

请注意,并非所有操作系统都支持 TCP KeepAlive。 如果您无法启用Keepalive,则可能需要考虑以下其他选项:

  • 如果在您的控制范围内,请重新配置正在断开连接的防火墙或者路由器或者网关等等,以便对于PostgreSQL 客户端连接不会这样做;
  • 在应用程序级别,您可能能够发送一些使数据库句柄保持活动状态的流量,例如每个小时左右发送诸如 SELECT 1 之类的语句。

KeepAlive 机制是在网络层面保证了连接的可用性 ,但站在应用框架层面我们认为这还不够。主要体现在三个方面:

  • KeepAlive 的开关是在应用层开启的,但是具体参数(如重试测试,重试间隔时间)的设置却是操作系统级别的,位于操作系统的 /etc/sysctl.conf 配置中,这对于应用来说不够灵活。
  • KeepAlive 的保活机制只在链路空闲的情况下才会起到作用,假如此时有数据发送,且物理链路已经不通,操作系统这边的链路状态还是 ESTABLISHED,这时会发生什么?自然会走 TCP 重传机制,要知道默认的 TCP 超时重传,指数退避算法也是一个相当长的过程。
  • KeepAlive 本身是面向网络的,并不面向于应用,当连接不可用,可能是由于应用本身的系统负载过高等情况,但网络仍然是通的,此时,应用已经失去了活性,连接应该被认为是不可用的。

我们已经为应用层面的连接保活做了足够的铺垫,下面就来一起看看,怎么在应用层做连接保活。

6. 连接的保活:应用层心跳

心跳请求应当和普通请求区别对待。”

心跳 是本文想要重点强调的一个重要的知识点。上面我们已经解释过了,网络层面的 KeepAlive 不足以支撑应用级别的连接可用性,本节就来聊聊应用层的心跳机制是实现连接保活的。

如何理解应用层的心跳?简单来说,就是客户端会开启一个定时任务,定时对已经建立连接的对端应用发送请求(这里的请求是特殊的心跳请求),服务端则需要特殊处理该请求,返回响应。如果心跳持续多次没有收到响应,客户端会认为连接不可用,主动断开连接。不同的服务治理框架对心跳,建连,断连,拉黑的机制有不同的策略,但大多数的服务治理框架都会在应用层做心跳。

7. 注意和 HTTP 的 KeepAlive 区别对待

  • HTTP 协议的 KeepAlive 意图在于连接复用,同一个连接上串行方式传递请求 - 响应数据
  • TCP 的 KeepAlive 机制意图在于保活、心跳,检测连接错误。

这压根是两个概念。

8. KeepAlive 常见错误

启用 TCP KeepAlive 的应用程序,一般可以捕获到下面几种类型错误

  1. TIMEOUT 超时错误,在发送一个探测保护包经过 (tcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes) 时间后仍然没有接收到 ACK 确认情况下触发的异常,套接字被关闭。

    1
    java.io.IOException: Connection timed out
  2. HOSTUNREACH host unreachable(主机不可达) 错误,这个应该是 ICMP 汇报给上层应用的。

    1
    java.io.IOException: No route to host
  3. 链接被重置,终端可能崩溃死机重启之后,接收到来自服务器的报文,然物是人非,前朝往事,只能报以无奈重置宣告之。

    1
    java.io.IOException: Connection reset by peer

9. 总结

有三种使用 KeepAlive 的实践方案:

  1. 默认情况下使用 KeepAlive 周期为 2 个小时,如不选择更改,属于误用范畴,造成资源浪费:内核会为每一个连接都打开一个保活计时器,N 个连接会打开 N 个保活计时器。 优势很明显:
    • TCP 协议层面保活探测机制,系统内核完全替上层应用自动给做好了
    • 内核层面计时器相比上层应用,更为高效
    • 上层应用只需要处理数据收发、连接异常通知即可
    • 数据包将更为紧凑
  2. 关闭 TCP 的 KeepAlive,完全使用应用层心跳保活机制。由应用掌管心跳,更灵活可控,比如可以在应用级别设置心跳周期,适配私有协议。
  3. 业务心跳 + TCP KeepAlive 一起使用,互相作为补充,但 TCP 保活探测周期和应用的心跳周期要协调,以互补方可,不能够差距过大,否则将达不到设想的效果。

最后再放一张最终运营商针对本文提到问题的解答,截图如下:

image-20200422171347347

参考资料