§ 3.2 · Section

TCP

Transmission Control Protocol

TCP 是互联网最"靠谱"的协议——它保证你发的数据不丢、不重、按顺序到达。微信聊天、文件下载、网页浏览,背后都是 TCP 在保驾护航。

生活场景
📞 打电话 vs 发微信语音

你打电话——先拨号,对方接起"喂?",你说"听得到吗?",对方说"听得到"——然后才开始说正事。
说完你说"我说完了",对方说"好的",你再说"拜拜",对方也说"拜拜"——挂断。
这个过程就是 TCP 的"三次握手"和"四次挥手"——先确认对方在,再说事,说完确认双方都听清了,再挂断

先把这一篇的术语翻译成人话

TCP 这一篇的英文缩写密度是全书最高的。但别怕——它们本质上就是"寄快递"这件事的各个环节,被起了洋名字而已。先把这张对照表过一遍:

术语换成大白话生活里对应的东西
连接(Connection,两端各自在自己内存里记的一份状态)说白了就是"咱俩各自在本子上记着这笔业务"银行给你和他自己各存一份合同——中间的马路、邮差可不知道你俩签过约
字节流(Byte Stream,数据像水一样连续,没有天然的一句一句的界线)说白了就是"倒进同一个桶里的水,分不出是哪一瓢"洗衣机里的水——你分三次倒进去,机器里只是一整缸水
报文段(Segment,TCP 一次实际发出去的一小块数据)说白了就是"一个箱子"一箱快递
序号 seq(Sequence Number,这一箱里第一个字节在整条流里排第几)说白了就是箱子上写的"第几号"搬家时给箱子编号"1/20、2/20……"
确认号 ack(Acknowledgment Number,我下一个想收的编号)说白了就是"前面我都收齐了,下一个请发几号"收货人回话:"1 到 5 号都到了,接着发 6 号"
窗口(Window,我的仓库还能再收多少)说白了就是"我家还能堆几箱,别再多寄了"超市后仓的空位数量
重传(Retransmission,没收到确认就再发一次)说白了就是"没签收,那就再寄一次"快递丢件后重新补发
拥塞(Congestion,路上车太多,大家都走不动)说白了就是堵车晚高峰的高架桥
MSS(Maximum Segment Size,最大报文段长度——一个包最多能装多少字节数据)说白了就是"一个纸箱最多装多重"快递公司规定单箱不超过 30 公斤
RTT(Round-Trip Time,往返时间——一句话发出去到回音回来的时间)说白了就是"喊一声到听见回音"的时间打电话说一句到对方应一声的间隔
RST(Reset,立刻掐断这条连接)说白了就是当面把电话摔了对方直接挂断,不说再见

这一整篇的所有机制,都可以用"寄快递 + 打电话"这两个画面套住。握手是打电话前的"喂?听得到吗",序号确认是快递单号和签收,窗口是对方仓库的空位,拥塞控制是看路况调整发货速度。往下读的时候,随时可以退回到这两个画面。

面向连接与字节流:TCP 的两个基本人设

TCP 定义在 RFC 793(1981 年 9 月),2022 年被 RFC 9293 整合替代。四十多年里它被打了几十个补丁,但两个最基本的设定从未改变,而且这两个设定解释了它后面几乎所有的行为特征。

第一个人设:面向连接(Connection-Oriented)。这里的"连接"是一个纯粹的"两端各自维护的一份状态",网络中间没有任何东西记得这条连接的存在。你的电脑内核里有一份记录(序号、窗口、缓冲区、定时器),服务器内核里也有一份,中间十几台路由器对此一无所知。这个理解非常重要:所谓"连接断了",往往不是某根线断了,而是某一端的状态被清除了。

第二个人设:字节流(Byte Stream)。TCP 眼里没有"消息"这个概念,只有一条从 0 开始一直往上编号的字节河流。你调用 send() 三次,每次发 10 字节,TCP 完全有权把它们合成一个 30 字节的段发出去;接收方调用 recv() 一次,也可能一口气拿到全部 30 字节。这就是"粘包"的根源——它不是 TCP 的 bug,而是 TCP 的定义。

TCP 承诺什么TCP 不承诺什么
你交给我的字节,我按顺序、不重、不漏地交给对方不承诺保留你的"消息边界"
数据损坏我能检测出来(校验和)并重传不承诺多快送到(没有延迟保证)
发得太快时我会自动减速,不把网络压垮不承诺加密(那是 TLS 的事)
对方缓冲区满了我会停下来等不承诺"send 返回了就等于对方收到了"
连接异常我会通知你(RST / 超时)不承诺实时发现对方掉线(可能几十分钟)

右列第四条是新手最容易踩的坑:send() 成功返回只意味着"数据被拷进了内核发送缓冲区",跟对方收没收到毫无关系。数据可能还在缓冲区排队,也可能刚发出去就丢了。想确认对方真的收到并处理了,唯一的办法是让应用层自己回一个确认——这就是为什么几乎所有可靠的业务协议都要在 TCP 之上再设计一层应用级 ACK。

Analogy · "已投进邮筒"不等于"对方读了"

这条坑换成大白话就是:你在邮局把信投进了邮筒,手一松那一刻,你只能确定"信离开我手了"——绝不能确定"对方看到了"。send() 返回成功,干的活儿相当于你听见信"咚"地掉进邮筒底部的那一声。

信可能还在筒里等下午收件的邮差;可能在分拣中心的传送带上;也可能刚上车就掉在路边。你唯一能确认"对方真的看到了"的办法,是对方给你回一封信。这就是为什么正经的业务协议都要在 TCP 之上自己再加一层"确认"——不是不信任 TCP,而是 TCP 的确认只到"对方的邮箱",管不到"对方有没有打开读"。

同一个道理还能解释右列第一条那个"消息边界"的问题:你连着往邮筒里投了三张纸条,写"我"、"爱"、"你"。邮差取件时是整筒一起掏出来的,他交给对方的可能是一叠三张、也可能被订成了一张——但绝不会带着"这是三次分别投的"这个信息。想让对方知道分几次说的,你必须自己在每张纸条上写"第 1/3 张"。这就是所谓"粘包",它不是邮局的失误,而是邮筒的工作方式本来就这样。

TCP 报文段结构:二十字节里塞了什么

TCP 头部固定 20 字节,加上选项最长 60 字节。这 20 字节里每一位都在干活,把它摊开看一遍,后面所有机制都会变得容易理解:

打个比方,这 20 字节就是贴在快递纸箱外面那张面单。面单本身不装货,但少了它这箱货谁也不知道该往哪送。"源端口 / 目的端口"是寄件人和收件人的门牌号,"序号"是"这是第几箱","确认号"是"我这边收到第几箱了","窗口大小"是"我家还能再堆几箱","校验和"是箱子上那个防拆封条。20 字节大概是二十个英文字母的长度——比一条短信还短,却撑起了整个互联网的可靠传输。

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-------------------------------+-------------------------------+
|        源端口(16 位)        |       目的端口(16 位)       |
+-------------------------------+-------------------------------+
|                     序号 Sequence Number(32 位)             |
+---------------------------------------------------------------+
|                 确认号 Acknowledgment Number(32 位)         |
+-------+-----------+-----------+-------------------------------+
|首部长 |  保留     |标志位 6个 |       窗口大小(16 位)       |
+-------+-----------+-----------+-------------------------------+
|      校验和(16 位)          |     紧急指针(16 位)         |
+-------------------------------+-------------------------------+
|              选项(0 ~ 40 字节,如 MSS、SACK、时间戳)        |
+---------------------------------------------------------------+
字段位宽作用与关键细节
源端口 / 目的端口各 16 位与 IP 一起构成五元组,唯一标识一条连接。16 位意味着最多 65535 个端口
序号 seq32 位本段第一个字节在整条流中的编号。初始值随机(防旧包还魂和序号猜测攻击),到 2³²−1 后回绕
确认号 ack32 位"我下一个期望收到的字节编号"。注意不是"我收到的最后一个",差一位就理解错了
首部长度4 位以 4 字节为单位,最大 15×4 = 60 字节。这就是选项最多 40 字节的原因
窗口大小16 位"我的接收缓冲区还剩多少字节"。最大 65535,在高带宽长链路上远远不够——所以有窗口缩放选项
校验和16 位覆盖头部 + 数据 + 一段伪首部(含源/目的 IP)。这是 TCP 唯一的数据完整性保障
紧急指针16 位配合 URG 使用,现代几乎不用(RFC 6093 建议新应用不要用)

