从生产环境遇到的问题聊聊TCP设计思路

6,338 阅读18分钟

本文正在参与 “网络协议必知必会”征文活动

我们在学校学习网络以及网络传输层的时候,大概总是会觉得枯燥难懂。 但是其实在生产活动中,这个是很常遇见的问题,如果不懂,大概就会比较懵逼。

生产环境遇到的问题

说说今年我遇到的TCP层的几个问题。

  • 问题1:长短连接的选择
  • 问题2: 连接超时了,为什么超时的时间是128s左右
  • 问题3:系统不可达,80端口连不通了,可是本地查看80端口是正常的,这是为什么?
  • 问题4: 客户端连接池很多处于CLOSE-WAIT

传输层

要完全说清楚那些问题,需要对传输层的协议有非常深刻的认识。 网络层或许离软件开发人员还有点遥远,但是传输层特别是用的广泛的TCP却和我们的工作息息相关。

传输层的目的

  • 从上到下依次包括 应用层、传输层、网络层、链路层、物理层。

  • 应用层就是对应不同的数据

  • 通过网络层,包已经能够正确的被路由到对应的主机 udp0.png

我们需要有一个机制将不同应用映射到同一个主机的网络层,也需要保证数据的准确到达。

区别不同的应用-UDP

传输层最开始引入了端口的设计来区分不同的应用。

udp.png

  • 每个应用对应一个端口,端口信息也封装到包里面,这样就能识别数据包是属于哪个应用的。
  • 作为传输层的一种简单的协议,UDP的报文格式也很简单。

udp2.png

  • UDP报头几乎只加入了端口的信息
  • 端口是一个16位的数字

保证传输的质量-TCP

UDP没有保证数据的准确传输,没有质量保证,万一包丢了,网络层是不会重传的。所以传输层还设计了一个新的协议TCP,除了端口映射,在应用层和网络层之间还加入了一些处理的机制,来保证传输的质量。

传输的质量对于应用来说其实就两点:

  • 所有对端发出的数据都收到
  • 所有对端发出的数据都按序收到

tcp6.png

  • C1、C2表示和app1有数据交互的两个客户端。

  • 假设app1给C2发送数据,那么app1的传输层要保证所有的数据都发送到了C2,确保没有丢包事件的发生

    • 不丢包不是真的不丢包,而是假如丢了包 还能够重传,保证数据最终是被收到的。
  • 假设C1给app1发送数据,那么发送的数据需要按序正确的让app1接收

重点:TCP的具体工作机制

TCP要做什么

根据上面的描述,TCP主要是要做两件事情,防止丢包保证包到达的顺序

为了做到防止丢包,发送方发出的消息,需要知道对端是否收到了。发送方给每个发出的包都编排一个序号X, 若是对端收到了序号为X的包,则要告知对方序号为X的包收到了。 因为TCP还要保证包到达的顺序,告知序号为X的包收到了,则意味着序号X之前的包也都收到了。

tcp31.png

所以为了使这个过程变得简单,TCP发包的序号按照发送的顺序依次+1, 回复序号为X的包收到了用ack=X+1表示,也就是序号小于X+1的包都收到了。

但是如果对端没有收到seq=X的呢?此时发送方在等待一段时间之后,就会使用重传机制。 另外为了减少丢包,需要考虑接收方的能力,如果接收方处理不过来就发送的缓和一点,另外我们知道网络就像交通道路一样,有时候比较拥塞,有时候比较通畅,TCP在网络情况变化时会采取一些聪明的做法,同样可以减少丢包。

总结来说,TCP的机制是以下几点:

  • TCP包按照seq递增编排
  • ack已经顺序收到的包
  • 保证包到达的顺序,如果包通过网络到达的顺序不一样,对端也会等待序号较小的包到达后再交付应用层。
  • 丢包重传,超时未收到则使用超时重传机制
  • 减少丢包
    • 告知接收窗口:感受对端的处理能力

    • 丢包的原因以及优化:感受网络的拥塞,控制传输的速率

基本的试探

为了做到防止丢包保证包到达的顺序,TCP设计了一系列的工作机制,但这些工作机制是建立在双方相互了解的基础上。

要让双方相互了解,TCP首先要试探两个对端的收发能力,试探的过程如下: tcp3.png

