TCP 的"连接"到底是什么

物理世界里并没有一根线真的从你连到服务器,数据包依然是一个个独立地在互联网上乱窜、走不同的路线。所谓"连接",纯粹是通信双方各自在内存里记的一本账。

打个比方:两个人开始通信前先对暗号——“我叫 A,我从 100 号开始编号”;“好,我叫 B,我从 500 号开始编号,你的 100 我收到了”。从此双方各自拿个小本子记着"我发到哪了、对方收到哪了",这本账就是连接。谁把本子撕了,连接就断了,哪怕网线还好好插着。

具体说,TCP 连接是双方各自维护的一套状态,包括:

  • 双方的 IP + 端口(即一对 socket,唯一标识这条连接)。
  • 各自的序列号(sequence number)、确认号、窗口大小等控制信息。
  • 当前处于连接状态机的哪个状态。

“建立连接"的本质,就是双方交换初始信息、把这套状态同步好、达成"我们现在可以可靠通信了"的共识。“关闭连接"则是双方确认"数据都发完了,可以安全释放这套状态了”。握手和挥手,就是达成这两个共识的通信过程。

TCP 还有三个基本性质,决定了握手/挥手的设计:

  • 面向连接:必须先建连接才能传数据(不像 UDP 发了就走)。
  • 全双工:连接建立后,两个方向可以同时独立地收发数据。这一点直接解释了为什么关闭要"四次”。
  • 面向字节流:TCP 把数据看成一串无边界的字节流,靠序列号给每个字节编号,从而实现有序和可靠。

握手/挥手前必须认识的几个 TCP 头部字段

握手和挥手,本质是双方来回发几个特殊的 TCP 报文,靠头部里的标志位(flags)和序号来协调。理解这几个字段,握手过程就不再是死记:

  • 序列号 seq(Sequence Number):本报文所携带数据的第一个字节的编号。TCP 给字节流编号,靠它实现有序。
  • 确认号 ack(Acknowledgment Number):告诉对方"我已经收到了 ack 之前的所有字节,期待你下一个从 ack 开始发"。
  • 标志位(各占 1 bit,置 1 表示"是"):
    • SYN(Synchronize):请求建立连接、同步序列号。只在握手时使用。
    • ACK(Acknowledge):确认号字段有效。连接建立后几乎所有报文都带 ACK。
    • FIN(Finish):我这边数据发完了,请求关闭。用于挥手。
    • RST(Reset):强制重置连接(出错、拒绝连接时),粗暴地直接断开。
  • 窗口大小 window:告诉对方"我还能接收多少字节",用于流量控制(下面会细讲)。

一句话:握手 = 交换 SYN 同步序列号;挥手 = 交换 FIN 声明各自结束。


三次握手(建立连接)

假设客户端主动连接服务器。服务器已经处于 LISTEN(监听)状态等待连接。三次握手过程:

   客户端                                          服务端
   CLOSED                                          LISTEN
     │                                               │
     │  ①  SYN=1, seq=x                             │
     │ ─────────────────────────────────────────▶    │
   SYN_SENT                                      SYN_RCVD
     │                                               │
     │  ②  SYN=1, ACK=1, seq=y, ack=x+1             │
     │ ◀─────────────────────────────────────────    │
     │                                               │
     │  ③  ACK=1, seq=x+1, ack=y+1                  │
     │ ─────────────────────────────────────────▶    │
 ESTABLISHED                                    ESTABLISHED
     │                                               │
     │           连接建立,开始传数据                    │
  1. 第一次(SYN):客户端发一个 SYN 报文,带上自己的初始序列号 seq=x,告诉服务器"我想建连接,我的起始序号是 x"。客户端进入 SYN_SENT。
  2. 第二次(SYN+ACK):服务器收到后,同意建连接,回一个报文:SYN=1(带上自己的初始序号 seq=y)+ ACK=1, ack=x+1(确认收到了客户端的 x,期待下一个是 x+1)。服务器进入 SYN_RCVD。
  3. 第三次(ACK):客户端回一个 ACK=1, ack=y+1,确认收到了服务器的 y。双方都进入 ESTABLISHED,连接建立。