那六个标志位(Flags)是 TCP 状态机的操纵杆,每一位的含义都必须记牢:

标志全称含义你在什么时候见到它
SYNSynchronize请求建立连接,同步初始序号三次握手的前两个包
ACKAcknowledgment确认号字段有效除第一个 SYN 外,几乎每个包都带
FINFinish我没数据要发了,想关闭四次挥手
RSTReset立刻强制重置连接,不走挥手流程连不存在的端口、程序崩溃、被防火墙阻断
PSHPush提示接收方尽快把数据交给应用,别缓着交互式应用、HTTP 请求最后一个包
URGUrgent有紧急数据,配合紧急指针基本已废弃,出现时往往是攻击流量

RST 是排障时最有价值的信号。抓包看到 RST,说明有人明确地拒绝了这条连接,而不是"网络不通"。常见的三种来源:①目标端口没有程序在监听(系统内核直接回 RST,这就是"Connection refused");②对端程序崩溃或已关闭 socket 但还收到了数据③中间设备主动阻断(防火墙伪造 RST 掐断连接,这是一种常见的封锁手段)。区分这三者的办法是看 RST 包的 TTL——伪造的 RST 其 TTL 通常与正常回包明显不同。

那六个标志位换成大白话就是打电话时的六种语气。SYN(Synchronize,请求建连)是"喂?在吗?";ACK(Acknowledgment,确认)是"嗯,听到了";FIN(Finish,我说完了)是"我这边说完了,你还有事吗";RST(Reset,强制重置)是"啪"地把电话摔了——不说再见,直接断PSH(Push,别缓着赶紧交给应用)是"这句急,你现在就听";URG(Urgent,紧急数据)基本已废弃,现在见到它多半是攻击流量。RST 之所以是排障金矿,就因为它意味着"有人明确拒绝了你",而不是"电话没接通"——这两件事的排查方向完全不同。

常用的选项(Options)也需要知道,它们全都是后来打的补丁:

先把两个最重要的选项翻译成人话MSS(Maximum Segment Size,最大报文段长度——一个包最多能装多少字节数据)好比快递公司规定的"单箱最大尺寸":以太网上典型值是 1460 字节,这个数字是怎么来的?一条线路上一个包最多能装 1500 字节(这叫 MTU,最大传输单元,好比一辆货车的车厢容积),减去 20 字节的地址标签、再减 20 字节的运单,剩下 1460 才是能装真货的空间。窗口缩放(Window Scale,把"我还能收多少"这个数字的量纲放大)好比超市后仓的容量单位从"箱"改成了"托盘"——同样一个两位数,代表的货量一下大了一百多倍。为什么非要改?因为原来那个数字最大只能写到 65535 字节,而一条跨太平洋的千兆线路上,光是"正在路上"的货就有 18 兆,用"箱"根本记不下。

MSS(Maximum Segment Size,kind=2)
  握手时宣告"我一次最多能收 1460 字节数据"
  以太网上典型值 = 1500 MTU - 20 IP - 20 TCP = 1460

窗口缩放(Window Scale,kind=3,RFC 7323)
  16 位窗口最大 65535 字节,在 100 Mbps × 100 ms 的链路上
  理论需要的窗口是 1.25 MB —— 差了 19 倍!
  这个选项给窗口值加一个左移因子(最多 14 位),
  把窗口上限扩到 1 GB。只在握手时协商一次。

SACK 允许(kind=4)+ SACK 块(kind=5,RFC 2018)
  选择性确认,后面详述

时间戳(Timestamps,kind=8,RFC 7323)
  每个包带上发送时刻,用于精确测 RTT,
  同时防止序号回绕造成的误判

# 三次握手的第一个 SYN 包典型选项组合:
SYN, MSS=1460, SACK_PERM, TSval=123456, WS=7
                                          ↑ 窗口左移 7 位 = ×128

三次握手 · 建立连接

TCP 通信前,双方必须先"握手"确认:

① 客户端:SYN

"我想和你建立连接,我的初始序号是 X。"——客户端发一个 SYN 包。

② 服务器:SYN + ACK

"我收到了你的 SYN(确认号 X+1),我也想建立连接,我的初始序号是 Y。"——服务器一次说两件事。

③ 客户端:ACK

"我收到了你的 SYN(确认号 Y+1)。"——三次握手完成,连接建立。

Analogy · 为什么必须三次

两次行不行?不行——
客户端说"我要连你"(第一次),服务器说"好"(第二次)——但服务器不知道客户端有没有收到它的"好"。如果客户端没收到,服务器就傻等着。
第三次客户端说"我收到了你的好"——服务器才确定"客户端真的在"。
三次之后,双方都确认对方听到了自己

为什么必须三次,不能两次也不能四次

"双方都确认对方收到了自己"是标准答案,但还有两个更深的理由,面试往深了问时就在考这个。

理由一:防止"旧包还魂"造成的幽灵连接。这是 RFC 793 里明确写出的动机。设想只有两次握手的世界:

【两次握手会出的事故】

t=0   客户端发 SYN,但这个包在网络里绕远路迷了 30 秒
t=1   客户端超时,重发一个 SYN
t=2   新 SYN 正常到达,连接建立、传完数据、正常关闭
t=30  那个迷路的旧 SYN 终于爬到了服务器
      → 两次握手的话,服务器立刻认为"来了个新连接",
        分配缓冲区、进入 ESTABLISHED、开始等数据
      → 但客户端早就走了,压根不知道有这回事
      → 这条连接会一直空占服务器资源直到超时

【三次握手怎么挡住它】
t=30  服务器回 SYN+ACK
t=31  客户端收到一个自己压根没在等的 SYN+ACK
      → 客户端回一个 RST,明确说"我没要建这个连接"
      → 服务器立刻释放资源,幽灵连接被消灭
      第三个包的真正作用:给客户端一次"最终否决权"

理由二:双方必须交换并确认初始序号(ISN)。TCP 的一切可靠性都建立在序号之上,而两端的初始序号都是随机生成的(防序号猜测攻击)。三次握手恰好是"让双方各自的 ISN 都被对方确认一次"所需的最小交换次数:第一个包送出客户端 ISN,第二个包既确认了它又送出服务器 ISN,第三个包确认服务器 ISN。三次握手在数学上是"双向可靠地同步两个数字"的最优解——两次不够,四次多余。

那为什么关闭要四次?因为关闭是单方向的。TCP 是全双工的,两个方向是两条独立的数据通路。客户端说"我发完了",只能关闭"客户端→服务器"这个方向;服务器可能还有一大堆数据没发完,所以不能立刻跟着关。这就是"四次"的来源——本质是两次独立的单向关闭,各占两个包。如果服务器恰好也没数据了,内核会把 ACK 和 FIN 合并成一个包,四次挥手就变成了三次,这在实际抓包里相当常见。

SYN Flood 攻击与 SYN Cookie 防御

三次握手有一个天生的软肋:服务器在收到第一个 SYN 之后就必须分配资源,而此时它还完全无法确认对方是真的还是假的。这个时间窗口造就了互联网史上最经典的拒绝服务攻击。

Analogy · 有人恶意占满所有号源

SYN Flood(伪造大量假身份来请求建连、却从不完成后续步骤)这种攻击换成大白话,就是医院挂号台被人恶意占号:

正常流程是这样的——你来说"我要挂内科",护士就先在本子上给你占一个号、写下你的名字(注意:她此刻还没法核实你是不是真人),然后叫你去缴费窗口;你缴完费回来确认,这个号才算真正生效。

