Computer Network

TCP

TCP

NetworkTCP

TCP

TCP 是传输层协议,它给应用层提供可靠的、面向连接的、字节流服务。简单说,就是它保证数据靠谱地从一台机器传到另一台,不会丢、不乱序、不重复

它的核心特点有这些:

  1. 面向连接,通信前必须建立连接(三次握手),通信后需要释放连接(四次挥手)。
  2. 可靠传输,通过序列号、ACK 确认、超时重传、校验和这些机制保证数据不丢、不重、不乱序。
  3. 流量控制,用滑动窗口,接收方说“我还能接多少”,发送方就不发太多,免得把对方淹了。
  4. 拥塞控制,通过慢启动、拥塞避免、快速重传、快速恢复等算法,防止把整个网络塞爆。
  5. 全双工通信,双方可以同时发送和接收数据。

在 TCP 中,每个报文段(segment)都有两个重要序号:

  • SEQ(序号):自己发送的数据起始位置

  • ACK(确认号):告诉对方“我已经收到你发送的数据到某个位置了”,比如ACK 100代表:我已经收到了序号 0 ~ 99 的字节,下一次我期望收到的字节序号 = 100。因此ACK 指向的是 下一个期望字节的序号

相比 UDP(无连接、不可靠、开销小),TCP 更适合需要可靠传输的场景,比如浏览网页(HTTP/HTTPS)、发邮件、文件传输等。

TCP连接其实就是通信双方在内存里维护的一堆状态信息叫 TCB(Transmission Control Block,传输控制块)。只要双方内存里的这些状态是对得上的,连接就存在

里面最关键的有:

  1. 套接字(Socket):源 IP + 源端口 + 目的 IP + 目的端口,告诉你“谁在跟谁聊”。
  2. 序列号(Seq):告诉你“到多少编号的字节了”,防乱序和丢包。
  3. 窗口大小(Window):告诉你“对方还能接多少数据”。

唯一能标识一个 TCP 连接的就是四元组:源 IP、源端口、目的 IP、目的端口。同一个机器上可以有成千上万个连接,就是靠这个四元组区分。

TCP的11种状态

状态含义
CLOSED连接关闭状态,初始状态,没有连接。
LISTEN服务器端监听状态,等待客户端发起连接(SYN)。
SYN-SENT客户端发起连接后,发送了 SYN 报文,等待服务器确认。
SYN-RECEIVED收到 SYN 后,服务器发送 SYN+ACK,等待客户端确认 ACK。
ESTABLISHED连接建立成功,双方可以进行数据传输。
FIN-WAIT-1客户端主动关闭连接,发送了 FIN,等待对方确认。
FIN-WAIT-2收到对方 ACK,等待对方发送 FIN 报文。
CLOSE-WAIT被动关闭连接,收到对方 FIN,等待应用层关闭连接。
CLOSING双方同时发起关闭,等待对方 ACK。
LAST-ACK被动关闭方等待自己发送的 FIN 被确认。
TIME-WAIT主动关闭方等待足够时间(2×MSL)确保对方收到 ACK 后,才完全关闭连接。

TCP协议Q&A

什么是粘包和拆包?

首先粘包和拆包是 TCP 面向字节流特性带来的问题,本质上是 TCP 不保留消息边界。

TCP是面向字节流,数据像水流一样,没有明确的边界。而UDP 是面向数据报的协议,每个 UDP 数据报都是独立的,有明确的边界。UDP 头部有 16 位的长度字段,接收方能精确知道每个数据报有多长。发一个报文收一个报文,天然带边界。

粘包:发送方发了多个消息,接收方一次收到时粘在一起了,分不清哪到哪是一条消息。比如发了"hello"和"world",收到的可能是"helloworld"。

拆包:发送方发了一个完整消息,接收方收到时被拆成多个部分。比如发了"helloworld",收到的可能是"hello"和"world"分两次到。

究其原因其实就是接收方不知道怎样才是一条完整的消息,不知道一个消息从哪里结束。并且TCP发送数据有缓冲区,当数据大于缓冲区大小时可能就会拆包,当数据小于缓冲区时可能就会和其他数据包一起发送

通常在应用层的解决方案就有三种:

  • 固定消息长度
  • 添加分隔符
  • 在消息前加上消息长度

固定消息长度

核心就是提前约定,比如每条消息固定10字节,那么接收方的逻辑就是每10字节当成一条消息。

对于粘包就10字节10字节读,对于拆包就攒够10字节再处理。

好处简单粗暴,坏处就是局限性很大,长度设小了不够用,设大了浪费贷款

添加分隔符

约定用一个特殊符号来做为每条消息的结尾,比如约定用 \n 换行符来作为结尾。

那么在读取消息时,读到 \n 就认为一条消息结束了。

对于粘包就是以 \n 作为消息的分界线,对于拆包就是读到 \n 才处理

好处就是相比固定长度的方案灵活了一点,但缺点就是如果消息中间出现了分隔符就麻烦了,就会出现半包问题。可以用base64对数据进行编码,然后选择一个base64字符集以外的字符作为分隔符。比如\r\n

长度字段 + 内容

这种方案其实就是在正式内容前用几字节来描述一条消息的长度,比如用4字节来表述。

那么接收方就会先读4字节,获取当前消息长度,然后往后读对应长度。

对于粘包就是先读4字节,知道长度,再读对应长度就获取了当前消息。对于拆包就是读够长度时再处理

好处就是更灵活,既占不了多少带宽,又不需要额外的编码,缺点就是长度字段需要根据业务合理设置,比如2字节最大表示65535,4字节最大可表示4GB

补充一下HTTP是如何解决的。

HTTP/1.1 用了两种方式。

一种是 Content-Length 头部,告诉对方 body 有多少字节,读够了就知道这条消息结束了。

另一种是 Transfer-Encoding: chunked,分块传输,每块前面带长度,最后一块长度为 0 表示结束,适合服务端不知道响应总长度的场景比如流式返回。

HTTP/2 改成了帧协议,每个帧都有长度字段,天然解决了边界问题。

什么是半包?和拆包有什么关系

半包其实是拆包的一种体现。

拆包指的是:一条消息可能由于发送缓冲区的限制,被拆分成多个包发送

半包指的是:一次只读到了一个消息的一部分,需要等待另一部分到达才能还原完整数据

网线拔了连接还在吗?

上面讲了,TCP本质是通信双方在各自的内存里维护一堆状态信息TCB,网线拔了之后,但双方都不发数据,但内存里的TCB还存在,状态依然是ESTABLISH。

只有当一方试图发送数据发不出去(超时),或者开启了 Keep-Alive 探测机制,才会发现“哦,路断了”,然后才会去销毁这个连接,释放 TCB。

既然拔网线连接还在,那服务器怎么发现客户端已经挂了?

有几种方式。

  1. 第一种是服务端主动发数据,发不出去,触发超时重传,重传几次还失败就认为连接断了。
  2. 第二种是开启 TCP Keep-Alive,默认是 2 小时没数据就发探测包,探测几次没响应就关连接,不过这个间隔太长,生产环境一般在应用层自己做心跳,比如每 30 秒发个 ping 包,3 次没响应就断开。
  3. 第三种是操作系统通知,比如在 Linux 上用 epoll,如果网卡挂了,内核会通知 socket 出错。

TCP连接这个所谓的连接究竟是什么?

在网络世界里,“建立连接”本质上是 通信双方在网络协议栈和操作系统内核里达成一种状态共识,保证双方能 可靠地交换数据。它并不是一根物理线,而是 协议层面的一种逻辑关系

因此TCP连接本质上是两个socket对出来的结果,这个连接并不是一根真实的可以看见的线,实质上是通信双方在本地内存中维护了一堆状态信息。比如本地IP,本地端口,目标IP,目标端口,TCP状态,发送序号,接收序号,窗口大小,缓冲区等等。因为TCP是面向连接的可靠协议,因此他就需要去维护这些状态信息来确保消息有序,不丢失,流量控制,拥塞控制等。

因此因为这个特性,即便把网卡拔了TCP的连接也不会立刻断开,依然是ESTABLISTED。但后续会随着数据无法发送到,导致超时会将状态信息释放掉,那这个TCP连接就算真正断开了。

既然 TCP 连接只是内核的一份状态,那为什么服务器会有 最大连接数限制?

虽然只是维护状态信息,但状态信息也不是免费的,他也会消耗很多系统资源

比如在Linux中一切皆文件,TCP连接也是一个文件,也会被分配文件描述符,而文件描述符的数量都是有限的。

并且每个TCP连接还要各自维护各自的接收缓冲区和发送缓冲区,这也是对内存的消耗。

此外服务器在调用listen时会有两个队列,半连接队列和全连接队列,前者存放还在握手的连接即SYN_RECV状态的连接,后者存放连接状态为ESTABLISED但还未调用accept的连接。前者满了则会直接丢弃新的SYN,后者满了则会导致已经完成了三次握手但连接会被丢弃。

