乐趣区

高并发架构的TCP知识介绍

做为一个有追求的程序员,不能只满足增删改查,我们要对系统全方面无死角掌控。掌握了这些基本的网络知识后相信,一方面日常排错中会事半功倍,另一方面日常架构中不得不考虑的高并发问题,理解了这些底层协议也是会如虎添翼。

本文不会单纯给大家讲讲 TCP 三次握手、四次挥手就完事了。如果只是哪样的话,我直接贴几个连接就完事了。我希望把实际工作中的很多点能够串起来讲给大家。当然为了文章完整,我依然会从 三次握手 起头。

再说 TCP 状态变更过程

不管是三次握手、还是四次挥手,他们都是完成了 TCP 不同状态的切换。进而影响各种数据的传输情况。下面从三次握手开始分析。

本文图片有部分来自网络,若有侵权,告知即焚

三次握手

来看看三次握手的图,估计大家看这图都快看吐了,不过为什么每次面试、回忆的时候还是想不起呢?我再来抄抄这过剩饭吧!

首先当服务端处于 listen 状态的时候,我们就可以再客户端发起监听了,此时客户端会处于 SYN_SENT 状态。服务端收到这个消息会返回一个 SYN 并且同时 ACK 客户端的请求,之后服务端便处于 SYN_RCVD 状态。这个时候客户端收到了服务端的 SYN&ACK,就会发送对服务端的 ACK,之后便处于 ESTABLISHED 状态。服务端收到了对自己的 ACK 后也会处于 ESTABLISHED 状态。

经常在面试中可能有人提问:为什么握手要 3 次,不是 2 次或者 4 次呢?

首先说 4 次握手,其实为了保证可靠性,这个握手次数可以一直循环下去;但是这没有一个终止就没有意义了。所以 3 次,保证了各方消息有来有回就足够了。当然这里可能有一种情况是,客户端发送的 ACK 在网络中被丢了。那怎么办?

  1. 其实大部分时候,我们连接建立完成就会立刻发送数据,所以如果服务端没有收到 ACK 没关系,当收到数据就会认为连接已经建立;
  2. 如果连接建立后不立马传输数据,那么服务端认为连接没有建立成功会周期性重发 SYN&ACK 直到客户端确认成功。

再说为什么 2 次握手不行呢?2 次握手我们可以想象是没有三次握手最后的 ACK, 在实际中确实会出现客户端发送 ACK 服务端没有收到的情况(上面的情况一),那么这是否说明两次握手也是可行的呢?
看下情况二,2 次握手当服务端发送消息后,就认为建立成功,而恰巧此时又没有数据传输。这就会带来一种资源浪费的情况。比如:客户端可能由于延时发送了多个连接情况,当服务端每收到一个请求回复后就认为连接建立成功,但是这其中很多求情都是延时产生的重复连接,浪费了很多宝贵的资源。

因此综上所述,从资源节省、效率 3 次握手都是最合适的。话又回来三次握手的真实意义其实就是协商传输数据用的: 序列号与窗口大小

下面我们通过抓包再来看一下真实的情况是否如上所述。

20:33:26.583598 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [S], seq 621839080, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 1050275400 ecr 0,sackOK,eol], length 0
20:33:26.660754 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [S.], seq 1754967387, ack 621839081, win 8192, options [mss 1452,nop,wscale 5,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,sackOK,eol], length 0
20:33:26.660819 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967388, win 4096, length 0

抓包: sudo tcpdump -n host www.baidu.com -S

  • S 表示 SYN
  • . 表示 ACK
  • P 表示 传输数据
  • F 表示 FIN

四次挥手

挥手,就是说数据传完了,同志们再见!

这里有个问题需要注意下,其实客户端、服务端都能够主动发起关闭操作,谁调用 close() 就先发送关闭的请求。当然一般的流程,发起建立连接的一方会主动发起关闭请求(http 中)。

关于 4 次挥手的过程,我就不多解释了,这里有两个重要的状态我需要解释下,这都是我亲自经历过的线上故障,close_waittime_wait