攻击者干的事:他每秒钟报一百个假名字来占号,报完就走,永远不去缴费。护士的本子上很快堆满了"张三、李四、王五……"这些永远不会回来的人。结果真正来看病的人一开口,护士只能说"今天号满了"。而且更气人的是,护士对每个假名字还会尽职尽责地喊五遍"张三请缴费",等一分钟才划掉——攻击成本几乎是零,攻击者连护士的回话都不需要听见。

SYN Cookie 的解法妙极了护士干脆不在本子上记了。她改成当场给你一个"经过特殊算法算出来的号码"——这个号码是用你的名字、今天的日期和她自己心里那个口令混算出来的。她一个字都不记,转头就忘。等你缴完费回来报号码,她拿同样的算法再算一遍:对得上,说明这号确实是我发的,直接办;对不上,你走吧。

为什么这招能治攻击?因为攻击者用的是假名字,那个算出来的号码根本送不到他手上——他永远报不出正确的号码。而护士的本子上,一个字都没被占用。

要理解它,先搞清服务器内核里的两个队列

那两个队列说白了就是医院的排队两段制。半连接队列(SYN Queue,已经报过名但还没完成确认的人)好比"已取号、待缴费"那一排;全连接队列(Accept Queue,手续办完了、就等医生叫号的人)好比诊室门口"已缴费、待就诊"那一排。两个队列都有容量上限,而且第二排的长度不光看医院怎么设,还得看医生叫号叫得快不快——医生要是喝茶去了,队伍照样会溢出。这就是为什么"只调内核参数不改代码"是个常见的无效优化:你把候诊椅加到一百张,可医生一小时只叫十个号,队伍照样满。

【半连接队列 SYN Queue】
  收到 SYN → 创建一个"半连接"条目(含 ISN、MSS、窗口等)
  → 状态 SYN_RCVD → 发出 SYN+ACK → 等第三个 ACK
  队列大小:net.ipv4.tcp_max_syn_backlog(常见 128 ~ 1024)

【全连接队列 Accept Queue】
  收到第三个 ACK → 从半连接队列挪到全连接队列
  → 状态 ESTABLISHED → 等应用程序调 accept() 取走
  队列大小:min(backlog 参数, net.core.somaxconn)

【SYN Flood 攻击】
  攻击者用伪造的随机源 IP 疯狂发 SYN,从不回第三个 ACK:

  攻击者 ──SYN(源IP=1.2.3.4)──→ 服务器:分配条目,回 SYN+ACK
  攻击者 ──SYN(源IP=5.6.7.8)──→ 服务器:分配条目,回 SYN+ACK
  攻击者 ──SYN(源IP=9.9.9.9)──→ 服务器:分配条目,回 SYN+ACK
  ...每秒几十万个...

  结果:半连接队列被塞满 → 真实用户的 SYN 被直接丢弃
        而且服务器还会对每个半连接重试 SYN+ACK 5 次
        (tcp_synack_retries=5,加起来要等 60 多秒才释放)
        攻击成本极低:攻击者不需要维护任何状态,
        发完就忘,甚至不需要能收到回包

防御的核心思路极其巧妙,叫 SYN Cookie(Daniel Bernstein,1996 年)既然存状态会被打爆,那就干脆不存——把状态编码进序号本身,让客户端替我们保管。

【SYN Cookie 的原理】

半连接队列满时,服务器不再分配任何内存,而是:

  ISN = 哈希(源IP, 源端口, 目的IP, 目的端口, 时间槽, 服务端密钥)
        + 编码进去的 MSS 档位 + 时间戳低位
  把这个精心构造的数字作为 SYN+ACK 的序号发出去,然后彻底忘掉

  → 若对方是真实客户端:会回一个 ACK,其确认号 = ISN + 1
  → 服务器拿 ACK 里的确认号减 1,用同样的密钥重算一遍哈希
    对得上 → 说明这确实是我发出过的,直接建立连接
    对不上 → 丢弃

  攻击者伪造源 IP 时收不到 SYN+ACK,也就永远算不出正确的 ACK
  于是攻击彻底失效,而服务器一个字节的状态都没存!

# Linux 上开启(多数发行版默认已开)
$ sysctl net.ipv4.tcp_syncookies=1
     0 = 关闭   1 = 队列满时才启用(推荐)   2 = 无条件启用

SYN Cookie 也有代价,要诚实说出来:因为不存状态,握手时协商的 TCP 选项(窗口缩放、SACK、时间戳)就没地方保存了。MSS 只能靠几个位编码成粗略的档位,其他选项在纯 SYN Cookie 路径下会丢失,导致这条连接的性能打折。所以推荐设成 1(只在队列满时启用)而不是 2——平时走正常路径保住性能,被打时才降级保住可用性。这是一个典型的"优雅降级"设计。

另外几个相关的实用参数和排查手段:

# 看当前有多少半连接(SYN_RECV 状态)
$ ss -n state syn-recv | wc -l
$ netstat -ant | grep SYN_RECV | wc -l

# 看全连接队列有没有溢出(这个数字持续增长就是有问题)
$ netstat -s | grep -i "listen"
    1234 times the listen queue of a socket overflowed
    1234 SYNs to LISTEN sockets dropped

# 调优三件套
$ sysctl -w net.ipv4.tcp_max_syn_backlog=8192   # 半连接队列
$ sysctl -w net.core.somaxconn=8192             # 全连接队列上限
$ sysctl -w net.ipv4.tcp_synack_retries=2       # 少重试,快释放

# 注意:应用程序 listen(fd, backlog) 里的 backlog 也要跟着调大,
#       实际全连接队列 = min(backlog, somaxconn)
#       只调内核不改代码是常见的无效优化

四次挥手 · 断开连接

说完事要断开,TCP 用"四次挥手":

① 客户端:FIN

"我说完了,准备断开。"

② 服务器:ACK

"我知道你说完了。"——但服务器可能还有数据没发完,所以先确认,不断开。

③ 服务器:FIN

服务器发完剩余数据后,说"我也说完了。"

④ 客户端:ACK

"我知道了。"——客户端等一小会(防止 ACK 丢失),然后彻底断开。

TIME_WAIT:那个"等一小会"到底在等什么

上面第④步那句"等一小会",是 TCP 里最容易被误解也最容易引发生产事故的一个设计。这个状态叫 TIME_WAIT,等待时长是 2×MSL(Maximum Segment Lifetime,报文最大生存时间)。RFC 793 建议 MSL 取 2 分钟,所以理论上要等 4 分钟;Linux 实现里把它硬编码成 60 秒(TCP_TIMEWAIT_LEN,改不了,要改得重编内核)。

TIME_WAIT(主动挂断的那一方在挂断后还要空等一会儿)听着玄,其实就是打电话说完"拜拜"之后,为什么手不会立刻按下挂断键。你会停顿那么一两秒,就为了两件事:一是万一对方那句"拜拜"你没听清、他还要再说一遍,你得接住;二是防止你立刻打给下一个人时,前一通电话的尾音串进新通话里。TCP 等的正是这两件事,只不过它等的是 60 秒。

再说说"2×MSL"这个数字换成大白话是什么意思。MSL(Maximum Segment Lifetime,一个包在网络里最长能活多久)好比快递行业里的"最长在途时间"——一件快递就算走错路绕了大圈,两分钟之后也一定已经被销毁或退回,绝不可能还在路上晃。等两个 MSL,就是等"我发出去的和对方可能发过来的"两个方向的迟到件全都彻底消失。为什么必须等?因为如果你立刻用同一个电话号码打新电话,而上一通的迟到语音刚好这时候到了,你会把它当成新通话的内容听进去——数据被悄悄污染,而且不会有任何报错。

它存在的两个理由都很硬:

问题是,TIME_WAIT 状态会占着那个本地端口不放。这对某类服务是致命的:

这个"端口耗尽"的账翻译成人话是这样的:一台机器可用的对外端口好比停车场的车位,一共约 2.8 万个。每辆车走了之后,那个车位还要空关 60 秒不许别人停(这就是 TIME_WAIT)。如果这个停车场每秒钟进出 5000 辆车,那么任意时刻有 5000 × 60 = 30 万个车位处在"空关等待"状态——而你总共只有 2.8 万个车位。结果就是新来的车一辆都停不进去,系统报"分配不到地址"。

解法的优先顺序也很好理解。最好的办法是"别让车走"——同一辆车办完事继续停着,下次接着用(这就是长连接、连接池),根本不产生空关车位。第二好的办法是让对方去等这 60 秒(谁主动挂断谁进 TIME_WAIT,那就别当主动挂断的那一方)。第三才是扩建停车场(把端口范围从 2.8 万扩到 6.4 万)——只是缓解,治不了根。至于那个绝对不能碰的 tcp_tw_recycle相当于一个"看车牌尾号顺序判断车该不该放"的荒唐规则:在很多人共用一个出口的场景下,它会随机把合法车辆拦在门外,而你完全查不出原因。Linux 内核在 4.12 版本直接把这个选项删掉了。

【什么时候会被 TIME_WAIT 打死】

场景:一台 Nginx 反向代理,每秒向后端发起 5000 个新连接,
      短连接模式(每次请求完就关)

  谁主动关闭 → 谁进 TIME_WAIT
  Nginx 主动关闭 → Nginx 侧堆积 TIME_WAIT
  5000 条/秒 × 60 秒 = 30 万个 TIME_WAIT
  而 Linux 默认可用端口范围 32768~60999 只有 28232 个

  → 端口耗尽,新连接直接失败:
    "Cannot assign requested address"(EADDRNOTAVAIL)

【查看当前 TIME_WAIT 数量】
$ ss -tan | awk '{print $1}' | sort | uniq -c
  30124 TIME-WAIT
    482 ESTAB
     12 LISTEN

【六种解法,按推荐程度排序】

① 用长连接(keep-alive / 连接池)—— 治本,首选
   根本不去建那么多连接,问题自动消失

② 让对端主动关闭 —— TIME_WAIT 就转移到对端去了
   (HTTP 里通过 Connection 头和超时策略控制)

③ 扩大端口范围
   $ sysctl -w net.ipv4.ip_local_port_range="1024 65535"
   把 2.8 万扩到 6.4 万,只是缓解不是解决

④ 开启 tcp_tw_reuse(谨慎,需要时间戳选项)
   $ sysctl -w net.ipv4.tcp_tw_reuse=1
   允许把 TIME_WAIT 的端口复用给"新的出向连接",
   靠时间戳区分新旧包。只对客户端侧有效。

⑤ 加大 tcp_max_tw_buckets
   $ sysctl -w net.ipv4.tcp_max_tw_buckets=262144
   超过这个数就直接砍掉最老的(会打日志告警)

⑥ tcp_tw_recycle —— 【绝对不要用】
   它在 NAT 环境下会因时间戳判断错误而
   随机丢弃合法客户端的 SYN,造成极难排查的
   "部分用户连不上"。Linux 4.12 起已经彻底删除了这个选项。

关于第⑥条要特别强调:tcp_tw_recycle 是网络运维史上最著名的"抄来的错误配置"。中文技术博客里流传了十几年"高并发调优必开这两个参数"的说法,害了无数人。它的问题是会对同一个源 IP 做全局的时间戳递增校验,而 NAT 后面的几百个用户共用一个出口 IP、各自时间戳互不相关,于是大量正常 SYN 被当成旧包丢弃。Linux 内核在 4.12 版本直接把这个选项删掉了——这是内核开发者对一个设计错误最强烈的表态。

TCP 的十一种状态:一张完整的状态机

握手和挥手其实是同一台状态机的两段旅程。把十一个状态列全,你就有了一张读 ssnetstat 输出的解码表:

想象一下这十一个状态就是打电话这件事的十一个时刻:拿起听筒(LISTEN 在等来电)、拨号中(SYN_SENT)、听到铃响正在接(SYN_RCVD)、通话中(ESTABLISHED)、我说完了在等对方回应(FIN_WAIT_1/2)、对方说完了但我还没挂(CLOSE_WAIT)、我挂了但还捏着听筒等最后一句(TIME_WAIT)。其中最该警觉的是 CLOSE_WAIT 堆积——它几乎百分之百是你自己代码的毛病,跟网络一点关系都没有。

为什么这么说?换成大白话:对方已经明确说了"我这边讲完了,你还有事吗",你的听筒也礼貌地"嗯"了一声,然后就一直举着听筒不放下好比办公室里一个人打完电话,把听筒往桌上一搁就去开会了——电话线一直占着,别人打不进来。这样的听筒攒够几千个,整个部门的电话就全瘫了(进程的文件描述符耗尽,报"Too many open files")。常见原因就三条:出异常的那条分支里忘了挂电话、连接池没有回收机制、没写在 finally 里。

状态谁会处于这个状态含义大量出现说明什么
CLOSED双方初始/终止状态,不是真实存在的连接
LISTEN服务端正在监听端口等连接正常,每个服务一个
SYN_SENT客户端发出 SYN 了,在等 SYN+ACK大量堆积 = 对端不可达或被防火墙丢包
SYN_RCVD服务端收到 SYN 回了 SYN+ACK,等第三个 ACK大量堆积 = 正在被 SYN Flood
ESTABLISHED双方连接已建立,可正常收发正常业务状态
FIN_WAIT_1主动关闭方发了 FIN,等对方 ACK堆积 = 对端没响应
FIN_WAIT_2主动关闭方对方 ACK 了我的 FIN,等对方的 FIN大量堆积 = 对端应用没调 close()
CLOSE_WAIT被动关闭方收到对方 FIN 并 ACK 了,等自己应用调 close()大量堆积 = 你自己的代码有连接泄漏 bug
LAST_ACK被动关闭方发了自己的 FIN,等最后一个 ACK短暂出现,正常
CLOSING罕见双方同时关闭时的过渡状态极少见
TIME_WAIT主动关闭方等 2×MSL 后彻底释放短连接高并发时正常,太多要治

CLOSE_WAIT 大量堆积是所有 TCP 状态异常里最值得警觉的一个,因为它几乎百分之百是你自己代码的 bug,而不是网络问题。逻辑链条是:对端已经发 FIN 说"我关了",你的内核回了 ACK,然后就在等你的应用程序调用 close()——而你的代码没调。常见原因包括:异常路径里忘了关连接、连接池没有回收机制、忘了在 finally 里关闭。这种连接会永久占用一个文件描述符,最终把进程的 fd 上限撑爆,报出 "Too many open files"。

FIN_WAIT_2 堆积是相反方向的同一个故事——对端的代码有 CLOSE_WAIT 泄漏,所以你在这边永远等不到它的 FIN。Linux 会用 tcp_fin_timeout(默认 60 秒)给这个状态兜底,超时就强制清理。

TCP 怎么保证"不丢"

核心机制:序号 + 确认 + 重传

这四条要往细里讲,得先讲清一个最容易搞错的点:TCP 的确认是"累积确认"(Cumulative ACK)。确认号的含义不是"我收到了这个",而是"这个编号之前的所有字节我都收到了,请从这个编号开始发"。这个语义差异会带来一个严重后果:

Analogy · 二十箱行李里丢了第二箱

累积确认(Cumulative ACK,只能报告"到某个编号之前我全收到了")这套机制换成大白话,就是搬家快递时一种很别扭的签收方式:

你寄了二十箱行李,编号 1 到 20。正常情况下收货人只需要说一句"1 到 20 全到了"——一句话搞定,省事。但如果第 2 箱丢了呢?麻烦来了:他只能反复说"1 号我收到了",因为这套签收方式规定他只能报一个连续的边界。他明明手里已经拿到了 3 到 20 号箱子,可这句话他说不出来。

于是你这边只知道"2 号没到",后面 18 箱到底到没到,完全不知道。保险的做法就是从 2 号开始把 19 箱全部重寄一遍——白白重寄了 18 箱已经在人家客厅里的东西。