这里主要是试探并确认了:

  • 确认A发送数据的能力:A协商了自己的初始序列号和接收窗口大小
  • 确认B接收数据的能力,B确认收到了A的第一条序列号的消息
  • 确认B发送数据的能力,B协商了自己的初始序列号和接收窗口大小
  • 确认A接收数据的能力,A确认收到了A的第一条序列号的消息 此外, 这个试探过程也叫做三次握手,我们说,通过三次握手两个应用程序之间已经相互了解,建立了一条TCP连接

具体的过程和状态

TCP连接是内核抽象给应用程序使用的。

我们可以更具体的看一看这个过程,包括建立连接之前、中间和之后。

三次握手的状态随时间变迁图如下:

tcp7.png

三次握手也就是三次发包的过程:

  • 1、发起端发送SYNC包:SYN,seq=x,自己进入Sync-Sent状态

    值得注意的是seq=x,下面还有详细描述

  • 2、监听端收到SYNC包,发送SYN,ACK,seq=y,ack=x+1,进入Sync-RCVD状态
  • 3、发起端收到SYNC,ACK包,发送ACK,seq=x+1,ack=y+1,进入Established状态
    • RTT表示发送数据到收到ACK的时间
  • 4、监听端收到ACK包,进入Established状态

accept和LISTEN

服务端的内核使用accept系统调用帮助建立连接,服务器调用accep函数之后处于LISTEN状态,可以等客户端来建立连接,使用netstat查看处于LISTEN状态的连接。

# netstat -alpnt
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 0.0.0.0:11110           0.0.0.0:*               LISTEN      26523/java     
  • *0.0.0.0: ** 表示接受网络上所有端口的连接
  • 内核使用了socket这样的对象来作为真正的tcp连接的对象,但是accept的socket是特殊的,并没有建立TCP连接。

ESTABLISHED

三次握手成功后,双方都会把这个连接信息保存在内存里面。

# netstat -alpnt
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 9.13.73.14:38604      9.13.39.104:3306       ESTABLISHED 26100/java          
tcp        0      0 9.13.73.14:32926      9.21.210.17:8080       ESTABLISHED 26100/java    
  • ESTABLISHED:连接已经建立
  • 从netstat命来看,连接的信息包括本地IP端口地址远程的IP端口
    • 本地 9.13.73.14:38604 和远程9.13.39.104:3306建立了连接

    • 本地9.13.73.14:32926和远程 9.21.210.17:8080 建立了连接

回答问题3

问题3:系统不可达,80端口连不通了,可是本地查看80端口是正常的,这是为什么?

有一次我们的服务不可达了,也就是80端口连不通,但是登录到机器上面发送80端口还是正常的listen状态,却发现有很多连接处于Sync-RCVD状态,查看日志发现系统Out of Memory过,完全有理由怀疑是因为内存原因导致服务建立连接的过程出现了异常。

所以这个时候虽然80端口的LISTEN状态是正常的,外部却不能正常连接。 重启服务解决问题

在另外一种情况下,如果出现了大量的Sync-RCVD和SYN等待队列溢出现象的解决方法:

  • net.ipv4.tcp_syncookies = 1表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭;

TCP报文格式和信息交换

三次握手中发的数据报都是TCP报文,TCP报文格式如下: tcp8.png

从这个报文格式和上面三次握手的过程可以看出:

  • SYN标记位为1说明这个报文是一个SYN类型的包,用于握手

    • 发起端发送SYN包:SYN,seq=x
    • 监听端收到SYN包后,也发送自己的SYN包:SYN,seq=y
    • 发起端和监听端的起始序号xy是32位序号,它们是系统随机生成的
    • 在SYN报文中交换了初始序列号之后,这个序列号就一直单调递增
  • ACK标记位为1说明这个报文是一个ACK类型的包

    • 32位确认号
    • 监听端收到SYN包,还发送ACK,ack=x+1表示小于x+1的全部字节已经收到,期待下一次收到seq=x+1的包
    • 发起端收到SYN,发送ACK,ack=y+1表示小于y+1的全部字节已经收到,期待下一次收到seq=y+1的包
  • 用于SYN连接的包的数据为空

  • ACK可以和其他数据包组合在一起,通过一个包发送,比如第二次握手的AYN+ACK包就是

另外报文格式中还包含下面信息:

  • 接收到的SYN包的窗口大小代表对方的接收窗口的大小
  • RST标记位为1: 可以用于强制断开连接
  • PSH标记位为1: 告知对方这些数据包收到后应该马上交给上层的应用
  • 选项中的MSS:TCP允许的从对方接收的最大报文段,这个和链路层的MTU大小有关