先给大家一个命令,统计 tcp 的各种状态情况。下面表格内容就来自这个命令的统计。

netstat -n | awk ‘/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}’

Tcp 状态 连接数
CLOSE_WAIT 505
ESTABLISHED 808
TIME_WAIT 3481
SYN_SENT 1
SYN_RECV 1
LAST_ACK 2
FIN_WAIT2 2
FIN_WAIT1 1

大量的 CLOSE_WAIT 这个在我之前的一篇文章 线上大量 CLOSE_WAIT 原因分析 已经有过介绍,它会导致大量的 socket 无法释放。而每个 socket 都是一个文件,是会占用资源的。这个问题主要是代码问题。它出现在被动关闭的一方(习惯称为 server)。

大量的 TIME_WAIT 这个问题在日常中经常看到,流量一高就出现大量的该情况。该状态出现在主动发起关闭的一方。该状态一般等待的时间设为 2MSL 后自动关闭,MSL 是 Maximum Segment Lifetime,报文最大生存时间 ,如果报文超过这个时间,就会被丢弃。处于该状态下的 socket 也是不能被回收使用的。线上我就遇到这种情况,每次大流量的时候,每台机器处于该状态的 socket 就多达 10w+,远远比处于 Established 状态的 socket 多的多,导致很多时候服务响应能力下降。这个一方面可以通过调整内核参数处理,另一方面避免使用太多的短链接,可以采用连接池来提升性能。另外在代码层面可能是由于某些地方没有关闭连接导致的,也需要检查业务代码。

上面两个状态一定要牢记发生在哪一方,这方便我们快速定位问题。

最后这里还是放上挥手时的抓包数据:

20:33:26.750607 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [F.], seq 621839159, ack 1754967720, win 4096, length 0
20:33:26.827472 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [.], ack 621839160, win 776, length 0
20:33:26.827677 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [F.], seq 1754967720, ack 621839160, win 776, length 0
20:33:26.827729 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967721, win 4096, length 0

不多不少,刚好 4 次。

TCP 状态变更

网络上有一张 TCP 状态机的图,我觉得太复杂了,用自己的方式搞个简单点的容易理解的。我从两个角度来说明状态的变更。

  • 一个是客户端
  • 一个是服务端

看下面两张图的时候,请一定结合上面三次握手、四次挥手的时序图一起看,加深理解。

客户端状态变更

通过这张图,大家是否能够清晰明了的知道 TCP 在客户端上的变更情况了呢?

服务端状态变更

这一张图描述了 TCP 状态在服务端的变迁。

TCP 的流量控制与拥塞控制

我们常说 TCP 是面向连接的,UDP 是无连接的。那么 TCP 这个面向连接主要解决的是什么问题呢?

这里继续把三次握手的抓包数据贴出来分析下:

20:33:26.583598 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [S], seq 621839080, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 1050275400 ecr 0,sackOK,eol], length 0
20:33:26.660754 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [S.], seq 1754967387, ack 621839081, win 8192, options [mss 1452,nop,wscale 5,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,sackOK,eol], length 0
20:33:26.660819 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967388, win 4096, length 0

上面我们说到 TCP 的三次握手最重要的就是协商传输数据用的序列号。那这个序列号究竟有些什么用呢?这个序号能够帮助后续两端进行确认数据包是否收到,解决顺序、丢包问题;另外我们还可以看到有一个 win 字段,这是双方交流的窗口大小,这在每次传输数据过程中也会携带。主要是告诉对方,我窗口是这么大,别发多了或者别发太少。

总结下,TCP 的几个特点是:

  • 顺序问题,依靠序号
  • 丢包问题,依靠序号
  • 流量控制,依靠滑动窗口
  • 拥塞控制,依靠拥塞窗口 + 滑动窗口
  • 连接维护,三次握手 / 四次挥手

顺序与丢包问题

这个问题其实应该很好理解。由于数据在传输前我们已经有序号了,这里注意一下这个序号是随机的,重复的概率极地,避免了程序发生乱入的可能性。