SACK(Selective Acknowledgment,选择性确认——除了报连续边界,还额外报"另外这几箱我也有了")就是给签收单上加了一栏备注:"1 号已收;另外 3 到 20 号我也都收到了。"你一看就明白:只补寄 2 号那一箱,其他一件都别动。重寄量从 19 箱降到 1 箱。

还有个叫 D-SACK 的补充也很妙——它让收货人能说"你这箱我已经收到过了,你重寄了"。这句话的价值在于:你能借此分清"是真丢了"还是"只是走得慢、晚到了"。如果只是晚到,说明路上其实没堵,你压根不该减速发货。

【累积确认的困境】

发送方发出五个段:seq=1001, 2001, 3001, 4001, 5001(各 1000 字节)
接收方收到:      1001 ✔  2001 ✘丢  3001 ✔  4001 ✔  5001 ✔

接收方只能回:ack=2001, ack=2001, ack=2001, ack=2001
              ↑ 它无法表达"我其实收到了 3001~6000"!
              ↑ 累积确认只有一个数字,只能表示"连续的边界"

发送方的困境:我只知道 2001 没到,
              后面那四个到底到没到?完全不知道。
              保守做法 → 从 2001 开始全部重发(超时重传的行为)
                          白白重传了 3 个已经到达的段!

这个浪费在 1981 年是可以接受的(当时链路慢、窗口小),但在今天的高带宽长链路上是灾难。所以 1996 年 RFC 2018 引入了 SACK(Selective Acknowledgment,选择性确认):在 TCP 选项里额外附上"我收到的那些不连续的块"。

【SACK 怎么解决】

接收方回:ack=2001, SACK=[3001-6001]
          ↑ 累积边界        ↑ "另外这一段我也有了"

发送方立刻明白:只需要重传 2001 那一个段,其他都别动。
                重传量从 4 个段降到 1 个段。

# SACK 选项在 TCP 头里最多能放 4 个块(因为选项总共只有 40 字节,
#   每个块要 8 字节,加上 kind+len 共 2 字节,4 块正好 34 字节)
# 握手时通过 SACK_PERM 选项协商是否启用

# 查看和开启
$ sysctl net.ipv4.tcp_sack        # 默认 1,开启
$ sysctl net.ipv4.tcp_dsack       # D-SACK,还能报告"我收到重复包了"
                                  # 帮助发送方区分"是丢包还是乱序"

D-SACK(RFC 2883)是一个精妙的补充:它让接收方能报告"我收到了一个重复的段"。这个信息让发送方能区分两种截然不同的情况:如果是真丢包,重传是必要的;如果只是包乱序到达,那么之前那次重传就是误判——说明网络其实没堵,不该减速。这对拥塞控制的准确性帮助很大。

超时重传:那个超时时间怎么算出来的

"超时了就重传"这句话背后藏着一个相当微妙的问题:超时时间(RTO,Retransmission Timeout)设多少?这个值设错了后果都很严重:设太短会疯狂重发已经在路上的包,加剧拥塞;设太长则丢包后要傻等很久,吞吐直接崩掉。

RTO(Retransmission Timeout,等多久没收到确认就重发)这个值怎么定,说白了就是点菜之后"等多久才该催菜"。催太早(比如刚下单一分钟就喊服务员),后厨正在炒你还要重下一遍单,只会让厨房更乱;催太晚(等了四十分钟才想起来问),那你这一顿饭全耽误了。而且这个时间不可能定死——快餐店五分钟不上就该问,正经酒楼炖汤要四十分钟都算正常。TCP 面对的差距更极端:同机房内一个来回是 0.2 毫秒,跨太平洋是 180 毫秒,差了近千倍。所以它必须一边发一边量,动态调整。

那个算法里最精华的一句翻译成人话是:不光看平均等了多久,还要看这家店的出菜时间稳不稳一家店平均 15 分钟上菜但忽快忽慢,你的催菜阈值就该定得宽松些;另一家稳定得像钟表,你就可以卡得很准。这就是公式里那个"四倍波动幅度"的余量。为什么这条特别重要?因为 1986 年的老 TCP 只用"平均值 × 2",结果一堵车就疯狂误判重发,重发又加剧堵车——正反馈直到彻底崩溃。加进波动项之后,路况越乱,它自动越沉得住气,反馈方向被彻底反转了。

而且这个值不能是固定的——同机房内 RTT 是 0.2 毫秒,跨太平洋是 180 毫秒,差了近千倍。所以 TCP 必须动态测量并自适应。经典算法(Jacobson/Karels,1988 年,RFC 6298)是这样的:

【RTO 的计算】

每收到一个 ACK,测出一个 RTT 样本,然后做两个指数加权平均:

  SRTT   = (1 - α) × SRTT   + α × RTT样本        α = 1/8
           ↑ 平滑后的 RTT 估计值

  RTTVAR = (1 - β) × RTTVAR + β × |SRTT - RTT样本|   β = 1/4
           ↑ RTT 的波动幅度(方差的近似)

  RTO    = SRTT + 4 × RTTVAR
           ↑ 关键:不只看平均值,还要为"抖动"留出四倍余量
           且 RTO 有下限(Linux 200ms)和上限(120s)

举例:稳定的机房内网
  SRTT = 0.5ms, RTTVAR = 0.1ms → RTO = 0.9ms,但被下限抬到 200ms
举例:抖动剧烈的 4G 网络
  SRTT = 80ms, RTTVAR = 40ms → RTO = 240ms
  (抖动大 → RTO 大 → 宁可多等也别误判)

【指数退避】重传还失败怎么办?RTO 翻倍
  第 1 次重传:等 1×RTO
  第 2 次:2×RTO    第 3 次:4×RTO    第 4 次:8×RTO ...
  Linux 默认 tcp_retries2 = 15,全部重试完累计约 15 分钟
  → 这就是"拔掉网线后程序卡了十几分钟才报错"的原因

那个"4 倍方差余量"是整个算法的精华。最初的 TCP 只用 SRTT × 2 作为 RTO,结果在 1986 年的 NSFNET 拥塞崩溃中被证明完全不够用——网络越堵、RTT 抖动越大,误判重传就越多,重传又加剧拥塞,形成正反馈直至崩溃。Jacobson 加入方差项后,网络越不稳定 RTO 就自动越保守,这个反馈方向被反转了过来。

快速重传(Fast Retransmit)是对超时重传的重要补充,因为等一个 RTO 太慢了。规则很简单:收到 3 个重复 ACK(同一个确认号连续出现 4 次)就立刻重传,不等超时。为什么是 3 个?这是个经验值——1 到 2 个重复 ACK 很可能只是包乱序造成的,3 个才比较确信是真丢了。这个"3"从 1990 年沿用至今。

现代 Linux 还有两个更聪明的机制值得知道:尾部丢包探测(TLP,Tail Loss Probe)专门解决"最后一个包丢了,凑不够 3 个重复 ACK,只能干等 RTO"的窘境;RACK(Recent ACKnowledgment,RFC 8985)用时间而不是计数来判断丢包,能更快更准地触发重传,现在已是 Linux 的默认机制。

TCP 怎么保证"不重复"

接收方按序号排序——如果收到重复的号,直接丢弃。就像你收到两封一样的快递,拆一封,另一封扔掉。

TCP 怎么"控速"

网络会堵——TCP 有两个机制防止"把路堵死":

01

滑动窗口 · Flow Control

接收方告诉发送方"我缓冲区还有多大"——发送方不超过这个量发。
防止"你发太快,我处理不过来"。

02

拥塞控制 · Congestion Control

TCP 会"试探"网络有多堵——慢慢加速,发现丢包就减速。
防止"大家都猛发,把路堵死"。

Analogy · 开车上高速

滑动窗口 = 你跟副驾说"我胃里还能吃三碗饭"——副驾就最多给你盛三碗。
拥塞控制 = 你上高速先慢慢开,发现不堵就加速,发现刹车灯亮就减速——TCP 也是这么"试探"网络的。