注意 ack = 对方 seq + 1: SYN 本身虽然不携带数据,但要消耗一个序号,所以确认号是对方序号加 1。

为什么是三次,不是两次?

这是最经典的问题。核心答案:三次握手是让双方都确认"我发的对方能收到、对方发的我能收到"所需的最少次数。

用打电话来体会一下。你打过去:

  • 你:“喂,能听见吗?” —— 对方听见了,但你还不知道他能不能听见你
  • 对方:“能听见,你能听见我吗?” —— 现在你确认了双向都通,但对方还不知道你能不能听见他
  • 你:“能听见。” —— 现在双方都确认了双向都通,可以开始说正事

少一句都不行:如果没有最后那句"能听见",对方心里始终悬着——他不知道自己的声音有没有传过去,贸然开始说话可能全是白说。

拆开看,建立可靠双向连接需要确认四件事:

  • 客户端的发送能力正常(SYN ①证明)。
  • 服务器的接收能力正常(服务器收到①)。
  • 服务器的发送能力正常(SYN+ACK ②证明)。
  • 客户端的接收能力正常(客户端收到②)。

到第二次握手结束,客户端已经确认了双向都通;但服务器此时只知道"我能收到客户端",还不知道"客户端能不能收到我"。所以必须有第三次,客户端回 ACK,服务器才能确认"客户端收到了我的 SYN,它的接收和我的发送都正常"。两次不够,三次刚好。

更本质的理由:防止"历史重复连接"造成资源浪费。 设想只有两次握手:客户端发的一个早已失效、在网络里游荡的旧 SYN,晚到了服务器。服务器无法分辨这是新请求还是旧包,直接就建立了连接并分配资源,而客户端根本没打算连接、不会理会——服务器白白挂着一个"幽灵连接"。有了第三次握手,服务器发出 SYN+ACK 后要等客户端最终确认;客户端发现这是个自己没发起的旧连接,会回 RST 拒绝,连接不会建立。第三次握手给了客户端"最终否决权"。

初始序列号为什么要随机(ISN)

第一次握手的 seq=x 不是从 0 开始,而是一个随机的初始序列号(ISN)。原因:

  • 防止旧连接的数据串到新连接:如果每次都从 0 开始,同一对 socket 的新旧连接序号会重叠,残留的旧包可能被误认为新连接的数据。随机 ISN 让新旧连接序号错开。
  • 安全:如果 ISN 可预测,攻击者能伪造序号正确的报文注入连接(TCP 序列号预测攻击)。随机化增加了伪造难度。

SYN 洪水攻击(SYN Flood)

握手机制本身也能被攻击,而且攻击方式非常"便宜"。

攻击者发送大量 SYN 报文,但从不回第三次 ACK。服务器每收到一个 SYN 就得当真:进入 SYN_RCVD、分配一份资源记着这个半成品连接、放进半连接队列等对方回话。

这个攻击的恶毒之处在于成本极不对等:攻击者发一个 SYN 包几乎零成本(还能伪造源 IP,连回包都不用收),服务器却要为每一个都掏出内存和一个队列位置傻等。队列很快被占满,真正的用户反而挤不进来——这就是经典的 SYN Flood 拒绝服务攻击。

防御手段主要是 SYN Cookie:服务器在收到 SYN 时先不分配资源,而是把连接信息编码进第二次握手的初始序号里发出去;只有当客户端回了合法的第三次 ACK,服务器才根据 ACK 里的确认号还原信息、真正建立连接。这样半连接队列就不会被占满。


四次挥手(关闭连接)