还有就是大量连接会占用大量端口,特别是客户端(因为服务端可以一对多),并且客户端释放连接后的TIME_WAIT也会占用资源长达2MSL

因此服务器最大 TCP 连接数限制主要来自五个方面:

  1. 文件描述符限制
  2. 缓冲区等导致的内存消耗
  3. SYN队列 / accept队列大小
  4. CPU处理能力
  5. 端口数量限制

一台服务器最多能建立多少的TCP连接?

关于这个问题没有一个固定的结论,理论上服务端能支持的 TCP 连接数受四元组(源IP:源端口 + 目的IP:目的端口)组合的理论上限支配,但实际受系统资源限制(文件描述符、内存等)

首先我们需要解除一个理解误区:很多人会认为连接数与端口号直接相关,因为TCP 协议头部中的端口号是 16 位的,所以最多有 2¹⁶ 个,即 65536 个,但端口 0 有特殊含义,不能使用,因此可用端口数量是 65535。所以就有人认为连接数上限就是65535

这是个典型的误区,端口号限制的是“单端”:比如单个客户端IP对单个服务器端口,最多发起65535个连接(因客户端端口只有65535个)。

而服务器支持多客户端,只要客户端 IP 不同,每个客户端都能发起 65535 个连接,所以理论上连接数是“客户端IP数×客户端端口数”,而非单端口的 65535。

所以理论上限应该是2³²(客户端IP)× 2¹⁶(客户端端口)× 2¹⁶(服务器端口)= 2⁶⁴可以认为是无穷大

但实际会被文件描述符内存卡死,但最终的瓶颈是内存:

  • 文件描述符:Linux中每个 TCP 连接是一个文件,系统/用户/进程级都有限制(如系统,默认可能为 long的最大值,2⁶³-1)
  • 内存:每个TCP连接静态约占3KB内存(含sock、tcp_sock等数据结构),而实际会有收发缓冲区,会根据连接的数据流动态调整,可能会有几 MB。

按实际情况来说,一般每条 TCP 连接占用 100 KB ~ 300 KB 来估算是比较合理的(包括了结构体、收发缓冲区、协议栈额外开销、内核管理数据结构)。

我们折中取 200KB 来计算,如果是 8G 内存的服务器,70% 用于 TCP 连接。

连接数上限 ≈ (物理内存 × 0.7) ÷ 200 KB。

即 8 GB × 0.7 ÷ 200 KB ≈ 2.9万条连接

C10K 和 C10M 问题

早年服务器撑 1 万并发就是个大问题,叫 C10K。瓶颈主要在 select/poll 的 O(n) 遍历、进程/线程模型的上下文切换开销。

后来 epoll、kqueue 这类多路复用出来了,加上事件驱动架构,Nginx、Redis 单机轻松撑十万级并发。

现在的挑战是 C10M,单机撑千万连接。这时候内核协议栈成了瓶颈,得上 DPDK 绕过内核、用户态协议栈、多队列网卡、NUMA 亲和性这些黑科技。

长连接 vs 短连接的区别

短连接场景下,连接用完就关,瓶颈在 TIME_WAIT。主动关闭方会进入 TIME_WAIT 状态等 2MSL,在Linux下大概 60 秒。高并发短连接场景下 TIME_WAIT 状态的 socket 可能堆积几万个,吃掉大量端口和内存。

长连接场景下,连接建立后一直保持,瓶颈在内存和文件描述符。但每个连接的维护成本低,没有频繁握手挥手的开销,同样资源能撑更多有效连接。

所以高并发场景基本都是长连接 + 连接池,像数据库连接、消息队列连接、RPC 连接都是这样。

长连接因为可以在一次TCP连接中发起多次请求,因此更适合频繁请求的场景,可以避免多次建立连接。因为每次建立和销毁TCP连接都有 三次握手 + 四次挥手 + TIME_WAIT,即增加延迟,又因为TIME_WAIT导致端口号占用。

另外长连接还适合服务端主动推送消息的场景,比如Websocket,SSE等,短连接只能通过轮训实现。

而相反,对于一些低频访问场景或者是内存比较敏感的场景,可能长连接就不是那么适合。因为长连接由于需要维护连接,会占用更多的资源和内存,如果内存敏感,或者是访问次数很少,那维护这些资源的成本就显得很浪费

如果服务器有多个网卡、多个 IP,对连接数上限有影响吗?

有影响。服务端 IP 也是四元组的一部分,多个 IP 意味着可以监听多个地址,理论上限翻倍。比如服务器有两个 IP,各监听 8080 端口,对于同一个客户端来说就是两个不同的目的四元组,能建立两倍的连接。不过实际瓶颈还是内存和 CPU,多 IP 只是理论上限更高。

百万连接的场景下,每秒能处理多少请求?

这得看请求处理逻辑多重。如果是纯推送场景,比如消息广播,百万连接下每秒发几万条消息问题不大,瓶颈在带宽。如果每个请求都要查数据库、调外部接口,CPU 和 IO 很快就成瓶颈,QPS 可能只有几千。所以百万连接和百万 QPS 是两回事,前者考验的是连接维护能力,后者考验的是请求处理能力。

客户端连不上服务器,报 address already in use,是什么原因?

客户端端口用完了,或者之前的连接进入 TIME_WAIT 还没释放。可能有客户端频繁发起短连接,导致端口被TIME_WAIT占满


TCP和UDP的区别

TCP 是面向连接、可靠的流协议,它保证数据不丢、不乱、不错,适合对准确性要求高的场景,如传文件、浏览网页。

UDP 是无连接、不可靠的数据报协议,它主打一个 字,发出去就不管了,适合对实时性要求高、能容忍少量丢包的场景,如视频通话、游戏。

特性TCPUDP
连接方式面向连接,需要三次握手无连接,知道IP和端口,拿起来就发拿
可靠性可靠,保证数据送达、有序、无重复不可靠,不保证送达,可能丢包、乱序
流量控制/拥塞控制有滑动窗口和拥塞控制没有,发多快取决于应用层
顺序保证保证数据顺序不保证数据顺序
头部大小较大,20 字节起步较小,固定 8 字节
性能较低,延迟大较高,延迟小
数据传输模式字节流,像水流一样,无边界,需处理粘包数据报,像发快递,一个个包裹,有边界
适用场景文件传输、HTTP/HTTPS、邮件 (SMTP)视频会议、直播、在线游戏

TCP和UDP的报文结构

TCP头部至少包含20字节,包含源端口(2字节),目标端口(2字节),确认号(4字节),序列号(4字节),窗口大小(2字节),校验和(2字节)等等,因为TCP需要通过这些字段来支撑可靠传输,流量控制以及拥塞控制

UDP头部这是固定的8字节,源端口,目标端口,UDP长度,校验和,分别都占2字节。结构简单到没什么可说的,也正是因为简单才快。

字节流 vs 数据报

TCP 是字节流协议,建立连接后,就像接了一根水管。你发数据就像往管子里倒水。发送方倒了三次水(100ml, 200ml, 300ml),接收方可能一次接到了 600ml,也可能分两次接。水流之间没有明显的界限。因为没有界限,应用层必须自己规定“多长算一句话”。比如在 HTTP 里用回车换行符,或者在包头写明 Content-Length,否则接收方不知道在哪里切分数据。不然就容易发生粘包,拆包这些问题

而 UDP 是数据报协议,发数据就像寄包裹。你发一个 100 克的包裹(100 字节报文),对方收到的就是一个 100 克的包裹。包裹和包裹之间是独立的,界限清晰,不会粘在一起。但代价是如果报文太大超过 MTU,IP 层会分片,任何一片丢了整个报文就废了。

基于 TCP 的常见协议

1)HTTP/HTTPS:Web 的基础,HTTPS 在 HTTP 上加了 SSL/TLS 加密层

2)FTP:文件传输协议,用于上传下载文件

3)SMTP:简单邮件传输协议,用于发送邮件

4)POP3/IMAP:用于接收邮件

5)SSH:安全远程登录协议

基于 UDP 的常见协议

1)DNS:域名解析,查询报文小且对实时性有要求,用 UDP 刚好

2)QUIC:HTTP/3 的底层传输协议,在 UDP 上自己实现了可靠传输、拥塞控制、多路复用,相当于把 TCP 的功能搬到应用层,好处是可以快速迭代不受内核限制

3)实时音视频:Zoom、微信视频通话底层都用 UDP,丢几帧卡一下比等重传卡死强

Q&A

为什么 UDP 不可靠还有人用

可靠性是有代价的。TCP 为了保证可靠,得维护连接状态、做确认重传、做拥塞控制,这些都会增加延迟。

对于实时音视频场景,数据有时效性,一帧画面过了 100ms 还没到就没意义了,重传过来也是废数据。与其等重传,不如丢掉继续往前走,用后面的数据做插值补偿,体验反而更好。

对于像 DNS 这种小报文查询场景,一个请求一个响应就完事了,没必要建立连接走三次握手那套,直接 UDP 一来一回搞定,省时省事。