滑动窗口:流量控制的细节

"接收方告诉发送方缓冲区还剩多少"这句话,落到实现上是一套相当精巧的指针游戏。发送方的缓冲区被切成四个区域:

滑动窗口(Sliding Window,"我一次最多能有多少货在路上没签收"这个额度)好比超市补货时的一条规矩:仓管说"我后仓最多同时堆 4 车货,堆满了你就别发了,等我卸掉几车腾出位置再发"。那四个区域换成大白话就是:已经卸完签收的(可以从账上划掉了)、正在路上跑的(发了但还没签收)、现在还能发的额度、以及超出额度暂时不许发的。每卸掉一车,这个额度就往后挪一格——这就是"滑动"两个字的来历。

还有一件必须分清的事:流量控制(Flow Control)和拥塞控制(Congestion Control)是两回事,而且保护的对象完全不同。流量控制护的是"收货那一方"——仓库堆不下了,人家会明确告诉你别发了;拥塞控制护的是"路"——路上堵不堵,没有任何人会通知你,你只能靠"货怎么老是丢、怎么老是走得慢"去猜。一个是有人明说,一个是全靠推测,这就是它们本质上的区别。

发送方视角(假设窗口 = 4000 字节):

字节编号 ─→
[已发送且已确认] [已发送未确认] [可以发但还没发] [不许发]
       ↑               ↑                ↑           ↑
   可以丢弃了      在飞行中(in-flight)  窗口余量    等窗口滑动

       └────────── 发送窗口 4000 字节 ──────────┘
                   窗口会随着 ACK 到来而"向右滑动"

关键公式:
  可发送的数据量 = min(接收窗口 rwnd, 拥塞窗口 cwnd) - 已发送未确认量
                     ↑ 对方缓冲区限制    ↑ 网络能力限制

  两个窗口中较小的那个是瓶颈:
    rwnd 小 → 对方处理不过来(应用读得慢)
    cwnd 小 → 网络堵或刚起步

这里必须区分清两个常被混为一谈的概念:流量控制保护的是"接收方",拥塞控制保护的是"网络"。前者是端到端的两人协商(对方明确告诉你还能收多少),后者是发送方对整个网络状况的猜测(没有任何人告诉你网络有多堵,只能靠丢包和延迟去推断)。

滑动窗口有两个经典的病态情况,都有对应的解药:

这两个毛病换成大白话都特别好懂。第一个是"零窗口死锁":仓管说"满了,别发了",你就停了;可后来他喊"腾出来了,接着发"那一句你没听见——于是他在那边等货,你在这边等通知,两个人能这么僵住一辈子。解法很朴素:你每隔一阵子主动打个电话问一句"现在能发了吗"第二个叫"糊涂窗口":仓管卸货奇慢,每腾出一个小格子就喊你"来一件"——结果你为了送一件小东西,得开一整趟货车、填一整套单据,有效载重只有 2.4%。解法是双方各让一步:仓管憋着,攒够半个仓库再喊;你也憋着,攒够一车再发。

还有一个必须提的现实限制:16 位窗口字段最大只能表示 65535 字节,这在今天严重不够。看一笔账:

带宽时延积(BDP,Bandwidth-Delay Product——这条路上"同时能装多少货")这个词听着玄,其实就是一条高速流水线上"从这头到那头之间同时躺着多少件货"。打个比方:一条跨太平洋的千兆线路,来回要 150 毫秒,算下来这条线上同时"在飞"的数据有 18.75 MB——差不多是四首高音质歌曲,或者一部手机拍的十几秒 4K 视频。而 TCP 那个老窗口字段最多只能记到 64 KB,也就是不到一张手机照片。差了将近六十倍。结果是什么?这条千兆线路实际只能跑到 3.4 Mbps——一千兆的路,只用上了千分之三。这就是为什么"窗口缩放"在今天是刚需,不是可选优化。

【带宽时延积 BDP:链路上能同时装多少数据】

  BDP = 带宽 × RTT

例 1:跨太平洋 1 Gbps 链路,RTT = 150 ms
  BDP = 1 Gbps × 0.15 s = 150 Mbit = 18.75 MB
  → 想跑满这条链路,窗口必须有 18.75 MB
  → 而 16 位窗口最大 64 KB,只有需求的 0.33%!
  → 不开窗口缩放,这条链路最多只能跑到
     64 KB / 0.15 s ≈ 3.4 Mbps —— 千兆链路只用上了 0.34%

例 2:同机房 10 Gbps,RTT = 0.2 ms
  BDP = 10 Gbps × 0.0002 s = 2 Mbit = 250 KB
  → 也超过了 64 KB

结论:现代网络里窗口缩放选项(RFC 7323)是刚需,不是优化。
      $ sysctl net.ipv4.tcp_window_scaling   # 默认 1

# 这类"带宽很高但传输很慢"的现象叫 LFN(Long Fat Network)问题
# 排查时先看窗口缩放有没有被中间设备剥掉(老防火墙的经典毛病)

拥塞控制四阶段:TCP 最精妙的部分

拥塞控制的诞生有一个明确的历史节点:1986 年 10 月,NSFNET 从加州大学伯克利到劳伦斯伯克利实验室的链路发生了著名的"拥塞崩溃",吞吐量从 32 kbps 暴跌到 40 bps——降到了原来的千分之一。原因是大家都在拼命重传,重传又加剧拥塞,形成死亡螺旋。1988 年 Van Jacobson 发表论文提出了拥塞控制算法,这套机制至今仍是互联网稳定运行的基石。

先把那个"从 32 kbps 跌到 40 bps"翻译成人话相当于原本每分钟能传完一篇短文,突然变成每分钟只能传出五个汉字。这已经不是"变慢",是彻底瘫了。而原因特别讽刺——不是线断了,是所有人都因为"没收到回音"而拼命重发,重发的包又把路挤得更死,于是更多包丢,于是更多人重发好比一场大型开会散场时所有人同时挤向同一个出口,越挤越走不动,越走不动越使劲挤。

Analogy · 一位刚入行的送货司机怎么摸清路况

拥塞控制的四个阶段,换成大白话就是一位新司机第一天跑快递路线的完整心路历程。他手上有一堆货,但完全不知道这条路能承受多大流量,只能靠试。

阶段一 · 慢启动(Slow Start,从很小的量开始试,每成功一轮就翻倍):他第一趟只敢发 1 车。顺利到了,第二趟发 2 车;又顺利,第四趟发 4 车、8 车、16 车。注意这个名字特别误导人——它一点都不"慢",是每一轮翻一倍的爆炸式增长。"慢"指的只是"起点很小"。

阶段二 · 拥塞避免(Congestion Avoidance,接近上限后改成一次只加一点):翻到某个量之后他心里发怵了——再翻一倍怕直接堵死。于是改成每轮只多加 1 车,小心试探。

阶段三 · 快重传(Fast Retransmit,连着三次听到"就缺那一车"就立刻补发):某一趟里,收货人连着三次说"就差 5 号车,后面的都到了"。他立刻意识到 5 号车丢了,马上补发——不用等到"这批货整个超时"才反应。而且他还能顺便判断出:后面的车都到了,说明路是通的,只是稍微堵了一下。

阶段四 · 快恢复(Fast Recovery,把发货量减半,然后继续小步加):既然路只是有点堵,他把发货量砍一半,然后从这个量开始重新一辆一辆往上加。这叫"减的时候按比例砍,加的时候按定量加"——这套规则有个漂亮的性质:几条同时抢一条路的车队,会自动收敛到公平分摊,谁也不用协调,谁也占不了大便宜。

最坏的情况 · 超时:某一趟他一个回音都没听到这跟"缺一车"完全是两种性质的坏消息——可能整条路封了、可能出了大事故。此时他必须最保守:发货量直接砍回 1 车,从头开始试探。这就是"丢一车只减半、彻底断联却归 1"的道理——两者传回来的信息量根本不一样。

它的四个阶段可以画成一条完整的曲线:

拥塞窗口 cwnd
    ↑
    │                              ╱╲        ← 拥塞避免:+1 线性增长
    │                          ╱╲╱  ╲
    │        ssthresh ····╱╲╱        ╲ ← 收到 3 个重复 ACK
    │                 ╱                ╲ ← 快恢复:cwnd 减半
    │              ╱ ← 指数增长               ╱╲╱╲
    │           ╱   (慢启动)           ╱╲╱
    │        ╱                      ╱  ← 从减半处继续线性增长
    │     ╱                    ╱
    │  ╱  1 MSS         超时!cwnd 直接归 1,重新慢启动
    └────────────────────────────────────────→ 时间(RTT)

【阶段一 · 慢启动 Slow Start】
  cwnd 从 1~10 个 MSS 开始(Linux 初始值 10,RFC 6928)
  每收到一个 ACK,cwnd += 1 MSS
  → 每个 RTT 窗口翻倍:1 → 2 → 4 → 8 → 16 → 32 ...
  名字很误导:它一点都不"慢",是指数爆炸式增长
  真正的含义是"从一个很小的值开始"

【阶段二 · 拥塞避免 Congestion Avoidance】
  cwnd 涨到 ssthresh(慢启动门限)后,改成温和的线性增长
  每个 RTT 只 cwnd += 1 MSS
  → 逻辑:已经接近网络容量了,得小心试探

【阶段三 · 快重传 Fast Retransmit】
  收到 3 个重复 ACK → 立刻重传丢的那个段,不等 RTO
  同时判定"网络轻度拥塞"(因为后面的包还能到,说明路没断)

【阶段四 · 快恢复 Fast Recovery】
  ssthresh = cwnd / 2
  cwnd = ssthresh(减半,而不是归 1)
  直接进入拥塞避免阶段,线性增长
  → 这叫"乘性减、加性增"(AIMD),是 TCP 公平性的数学基础

【最坏情况 · 超时重传】
  一个 RTO 都没等到任何 ACK → 判定"网络严重拥塞或断了"
  ssthresh = cwnd / 2
  cwnd = 1 MSS  ← 直接归零重来,代价极其惨重
  重新走慢启动

为什么快恢复只减半而超时要归 1?因为两者传递的信息量完全不同。收到 3 个重复 ACK 说明"后续的包还在陆续到达",链路是通的,只是稍微堵了一下——减半是合理的克制。而超时意味着"一个包都没回来",可能路由断了、可能严重拥塞,此时必须最保守地从头试探。这个区分是 TCP Reno(1990)相对于 TCP Tahoe(1988)的核心改进,把丢包恢复的代价降低了一个数量级。

那个 AIMD(Additive Increase, Multiplicative Decrease,加性增、乘性减)规则有一个漂亮的数学性质:多条竞争同一条链路的 TCP 连接,会自动收敛到公平共享带宽。原因是"减法按比例、加法按定量"——占带宽多的连接每次减得也多,几轮之后大家就趋于相等。这是纯分布式的、无需任何中央协调的自组织行为。

现代的算法已经演进了好几代,各有适用场景:

算法年份核心思路适用场景
Tahoe1988丢包就把 cwnd 归 1历史,已淘汰
Reno1990加入快重传快恢复,丢包只减半教科书标准模型
NewReno1999 / RFC 6582改进一个窗口内多次丢包的处理
CUBIC2008 / RFC 8312用三次函数曲线增长,在上次的丢包点附近"减速探测"Linux 默认,高带宽长链路友好
BBR2016(Google)不看丢包,改为主动测量瓶颈带宽和最小 RTT丢包率高的链路(跨国、无线)提升巨大

BBR 是近三十年来拥塞控制最重要的一次范式转变。所有传统算法都把"丢包"当作拥塞信号,但这个假设在两种情况下会失效:①无线链路的丢包大多是信号干扰而非拥塞——此时减速是纯粹的误判;②"缓冲区膨胀"(Bufferbloat)——现代路由器缓冲区做得很大,堵的时候它不丢包,只是让延迟涨到几百毫秒,传统算法完全察觉不到,直到缓冲区终于溢出才反应。BBR 直接测量"实际投递速率"和"最小 RTT",据此算出瓶颈带宽,在丢包率 1% 的跨国链路上吞吐能比 CUBIC 高出几倍

BBR 到底换了什么思路,换成大白话是这样:老办法好比一个只会"撞到墙才知道该拐弯"的司机——他不看路,全靠丢包(撞墙)当信号。可这个信号在两种情况下会骗他:一是走山路时车偶尔颠一下掉了件货,那不是堵,他却误以为堵了赶紧减速;二是路上排了个超长的缓冲车道,车其实早就排到了两公里外,可因为一辆车都没被劝返,他压根没意识到自己已经在堵了——只是每件货都要多等半秒。

BBR 改成了"边走边量":它不等撞墙,而是持续测两个数——"这条路实际每秒能过多少货"和"空路时跑一趟最快要多久"好比司机手上有个仪表盘,随时看着实际通行率和最短耗时,主动把车速稳在"刚好跑满、又不排队"的那个点上。效果有多大?在一条丢包率 1% 的跨国线路上,老办法能被那 1% 的颠簸吓得只跑三成,BBR 能跑满——吞吐差好几倍。

# 查看和切换拥塞控制算法(Linux 4.9+ 内置 BBR)
$ sysctl net.ipv4.tcp_congestion_control
net.ipv4.tcp_congestion_control = cubic

$ sysctl net.ipv4.tcp_available_congestion_control
reno cubic bbr

$ sysctl -w net.ipv4.tcp_congestion_control=bbr
# 注意:拥塞控制是"发送方"的行为,
#       所以只在服务器(下行发送方)上改才有意义

# 观察某条连接的实时拥塞状态
$ ss -tin
  cubic wscale:7,7 rto:204 rtt:2.5/1.2 mss:1448
  cwnd:42 ssthresh:35 bytes_sent:1048576 retrans:0/3
       ↑ 当前拥塞窗口   ↑ 门限          ↑ 重传了 3 次
  这一行几乎包含了诊断 TCP 性能所需的全部信息

Nagle 算法与延迟确认:一对危险的组合

前面提过 TCP 会"攒包",这里说清两个攒包机制,以及它们凑在一起时会出的问题。

Nagle 算法(发送方攒够一定量或收到回音才发,别为一点点东西跑一趟)好比快递员的攒件习惯:你要寄一个指甲刀,他不会为这一件专门跑一趟——他会说"我等等,看还有没有别人也要寄,攒一车再走"。延迟确认(Delayed ACK,收到东西先不急着回话,等一会儿看能不能顺便捎带点别的)好比收货人的省事习惯:"我先不急着给你回电话,等我这边也有话要说,一起说了省一个电话费。"

Analogy · 两个都很懂事的人,凑一起把事办黄了

这两个习惯单独看都很得体,凑在一起就出了个 40 毫秒的怪事。点菜的场景演一遍:

你要点两样东西,分了两句话说。第一句"来碗面"发出去了——服务员听到了,但他心想"我先不吱声,看客人还要不要加菜,攒着一起回话省一趟"(延迟确认)。而你这边,因为第一句话还没得到回应,Nagle 说"上一句还没确认,第二句先攒着别发"

于是两个人就这么互相干等着。你等他回话才敢说第二句,他等你说第二句才准备回话。僵持到他那边的计时器到点(40 毫秒),他终于开口"好的"——你这才把"加个鸡蛋"说出去。

一件本该 1 毫秒办完的事,花了 41 毫秒。而且这个延迟稳得像钟表——凡是遇到"延迟死死卡在 40 毫秒或 200 毫秒整"这种现象,几乎一定是协议栈的机制导致的,绝不是网络慢。真实的网络延迟不会这么整齐。

这件事的教训很有普适性:两个各自都很正确的优化,在交界处可能产生谁都没预料到的怪毛病。解法也朴素——要么让快递员别攒(关掉 Nagle),要么你干脆一口气把两句话说完(应用层合并成一次写出)。