由于我们每个数据包有序号,虽然发送与到达可能不是顺序的,但是 TCP 层收到数据后,可以根据序号进行重新排列;另外在这个排列过程中,发现有了 1,2,3,5,6 这几个包,一检查就知道 4 要么延时未到达,要么丢包了,等待重传。

这里需要重要说明的一点是。为了提升效率,TCP 其实并不是收到一个包就发一个 ack。那是如何 ACK 的呢?还是以上面为例,TCP 收到了 1,2,3,5,6 这几个包,它可能会发送一个 ack,seq=3 的确认包,这样次一次确认了 3 个包。但是它不会发送 5,6 的 ack。因为 4 没有收到啊!一旦 4 延时到达或者重发到达,就会发送一个 ack, seq=6,又一次确认了 3 个包。

流量控制与拥塞控制

这两个概念说实话,让我理解了挺长时间,主要是对它们各自控制的内容以及相互之间是否有作用一直没有闹清楚。

先大概说下:

  • 流量控制:是根据接收方的窗口大小来感知我这次能够传多少数据给对方;———— 滑动窗口
  • 拥塞控制:而拥塞控制主要是避免网络拥塞,它考虑的问题更多。根据综合因素来觉得发多少数据给对方;———— 滑动窗口 & 拥塞窗口

举个例子说下,比如:A 给 B 发送数据,通过握手后,A 知道 B 一次可以收 1000 的数据(B 有这么大的处理能力),那么这个时候滑动窗口就可以设置成 1000。那是不是最后真的可以一次发这么多数据给 B 呢?还不是,这时候得问问拥塞窗口,老兄,现在网络情况怎么样?一次运 1000 的数据有压力吗?拥塞窗口一通计算说不行,现在是高峰期,最多只能有 600 的货上路。最终这次传数据的时候就是 600 的标注。大家也可以关注抓包数据的 win 值,一直在动态调整。

当然另外一种情况是滑动窗口比拥塞窗口小,虽然运输能力强,但是接收能力有限,这时候就要取滑动窗口的值来实际发生。所以它们二者之间是有关系的。

所以具体到每次能够发送多少数据,有这么一个公式:

LastByteSend – LastByteAcked <= min{cwnd,rwnd}

  • LastByteSend 是最后一个发送的字节的序号
  • LastByteAcked 最后一个被确认的字节的序号

这两个相减得到的是本次能够发送的数据,这个数据一定小于或等于 cwnd 与 rwnd 中最小的一个值。相信大家能够理清楚。

那么这部分知识对于实际工作中有什么作用呢?指导意义就是:如果你的业务很重要、很核心一定不要混布;二是如果你的服务忽快忽慢,而确信依赖服务没有问题,检查下机器对应的网络情况;三是窗口这个速度控制机制,在我们进行服务设计的时候,非常具有参考意义。是不是有点消息队列的感觉?(很多消息队列都是匀速的,我们是否可以加一个窗口的概念来进行优化呢?)

是什么限制了你的连接

到了最关键的地方了,精华我都是留到最后讲。下面放一张网上找的 socket 操作步骤图,画的太好了我就直接用了。

我们假设我的服务端就是 Nginx,我来尝试解读一下。当客户端调用 connect() 时候就会发起三次握手,这次握手的时候有几个元素唯一确定了这次通信(或者说这个 socket),[源 IP: 源 Port,目的 IP: 目的 Port],当然这个 socket 还不是最终用来传输数据的 socket,一旦握手完成后,服务端会在返回一个 socket 专门用来后续的数据传输。这里暂且把第一个 socket 叫 监听 socket,第二个叫 传输 socket 方便后文叙述。

为什么要这么设计呢?大家想一想,如果监听的 socket 还要负责数据的收发,请问这个服务端的效率如何提升?什么东西、谁都往这个 socket 里边丢,太复杂!

提高连接常用套路

到了这一步,我们现在先停下来算算自己的服务器机器能够有多少连接呢?这个极限又是如何一步步被突破呢?