QUIC 既然是基于 UDP 的,那它怎么实现可靠传输?

QUIC 在应用层自己实现了一套类似 TCP 的机制。它有序列号、ACK、重传、拥塞控制这些,只不过都在用户态实现。还做了改进,比如用 Stream ID 区分不同的流,一个流丢包不影响其他流,解决了 TCP 的队头阻塞问题。用 UDP 只是为了能穿透现有网络设备,快速部署,本质上 QUIC 是个可靠传输协议。

UDP 也有校验和,为什么还说它不可靠?

UDP 的校验和只能检测数据有没有被破坏,检测到错了就直接丢掉,不会重传。不可靠说的是不保证送达、不保证顺序、不保证不重复,这些 UDP 都不管。校验和解决的是数据完整性,不可靠说的是传输可靠性,两码事。

校验和是为了防止数据在传输过程中由于网络问题,传输的数据发生了变化。

再传之前就会对 TCP/UDP 的头部 + 数据做一个数学计算,得到一个 16 位的校验值。

接收方会用同样算法再算一遍,看结果是否一致

一致 → 数据没坏 不一致 → 数据损坏


三次握手

TCP的三次握手其实就是客户端和服务端通过发送和确认三个包,保证双方的收发能力正常,且同步初始化序列号,并建立可靠连接的过程。

1)第一次握手(客户端 -> 服务端)

客户端发送一个 SYN 报文(SYN=1),并携带一个随机生成的初始化序列号(seq=x)。

此时客户端处于 SYN_SENT(同步已发送)状态。

2)第二次握手(服务端 -> 客户端)

服务端收到 SYN 后,回发一个 SYN+ACK 报文(SYN=1, ACK=1)。确认号是客户端的序列号 + 1(ack=x+1),同时自己也生成一个初始化序列号(seq=y)。

此时服务端处于 SYN_RCVD(同步收到)状态。

3)第三次握手(客户端 -> 服务端)

客户端收到 SYN+ACK 后,发送一个 ACK 报文(ACK=1)。确认号是服务端的序列号 + 1(ack=y+1)。

此时客户端进入 ESTABLISHED(已建立连接)状态。服务端收到 ACK 后,也进入 ESTABLISHED 状态,连接正式建立。

这里为什么ACK的值等于seq + 1呢?且为什么是1呢?

原因在于因为TCP是字节流,ACK的含义代表,我期望下一次收到的字节序号是多少,SYN虽然不携带数据,但 TCP 把 SYN 也算作占用了一个序号,接收方收到后需要将seq + 1代表你收到了本次的SYN报文,并期望下次的字节序号是xxx

为什么需要三次握手

首先,三次握手的主要原因并不是为了证明双方的收发能力,而是:

  • 防止历史连接的建立,减少通信双方不必要的资源消耗(首要原因)
  • 帮助通信双方同步初始化序列号

避免历史错误连接的建立

RFC 793 明确指出了使用三次握手的首要原因是:为了阻止历史的重复连接初始化导致的混乱

网络环境是复杂的,数据包可能会在网络中滞留很长时间。

假设只有两次握手

  1. 客户端发送了一个 SYN 请求,因为网络拥堵滞留了。
  2. 客户端超时,重发了新的 SYN 。
  3. 但是后来的那个滞留的旧 SYN 反而先到达了服务端。
  4. 服务端无法识别当前的请求是旧的请求还是新的请求,误以为是新的连接请求,于是回复 SYN+ACK 并直接进入 ESTABLISHED 状态
  5. 紧接着服务端给客户端发送数据
  6. 客户端接收后发现 ack 不对,于是发送 RST 终止连接。
  7. 新的 SYN 终于到达服务端,服务端依旧无法识别当前的请求是旧的请求还是新的请求,以为是新的连接请求,于是回复 SYN+ACK 并直接进入 ESTABLISHED 状态
  8. 终于建连成功

从上述流程可以得知,两次握手无法避免历史连接的建立,导致建立了一个无效的连接,还可能无效发送数据,白白浪费了资源。

因此为了避免这种情况发生,两次通信是不够的。客户端需要知晓服务端到底接受了哪个连接

  • 如果接受的是老连接,那么客户端需要告知服务端,这个连接不对!也就是 RST 通知。
  • 如果接受的是新连接,那么客户端就返回 ACK 告诉服务端 OK!这就使得一次握手至少需要 3 次。

因此只有三次握手,才能让客户端有“开口解释”的机会,确认服务端接受的连接是否正确,从而阻止历史连接的错误建立。

帮助通信双方同步初始化序列号

因为网络本身的不稳定性可能导致:

  • 数据丢失
  • 数据重复传输
  • 数据乱序

而 TCP 是一个可靠传输协议,它需要保证数据不丢失且有序的传输。基于上述的问题,TCP 引入了序列号,它使得:

  • 服务端可以根据序列号去重
  • 服务端可以根据序列号排序
  • 客户端针对为接收到 ACK 的序列号对应的数据包,可以重传

序列号是有序的,因此在通信的初始化阶段,双方就需要同步序列号,不然数据后面就都对不上了。

  • 客户端需要告诉服务端:“我发送数据的起始序号是 X”。
  • 服务端需要告诉客户端:“我发送数据的起始序号是 Y”。

这需要一个 发送-确认 的交互过程。

  1. 第一步:Client 告诉 Server 自己的 seq 是 X
  2. 第二步:Server 确认收到 X
  3. 第三步:Server 告诉 Client 自己的 seq 是 Y
  4. 第四步:Client 确认收到 Y。

这样双方的序列号才算同步完成。

从这里看上去好像需要四步,但其实中间Server确认收到X和告诉自己的seq是Y可以合并。因此,四次握手就可以减到三次了。

所以再次巩固一下三次连接:

  • 客户端通过 SYN 控制消息并携带自己期望的初始序列号 SEQ 给服务端
  • 服务端收到 SYN 消息之后,通过 ACK 控制消息以及 SEQ+1 来进行确认,并带上自己的 SEQ
  • 客户端通过 ACK 控制消息以及服务端的 SEQ+1 来进行确认,并且还能够在第三次握手通信的同时,直接携带数据进行传输

三次握手Q&A

为什么不是两次握手?

如上所述,两次握手无法防止历史连接导致的资源浪费,也无法保证服务端序列号的正确同步(服务端不知道客户端有没有收到自己的 SYN)。

为什么不是四次握手?

逻辑上确实是四次(SYN -> ACK -> SYN -> ACK),但为了效率,TCP 协议将中间的确认收到我也要连接 合并成了一个 SYN+ACK 报文,所以四次就优化成了三次。

其实,理论上 3 次以上的握手都行,但是 3 次就已经够用了,没必要选择更多的握手次数。

如果第三次握手的 ACK 丢了会怎么样?

客户端发送完第三次握手的ACK后就会进入ESTABLISHED状态,如果此时ACK丢了,服务端就会卡在SYN_RCVD状态,触发超时重传,SYN+ACK,客户端收到后会再发一次ACK,如果重新传了几次都没收到ACK,服务端可能会放弃这次半连接。客户端如果这时候发数据,服务端会发现这个连接没建好,直接回 RST 把它终止掉。

三次握手能携带数据吗?

第一次和第二次握手不能携带数据,因为连接还没建立,如果允许带数据会有安全风险,攻击者可以在 SYN 里塞大量数据进行攻击。第三次握手是可以携带数据的,因为这时候客户端已经知道服务端的接收能力是正常的,连接在客户端看来已经建立了。不过实际上很少这么用,因为这需要应用层配合,大多数场景下还是等连接建好再发数据。

什么是SYN Flood攻击?如何防御

SYN Flood是一种经典的网络攻击手段,攻击者利用TCP三次握手的机制,疯狂伪造ip发送SYN包,但不建立连接,从而通过大量半连接直接把服务器的半连接队列撑爆,导致正常用户连接不上。

具体原理是这样的:正常情况下客户端发 SYN,服务器回 SYN+ACK,客户端再回 ACK,三次握手完成。但攻击者只发第一步的 SYN 包,还伪造源 IP 地址,服务器回的 SYN+ACK 发到了一个不存在或者不相关的地址,自然收不到 ACK。

服务器就一直在 SYN-RECEIVED 状态等着,默认要等 63 秒才会放弃这个连接。攻击者每秒发几万个这种请求,服务器的半连接队列很快就满了,正常用户的 SYN 就进不来了。

为什么等63s?因为服务器收到SYN后,如果自己回复的SYN+ACK迟迟得不到回应,就会采取指数退避策略来重试,默认重试五次:1s、2s、4s、8s、16s、32s..... 越来越久。

在第 5 次重发后,Server 还会再等待 32s。如果还是没动静,Server 就会判定连接建立失败,将该连接从半连接队列中移除,释放资源。总耗时:大约 1s + 2s + 4s + 8s + 16s + 32s = 63秒。

半连接队列