连接建立后,传输数据

连接建立后,可以传输数据了。

回顾前面说到的TCP主要保证的两件事情和基本思路:

tcp9.png

  • 序列号seq有序递增保证数据包按正确的顺序交付
  • ack信号超时重传机制防止丢包
  • 使用窗口流控,进一步减少丢包

建立连接的过程已经为这个事情做好了铺垫,下面看看具体的运作机制。

超时重传和ACK深层含义

  • 发送出去的数据如果一直没收到ack就重传,需要确定多久时间没收到就重传

tcp10.png

  • 接收端如果多次收到同一个seq的数据就丢弃
  • 需要大于RTT,又不能太大
  • RTT是在动态变化的,内核采样,使用RTO来计算 每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。多次超时,就说明网络环境差,不宜频繁反复发送,就会每次变为原来的两倍,可以设置最大重传次数

可以回答问题2了

问题2: 连接超时了,为什么超时的时间是128s左右

初始1S超时,然后因为系统设置的重传次数是6,重传了6次,1+2+4+8++16+32+64加起来大约是128s左右。 可以批量提交ack

tcp11.png

seq=4的ack没有正确的收到, 但如果在超时时间内收到了ack=6 则表示6之前的seq都已经正确接收到了, seq=4的数据也不会重传

滑动的窗口和右移的指针

假设在某个时刻。发送端的socket缓冲区示意图如下:

tcp12.png

图示的发送窗口和收到ack报文中的window大小相关,假设这个变量为为windowSize

  • ack指针之前是:已发送并收到ACK确认的数据,这块可以从发送缓冲区移除了
  • ack指针之后,但seq指针之前的是:已发送但未收到ACK确认的数据,假设大小为notAckedSize
  • seq指针之后的大小为windowSize-notAckedSize的范围是:未发送但总大小在接收方处理范围内,这个是真正的可用窗口
  • 再之后的发送缓冲区的数据是未发送但总大小超过接收方处理范围的包 两个指针都是从左往右移动,在发送窗口一定的情况下
  • ack指针右移则可用窗口变大
  • 发送seq指针右移则可用窗口变小

此时接收端的socket缓冲区示意图如下:

tcp14.png

从图上可以看出,接收端正在发送的ACK包,ack=16,表示seq=16之前的包都收到了。

  • 应用下一个要取走的seq指针和ack指针之间的是:已成功接收并确认的数据,这块数据是可以交付给应用的,当ack指针右移的时候,则这个范围也是右移了。

  • ack指针之后是未收到数据但可以接收的数据,但这个大小是有限定的(跟应用程序和网络拥塞有关),这个范围也叫做接收窗口,这个范围的大小则称为接收窗口的大小

    • 图上windowSize=16
    • 接收窗口和应用程序获取数据的能力有关,如果应用程序一直不从socket缓冲区获取数据,则接收窗口也会变得越来越小
    • 滑动窗口并不是一成不变的。当接收方的应用读取数据的速度非常快的话,接收窗口可以很快的空缺出来。 可以看出,通过使用接收窗口可以起到流控的作用,可以减少不必要的丢包,并减少网络拥塞

顺序的保证

前面讲到顺序交付是TCP的目的之一,为了保证顺序,通过有序递增的seq是必要的。具体是怎么工作的,要从发送和接收窗口来看看。

tcp15.png

假设接收端未收到16,17的报文,而后面18-27的数据都收到了, 则并不会发ack=28给发送方 当发送端超时重传16,17之后,且接收端收到之后 则接收端返回ack=28的报文给发送端,还是发送ack=16

  • ack指针只能右移,不能往回走
  • 这样可以保证顺序交付

传输的总结

我们之前讲到TCP的主要作用就是防止丢包保证包到达的顺序,这个小节通过描述TCP的具体工作机制证明了TCP确实达到了这样的目的。

高并发系统和关闭连接的设计

终于讲完了TCP连接的建立和传输的基本原理和过程,以为可以松一口气。

关闭连接是TCP的一环,但是比起TCP要达到的目的来说,这个并不重要。

但是近来发现在生产活动中,大家也都很关注TCP四次挥手的过程。主要原因是因为现在系统的并发量大

而TCP连接是衡量系统并发量的重要因素, 如果关闭连接出现了异常,势必影响系统的运行。

关于连接对并发量的影响,可以先分析分析开头提出的问题。