数据传完,要拆连接。因为 TCP 是全双工——两个方向各自独立——所以关闭要两个方向分别关,于是通常是四次:

   主动关闭方(如客户端)                          被动关闭方(如服务端)
   ESTABLISHED                                    ESTABLISHED
     │                                               │
     │  ①  FIN=1, seq=u                             │  "我没数据要发了"
     │ ─────────────────────────────────────────▶    │
   FIN_WAIT_1                                     CLOSE_WAIT
     │                                               │
     │  ②  ACK=1, ack=u+1                            │  "知道了"(但我可能还有数据要发)
     │ ◀─────────────────────────────────────────    │
   FIN_WAIT_2                                        │  ← 这段时间被动方还能继续发数据(半关闭)
     │                                               │
     │  ③  FIN=1, seq=w                              │  "我的数据也发完了"
     │ ◀─────────────────────────────────────────     │
     │                                            LAST_ACK
     │  ④  ACK=1, ack=w+1                            │  "知道了,再见"
     │ ─────────────────────────────────────────▶     │
   TIME_WAIT                                       CLOSED
     │  (等待 2·MSL)                                   │
     ▼                                               
   CLOSED
  1. 第一次(FIN):主动方数据发完,发 FIN,进入 FIN_WAIT_1,表示"我不再发数据了"。
  2. 第二次(ACK):被动方回 ACK 确认,进入 CLOSE_WAIT。此时连接进入"半关闭"状态:主动→被动 方向关了,但被动→主动 方向还开着,被动方还能继续发送它没发完的数据。主动方进入 FIN_WAIT_2,等待对方也关闭。
  3. 第三次(FIN):被动方把自己剩下的数据也发完后,才发自己的 FIN,进入 LAST_ACK,表示"我这边也发完了"。
  4. 第四次(ACK):主动方回 ACK 确认,进入 TIME_WAIT;被动方收到后进入 CLOSED,连接释放。主动方再等一段时间(2·MSL)后也进入 CLOSED。

为什么是四次,不是三次?

还是用打电话理解:挂电话和接电话不一样。

接电话时,对方问"能听见吗",你可以一口气回"能听见,你能听见我吗"——确认和提问合成一句,因为你立刻就能决定。

挂电话时不行。对方说"我说完了",你不能马上说"那我也说完了"——因为你可能还有话没说。你只能先应一声"嗯,知道了"(第二次),然后把自己的话讲完,过一会儿才说"好,我也说完了"(第三次)。

回到协议:握手时服务器可以把 ACK 和自己的 SYN 合成一个报文一起发;但挥手时主动方发来 FIN,被动方收到 FIN 时自己可能还有数据没发完。它必须:

  • 先立刻回 ACK(“我知道你要关了”);
  • 等自己把手头数据都发完,过一会儿才发自己的 FIN。

这两个动作之间有时间差,无法合并,所以 ACK 和 FIN 分成两次,总共四次。(如果被动方确实没有数据要发,内核也可能把 ACK 和 FIN 合并,退化成"三次挥手"——但标准模型是四次。)

TIME_WAIT:那个让无数工程师头疼的状态

主动关闭方在最后一次 ACK 后,不会立刻 CLOSED,而是进入 TIME_WAIT,等待 2 倍 MSL 时间(MSL = Maximum Segment Lifetime,报文在网络中的最大存活时间,通常设为 30s~2min)才真正关闭。

这一步很反直觉——都已经说完再见了,为什么还要在原地站两分钟? 两个原因,一个是"怕对方没听见我的再见",一个是"怕留下的东西影响下一个人":

  1. 保证最后那个 ACK 能到达对方。 如果第四次的 ACK 在网络中丢了,被动方(处于 LAST_ACK)收不到确认,会重传它的 FIN。主动方必须在 TIME_WAIT 期间"守着",一旦收到重传的 FIN,就再回一次 ACK。如果主动方发完 ACK 立刻走人,重传的 FIN 就没人处理,被动方会一直卡在 LAST_ACK 傻等。等 2·MSL(一去一回的最大时间)足以覆盖这种重传——相当于说完再见后在门口多站一会儿,万一对方没听清再喊一声,你还在。

  2. 让本次连接的"迷路旧包"彻底消散。 网络里可能还有属于这条连接的、走错路绕远了的包在飘。等待 2·MSL 保证它们全部过期消失。

    为什么这很重要?因为 TCP 连接是靠四元组(源IP + 源端口 + 目标IP + 目标端口)来识别的。如果你立刻关闭并且新连接恰好复用了同一组四元组,那些迟到的旧包就会被新连接当成自己的数据收下——相当于前一个租户的信寄到了新租户手里,数据直接错乱,而且不报错。多等这 2·MSL,就是等前一个租户的信全部退回,再让新人搬进来。