Server 收到 SYN 后,会在半连接队列里创建一个条目,记录这个"正在握手中"的连接。这个队列也叫 SYN Queue。只有收到第三次握手的 ACK,连接才会从半连接队列移到全连接队列,等待 accept() 取走。

为什么SYN Flood攻击这么有效

因为收到SYN后,服务器默认的配置是,如果自己的SYN+ACK得不到回应会采取指数退避策略重试5次,一共大约就是63s。一个伪造的几十字节的SYN包,却要让服务器维持63s这个资源,每个半连接都要占用服务器内存,队列满了之后正常请求全部被拒绝。

而且攻击成本极低,攻击者只需要发一个小小的 SYN 包,服务器却要分配内存、创建数据结构、设置定时器、发送 SYN+ACK、等待超时重传。

防御手段

防御手段主要有:

1)SYN Cookie:核心思想是在收到 SYN 时不分配任何资源即不在半连接队列里存状态,把连接信息编码到 SYN+ACK 的序列号里。正常的客户端发送的第三次握手的ACK号减1就是这个cookie,服务器验证通过后再分配资源建立连接。攻击者伪造的 IP 收不到 SYN+ACK,自然也没法回正确的 ACK,服务器不用为它们浪费任何资源。Linux 下通过 tcp_syncookies 参数开启,默认是 1。

这属于一种降级机制,正常情况还是先塞半连接队列,因为无法确认究竟是正常连接还是恶意攻击,只有在半连接队列要塞满时,才会觉得可能发生了SYN Flood攻击,最终开启SYN Cookie防止继续塞爆半连接队列。

2)缩短 SYN+ACK 重试次数:把 tcp_synack_retries 调小,比如 2 或 3,让无效半连接更快释放,但也有副作用就是网络抖动时正常用户也可能被误杀。

3)增大半连接队列:治标不治本,但能扛住更大的攻击流量。

4)硬件防火墙/云防护:在网络层就把攻击流量过滤掉,不让它到达服务器。

SYN Cookie有什么缺点
  1. 序列号的空间有限,能编码的信息不多,比如 Window Scale、SACK 这些需要在握手阶段协商的选项,SYN Cookie 模式下可能丢失
  2. SYN Cookie 的计算有一定 CPU 开销,攻击量特别大的时候还是会有影响。所以 Linux 默认只有半连接队列快满的时候才启用 SYN Cookie,不是一直开着。
如何判断服务器是否正遭受SYN Flood攻击

用 netstat 看 SYN_RECV 状态的连接数,netstat -an | grep SYN_RECV | wc -l,正常情况下这个数不应该很大。如果突然飙升到几千上万,而且源 IP 很分散、很多是不存在的地址,基本就是被攻击了。还可以看 dmesg 日志,内核会打印"possible SYN flooding"之类的警告。

全连接队列满了会怎样?

要看 tcp_abort_on_overflow 参数。默认是 0,满了就静默丢弃 ACK,Client 会重传 ACK,有机会在队列腾出空间后完成连接。设成 1 的话,Server 会直接回 RST,Client 立刻知道连接失败。生产环境一般建议保持默认,让 Client 有重试机会,同时监控全连接队列溢出次数,一旦频繁溢出就得调大队列或者优化 accept() 处理速度。

初始序列号为什么要随机?

两个原因:

  1. 防止历史连接的包干扰新连接,假设上一个连接刚关闭,网络里还有它的残留包在飘,新连接如果用同样的四元组和相近的序列号,残留包可能被误认为是新连接的数据。随机 ISN 能大幅降低这种碰撞概率。
  2. 安全考虑,如果 ISN 是固定的或者可预测的,攻击者可以伪造 TCP 包进行会话劫持或者 TCP 重置攻击。随机 ISN 让攻击者很难猜到正确的序列号。现代系统一般用时间戳加上 Hash 来生成 ISN,既保证不重复又难以预测。

TCP的初始序列号是基于时间递增的 32 位序列号 + 一点随机扰动

RFC793 规定 ISN 要和一个假的时钟绑定在一起,ISN 每 4 微秒加 1,当超过 2^32 之后又从 0 开始,大概四个半小时左右发生 ISN 回绕。

后续的RFC 6528 提出了安全改进,用于增强 TCP 初始序列号的生成,抵御序列号预测攻击。 指出ISN = M + F(localip, localport, remoteip, remoteport, secretkey)

公式里 M 是每 4 微秒加 1 的计时器,F 是一个伪随机函数,可以基于 MD5 将源 IP、源端口、目的 IP、目的端口和一个密钥生成一个哈希值。这样即使攻击者知道了四元组,没有密钥也算不出 ISN,大大提高了安全性。

序列号预测攻击是什么

攻击者如果能预测到下一个 ISN 是多少,就能伪造 TCP 报文注入到连接里。经典的攻击场景是 TCP 会话劫持:攻击者伪装成客户端,向服务端发送带有正确序列号的数据包,服务端以为是正常客户端发的,就接受了。

早期一些操作系统的 ISN 生成算法太弱,攻击者只要抓几个包就能摸出规律,然后预测下一个 ISN。Morris 蠕虫就利用过这个漏洞。现代系统都用了 RFC 6528 这样的加密随机算法,预测难度从"做几道数学题"变成了"破解 MD5",基本没戏。

ISN 每 4 微秒加 1,那四个半小时回绕一次,会不会出问题?

正常情况下不会。TCP 有个 MSL 的概念,一个报文在网络里最多存活 2 分钟。四个半小时回绕一次,远大于 MSL,等 ISN 绕回来的时候,之前的报文早就被丢弃了,不会有序列号冲突。但如果网络带宽特别大、传输速度特别快,比如万兆网卡跑满的场景,序列号消耗得快,可能还没到四个半小时就用完了,这时候需要开启 TCP 时间戳选项PAWS来辅助判断报文新旧,这是个毫秒级单调递增的时间戳,如果收到的包的 TSval < 最近收到的 TSval → 丢弃。

序列号和确认号有什么关系?

序列号标识的是"我发的这段数据从第几个字节开始",确认号标识的是"我已经收到了第几个字节之前的所有数据,下一个期望收到第几个"。比如客户端序列号是 1000,发了 100 字节数据,服务端收到后回复 ACK,确认号就是 1100,意思是"1100 之前的都收到了,下次从 1100 开始发"。两者配合实现了 TCP 的可靠传输和流量控制。


四次挥手

整个过程可以分为四个步骤,我们假设是客户端先发起断开请求:

1)第一次挥手(客户端说:我没数据发了):客户端发送一个 FIN 包(Finish),并停止发送数据,进入 FIN_WAIT_1 状态。服务器收到 FIN 后,表示不再接收数据,但仍可能继续发送数据。

2)第二次挥手(服务端说:我知道了,你等会儿):服务端收到 FIN,回一个 ACK 包。此时服务端进入 CLOSE_WAIT(关闭等待)状态,客户端收到后进入 FIN_WAIT_2 状态。

3)第三次挥手(服务端说:我也没数据发了):服务端的数据终于发完了,它也发送一个 FIN 包给客户端,进入 LAST_ACK(最后确认)状态。

4)第四次挥手(客户端说:好的,再见):客户端收到 FIN,回一个 ACK 包。此时客户端进入 TIME_WAIT 状态,等待 2MSL 时间后才真正关闭(CLOSED)。服务端一收到这个 ACK,就立马关闭连接(CLOSED)。

为什么建立连接是三次,断开却是四次

因为半关闭的存在。建立连接时,服务端的 ACK(确认)和 SYN(同步)可以合并在一个包里发回去(这就是第二次握手),因为双方一开始都没数据。

但断开时,客户端发 FIN 只是代表客户端没数据了。服务端收到后,可能还有数据没处理完,所以服务端必须先回一个 ACK 稳住客户端,等自己事情办完了,再发自己的 FIN。

这一前一后分开发,就变成了四次。

挥手一定需要四次吗

不一定,特殊情况下可以是三次

如果服务端在收到客户端的 FIN 时,恰好也没有数据要发了,它就可以把 确认收到(ACK)我也要关(FIN) 合并成一个包发过去。这就类似握手时的合并,变成了三次挥手。

为什么有 TIME_WAIT 状态

主要作用有两个:

1)为了确保连接可靠关闭

客户端发了最后一个 ACK,如果这个 ACK 在网络中丢了。服务端收不到 ACK,会以为客户端没收到自己的 FIN,于是服务端会重发 FIN。

如果客户端此时已经跑路了(CLOSED),服务端就会收到 RST(报错),连接关闭就会报错。

所以客户端必须在 TIME_WAIT 状态等着,一旦收到重发的 FIN,就立马补发一个 ACK,告诉服务端“放心关吧,我真收到了”。

2)为了防止旧数据干扰新连接

旧连接关闭后,网络里可能还有一些“迟到”的旧数据包在网络中传输。

如果前一个连接刚断,立马在原来的 IP 和端口上建立了一个新连接。这时候,那个“迟到”的旧数据包终于到了,新连接会把它当成自己的数据收下,导致数据错乱。

