TCP
TCP 是互联网最"靠谱"的协议——它保证你发的数据不丢、不重、按顺序到达。微信聊天、文件下载、网页浏览,背后都是 TCP 在保驾护航。
你打电话——先拨号,对方接起"喂?",你说"听得到吗?",对方说"听得到"——然后才开始说正事。
说完你说"我说完了",对方说"好的",你再说"拜拜",对方也说"拜拜"——挂断。
这个过程就是 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。
这条坑换成大白话就是:你在邮局把信投进了邮筒,手一松那一刻,你只能确定"信离开我手了"——绝不能确定"对方看到了"。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 个端口 |
| 序号 seq | 32 位 | 本段第一个字节在整条流中的编号。初始值随机(防旧包还魂和序号猜测攻击),到 2³²−1 后回绕 |
| 确认号 ack | 32 位 | "我下一个期望收到的字节编号"。注意不是"我收到的最后一个",差一位就理解错了 |
| 首部长度 | 4 位 | 以 4 字节为单位,最大 15×4 = 60 字节。这就是选项最多 40 字节的原因 |
| 窗口大小 | 16 位 | "我的接收缓冲区还剩多少字节"。最大 65535,在高带宽长链路上远远不够——所以有窗口缩放选项 |
| 校验和 | 16 位 | 覆盖头部 + 数据 + 一段伪首部(含源/目的 IP)。这是 TCP 唯一的数据完整性保障 |
| 紧急指针 | 16 位 | 配合 URG 使用,现代几乎不用(RFC 6093 建议新应用不要用) |
那六个标志位(Flags)是 TCP 状态机的操纵杆,每一位的含义都必须记牢:
| 标志 | 全称 | 含义 | 你在什么时候见到它 |
|---|---|---|---|
| SYN | Synchronize | 请求建立连接,同步初始序号 | 三次握手的前两个包 |
| ACK | Acknowledgment | 确认号字段有效 | 除第一个 SYN 外,几乎每个包都带 |
| FIN | Finish | 我没数据要发了,想关闭 | 四次挥手 |
| RST | Reset | 立刻强制重置连接,不走挥手流程 | 连不存在的端口、程序崩溃、被防火墙阻断 |
| PSH | Push | 提示接收方尽快把数据交给应用,别缓着 | 交互式应用、HTTP 请求最后一个包 |
| URG | Urgent | 有紧急数据,配合紧急指针 | 基本已废弃,出现时往往是攻击流量 |
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)。"——三次握手完成,连接建立。
两次行不行?不行——
客户端说"我要连你"(第一次),服务器说"好"(第二次)——但服务器不知道客户端有没有收到它的"好"。如果客户端没收到,服务器就傻等着。
第三次客户端说"我收到了你的好"——服务器才确定"客户端真的在"。
三次之后,双方都确认对方听到了自己。
为什么必须三次,不能两次也不能四次
"双方都确认对方收到了自己"是标准答案,但还有两个更深的理由,面试往深了问时就在考这个。
理由一:防止"旧包还魂"造成的幽灵连接。这是 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 之后就必须分配资源,而此时它还完全无法确认对方是真的还是假的。这个时间窗口造就了互联网史上最经典的拒绝服务攻击。
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,就是等"我发出去的和对方可能发过来的"两个方向的迟到件全都彻底消失。为什么必须等?因为如果你立刻用同一个电话号码打新电话,而上一通的迟到语音刚好这时候到了,你会把它当成新通话的内容听进去——数据被悄悄污染,而且不会有任何报错。
它存在的两个理由都很硬:
- 理由一 · 保证最后那个 ACK 能到达如果最后一个 ACK 在路上丢了,服务器会以为自己的 FIN 没送到,于是重发 FIN。此时如果客户端已经彻底关闭并忘掉了这条连接,它会回一个 RST——服务器就会认为"连接异常终止"而不是正常关闭,可能触发错误日志或数据丢失。TIME_WAIT 让主动关闭方多留 60 秒,专门用来接住这种重发的 FIN 并再回一次 ACK。
- 理由二 · 让旧连接的迷路包彻底死掉如果立刻释放端口,一个新连接可能凑巧用了完全相同的五元组。此时上一条连接遗留在网络里的迟到包如果爬到了,序号又恰好落在新连接的窗口内,就会被当成新连接的合法数据接收——数据静默污染,没有任何报错。等待 2×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 的十一种状态:一张完整的状态机
握手和挥手其实是同一台状态机的两段旅程。把十一个状态列全,你就有了一张读 ss 和 netstat 输出的解码表:
想象一下这十一个状态就是打电话这件事的十一个时刻:拿起听筒(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 怎么保证"不丢"
核心机制:序号 + 确认 + 重传。
- 序号每个字节都编一个号——1, 2, 3, 4... 接收方按号排序。
- 确认接收方定期告诉发送方"我收到 1-1000 号了"——叫 ACK。
- 超时重传发送方发现"1001-1500 号"迟迟没收到确认——自动重发。
- 快速重传接收方发现"1001 号没到,但 1002 到了"——立刻告诉发送方"缺 1001",不用等超时。
这四条要往细里讲,得先讲清一个最容易搞错的点:TCP 的确认是"累积确认"(Cumulative ACK)。确认号的含义不是"我收到了这个",而是"这个编号之前的所有字节我都收到了,请从这个编号开始发"。这个语义差异会带来一个严重后果:
累积确认(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 有两个机制防止"把路堵死":
滑动窗口 · Flow Control
接收方告诉发送方"我缓冲区还有多大"——发送方不超过这个量发。
防止"你发太快,我处理不过来"。
拥塞控制 · Congestion Control
TCP 会"试探"网络有多堵——慢慢加速,发现丢包就减速。
防止"大家都猛发,把路堵死"。
滑动窗口 = 你跟副驾说"我胃里还能吃三碗饭"——副驾就最多给你盛三碗。
拥塞控制 = 你上高速先慢慢开,发现不堵就加速,发现刹车灯亮就减速——TCP 也是这么"试探"网络的。
滑动窗口:流量控制的细节
"接收方告诉发送方缓冲区还剩多少"这句话,落到实现上是一套相当精巧的指针游戏。发送方的缓冲区被切成四个区域:
滑动窗口(Sliding Window,"我一次最多能有多少货在路上没签收"这个额度)好比给超市补货时的一条规矩:仓管说"我后仓最多同时堆 4 车货,堆满了你就别发了,等我卸掉几车腾出位置再发"。那四个区域换成大白话就是:已经卸完签收的(可以从账上划掉了)、正在路上跑的(发了但还没签收)、现在还能发的额度、以及超出额度暂时不许发的。每卸掉一车,这个额度就往后挪一格——这就是"滑动"两个字的来历。
还有一件必须分清的事:流量控制(Flow Control)和拥塞控制(Congestion Control)是两回事,而且保护的对象完全不同。流量控制护的是"收货那一方"——仓库堆不下了,人家会明确告诉你别发了;拥塞控制护的是"路"——路上堵不堵,没有任何人会通知你,你只能靠"货怎么老是丢、怎么老是走得慢"去猜。一个是有人明说,一个是全靠推测,这就是它们本质上的区别。
发送方视角(假设窗口 = 4000 字节):
字节编号 ─→
[已发送且已确认] [已发送未确认] [可以发但还没发] [不许发]
↑ ↑ ↑ ↑
可以丢弃了 在飞行中(in-flight) 窗口余量 等窗口滑动
└────────── 发送窗口 4000 字节 ──────────┘
窗口会随着 ACK 到来而"向右滑动"
关键公式:
可发送的数据量 = min(接收窗口 rwnd, 拥塞窗口 cwnd) - 已发送未确认量
↑ 对方缓冲区限制 ↑ 网络能力限制
两个窗口中较小的那个是瓶颈:
rwnd 小 → 对方处理不过来(应用读得慢)
cwnd 小 → 网络堵或刚起步
这里必须区分清两个常被混为一谈的概念:流量控制保护的是"接收方",拥塞控制保护的是"网络"。前者是端到端的两人协商(对方明确告诉你还能收多少),后者是发送方对整个网络状况的猜测(没有任何人告诉你网络有多堵,只能靠丢包和延迟去推断)。
滑动窗口有两个经典的病态情况,都有对应的解药:
这两个毛病换成大白话都特别好懂。第一个是"零窗口死锁":仓管说"满了,别发了",你就停了;可后来他喊"腾出来了,接着发"那一句你没听见——于是他在那边等货,你在这边等通知,两个人能这么僵住一辈子。解法很朴素:你每隔一阵子主动打个电话问一句"现在能发了吗"。第二个叫"糊涂窗口":仓管卸货奇慢,每腾出一个小格子就喊你"来一件"——结果你为了送一件小东西,得开一整趟货车、填一整套单据,有效载重只有 2.4%。解法是双方各让一步:仓管憋着,攒够半个仓库再喊;你也憋着,攒够一车再发。
- 零窗口与窗口探测接收方缓冲区满了,回一个
win=0,发送方必须停下。但如果之后那个"缓冲区空了"的通知包丢了,双方就永久死锁——发送方等通知,接收方以为通知发过了。解法是发送方启动一个持续计时器(Persist Timer),定期发一个 1 字节的窗口探测包问"现在能收了吗"。 - 糊涂窗口综合症Silly Window Syndrome:接收方应用每次只读 1 字节,于是每次都通告"窗口 1 字节",发送方就发 1 字节数据 + 40 字节头部,效率 2.4%。解法是双管齐下——接收方(Clark 方案)在窗口小于一个 MSS 或缓冲区一半时干脆通告 0,攒够了再张开;发送方(Nagle 算法)则攒够一个 MSS 或收到 ACK 才发。
还有一个必须提的现实限制: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"翻译成人话:相当于原本每分钟能传完一篇短文,突然变成每分钟只能传出五个汉字。这已经不是"变慢",是彻底瘫了。而原因特别讽刺——不是线断了,是所有人都因为"没收到回音"而拼命重发,重发的包又把路挤得更死,于是更多包丢,于是更多人重发。好比一场大型开会散场时所有人同时挤向同一个出口,越挤越走不动,越走不动越使劲挤。
拥塞控制的四个阶段,换成大白话就是一位新司机第一天跑快递路线的完整心路历程。他手上有一堆货,但完全不知道这条路能承受多大流量,只能靠试。
阶段一 · 慢启动(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 连接,会自动收敛到公平共享带宽。原因是"减法按比例、加法按定量"——占带宽多的连接每次减得也多,几轮之后大家就趋于相等。这是纯分布式的、无需任何中央协调的自组织行为。
现代的算法已经演进了好几代,各有适用场景:
| 算法 | 年份 | 核心思路 | 适用场景 |
|---|---|---|---|
| Tahoe | 1988 | 丢包就把 cwnd 归 1 | 历史,已淘汰 |
| Reno | 1990 | 加入快重传快恢复,丢包只减半 | 教科书标准模型 |
| NewReno | 1999 / RFC 6582 | 改进一个窗口内多次丢包的处理 | — |
| CUBIC | 2008 / RFC 8312 | 用三次函数曲线增长,在上次的丢包点附近"减速探测" | Linux 默认,高带宽长链路友好 |
| BBR | 2016(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,收到东西先不急着回话,等一会儿看能不能顺便捎带点别的)好比收货人的省事习惯:"我先不急着给你回电话,等我这边也有话要说,一起说了省一个电话费。"
这两个习惯单独看都很得体,凑在一起就出了个 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-Alive | HTTP 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 为主 | 查询简单,一次问答搞定 |
TCP = "靠谱的快递"——三次握手确认身份,序号+确认+重传保证不丢,滑动窗口+拥塞控制防止堵路。
代价是慢一点、复杂一点——所以视频、直播、游戏这些"要快不要全"的场景,会用 UDP(下一篇讲)。