赞
踩
概述
本文是根据有赞中间件团队多年的TCP网络编程实践经验总结而来,目的是为了避免应用因各种网络异常而出现各种非预期行为,从而造成非预期的影响,影响系统稳定性与可靠性。
本文不会涉及TCP的各个基础知识点,主要是总结一些TCP网络编程实践中可能碰到的一些问题,以及相应的经过实践验证的解决方案等。虽然本文档很多细节主要是针对于Linux系统,不过,大部分建议适合于所有系统。
本文共总结了16项建议,下面逐一进行介绍。
1. 服务端监听设置SO_REUSEADDR选项
当我们重启服务端程序的时候可能会碰到“address already in use”这样的报错信息,即地址已被使用,导致程序无法快速成功重启。老的进程关闭退出了,为什么还会报地址已被使用呢?
我们先来理解如下两点:
我们先简单回顾一下TCP连接关闭过程中的TIME_WAIT状态,如下所示:
(图片来源:Wikipedia)
TIME_WAIT存在的意义主要有两点:
因为每个TCP报文最大存活时间为MSL,一个往返最大是2*MSL,所以TIME_WAIT需要等待2MSL。
当进程关闭时,进程会发起连接的主动关闭,连接最后会进入TIME_WAIT状态。当新进程bind监听端口时,就会报错,因为有对应本地端口的连接还处于TIME_WAIT状态。
实际上,只有当新的TCP连接和老的TCP连接四元组完全一致,且老的迷走的报文序号落在新连接的接收窗口内时,才会造成干扰。为了使用TIME_WAIT状态的端口,现在大部分系统的实现都做了相关改进与扩展:
因此,在开启了TCP timestamps扩展选项的情况下(net.ipv4.tcp_timestamps = 1),可以放心的设置SO_REUSEADDR选项,支持程序快速重启。
注意不要与net.ipv4.tcp_tw_reuse系统参数混淆,该参数仅在客户端调用connect创建连接时才生效,可以使用TIME_WAIT状态超过1秒的端口(防止最后一个ACK丢失);而SO_REUSEADDR是在bind端口时生效,一般用于服务端监听时,可以使用本地非LISTEN状态的端口(另一个端口也必须设置SO_REUSEADDR),不仅仅是TIME_WAIT状态端口。
2. 建立并遵守应用监听端口规范
每个应用、每个通信协议要有固定统一的监听端口,便于在公司内部形成共识,降低协作成本,提升运维效率。如对于一些网络ACL控制,规范统一的端口会给运维带来极大的便利。
应用监听端口不能在
net.ipv4.ip_local_port_range区间内,这个区间是操作系统用于本地端口号自动分配的(bind或connect时没有指定端口号),Linux系统默认值为[32768, 60999]。现在一个应用服务器实例(无论是VM还是K8S Pod等),本地不仅仅会包含应用进程自身,还可能会包括监控采集、sidecar代理等进程。如果选了
net.ipv4.ip_local_port_range这个范围内的端口作为监听端口,你的应用进程启动前,对应的端口很可能已经被自动分配给其他进程的TCP连接,就会导致监听端口绑定失败,从而导致进程启动失败;当然,如果已经分配的端口设置了SO_REUSEADDR也不会导致你的应用监听端口绑定失败,但这些临时端口一般都不会设置SO_REUSEADDR。如果确实有需求监听
net.ipv4.ip_local_port_range区间内的端口(如保留三方系统的默认端口),可以设置
net.ipv4.ip_local_reserved_ports系统参数进行预留,预留的端口不会被自动分配出去;但这样会给运维增加系统的交付难度,所以,一般不建议这样做。
有赞的
net.ipv4.ip_local_port_range系统值设置为[9000, 65535],并且对所有类型的应用、通信协议监听端口都进行了统一规范,监听端口都小于9000。
3. 应用服务端口与管理端口分离
服务端口即业务请求的处理端口,管理端口为框架或应用的管理请求处理端口(如服务注册上线、下线)。以Spring Boot为例,应用端口对应server.port,管理端口对应management.port。
应用的服务端口与管理端口分离有如下意义:
有赞线上曾经碰到过一个问题:一个Dubbo业务应用提供HTTP服务和Dubbo服务,HTTP服务端口与HTTP管理端口是同一个,该应用的一个实例因内部逻辑问题发生了死锁,造成请求阻塞超时,但这时服务注册的健康保活线程仍然正常,所以该异常服务实例还是在线的,客户端仍在发送请求到该实例。这时想将该实例进行服务注册下线操作但保留进程以便排查问题,但由于业务线程阻塞导致HTTP线程池所有线程阻塞,进而导致管理模块无线程可处理HTTP服务注册下线请求,最终无法正常下线。有赞Dubbo框架已经对应用服务端口与管理端口进行了分离,并进行了线程池隔离,避免再出现类似的问题。当然,熔断等其他机制也有助于应对个别实例异常问题,这里我们主要关注端口分离问题。
网络拥塞、IP不可达、握手队列满时,都可能会导致建立连接阻塞与超时,为了避免不可控的阻塞时间对应用造成难以预知的影响,建议在建立连接时设置超时时间,进行超时控制。如果没有主动进行设置,超时时间是由系统默认行为进行控制的,而系统的默认行为肯定是无法满足所有应用场景的。(注:握手队列满时,如果设置了系统参数
net.ipv4tcp_abort_on_overflow,连接会立刻被重置)
我们看一下系统默认是如何控制连接建立超时时间的?
TCP三次握手的第一个SYN报文没有收到ACK,系统会自动对SYN报文进行重试,最大重试次数由系统参数net.ipv4.tcp_syn_retries控制,默认值为6。初始RTO为1s,如果一直收不到SYN ACK,依次等待1s、2s、4s、8s、16s、32s发起重传,最后一次重传等待64s后放弃,最终在127s后才会返回ETIMEOUT超时错误。
建议根据整个公司的业务场景,调整net.ipv4.tcp_syn_retries系统参数进行兜底。有赞将该参数设为3,即最大15s左右可返回超时错误。
5. 使用应用层心跳对连接进行健康检查
当TCP连接有异常时,我们需要尽快感知到,然后进行相应的异常处理与恢复。对于FIN或RST这种连接关闭、重置场景,应用层是可以快速感知到的。但是对于对端机器掉电、网线脱落、网络设备异常等造成的假连接,如果没有特殊措施,应用层很长时间都感知不到。
提到网络异常检测,大家可能首先想到的是TCP Keepalive。系统TCP Keepalive相关的三个参数为
net.ipv4.tcp_keepalive_time、
net.ipv4.tcp_keepalive_intvl、
net.ipv4.tcp_keepalive_probes,默认值分别为7200s、75s、9,即如果7200s没有收到对端的数据,就开始发送TCP Keepalive报文,如果75s内,没有收到响应,会继续重试,直到重试9次都失败后,返回应用层错误信息。
为什么需要实现应用层的心跳检查呢?系统的TCP Keepalive满足不了需求吗?是的,系统的TCP Keepalive只能作为一个最基本的防御方案,而满足不了高稳定性、高可靠性场景的需求。原因有如下几点:
对于TCP状态无法反应应用层状态问题,这里稍微介绍几个场景。第一个是TCP连接成功建立,不代表对端应用感知到了该连接,因为TCP三次握手是内核中完成的,虽然连接已建立完成,但对端可能根本没有Accept;因此,一些场景仅通过TCP连接能否建立成功来判断对端应用的健康状况是不准确的,这种方案仅能探测进程是否存活。另一个是,本地TCP写操作成功,但数据可能还在本地写缓冲区中、网络链路设备中、对端读缓冲区中,并不代表对端应用读取到了数据。
这里重点解释一下TCP KeepAlive与TCP重传的冲突问题。Linux系统通过net.ipv4.tcp_retries2参数控制TCP的超时重传次数,即影响TCP超时时间。初始RTO为TCP_RTO_MIN(200ms),RTO进行指数退让,最大RTO为TCP_RTO_MAX(2min),net.ipv4.tcp_retries2默认为15,大概924.6s超时。详细重传次数、RTO、超时时间关系,如下表所示。
重传次数 | RTO(毫秒) | 总超时时间 |
|
1 | 200 | 0.2 秒 | 0.0 分钟 |
2 | 400 | 0.6 秒 | 0.0 分钟 |
3 | 800 | 1.4 秒 | 0.0 分钟 |
4 | 1600 | 3.0 秒 | 0.1 分钟 |
5 | 3200 | 6.2 秒 | 0.1 分钟 |
6 | 6400 | 12.6 秒 | 0.2 分钟 |
7 | 12800 | 25.4 秒 | 0.4 分钟 |
8 | 25600 | 51.0 秒 | 0.9 分钟 |
9 | 51200 | 102.2 秒 | 1.7 分钟 |
10 | 102400 | 204.6 秒 | 3.4 分钟 |
11 | 120000 | 324.6 秒 | 5.4 分钟 |
12 | 120000 | 444.6 秒 | 7.4 分钟 |
13 | 120000 | 564.6 秒 | 9.4 分钟 |
14 | 120000 | 684.6 秒 | 11.4 分钟 |
15 | 120000 | 804.6 秒 | 13.4 分钟 |
16 | 120000 | 924.6 秒 | 15.4 分钟 |
如果TCP发送缓冲区中有数据未发送成功,TCP会进行超时重传,而不会触发TCP Keepalive。也就是说,即使应用设置了很小的TCP Keepalive参数,如time=10s、interval=10s、probes=3,在net.ipv4.tcp_retries2默认配置下,可能还是一直等到15min左右才能感知到网络异常。可能有的人不理解为什么Keepalive会被重传干扰,其实这里就是个优先级的问题。TCP最大重传次数的作用高于Keepalive参数的作用,未达到最大重传次数,不会向应用层报告网络错误信息。如果Keepalive不受重传影响,同样也会对关注重传的人造成干扰,比如为什么还没达到最大重传次数就放弃重传并关闭连接了?我们可以通过netstat -ot或ss -ot命令查看当前连接的计时器信息。
建议根据实际情况调低net.ipv4.tcp_retries2参数。RFC 1122建议对应的超时时间不低于100s,即至少为8,有赞系统该参数默认为10。
因此,想实现一个网络健壮的应用,应用层心跳必不可少。对于HTTP2、gRPC、Dubbo等协议都支持心跳,如果是基于这些协议开发的应用,可以直接使用这些协议的特性来实现应用层心跳。
实现应用层心跳需要考虑如下点:
6. 连接重连需要增加退让与窗口抖动
当网络异常恢复后,大量客户端可能会同时发起TCP重连及进行应用层请求,可能会造成服务端过载、网络带宽耗尽等问题,从而导致客户端连接与请求处理失败,进而客户端触发新的重试。如果没有退让与窗口抖动机制,该状况可能会一直持续下去,很难快速收敛。
建议增加指数退让,如1s、2s、4s、8s...,同时必须限制最大退让时间(如64s),否则重试等待时间可能越来越大,同样导致无法快速收敛。同时,为了降低大量客户端同时建连并请求,也需要增加窗口抖动,窗口大小可以与退让等待时间保持一致,如: nextRetryWaitTime = backOffWaitTime + rand(0.0, 1.0) * backOffWaitTime
在进行网络异常测试或演练时,需要把网络异常时间变量考虑进来,因为不同的时长,给应用带来的影响可能会完全不同。
7. 服务端需要限制最大连接数
一个服务端口,理论上能接收的最大TCP连接数是多少呢?TCP四元组中的服务端IP、服务端端口已经固定了,理论上的上限就是客户端可用IP数量*客户端可用端口数量。去除一些IP分类、端口保留等细节,理论上限就是2^32 * 2 ^16 = 2^48。
当然,目前现实中肯定达不到理论上限的瓶颈。一个TCP socket所关联的主要资源有内存缓冲区、文件描述符等,因此,实际限制主要取决于系统内存大小与文件描述符数量限制。
服务端限制最大连接数,主要有两个目的:
每个TCP连接的socket都占用一个FD,每个进程以及整个系统的FD数量都是有限制的。Linux系统下,通过ulimit -n可以查看单个用户的进程运行打开的FD最大数量,通过cat /proc/sys/fs/file-max可以查看所有进程运行打开的最大FD数量,如果不符合应用的需求,那就需要进行相应的调整。
达到FD上限会有什么影响呢?首先,肯定是无法接收新TCP连接了;其次,除了TCP连接占用的FD外,你的应用肯定还有内部场景占用或需要分配新的FD,比如日志文件发生轮转创建新日志文件时,如果日志文件创建失败,对于依赖本地存储的应用(如KV、MQ等存储型应用),就导致服务不可用了。所以,要在系统限制的基础上,根据应用的特性预留一定数量的FD,而不能把所有的FD都给客户端TCP连接使用。
有赞在线上压测时,一个应用就碰到过类似的一个问题。压测期间,压力比较高,导致磁盘IO压力增高,请求处理延迟增高,导致客户端超时。客户端发现超时关闭连接,创建新连接重试,但此时服务端由于IO阻塞带来的延迟并未能够及时回收连接关闭(CLOSE_WAIT)的socket以及FD,导致FD消耗越来越多,最终导致FD耗尽,新日志文件创建失败,而该应用又是存储类型应用,强依赖于日志落盘,最终导致服务不可用。
除了服务端限制最大连接数外,如果应用有对应的客户端SDK,最好也在客户端SDK也做一层保护。
8. 尽量不要依赖中心化四层负载均衡器
LVS是一个经典的中心化四层负载均衡解决方案,也有各种云厂商提供的类似LVS的产品,原理大多是一致的。它们的优点这里我们就不谈了。使用该类方案可能会面临如下问题:
建议通过分布式的动态服务注册与发现以及客户端负载均衡来替代中心化负载均衡方案,如微服务架构中的服务注册、服务发现、负载均衡等解决方案。
在不得不使用中心化负载均衡器的场景下,也需要注意以下问题:
有赞线上环境曾多次碰到过LVS引起的相关问题,也正在研发分布式的四层代理。
9. 警惕大量CLOSE_WAIT
先介绍曾经碰到的一个问题。线上环境告警提示有服务器发生较高的TCP重传,经抓包分析重传包都是FIN包,且目标IP已不存在。查看连接状态发现大量CLOSE_WAIT状态连接。该问题并不是一直持续,时有时无。经过对应用日志与应用代码分析,发现某个场景应用读取到EOF时,未关闭本地socket。进一步分析,原因是客户端应用是K8S部署的,发布后,旧实例下线,作为客户端发起主动关闭连接,并且旧实例的IP很快会被回收;服务端未关闭的socket,在几分钟后GC时(Go语言应用)才会进行socket回收关闭操作,但此时,客户端IP已不存在,因此,最后一个FIN报文不断重传,一直到超过最大重传次数,从而问题恢复。等到再次有客户端应用发布时,又会出现。该问题对于没有GC机制的编程语言开发的应用,可能会造成更严重的后果,socket不断泄露,导致FD耗尽、内存耗尽等问题。
因此,一定要警惕大量CLOSE_WAIT状态连接的出现,这种情况出现时,首先要排除一些相关代码。同时,开发过程中,一定要注意正确关闭socket,通过一些语言特性进行兜底处理,如Go语言的defer,Java语言的try...catch...finally,C++语言的RAII机制等。
10. 合理设置长连接TTL
长连接减少了像短连接频繁建立连接的开销,包括三次握手开销、慢启动开销等。但也有一定的弊端:长连接的持续时间过长,可能会导致一些负载均衡问题,以及其他一些长时间难以收敛的问题。比如LVS场景,随着后端应用实例的重启,对于一些负载均衡算法(如轮询),会导致最新启动的实例连接数最少,最早启动的实例连接数最多。对于一些客户端负载均衡方案,当只需要连接后端集群中的一个节点时,长连接也会出现类似的问题,比如类似Etcd watch的场景。有赞内部有很多使用Etcd的场景,早期运维每次变更Etcd集群的时候都特别谨慎,避免连接的不均衡。
有赞中间件团队规定任何应用的TCP长连接TTL不能超过2小时。当然,这已经是一个很保守的时长了,建议根据应用场景,合理设置TTL。
11. 通过域名访问服务需定期解析DNS
DNS是一种服务发现机制,应用通过配置DNS访问其他服务,本意是为了解决其他服务实例IP变动带来的影响,但如果处理不当还是会有问题。通过域名访问其他服务时,需要定时更新域名解析,如果解析有更新,则需要重新建立连接,避免后端实例迁移(IP有变化)时导致难以收敛。千万不要只在应用启动的时候进行一次域名解析,这种情况在DNS变更后想实现快速收敛,只能重启或发布所有相关应用了。一些语言内置了DNS相关的实现,需要注意对应的一些参数以及行为是否符合预期。
另外,某些应用提供了获取最新集群成员列表的接口,如Etcd、Redis,这样即使客户端启动的时候只进行一次域名解析,只要定期从服务端同步服务集群的成员列表也能支持服务端集群成员的动态变化。
12. 降低网络读写系统调用次数
当我们调用read/write系统函数从socket读写数据时,每次调用都至少进行两次用户态与内核态的上下文切换,成本比较高。针对该问题,一般有两种优化思路:
对于批量写操作还有一个优点,就是可以避免Nagle算法带来的延迟(一般也不建议开启Nagle算法)。假如当前写缓冲区中没有数据,我们先通过write写4个字节,这时TCP协议栈将其发送出去,然后再通过write写96个字节,这时,由于前面发送了一个报文,还没有收到ACK,并且当前可发送数据未达到MSS,Nagle算法不允许继续发送报文,必须等到前一个报文的ACK回来才能继续发送数据,大大降低了吞吐量并且提高了延迟。如果接收端开启了延迟ACK,影响更大。
因此,应该尽量批量读写网络数据,以提升性能。
13. 谨慎设置TCP缓冲区大小
一般来说我们不需要更改TCP默认缓冲区大小,如果我们确实有需求设置,也需要谨慎考虑与评估。
TCP缓冲区大小设置为多少合适呢?我们知道,TCP 的传输速度,受制于发送窗口与接收窗口大小,以及网络传输能力。其中,两个窗口由缓冲区大小决定,如果缓冲区大小与网络传输能力匹配,那么缓冲区的利用率就是最高的。
带宽时延积(缩写为 BDP,Bandwidth-delay Product)是用来描述网络传输能力的。如最大带宽是 100MB/s、网络时延是 10ms 时,客户端到服务端之间的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节,这个 1MB 是带宽与时延的乘积,也就是带宽时延积。这 1MB 字节存在于飞行中的 TCP 报文,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1MB,就一定会让网络过载,最终导致丢包。
由于发送缓冲区决定了发送窗口的上限,而发送窗口又决定了已发送但未确认的飞行报文的上限,因此,发送缓冲区不能超过带宽时延积,因为超出的部分没有办法用于有效的网络传输,且飞行字节大于带宽时延积还会导致丢包,从而触发网络拥塞避免;而且,缓冲区也不能小于带宽时延积,否则无法发挥出高速网络的价值。
总结而言:缓冲区太小,会降低TCP吞吐量,无法高效利用网络带宽,导致通信延迟升高;缓冲区太大,会导致TCP连接内存占用高以及受限于带宽时延积的瓶颈,从而造成内存浪费。如果缓冲区过小,如2K,还可能会导致快速重传无法生效,因为未确认的报文可能最多只有2个,不会出现3个重复的ACK。
Linux系统是可以根据系统状态自动调节缓冲区大小的,相关参数由net.ipv4.tcp_wmem和net.ipv4.tcp_rmem控制,参数是一个3元组<min, default, max>,即最大值、初始默认值、最大值。但如果在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的系统动态调整功能,这样操作前务必要进行充分的评估。因此,除非非常明确自己的需求,以及进行充分的评估与验证,否则,不要轻易设置TCP缓冲区大小。
14. 网络相关参数支持灵活配置
当应用可能有多种部署环境、部署场景时,需要根据使用场景、网络环境等因素,调整合适的网络相关参数。LAN和WAN的网络状况差别很大,会涉及到诸多参数的调整。
比如对于有赞的服务代理组件Tether,既有数据中心内的sidecar部署场景,又有跨公网的网关部署场景,这时就需要按需调整对应的参数,否则难以适应不同的网络环境。如连接超时、读写超时、健康检查超时、健康检查失败阈值等都应该支持灵活配置。
15. 合理设置连接池大小
对于不同类型的协议,连接池的设计也不同。我们将协议是否支持连接多路复用划分为两类:非多路复用协议和多路复用协议。非多路复用协议,一个连接发送请求后,必须等待响应返回后,该连接才能发送新的请求,如HTTP1.1、Redis等;多路复用协议,支持同一个连接同时发送多个请求,如HTTP2、gRPC、Dubbo等。
我们先看一下非多路复用协议如何设置连接池大小。连接池涉及到的参数一般有:最小连接数、最大连接数、最大空闲时间、连接获取超时时间、连接获取超时重试次数等。应用与连接池主要交互逻辑如下所示:
我们主要讨论最小连接数和最大连接数。之所以不是固定连接数,是因为流量有高峰、有低谷;固定连接数太小,流量高峰期容易导致请求等待时间过长;固定连接数太大,流量低谷期容易造成资源浪费。因此,最小连接数对应的就是流量低谷期连接数多少为合适,最大连接数对应的就是流量高峰期连接数多少为合适,也就是连接数与流量大小是相关的。除了流量大小,还需要考虑请求RT,即每个请求占用连接的时间。所需要的连接数其实就是请求并发数,这里我们可以利用著名的利特尔法则(Little's law)来计算,L=λW,在该场景即:并发数 = 请求QPS * 请求RT。比如流量低谷期请求QPS为100,请求RT为0.05s,则并发数为5,所需连接数为5;流量高峰期请求QPS为500,请求RT为0.1s,则并发数为50,所需连接数为50。这类问题其实与排队论相关,不过我们这里不做过多讨论,如果有更复杂的需求场景,可以参考更多排队论相关资料。
接下来我们继续看一下多路复用协议如何设置连接池大小。连接池涉及到的参数一般有:最小连接数、最大连接数、单连接并发请求数高水位、单连接并发请求数低水位。当单连接并发请求数高于高水位时,如果连接池未达到最大连接数,进行连接池扩容,创建连接;当单连接并发请求数低于低水位时,如果连接池未达到最小连接数,进行连接池缩容,释放连接(释放过程需要做到平滑)。由于每个请求不独占连接,请求是可以选择任意连接的,所以这里也面临负载均衡的问题,需要尽可能的确保每个连接上的处理中的请求数接近平均值。一般使用最少请求数负载均衡,但最少请求数负载均衡时间复杂度可能比较高,最简单的实现需要扫描整个连接池。我们可以使用其近似的优化实现,随机选择两个连接,选择Pending请求数少的连接;为了更加近似最少请求,可以选择3个、5个,甚至更多个连接,取其中Pending请求数最少的连接。
16. 完善网络指标监控
需要对各个关键网络指标进行监控与告警,包括但不限于:
如果能尽早发现这些指标的异常,那么就可以尽快发现问题,从而降低问题影响面。
总结
本文根据有赞TCP网络编程实践经验总结了16项建议,希望能够在TCP网络编程方面帮助大家提升应用的健壮性、可靠性,减少线上问题与故障。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。