Nagle 算法(RFC 896,1984 年)解决的是发送方的小包泛滥。规则只有一条:如果还有未被确认的小包在飞,就把新的小数据先攒着,等 ACK 回来或攒够一个 MSS 再发。它的动机很实在——1984 年 Telnet 用户每敲一个键就发一个 41 字节的包,有效载荷率 2.4%,会把当时脆弱的链路撑爆。

延迟确认(Delayed ACK,RFC 1122)解决的是接收方的纯 ACK 泛滥。规则是:收到数据后先不急着回 ACK,等最多 40~200 毫秒,看能不能顺便捎带上自己要发的数据(这叫"捎带确认",piggyback),或者等到第二个数据段一起确认。这能省掉大量 78 字节的纯 ACK 包。

两个机制单独看都很合理,凑在一起就出事了:

【Nagle + 延迟确认 = 40 毫秒的诡异延迟】

场景:客户端要发一个请求,但分成两次 write()
      (比如先写头部 100 字节,再写正文 50 字节)

客户端                             服务器
  write(头部 100B) → 立刻发出(没有未确认的包)
                              → 收到,触发延迟确认,
                                等 40ms 看有没有别的数据
  write(正文 50B)  → Nagle 说:
                     "有未确认的小包在飞,先攒着"
       ↓                            ↓
    双方互相等待……               等 40ms 超时
                              ← 终于回 ACK
  收到 ACK → 发出正文 50B  →   收到完整请求,开始处理

  结果:一个本该 1 ms 完成的请求花了 41 ms
        而且这个延迟极其固定(40ms 或 200ms),
        像是"某处有个硬编码的 sleep"

【三种解法】

① 关掉 Nagle(最常用)
   setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));
   → 几乎所有现代 RPC 框架、Redis、Nginx 都默认开 TCP_NODELAY

② 从根本上避免"分两次 write"
   在应用层把头部和正文拼成一个 buffer 一次写出
   或用 writev() 聚集写

③ 用 TCP_QUICKACK 减少延迟确认(Linux,且是一次性的)
   setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &on, sizeof(on));

这个组合问题的教训很有普适性:两个各自正确的优化,在交界处可能产生谁都没预料到的病态行为。它也解释了为什么"延迟稳定在 40 毫秒整"这种现象几乎一定是协议栈机制导致的,而不是网络慢——真实的网络延迟不会这么整齐。

Keep-Alive:怎么发现对方已经"死"了

TCP 有一个反直觉的性质:如果双方都不发数据,那么即使对方的机器已经断电、网线已经被拔掉,你这边的连接依然会显示 ESTABLISHED,可能持续几天。因为 TCP 的连接状态纯粹保存在两端内存里,没有任何"心跳"是协议强制的。

这件事换成大白话:你和朋友打电话,双方都沉默着不说话。哪怕对方那头手机已经掉进水里、彻底没电了,你手上这只听筒也不会告诉你任何异常——你会一直"以为还在通话中",可能一直以为好几天。因为"通话中"这个状态只写在你自己的脑子里(内存里),线路上没有任何人负责通知你。

TCP Keep-Alive(内核每隔一段时间发个空包探探对方还在不在)和 HTTP Keep-Alive(一次请求完了不挂电话,下次接着用这条线)虽然同名,本质上就是两件完全不同的事:前者是"喂?还在吗?",后者是"别挂,我等下还有事问你"。把 Linux 的默认值翻译成人话更吓人:它要空闲整整两小时才开始问第一句"还在吗",然后再问九遍每遍隔 75 秒——加起来发现对方失联要 2.2 小时。对绝大多数业务,这个默认值等于没有。

而更推荐的做法是自己在应用层做心跳,理由有三条,都很实在。第一,内核的探测只能证明"对方机器还开着",不能证明"对方那个人还清醒"——好比你确认了对方手机有信号,可他其实已经睡着了。一个卡死的程序,它的内核照样会礼貌地回应探测包。第二,中间那些设备的记性很短:路由器和负载均衡的会话表通常只记几分钟,比两小时短得多,你不隔几分钟说句话,中间人就把你俩的对应关系忘了。第三,自己的心跳可以捎带信息——顺便报个"我这边负载多少、版本多少、健康不健康",一举两得。

为此有两层机制,注意它们完全是两回事,名字却容易混:

TCP Keep-AliveHTTP Keep-Alive
在哪一层传输层,内核实现应用层,HTTP 协议特性
干什么探测对端是否还活着请求完不关连接,下次复用
怎么开启setsockopt(SO_KEEPALIVE)Connection: keep-alive 头(1.1 默认)
默认时间Linux 空闲 7200 秒(2 小时)才开始探测Nginx 默认 keepalive_timeout 75 秒
# TCP Keep-Alive 的三个内核参数
$ sysctl net.ipv4.tcp_keepalive_time    # 7200:空闲多久开始探测
$ sysctl net.ipv4.tcp_keepalive_intvl   # 75:探测包间隔
$ sysctl net.ipv4.tcp_keepalive_probes  # 9:连续失败几次就判定死亡

# 默认配置下发现对端死亡需要:7200 + 75×9 = 7875 秒 ≈ 2.2 小时!
# 对绝大多数业务来说这个默认值完全不可用

# 生产环境常见调整(也可以只对单个 socket 用 setsockopt 设置)
$ sysctl -w net.ipv4.tcp_keepalive_time=60
$ sysctl -w net.ipv4.tcp_keepalive_intvl=10
$ sysctl -w net.ipv4.tcp_keepalive_probes=3
# → 90 秒内能发现对端失联

实践中更推荐用应用层心跳而不是依赖 TCP Keep-Alive,理由有三条:①TCP Keep-Alive 只能证明"内核活着",不能证明"应用进程没卡死"——一个陷入死锁的进程,它的内核依然会正常回应 keep-alive 探测;②NAT 和负载均衡器的会话表超时通常只有几分钟,比 TCP 默认的 2 小时短得多,必须靠更频繁的心跳撑住映射;③应用层心跳可以携带业务信息(负载、版本、健康度),顺便完成服务发现。

TCP 的"粘包"问题

TCP 是"流式"协议——数据像水一样连续流,没有边界。你发两条消息"Hello"和"World",接收方可能一次收到"HelloWorld"——这叫粘包

解决办法:应用层自己定边界——比如每条消息前加长度,或用特殊字符分隔(HTTP 用 \r\n\r\n 分隔头部和 body)。

粘包(Sticky Packet,两条消息被合成一坨、或者一条被拆成两半)这个词听着玄,其实就是你在洗衣房里遇到的事:你分三次往洗衣机里倒了三瓢水,可机器里只是一整缸水——它绝不会记得"这是三瓢"。TCP 就是这缸水,它承诺"你倒进去的水一滴不少地流到对面",但从来没承诺"帮你记住你倒了几瓢"。

解决办法也和生活里一样朴素。要么先说数量再倒——像快递面单上写"内含 3 件",收货人数够 3 件就知道齐了(这叫长度前缀);要么在每瓢之间放个记号——像笔记本上每记完一条就画一道横线(这叫分隔符,HTTP 用的就是连续两个换行)。注意这不是在修 TCP 的 bug,而是在补 TCP 明确说过它不管的那件事。

TCP vs UDP · 什么时候用哪个

场景用 TCP 还是 UDP为什么
网页浏览TCP网页内容不能错一个字
文件下载TCP文件必须完整
微信文字聊天TCP消息不能丢
视频通话UDP偶尔卡一下没关系,但延迟不能高
直播UDP观众容忍花屏,不能容忍延迟
在线游戏UDP 为主位置同步要实时,偶尔丢包无所谓
DNS 查询UDP 为主查询简单,一次问答搞定
Recap · 收束

TCP = "靠谱的快递"——三次握手确认身份,序号+确认+重传保证不丢,滑动窗口+拥塞控制防止堵路。
代价是慢一点、复杂一点——所以视频、直播、游戏这些"要快不要全"的场景,会用 UDP(下一篇讲)。

☰ 主页
Xue Hai Wu Ya · Network · § 3.2 · TCP