先说 监听 socket,服务器的 prot 一般都是固定的,服务器的 ip 当然也是固定的(单机)。那么上面的结构 [源 IP: 源 Port,目的 IP: 目的 Port] 其实只有客户端的 ip 与端口可以发生变化。假设客户端用的是 IPv4,那么理论连接数是:2^32(ip 数) * 2^16(端口数) = 2^48。

看起来这个值蛮大的。但是真的能够有这么多连接吗?不可能的,因为每一个 socket 都需要消耗内存;以及每一个进程的文件描述符是有上限的。这些都限制了最终的连接数。

那么如何进行调和呢?我知道的操作有:多进程、多线程、IO 多路服用、协程等手段组合使用。

多进程

也就是监听是一个进程,一旦 accept 后,对于 传输 socket 我们就 fork 一个新的子进程来处理。但是这种方式太重,fork 一个进程、销毁一个进程都是特别费事的。单机对进程的创建上限也是有限制的。

多线程

线程比进程要轻量级的多,它会共享父进程的很多资源,比如:文件描述符、进程空间,它就是多了一个引用。因此它的创建、销毁更加容易。每一个 传输 socket 在这里就交给了线程来处理。

但是不管是多进程、还是多线程都存在一个问题,一个连接对应一个进程或者协程。这都很难逃脱 C10K 的问题。那么该怎么办呢?

IO 多路复用

IO 多路复用是什么意思呢?在上面单纯的多进程、多线程模型中,一个进程或线程只能处理一个连接。用了 IO 多路复用后,我一个进程或线程就能处理多个连接。

我们都知道 Nginx 非常高效,它的结构是:master + worker,worker 会在 80、443 端口上来监听请求。它的 worker 一般设置为 cpu 的 cores 数,那么这么少的子进程是如何解决超多连接的呢?这里其实每个 worker 就采用了 epoll 模型(当然 IO 多路复用还有个 select,这里就不说了)。

处于监听状态的 worker,会把所有 监听 socket 加入到自己的 epoll 中。当这些 socket 都在 epoll 中时,如果某个 socket 有事件发生就会立即被回调唤醒(这涉及 epoll 的红黑树,讲不清楚不细说了)。这种模式,大大增加了每个进程可以管理的 socket 数量,上限直接可以上升到进程能够操作的最大文件描述符。

一般机器可以设置百万级别文件描述符,所以单机单进程就是百万连接,epoll 是解决 C10K 的利器,很多开源软件用到了它。

这里说下,并不是所有的 worker 都是同时处于监听端口的状态,这涉及到 nginx 惊群、抢自旋锁的问题,不再本文范围内不多说。

关于 ulimit

在文章的最后,补充一些单机文件描述符设置的问题。我们常说连接数受限于文件描述符,这是为什么?

因为在 linux 上一切皆文件,故每一个 socket 都是被当作一个文件看待,那么每个文件就会有一个文件描述符。在 linux 中每一个进程中都有一个数组保存了该进程需要的所有文件描述符。这个文件描述符其实就是这个数组的 key,它的 value 是一个指针,指向的就是打开的对应文件。

关于文件描述符有两点注意:

  1. 它对应的其实是一个 linux 上的文件
  2. 文件描述符本身这个值在不同进程中是可以重复的

另外补充一点,单机设置的 ulimit 的上线受限与系统的两个配置:

fs.nr_open,进程级别

fs.file-max,系统级别

fs.nr_open 总是应该小于等于 fs.file-max,这两个值的设置也不是随意可以操作,因为设置的越大,系统资源消耗越多,所以需要根据真实情况来进行设置。


至此,本篇长文就完结了。这跟上篇 高并发架构的 CDN 知识介绍 属于一个系列,高并发架构需要理解的网络基础知识。

后面还会写一下 HTTP/HTTPS 的知识。然后关于高并发网络相关的东西就算完结。我会开启下一个篇章。


如果你想对网络协议了解更多,推荐一个课程:

退出移动版