为什么 TIME_WAIT 等待的是 2MSL

MSL(Maximum Segment Lifetime) 是 TCP 报文段在网络中可以存活的最大时间。RFC 793 定义的 MSL 时间是 2 分钟,Linux 实际实现是 30s,那么 2MSL 是一分钟

那为什么设置了 2MSL ? 也就是两倍?

这就好比一来一回的路程:

  • 去程:我发的 ACK 包,最长可能在路上跑 1 MSL。
  • 回程:如果对方没收到 ACK,重发的 FIN 包,最长也可能在路上跑 1 MSL。

所以等待 2MSL,就能保证“我发的 ACK 到达对方”以及“对方重发的 FIN 到达我”这两个最坏情况下的时间总和都涵盖在内。

如果 2MSL 后还没动静,说明对方肯定正常关闭了,也说明不可能存在之前的旧数据包了,因为无论是哪个阶段发的,等待2MSL都没到的话,那旧数据就已经不再存活了。

等待 2MSL 会产生什么问题

如果服务器主动关闭大量的连接(比如爬虫服务器),那么会出现大量的资源占用,需要等到 2MSL 才会释放资源。

如果是客户端主动关闭大量的连接,那么在 2MSL 时间内那些端口都是被占用的,端口只有 65535 个,客户端发起连接需要随机端口(范围通常是 32768-60999),如果都卡在 TIME_WAIT,就没法发起新连接了,会报错 Cannot assign requested address。

如何解决 2MSL 产生的问题

比如什么tcp_tw_recycle + tcp_timestamps,tcp_tw_reuse + tcp_timestamps,SO_REUSEADDR之类的,其实都不能说有一个可以很好解决服务器主动关闭大量的连接问题的方案,因此建议是服务端不要主动关闭,把主动关闭方放到客户端。毕竟服务器是一对很多很多服务,资源比较宝贵。

TIME_WAIT 状态占用的资源多吗?到底有多大影响?

对于内存而言,占用的内存其实并不高,每个 TIME_WAIT 连接大概占用 3.3KB 内存。如果有 10 万个 TIME_WAIT 连接,也就占用 330MB 左右。

真正的问题是端口耗尽,客户端的可用端口范围一般是 32768-60999,大概 2.8 万个,如果短连接频繁创建销毁,很容易把端口用光。

怎么查看系统当前有多少 TIME_WAIT 连接?

用 netstat 或 ss 命令。ss -tan state time-wait | wc -l 可以直接统计数量,netstat -an | grep TIME_WAIT 可以看详细列表。如果数量过多,要排查是不是短连接太多、或者应该用连接池的地方没用。

HTTP 长连接和短连接哪个会产生更多 TIME_WAIT?

短连接。HTTP/1.0 默认短连接,每次请求完就关闭,服务端主动关闭的话每个请求都会产生一个 TIME_WAIT。HTTP/1.1 默认长连接 keep-alive,一个连接可以复用多次请求,关闭频率大大降低。所以高并发场景下长连接是标配,Nginx 默认 keepalive_timeout 是 75 秒。

为什么 Linux 把 MSL 从标准的 2 分钟改成了 30 秒?

2 分钟是 RFC 793 在 1981 年定的(很早之前了),当时网络条件差、延迟高。现代网络环境下,数据包很少能在网络里存活那么久,30 秒已经足够覆盖绝大多数情况了。缩短 MSL 可以更快地回收 TIME_WAIT 资源,对高并发服务器更友好。

FIN_WAIT_2 状态会一直等下去吗?如果服务端一直不发 FIN 怎么办?

不会无限等。Linux 下 FIN_WAIT_2 状态有个超时时间,默认是 60 秒,由 tcp_fin_timeout 参数控制。超时后连接会被强制关闭。这种情况一般是服务端程序有 bug,比如没有正确调用 close()。

CLOSE_WAIT 状态积压很多是什么原因?

典型的服务端 bug。服务端收到了客户端的 FIN 并回了 ACK,但是应用层代码没有调用 close() 关闭 socket,连接就一直卡在 CLOSE_WAIT。常见原因是代码里 socket 忘记关闭、异常处理没写好、或者死锁导致程序卡住。用 netstat 看到大量 CLOSE_WAIT 就该查代码了。

主动关闭方一定是客户端吗?

不一定。TCP 连接是对等的,谁先调用 close() 谁就是主动关闭方。比如 HTTP/1.0 默认是服务端主动关闭,HTTP/1.1 开启 keep-alive 后通常是客户端主动关闭。服务端检测到客户端超时、或者主动踢掉空闲连接时,也是服务端主动关闭。不过通常建议由客户端主动发起关闭,因为服务器通常需要服务多个客户端,大量的TIME_WAIT可能导致服务器的端口耗尽


滑动窗口

TCP 滑动窗口最核心的作用就是做流量控制,让发送方知道接收方还能吃下多少数据,别一股脑往外发结果把人家缓冲区撑爆了。

同时,滑动窗口还解决了效率问题。如果每发一个包都要傻等 ACK 回来才能发下一个,那网络利用率就太低了。有了滑动窗口,发送方可以在窗口范围内连续发多个包,不用一个个等确认,吞吐量直接上去了。

滑动窗口的原理

网络状况是动态变化的,有时候链路畅通无阻,有时候堵得水泄不通。发送方不能蒙着头一个劲儿发,得知道接收方的承受能力。

TCP 报文头里有个 Window 字段,接收方通过这个字段告诉发送方"我的接收缓冲区还剩多少空间"。发送方根据这个值来控制发送速率,这就是滑动窗口机制。

发送方维护的窗口大致为:

已收到 ACK 的数据已经发出去但是还没收到 ACK 的数据在窗口内可以发送但是还没发送的数据还不能发送的数据。

窗口大小为 0 会怎样

当接收方缓冲区满了,它会通告窗口大小为 0,发送方收到后就停止发送。但这时候会有个问题:接收方处理完数据、缓冲区腾出空间后,怎么通知发送方继续发?

TCP 用零窗口探测来解决这个死锁。发送方会定期发一个 1 字节的探测包,接收方收到后必须回复当前窗口大小。一旦窗口不再是 0,数据传输就恢复了。

滑动窗口和拥塞控制的区别

这俩经常被搞混,但解决的问题完全不一样:

维度滑动窗口拥塞控制
关注对象接收方的处理能力网络链路的承载能力
控制变量rwnd 接收窗口cwnd 拥塞窗口
信息来源接收方通过 TCP 报文告知发送方根据丢包、RTT 自己推算
典型场景接收方 CPU 慢、缓冲区小中间路由器负载高、带宽不够

实际发送时,发送窗口取 rwnd 和 cwnd 的较小值。就算接收方说我能收 64KB,但网络撑不住,发送方也得悠着点。

窗口扩大选项

TCP 报头里 Window 字段只有 16 位,最大只能表示 65535 字节。在早期低带宽网络够用了,但现在动辄 1Gbps 的带宽,64KB 的窗口就成了瓶颈。

RFC 1323 引入了 Window Scale 选项,在三次握手时协商一个缩放因子,最大可以把窗口扩展到 1GB。高带宽高延迟的网络环境下,这个选项对吞吐量提升非常明显。

如果接收方一直通告零窗口,发送方会怎么处理?

发送方会启动持续计时器,定期发零窗口探测报文。这个探测包只有 1 字节,接收方必须响应。如果一直是零窗口,探测会按指数退避,但不会无限等下去,最终可能触发超时断开连接。

什么是rwnd,什么又是cwnd

接收窗口(rwnd)——流量控制

  • 接收方告诉发送方:我还能接收多少数据
  • 表示:我现在还能接收多少数据
  • 防止:把接收方内存撑爆

在 TCP 头里:

Window Size = 接收窗口大小

拥塞窗口(cwnd)——拥塞控制

  • 发送方根据网络情况估算的“当前网络能承受的数据量”由 发送方自己维护
  • 表示:当前网络大概能承受多少数据
  • 防止:把网络撑爆

TCP传送包,需要得到确认,才会发送下一个包吗?

首先TCP并不是严格按照发一个包 -> 等待ACK -> 再发下一个包 这样的逻辑。如果是这样的话那效率会非常低,尤其是高带宽、高延迟网络。

TCP利用滑动窗口,将数据包分成以下区域

已收到 ACK 的数据已经发出去但是还没收到 ACK 的数据在窗口内可以发送但是还没发送的数据还不能发送的数据。

因此只要是在窗口内的数据包都能发送,收到对应的ACK后窗口再向右移动加入新的可发送的数据包,这样使得既不会发送太快导致挤压接收方缓冲区,也不会发一个等一个导致性能底下

tcp建立连接的时候,是怎么确定滑动窗口大小的呢?

在三次握手时,第一次握手客户端会告诉服务端自己的接收缓冲区,第二次握手的时候服务端会告诉客户端自己的接收缓冲区大小。这样就初始化好了双方的滑动窗口(现代TCP支持更新窗口大小)