TIME_WAIT 的实战代价: 高并发短连接的服务器(尤其主动关闭连接的一方,如爬虫、频繁调用下游的客户端),会瞬间堆积大量 TIME_WAIT,每个占用一个本地端口和一点内存,可能耗尽端口导致无法建立新连接。常见缓解:

  • 用连接池 / 长连接(keep-alive),少建少关,从根上减少 TIME_WAIT——这是最推荐的做法。
  • 内核参数 net.ipv4.tcp_tw_reuse(允许重用 TIME_WAIT 端口给新的出站连接)、调大端口范围 ip_local_port_range。
  • tcp_tw_recycle(利用TCP的时间戳选项,记住最近收到的时间戳,凡事同一个IP,时间戳不递增的包,一律丢弃) 这个选项已被废弃,NAT 环境下会引发问题,不要用。

CLOSE_WAIT 堆积:这是你代码的 bug

和 TIME_WAIT 相对,CLOSE_WAIT 出现在被动关闭方。如果你的服务器上观察到大量连接卡在 CLOSE_WAIT 不释放,几乎可以断定:你的程序收到了对方的 FIN,但迟迟没有调用 close()——第三次挥手的 FIN 发不出去,连接就一直挂着。

这通常是应用层代码 bug:忘记关闭 conn、连接泄漏、某个 goroutine 阻塞导致 close 没执行。记忆口诀:TIME_WAIT 多是"正常但要调优",CLOSE_WAIT 多是"你的代码漏了 close()"。


半连接队列与全连接队列(backlog)

这是个不看内核就完全想不到、但线上排查绕不开的机制。

问题是这样的:三次握手是内核自动完成的,不需要你的程序参与;但握手完成后,得等你的程序调用 Accept() 把连接取走。如果握手完成的速度比你取走的速度快,这些连接堆在哪儿?

答案是队列。而且是两个——因为握手有"进行中"和"已完成"两个阶段,得分开放:

就像餐厅门口:
  半连接队列 = 正在填登记表的客人(握手进行中)
  全连接队列 = 已登记好、等着服务员领位的客人(握手完成,等 Accept)

服务器内核为每个 LISTEN 的 socket 维护这两个队列:

  • 半连接队列(SYN queue):收到第一次 SYN、发出 SYN+ACK、还没收到第三次 ACK 的连接(SYN_RCVD 状态)放这里。SYN Flood 攻击撑爆的就是它。
  • 全连接队列(accept queue):三次握手已完成、进入 ESTABLISHED、等待应用程序调用 accept() 取走的连接放这里。

如果应用 accept() 太慢、全连接队列满了,新完成握手的连接会被丢弃或拒绝(表现为客户端连接超时或被 reset)。队列长度由 listen() 的 backlog 参数和内核 somaxconn 共同决定。“明明握手成功了客户端却报错” 常常就是全连接队列溢出——这类问题不理解握手队列根本无从查起。


注意事项

  • 谁主动 Close,谁进 TIME_WAIT。 设计高并发服务时,可以考虑让客户端主动关闭(把 TIME_WAIT 压力分散到大量客户端,而非集中在服务器)。
  • 务必确保 conn.Close() 被调用(常用 defer conn.Close()),否则被动方会积累 CLOSE_WAIT。
  • 优雅关闭:TCPConn.CloseWrite() 只关闭写方向(发 FIN 但仍可读),对应"半关闭",适合"我发完了但还要接收对方剩余响应"的场景。
  • HTTP 服务默认开启 keep-alive 复用 TCP 连接,就是为了避免每个请求都握手/挥手一轮,大幅减少开销和 TIME_WAIT。
  • 实际工作中用 netstat -antp 或 ss -antp 能实时看到系统里每条连接处在哪个状态——排查网络问题时,第一步往往就是看这个。