问题1:长短连接的选择

长连接 VS 短链接

  • 短连接一般只会在 client/server间传递一次请求操作
    • 这时候双方任意都可以发起close操作
    • 短连接管理起来比较简单,存在的连接都是有用的连接,不需要额外的控制手段。
    • 通常浏览器访问服务器的时候一般就是短连接。
  • 长连接
    • Client与server完成一次读写之后,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接
    • 所以一条连接保持几天、几个月、几年或者更长时间都有可能,只要不出现异常情况或由用户(应用层)主动关闭。
    • 长连接可以省去较多的TCP建立和关闭的操作,减少网络阻塞的影响,
    • 减少CPU及内存的使用,因为不需要经常的建立及关闭连接。
    • 连接数过多时,影响服务端的性能和并发数量。

所以,长短连接怎么选择呢?

  • 所以对于并发量大,请求频率低的,建议使用短连接
    • 对于服务端来说,长连接会耗费服务端的资源
    • 如果有几十万,上百万的连接,服务端的压力会非常大,甚至会崩溃
  • 对于并发量小,性能要求高的,建议选择长连接
    • 比如mysql连接池

TCP关闭连接

长短连接的选择都那么重要了,那如果不能正确的关闭连接将引起非常大的麻烦。 TCP设计了完善的关闭连接的机制,关闭连接的交互过程如下:

tcp17.png

  • 关闭连接发起方 发起第一个FIN,处于FIN-WAIT1
  • 关闭连接被动方的内核代码回复ACK,此时还可以发送数据,处于Close-WAIT状态
    • 关闭连接被动方等待应用程序发送FIN,如果上层应用一直不发FIN,就还可以继续发送数据
  • 关闭连接发起方收到ACK后处于FIN-WAIT2状态,还可以接收数据自己不再发送数据
  • 关闭连接被动方直到应用程序发出FIN,处于LAST-ACK状态
  • 关闭连接发起方收到FIN,会发送ACK,自己会处于TIME-WAIT状态,此时
    • 若是关闭连接被动方收到ack,就close连接
    • 若是是关闭连接被动方没收到ack,则会重传FIN

TIME-WAIT等多长时间

MSL是报文最大生存时间,他是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。 2MSL即两倍的MSL,TCP的TIME_WAIT状态也称为2MSL等待状态。 tcp18.png

如上图所示,正常情况下等待2MSL时间主要目的是怕最后一个ACK包对方没收到,那么对方在等待ACK超时后重发第三次挥手的FIN包

tcp19.png

如果对方收到了ACK,那么皆大欢喜。等待2MSL也不冤。

如果交互异常了,也是假设收到重传的FIN最多用2MSL,收到重传的FIN则再次ACK。如果未再次收到FIN包,有可能对方已经关闭,也有可能还是网络异常,但再等也没有意义,只会平白无故的浪费网络连接资源,所以等待2MSL的时间后也会关闭连接。

当系统出现很多time-wait状态的时候,为了更好的回收资源,我们可以设置一些系统参数:

  • net.ipv4.tcp_fin_timeout 修改系統默认的TIMEOUT时间,也就是TIME-WAIT等待的时间
  • net.ipv4.tcp_tw_reuse = 1表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
  • net.ipv4.tcp_tw_recycle = 1表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。

最后一个问题

问题4: 客户端连接池很多处于CLOSE-WAIT? 有一次,有一个同步数据的应用从一个服务器并发同步大量数据,这个过程比较慢,于是想着怎么去加快同步的速度。

  • 首先查看网络连接,发现20个连接的连接池里面还有很多处于close_wait状态的连接

通过上面的分析我们知道,Close-WAIT是对方可能认为连接空闲时间太长关闭了连接,不过我这边应用的连接池还没有发FIN释放包。

可见连接的数量没有成为系统瓶颈,为了提高并发量,我们还可以继续增大并发的线程数,使得过多的连接不要处于空闲状态。

总结

本文通过详细描述传输层和TCP协议回答了生产遇到的TCP相关的问题,希望对你也有所帮助。 总结本文的内容来查漏补缺,你是否了解到了这些?

  • udp和tcp的区别
  • 序列号和ack是tcp独有的
  • 建立连接三握手
  • 发送接收滑窗口
  • 序列交付有顺序
  • 超时重传保质量
  • 四次挥手放资源
  • 内核自动ack,应用控制FIN
  • 关闭控方等2MSL