滑动窗口的调整会受哪些制衡

主要是流量控制和拥塞控制。

流量控制主要是防止接收方的缓冲区溢出,因此滑动窗口不会超过 接收端能接收的最大容量

拥塞控制主要是防止网络拥塞,保证全网稳定,这个是由发送方维护的。通过慢启动、拥塞避免、快速重传、快速恢复等算法维护一个拥塞窗口,以根据实时的网络状态调整窗口大小,避免网络故障。

综合来看滑动窗口的大小就是拥塞窗口和滑动窗口之间的最小值

假如信号变差了,滑动窗口会受到什么影响呢?

信号变差后,主要会影响拥塞窗口,进而影响滑动窗口。

信号变差后,可能会频繁出现丢包,导致重传,出发慢启动,拥塞避免,快速重传,快速恢复等机制导致拥塞窗口缩小,进而导致滑动窗口变小

send命令调用成功是怎么保证对端收到数据

send() 调用成功 ≠ 对端已经收到,因为数据是被复制到了发送缓冲区,后续TCP负责将数据分段分装成TCP报文发送。因此send() 返回成功,只意味着 数据已经被内核接收并准备发送,相当于是send只是将快递放到了快递公司,但不代表你已经收到快递,实际还需要等快递小哥也就是TCP来确保快递安全到你手上

而TCP通过序列号 + ACK确认机制 + 超时重传等机制确保数据的传输有序可靠不丢失


超时重传

超时重传解决的核心问题是:数据包丢了怎么办

网络环境是不稳定的,路由器可能过载丢包,链路可能出故障,数据包到不了目的地是常有的事。而TCP 作为可靠传输协议,不能让数据丢了就丢了,必须有兜底机制。

超时重传的逻辑很简单:发送方发出数据后启动一个计时器,如果在规定时间内没收到 ACK 确认,就认为这个包大概率丢了,于是重新发一遍。这个"规定时间"叫 RTO,怎么定这个值是超时重传的核心难题

RTO怎么定?

TCP 的可靠性是靠确认号的,比如我发给你1、2、3、4这4个包,你告诉我你现在要 5 那说明前面四个包你都收到了,就是这么回事儿。

不过这里要注意,SeqNum 和 ACK 都是以字节数为单位的,也就是说假设你收到了1、2、4 但是 3 没有收到你不能 ACK 5,如果你回了 5 那么发送方就以为你 5 之前的都收到了。所以只能回复确认最大连续收到包,也就是 3。

在上面的例子中,发送方不清楚 3 这个包到底是还没到呢还是已经丢了,于是发送方需要等待,这等待的时间就比较讲究了。

如果太心急可能 ACK 已经在路上了,你这重传就是浪费资源了,如果太散漫,那么接收方急死了。

所以这个等待超时重传的时间很关键,怎么搞?我估摸一下正常来回一趟时间是多少不就好了,我就等这么长。

这就来回一趟的时间就叫 RTT(一个数据段从发送 → 对方收到 → ACK 返回 → 发送方收到 ACK 的时间),然后根据这个时间制定超时重传的时间 RTO。

RTO 具体要怎么算?首先肯定是采样,然后一波加权平均得到 RTO。

RFC793 定义的公式如下:

1、先采样 RTT

2、SRTT = ( ALPHA * SRTT ) + ((1-ALPHA) * RTT)

3、RTO = min[UBOUND,max[LBOUND,(BETA*SRTT)]]

ALPHA 是一个平滑因子取值在 0.8-0.9之间,UBOUND 就是超时时间上界-1分钟,LBOUND 是下界-1秒钟,BETA 是一个延迟方差因子,取值在 1.3-2.0。

重传时的 RTT 采样问题

在上述关于RTO如何定的讨论中,我们得到了一个结论,我直接采样一个数据端从发送到收到到ACK返回再到发送方收到ACK的时间,然后通过一系列加权平均的计算即可得到RTO。

但随之而来的问题是:RTT 采样的时间用一开始发送数据的时间到收到 ACK 的时间作为样本值还是重传的时间到 ACK 的时间作为样本值?

  1. 如果是用一开始发送数据的时间到收到 ACK 的时间作为样本值,流程就是第一次发送 -> ACK返回超时,开始重传 -> 收到返回ACK,就会发现算长了
  2. 如果是重传的时间到 ACK 的时间作为样本值,流程就是第一次发送 -> ACK返回超时,开始重传 -> 就在此时,第一次发送的ACK返回了,这样就会发现算短了

问题的根本原因在于:你不知道这个 ACK 到底是回复谁的,第一种情况就是你用第一次发送数据包到重传ACK回来的时间算做RTT,但实际上第一次的ACK根本没回来。而第二种情况则是,你用重传数据包到第一次数据包的ACK返回的时间作为RTT,但实际这过程很短。

所以怎么办?发生重传的来回我不采样不就好了,我不知道这次 ACK 到底是回复谁的,我就不管他,我就采样正常的来回。

这就是 Karn / Partridge 算法,不采样重传的 RTT。

但是不采样重传会有问题,比如某一时刻网络突然就是很差,你要是不管重传,那么还是按照正常的 RTT 来算 RTO, 那么超时的时间就过短了,于是在网络很差的情况下还疯狂重传加重了网络的负载。

因此 Karn 算法就很粗暴的搞了个发生重传我就将现在的 RTO 翻倍,就是这么简单粗暴。

但是这种平均的计算很容易把一个突然间的大波动,平滑掉,所以又搞了个算法,叫 Jacobson / Karels Algorithm。

它把最新的 RTT 和平滑过的 SRTT 做了波计算得到合适的 RTO,仅做了解即可。

超时重传的代价

超时重传虽然能保证可靠性,但代价不小:

1)等待时间长。RTO 通常是 RTT 的好几倍,如果真的丢包了,发送方得傻等一段时间才能反应过来。这段时间内吞吐量直接降到 0。

2)触发拥塞控制。一旦发生超时重传,TCP 会认为网络出现严重拥塞,直接把拥塞窗口砍到 1 个 MSS,然后重新慢启动。这对吞吐量的打击是毁灭性的。

3)可能重传已经到达的数据。有时候数据没丢,只是网络抖动导致 ACK 回来晚了,这种情况下重传纯属浪费带宽。

正因为超时重传代价太大,TCP 后来又引入了快速重传机制作为补充,收到 3 个重复 ACK 就立即重传,不用傻等超时。

如果 RTO 设置不合理会有什么后果?

RTO 太小,正常的包还没回来就开始重传,浪费带宽还可能加剧网络拥塞。RTO 太大,真丢包了要等很久才能恢复,用户体验差。所以 RTO 必须动态计算,跟着网络状况走。早期实现用固定值或者简单平均,效果很差,现在都是 Jacobson 算法,把 RTT 的波动也考虑进去了。


快速重传

超时重传是时间驱动的,RTO 通常是 RTT 的好几倍,一般在几百毫秒甚至秒级。如果网络真的崩了,等这么长时间无所谓。但很多时候网络状况是好的,只是某个包恰好丢了,后面的包都正常到达。这种情况下傻等超时就太浪费了。

快速重传是数据驱动的。当发送方连续收到 3 个重复 ACK,说明后面的包都正常到达了,只有中间某个包没到,大概率是丢了。既然网络畅通,立马重传就行,没必要等超时计时器。

通过快速重传,丢包恢复时间从几百毫秒缩短到几个 RTT,对吞吐量的提升非常明显。

为什么是三次重复ACK?而不是一次两次或者四次五次

TCP 收到乱序包时也会发重复 ACK,网络本身就可能存在乱序,先发的包后到是常有的事。如果只收到 1-2 个重复 ACK 就认定丢包,误判率太高,很可能那个包只是稍微迟到了。

3 个重复 ACK 意味着后面至少 3 个包都到了,中间那个大概率真丢了。这是个经验值,在大多数网络环境下能很好地平衡"快速响应"和"避免误判"。

有些 TCP 实现支持配置这个阈值,但默认 3 次是 RFC 定义的标准行为。

快速重传的局限性

快速重传虽好,但也有覆盖不到的场景:

1)窗口末尾的包丢了。没有后续包能触发重复 ACK,只能等超时。

2)连续丢多个包。快速重传一次只能触发一个包的重传,剩下的还是得靠超时或者后续的重复 ACK。

3)网络完全断开。一个包都收不到,更别说重复 ACK 了。

所以快速重传和超时重传是互补关系,快速重传处理常见的单包丢失场景,超时重传兜底处理极端情况。

快速恢复

快速重传通常和快速恢复配合使用。传统超时重传会把拥塞窗口砍到 1 个 MSS 然后慢启动,代价太大。

快速恢复的逻辑是:既然收到了 3 个重复 ACK,说明网络还在正常工作,只是丢了一个包,没必要从头来。所以拥塞窗口只减半,然后直接进入拥塞避免阶段,不走慢启动。这样吞吐量的恢复快得多。

RFC 5681 定义了这套机制的标准行为:

1)收到 3 个重复 ACK,ssthresh 设为当前 cwnd 的一半

2)cwnd 设为 ssthresh + 3 * MSS

3)每收到一个重复 ACK,cwnd 增加一个 MSS

4)收到新的 ACK 后,cwnd 设为 ssthresh,进入拥塞避免

快速重传和 SACK 的配合

没有 SACK 的快速重传有个问题:不知道除了触发点那个包之外还有哪些丢了。重传一个包后,如果还有其他丢的,得再等 3 个重复 ACK 或者超时。

有了 SACK,接收方会精确告知哪些段收到了、哪些没收到。发送方可以一次性把所有丢失的段都重传出去,恢复效率大幅提升。这就是 SACK 配合快速重传的威力。

快速重传解决的是"什么时候触发重传",收到 3 个重复 ACK 就动手,不用傻等超时。SACK 解决的是"重传哪些数据",精确定位丢失的段。两者配合,既快又准。没有 SACK 的快速重传只能重传触发点那个包,后面如果还有丢的,还得再等 3 个重复 ACK 或者超时。

什么是SACK

SACK 允许接收方“明确告诉发送方:我已经收到了哪些不连续的数据段”,从而让发送方只重传真正丢失的部分,而不是傻傻地从缺口开始全重传。

在没有SACK之前,假设发送方发送了这些数据

1   2   3   4   5   6

但接收方实际收到的是:

1   2   ❌   4   5   6
        ↑ 3 丢了

此时即便接收方收到了4,5,6的数据也只能回复ACK = 3。这样的后果就是,接收方回复ACK = 3,但发送方并不知道4,5,6接收方收到没,所以要么就是:

  1. 等三个重复ACK,然后重传3
  2. 或者等超时,重传3之后的数据

假设中间丢失的数据包很多,那么就会反复等超时或等三个ACK,严重浪费带宽。

因此SACK专门为这种痛点而生,它让接收方在 ACK 里额外告诉发送方:“我还收到了哪些不连续的区间”

有了SACK后就长这样

ACK = 3
SACK = [4,7)

含义是:

  • ACK = 3: 👉 我只按序确认到 2
  • SACK = [4,7): 👉 4、5、6 我已经收到了
  • 区间是 左闭右开
1 2 ❌ 4 5 6 ❌ 8 9
     ↑ 3 丢了 ↑ 7丢了

以上述为例,发送方发送1-9的数据,但接收方丢失了数据3和7

  1. 超时重传就是大保底,因为代价太高。对于这种情况需要等待一个RTO后,发送3的ACK超时,于是重传3,收到ACK后又等了一个RTO发现7的ACK超时,又重传。需要经过两个RTO才能收到这两个丢失的数据,太费时间了。
  2. 快速重传则是先收到三个连续ACK = 3,得知数据3丢失,立刻重传,后续配合快速恢复,当收到的ACK = 7时,由于7不能覆盖之前所有进入快速恢复前已发送的数据如8,9,于是判定数据7丢失,并且不会退出快速恢复阶段,立刻重传对应数据包,知道收到ACK = 10,发现覆盖了之前的数据才退出快速恢复阶段。(在Reno版本下,如果收到了ACK = 7就会退出快速恢复,又需要等三个ACK = 7才会重传)
  3. SACK则是能准确知道接收方收到了哪些不连续的段,然后准确重传缺失部分,比如在2,4,5时都会发送ACK = 3,但此时4,5已经收到了,就会发送ACK = 3,SACK = [4,6)。然后7丢失,收到后面的8,9时又会发送ACK = 3, SACK = [4,6), [8,10)

如果网络设备不支持 SACK,但两端都支持,会发生什么?

如果中间设备只是透传不认识的选项,没问题。但有些老防火墙会把不认识的 TCP 选项直接删掉,这就麻烦了。两端握手时协商成功,以为对方支持,但实际数据传输时 SACK 信息根本到不了发送方。这种情况下不会出错,但 SACK 的优化效果就没了,丢包恢复会退化成传统模式。

D-SACK

D-SACK 是 SACK 的扩展,用于告知发送方它可能错误重传了已经收到的数据段。有助于检测网络中的重复数据包、误判丢包的情况,并进行相应的调整。

它利用 SACK 的第一段来描述重复接受的不连续的数据序号,如果第一段描述的范围被 ACK 覆盖,说明重复了。比如我都 ACK 到 6000 了你还给我回 SACK 5000-5500 呢?

说白了就是从第一段的反馈来和已经接受到的 ACK 比一比,参数是 tcp_dsack,Linux 2.4 之后默认开启。

那知道重复了有什么用呢?

1)知道重复了说明对方收到刚才那个包了,所以是回来的 ACK 包丢了。

2)是不是包乱序的,先发的包后到?

3)是不是自己太着急了,RTO 太小了?

4)是不是被数据复制了,抢先一步呢?

5)可以避免误判网络拥塞,导致不必要的性能损失

D-SACK 让发送方知道自己是不是重传过头了。比如发送方以为丢了就重传,结果原来的包只是迟到了,接收方收到两份一样的数据。通过 D-SACK,发送方知道"我刚才那个重传是多余的"。这个信息很有价值:可能是 RTO 设太短了需要调大,也可能是网络有乱序需要调整拥塞窗口策略。长期来看能让 TCP 的重传决策更准确。

快速重传和超时重传可能同时触发吗?

不会同时触发,它们是互斥的。快速重传触发后会重置超时计时器,相当于"抢先"处理了丢包。只有快速重传没机会触发的场景才会等到超时,比如窗口末尾丢包或者网络完全断开。

如果网络乱序很严重,快速重传会不会误判导致频繁重传?

会的,这是个已知问题。极端乱序的网络下,包的到达顺序和发送顺序差很多,很容易凑出 3 个重复 ACK。一些 TCP 变种会引入额外的乱序检测逻辑,比如根据 SACK 信息判断是乱序还是真丢包(D-SACK),或者动态调整触发阈值。Linux 内核里有 tcp_reordering 参数就是干这个的,默认值 3,如果检测到经常乱序会自动调大。


拥塞控制

TCP 拥塞控制的核心目的就是别把网络撑爆,发送方要根据网络状况动态调整发送速率。主要分四个阶段:

1)慢启动:连接刚建立时,发送方不知道网络能承受多少,所以从一个 MSS 开始试探。每收到一个 ACK 就把拥塞窗口 cwnd 翻倍,指数级增长,直到碰到慢启动阈值 ssthresh 或者丢包。

2)拥塞避免:cwnd 到了 ssthresh 之后就不能再这么激进了,改成每个 RTT 只增加一个 MSS,线性增长,小心翼翼地探测网络上限。

3)快速重传:发送方连着收到 3 个重复 ACK,说明某个包大概率丢了,不用傻等超时,直接重传那个包。

4)快速恢复:快速重传之后不需要从头再来,把 cwnd 减半、ssthresh 设为新的 cwnd,然后继续线性增长,快速恢复到丢包前的传输速率。

慢启动和拥塞避免的细节

MSS指的是一个 TCP 段中,数据部分的最大长度,即TCP 每次最多往一个段里塞多少“业务数据”。

他的单位是字节,通常是1460字节(常见以太网 MTU 1500 - TCP/IP 头部 40 字节)

如果要发送6000字节的数据,就会拆分成多个段来分开发送,比如:

  • 段 1:序列号 1~1460

  • 段 2:序列号 1461~2920

  • ……

然后每个段最多携带 MSS 字节数据,通常是1460.

为什么要定义一个MSS呢?因为如果你一次性就发送6000字节的数据,而实际网络环境是很不稳定的,如果这个数据包丢了,那么整个6000字节的数据都要重传,代价很大。所以TCP宁可拆分成很多小段发送,这样即便发生丢失,最坏才是全部重传,而绝大部分情况下要么就是数据完整,要么就丢一两个。

慢启动时,初始 cwnd 为 1 个 MSS,每收到一个 ACK 就 cwnd++。因为一个 RTT 内能收到的 ACK 数量等于当前 cwnd,所以实际效果是每个 RTT cwnd 翻倍,指数增长。

到了 ssthresh 之后进入拥塞避免,这时每收到一个 ACK,cwnd 增加 1/cwnd。一个 RTT 收到 cwnd 个 ACK,加起来刚好增加 1,变成线性增长。

为什么慢启动要从 1 开始指数增长,不直接用一个大窗口?

因为发送方压根不知道网络能承受多少。如果一开始就发一大堆包,可能直接把中间某个路由器的缓冲区撑爆,导致大面积丢包。指数增长的好处是既能快速探测到网络容量,又不至于一上来就把网络打崩。

ssthresh 初始值一般设多少?

RFC 建议初始值设成一个很大的数,比如 65535 字节,让慢启动能充分探测网络容量。实际发生拥塞之后,ssthresh 才会被设成一个有意义的值。

丢包时的处理策略

TCP 重传有两种情况,处理方式不一样:

超时重传说明情况比较糟糕,可能是网络严重拥塞。处理方式比较激进:ssthresh 设为当前 cwnd 的一半,cwnd 直接打回 1,重新进入慢启动。

快速重传说明只是丢了个别包,网络还没那么差。这里有两种实现:

  • TCP Tahoe:跟超时重传一样,cwnd 打回 1,比较保守
  • TCP Reno:cwnd 减半,ssthresh 设为新的 cwnd,进入快速恢复

快速恢复的演进

在讲快速重传和快速恢复的时候,我们提到了:在TCP Reno里他的快速恢复只要收到一个ACK就退出快速恢复阶段了。如果同时丢了多个包,就完了,只能每次都等重复ACK或者超时,这样频繁的触发快速重传和快速恢复,会导致cwnd指数下降

于是New Reno就解决了这个问题,它会观察重传后收到的 ACK 是不是已发送的最大序号。比如发了 1、2、3、4,对方没收到 2 但收到了 3、4,重传 2 之后 ACK 应该直接跳到 5。如果 ACK 不是 5,说明还有其他包丢了,继续重传,直到确认全部包都收到了才退出快速恢复。

还有个 FACK 算法,它基于 SACK 来做拥塞控制。有了 SACK 就知道具体哪些包丢了,不用像 New Reno 那样一个一个试。

主流拥塞控制算法对比

算法核心思路适用场景
Tahoe/Reno丢包后 cwnd 重置或减半低延迟、中等带宽网络
Vegas基于 RTT 变化提前感知拥塞延迟稳定的网络
New Reno改进快速恢复,处理多丢包中高带宽、低丢包率网络
BIC二分查找调整 cwnd高带宽延迟乘积网络
CUBIC立方函数增长曲线Linux 默认算法,适用广泛
BBR测量瓶颈带宽和 RTT,不依赖丢包需要最大化带宽利用率的场景

BBR 算法为什么火

传统算法都是靠丢包来判断拥塞,但丢包已经是拥塞的结果了,属于"事后诸葛亮"。Google 搞的 BBR 换了个思路:直接测量链路的瓶颈带宽和最小 RTT,主动控制发送速率,不等丢包。

BBR 在 YouTube 上线后,全球平均吞吐量提升了 4%,某些地区甚至提升了 14%。现在 Linux 4.9+ 都支持 BBR,用一行命令就能开启:

sysctl net.ipv4.tcp_congestion_control=bbr

不过 BBR 也有争议,在共享网络环境中可能会抢占其他流量的带宽,公平性存疑。

CUBIC 为什么用立方函数?

立方函数的特点是离目标值远的时候增长快,靠近目标值时增长慢。cwnd 远低于上次丢包点时快速恢复,接近时又变得保守,两头快中间慢,兼顾了效率和稳定性。BIC 用二分查找虽然也能达到类似效果,但窗口调整不够平滑。

BBR 和传统算法混用会有什么问题?

BBR 不靠丢包控制速率,它会一直把带宽吃满直到测出瓶颈。如果网络里同时跑着 CUBIC 这类传统算法,CUBIC 收到丢包就退让,BBR 不退让,长期下来 BBR 会抢走大部分带宽。这就是所谓的公平性问题,Google 内部用 BBR 没问题,但公网混用就需要谨慎。

拥塞控制的各阶段算法的补充说明

拥塞控制:TCP 有拥塞控制的机制, 通过慢启动、拥塞避免、拥塞发生,快速重传和快速恢复等算法调整发送速率来避免网络拥塞。当网络出现拥塞时,TCP 会降低发送速率,以减少网络负载,保证数据的可靠传输。

拥塞控制的慢启动门限,拥塞窗口变化两种情况如下: 慢启动->拥塞避免->超时重传->慢启动:

img

慢启动->拥塞避免->快速重传->快速恢复->拥塞避免:

img

TCP协议的拥塞控制主要通过五个算法来实现:慢启动、拥塞避免、超时重传、快速重传和快速恢复。

  1. 慢启动:发送方开始时设置一个较小的拥塞窗口大小,在每收到一个对新的报文段的确认后,每当成功发送跟拥塞窗口大小等量的数据后,拥塞窗口大小就会翻倍,以指数方式增长,直到拥塞控制窗口达到慢启动门限。
  2. 拥塞避免:当窗口大小达到慢启动门限后,就进入拥塞避免阶段,每当成功发送跟拥塞窗口大小等量的数据后,拥塞窗口大小就会增加一个报文段的大小,以线性方式增长。
  3. 拥塞发生:随着发送速率慢慢增长,可能网络会出现拥塞,发生了数据包丢失,这时候就需要重传数据,重传机制主要有两种,一个是超时重传和快速重传。
  • 超时重传:当发生了超时重传,慢启动门限会设置为拥塞窗口的一半,并且将拥塞窗口恢复为初始值,接着,就重新开始慢启动,发送速率就会瞬间下降了很多。
  • 快速重传和快速恢复:当发送方连续收到三个重复确认时,就认为发生了丢包,这时候拥塞窗口会减少到原来的一半,然后慢启动门限设置为减少后的拥塞窗口大小,然后进入到快速恢复阶段,这时候会把拥塞控制窗口+3,3 的意思是确认有 3 个数据包被收到了,然后重传丢失的报文,如果收到重传丢失报文的 ACK 后,将拥塞窗口设置为慢启动门限,这样就直接进入拥塞避免,继续增大发送速率。

这些拥塞控制算法,可以让TCP协议能够根据网络状况动态调整发送速率,避免因为过大的流量导致网络拥塞。

深入挖掘-慢启动

TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,这不是给网络添堵吗?

慢启动的算法主要记住一个规则:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。

这里假定拥塞窗口 cwnd 和发送窗口 swnd 相等,下面举个例子:

  • 连接建立完成后,一开始初始化 cwnd = 1,表示可以传一个 MSS 大小的数据。
  • 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个
  • 当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个
  • 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1, 4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个。

慢启动算法的变化过程如下图:

image.png

可以看出慢启动算法,发包的个数是指数性的增长。

慢启动门限

在慢启动涨到头之后,会有一个东西叫做慢启动门限的东西。

  • 当 cwnd < ssthresh 时,使用慢启动算法。
  • 当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。

拥塞避免算法

当拥塞窗口 cwnd 「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。

一般来说 ssthresh 的大小是 65535 字节。

那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。

接上前面的慢启动的例子,现假定 ssthresh 为 8:

  • 当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。

拥塞避免算法的变化过程如下图:

image.png

所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。

就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。

当触发了重传机制,也就进入了「拥塞发生算法」。

拥塞发生算法

当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:

  • 超时重传
  • 快速重传

这两种使用的拥塞发送算法是不同的,接下来分别来说说。

超时重传

当发生了「超时重传」,则就会使用拥塞发生算法。

这个时候,ssthresh 和 cwnd 的值会发生变化:

  • ssthresh 设为 cwnd/2,
  • cwnd 重置为 1 (是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1)

拥塞发生算法的变化如下图:

image.png

接着,就重新开始慢启动,慢启动是会突然减少数据流的。这真是一旦「超时重传」,马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。

就好像本来在秋名山高速漂移着,突然来个紧急刹车,轮胎没办法接受。

快速重传

还有更好的方式,前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候,发送三次相同的 ACK,于是发送端就会快速地重传,不必等待超时再重传。

TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:

  • cwnd = cwnd/2 ,也就是设置为原来的一半;
  • ssthresh = cwnd;
  • 进入快速恢复算法

快速恢复

快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO 超时那么强烈。

正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新了:

  • cwnd = cwnd/2 ,也就是设置为原来的一半;
  • ssthresh = cwnd;

然后,进入快速恢复算法如下:

  • 拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了);
  • 重传丢失的数据包;
  • 如果再收到重复的 ACK,那么 cwnd 增加 1;
  • 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;

快速恢复算法的变化过程如下图:

image.png

也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。

快速恢复算法中,会把cwnd设置为ssthresh + 3?

首先,在快速重传阶段,只有重复收到三个相同ACK才会出发快速重传,他会把ssthresh设置为cwnd / 2, 然后把cwnd设置为ssthresh。

需要注意的是,每一个重复 ACK,都意味着:有一个报文段已经成功到达了接收端,所以这里收到三个重复ACK代表窗口内已经至少有3个报文段被接收方收到了,所以说明网络中至少腾出了 3 个 MSS 的空间

那么既然这 3 个包已经安全到达,那我就假装窗口里还能再放 3 个包,所以+3,确保在快速恢复阶段,真的能快速把缺失的数据包都发送给接收方

快速恢复算法过程中,为什么收到新的数据后,cwnd 设置回了 ssthresh ?

之前的+3只是临时账本,是因为有三个数据已经在那个情况被接受了,但是退出快速阶段后,这种情况已经被处理了,又回了正常的状态。所以需要把+3撤销掉。所以会重新设置为ssthresh,正式进入 拥塞避免(线性增长),不再使用快速恢复的特殊规则

post.comments