ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从端口复用到拥塞控制:TCP/UDP传输层排障与调优

从端口复用到拥塞控制:TCP/UDP传输层排障与调优

传输层这一块,我见过太多人背完三次握手、四次挥手就以为过关了,结果一到线上排查就卡壳:明明服务端在 listen,客户端就是连不上;明明带宽没跑满,吞吐量却上不去;明明代码里写了 send,对端就是收不全。这些问题的答案几乎都埋在传输层里。端口怎么复用、TCP 的可靠到底靠哪几个机制兜底、UDP 砍掉的到底是什么、拥塞控制和流量控制为什么不能混为一谈——把这些串起来,才算是真正读懂了 TCP 和 UDP 这两套传输层协议。下面这些内容适合已经了解 IP 层基础、正在啃网络协议栈的开发者,也适合工作几年但一直靠“背八股”应付面试、想补上实操这一块的朋友。我尽量把每个结论都落到能抓包验证、能改参数复现的层面,而不是停在概念描述上。

1. 端口不是编号,是让一台主机同时跑几十个服务的复用键

很多人对传输层的第一印象就是“IP 找主机,端口找进程”,这句话没错,但它太省事了,省掉了最关键的一层意思:端口是复用与分用的键。一台机器只有一个 IP,却可能同时跑 Web 服务、数据库、缓存、监控上报、日志采集,几十个进程都在收发数据。IP 层把数据报丢上来的时候,它根本不知道这份数据属于哪个进程,是传输层用端口号完成了这次“分用”。理解这一点,后面所有关于连接、四元组、端口耗尽的问题才有落脚点。

1.1 16 位端口号的空间分配与真实约束

端口号是 16 位无符号整数,范围 0 到 65535。习惯上分成三段:0 到 1023 是知名端口,1024 到 49151 是注册端口,49152 到 65535 是动态端口(也叫临时端口)。这个划分在教科书里很清晰,但在实际系统里,真正的边界是“谁能 bind 小于 1024 的端口”——在多数类 Unix 系统上,这需要特权,所以普通用户进程绑不了 80 和 443,这不是协议规定,是内核的权限约束。

临时端口的实际范围由内核参数决定,可以这么看:

cat /proc/sys/net/ipv4/ip_local_port_range # 典型输出:32768 60999

这个范围大约 2.8 万个端口。这就是单机对单个目标地址发起并发连接的理论上限之一。如果你的服务是大量短连接、且对端只有一台,那么短时间内的连接数一旦逼近这个量级,就会撞上“Cannot assign requested address”。这个报错不是内存不够,也不是文件描述符不够,而是临时端口被 TIME_WAIT 状态的连接占满了,内核找不到可用的源端口。我见过一个采集程序,每采集一条数据就新建一次 TCP 连接,跑了几分钟就开始报这个错,最后改成连接池加长连接,问题直接消失。

1.2 四元组才是连接的唯一身份证

服务端只 bind 了一个端口,比如 8080,为什么能同时服务上万客户端?因为一条 TCP 连接由四元组唯一确定:源 IP、源端口、目的 IP、目的端口。客户端的源端口是内核从临时端口范围里挑的,不同客户端、同一客户端的不同连接,源端口都不一样,所以四元组不同,内核能把它们区分成不同的 socket。

这也解释了一个常见疑惑:服务端进程明明只创建了一个监听 socket,为什么 accept 出来一堆新 socket?监听 socket 只负责接收 SYN,握手完成后内核创建新的连接 socket,绑定到具体的四元组上,然后放进 accept 队列等应用来取。监听 socket 和连接 socket 是两码事,前者管“有没有人来”,后者管“这个人要说什么”。

再往下推一层,listen(fd, backlog)里的 backlog 到底限制什么?在 Linux 上它限制的是accept 队列的长度(已完成三次握手、等待应用 accept 的连接),而半连接队列(收到 SYN、还没完成握手)由tcp_max_syn_backlog控制。而且 backlog 的实际生效值还要和net.core.somaxconn取小:

sysctl net.core.somaxconn # net.core.somaxconn = 4096

如果应用 accept 得慢,accept 队列满了,新完成握手的连接会被丢弃(或者按tcp_abort_on_overflow的配置直接回 RST),客户端表现就是“连上了但发数据没响应”或者“偶发连接超时”。这种问题特别隐蔽,因为服务端日志里什么都看不到——连接压根没进到应用层。

1.3 端口复用的两个不同含义

SO_REUSEADDR和SO_REUSEPORT名字像,作用完全不同,混用会踩坑。

选项解决什么问题典型场景
SO_REUSEADDR允许 bind 处于 TIME_WAIT 的地址端口组合服务重启时报 Address already in use
SO_REUSEPORT允许多个 socket 绑同一端口,内核做负载均衡多进程/多线程共享监听端口

SO_REUSEADDR是服务端重启的救命稻草,不加这个选项,进程重启时上一轮遗留的 TIME_WAIT 连接会让 bind 直接失败。SO_REUSEPORT则是多进程模型的加速器,每个 worker 各自 bind 同一个端口,内核按四元组哈希把新连接分给其中一个,避免了“一个进程 accept 再分发”的惊群和额外一次上下文切换。但它有个副作用:如果各个 worker 的处理能力不均,负载就会倾斜,因为哈希是固定的,同一个客户端 IP 段可能总是落到同一个进程上。

2. UDP 砍掉的那些东西,恰恰是它存在的理由

说起 UDP,很多人的第一反应是“不可靠、会丢包、不推荐用”。这个判断在业务选型上是错的。UDP 的极简不是能力不足,是刻意的取舍:它只做传输层最不可省略的那部分工作——端口寻址和可选的差错检测,其余全部交给应用层决定。当你不需要 TCP 那套机制,或者你需要一套比 TCP 更适合自己业务的机制时,UDP 才是正解。

2.1 八个字节的头部里藏着什么信息

UDP 头部固定 8 字节,四个字段各 2 字节:源端口、目的端口、长度、校验和。对比 TCP 的 20 字节固定头加可变选项,这个差距不是简单的 12 字节,而是少了序号、确认号、窗口、标志位、紧急指针这些“可靠性基础设施”。

字段长度作用备注
源端口2 字节标识发送进程不需要回包时可以置 0
目的端口2 字节标识接收进程分用的依据
长度2 字节头部加数据的字节数最小 8,最大 65535
校验和2 字节覆盖伪首部、UDP 头和数据IPv4 中可为 0 表示不校验

这里有个细节值得单独说:校验和计算时会带上一个“伪首部”,里面包含源 IP、目的 IP、协议号和 UDP 长度。伪首部并不真的在网络上传输,它只是参与计算,目的是让 UDP 能验证“这个数据报是不是真的发给我了、是不是真的来自那个地址”。这也是为什么 NAT 设备改写了 IP 地址之后,必须同步重算 UDP 和 TCP 的校验和,否则接收端会直接丢包。

另一个坑是长度字段只有 16 位,理论上 UDP 数据报最大 65535 字节,但实际发送时受 MTU 限制。超过 MTU 会在 IP 层分片,而UDP 数据报只要丢失任何一个分片,整个数据报都会被丢弃,因为 IP 层没有重传。所以实际做业务的时候,单个 UDP 包我一般控制在 1200 字节以内,给 IPv6 头部和各种隧道封装留出余量,这样能最大程度避免分片。

2.2 什么时候该果断选 UDP

判断标准其实很简单:看你能不能接受“丢了就丢了”,或者你能不能自己处理“丢了怎么办”。

  • 实时音视频和语音通话:丢一帧画面、丢 20 毫秒的声音,用户几乎察觉不到;但如果为了等一个重传卡住 200 毫秒,体验直接崩掉。
  • 游戏状态同步:位置信息是高频刷新的,旧的丢了无所谓,下一帧就覆盖了,重传反而带来延迟。
  • DNS 查询:一问一答,一次请求一个响应,用 TCP 建立连接的开销远大于收益。响应太大放不下时才退化成 TCP。
  • 指标上报、日志采集:允许采样丢失,但不能因为对端慢而阻塞业务线程。

反过来,文件传输、数据库连接、HTTP 接口调用这类“一个字节都不能少”的场景,老老实实用 TCP。至于那些既要可靠又要低延迟的场景,现在的做法是在 UDP 之上自建一套机制——QUIC 就是这么干的,它在用户态重新实现了序号、确认、重传和拥塞控制,因为在内核里改 TCP 的代价太高了。

提醒一句:UDP 没有拥塞控制,不代表你可以无脑狂发。在一个共享的链路上,多个 UDP 流不受约束地打满带宽,会把同链路上的 TCP 流挤到几乎无法工作。做 UDP 业务时,应用层自己实现一个速率控制是基本素养。

3. TCP 的“可靠”不是自动生效的,是三个机制在协同兜底

“TCP 是可靠的”这句话容易被误解成“用了 TCP 就不会丢数据”。准确的说法是:TCP 提供的是面向连接的、可靠的、基于字节流的传输服务,它保证的是“到达应用层的数据是完整且有序的”,代价是延迟和开销。而且这份可靠性是靠序号、确认、重传、校验这一整套机制联手实现的,任何一环配置不当,你依然会遇到数据收不全的问题。

3.1 序号编的是字节,不是报文

这是最容易被忽略的一点。TCP 的 seq 是字节序号,不是包序号。假设初始序号是 1000,你发了一个 500 字节的报文,下一个报文的 seq 就是 1500。接收方回 ACK 1500,意思是“1499 及之前的字节我都收到了,下一个我要 1500”。

这个设计带来两个直接后果。第一,累积确认:ACK n 表示 n 之前的所有字节都到齐了,不需要逐包确认。第二,重传的边界是字节不是包:接收方发现中间缺了一段,可以只请求那一段,而不是把后面收到的一股脑全丢。后来的 SACK(选择性确认)选项就是把这个能力显式化,让发送方知道“哪几段到了、哪几段没到”,避免一次性重传大量已经到达的数据。

顺手说一句,初始序号不是从 0 或 1 开始的,而是随机化的。原因有两层:一是避免新旧连接的报文混淆,二是提高序列号的可猜测难度。这也是为什么抓包看到的第一条 SYN 里 seq 是个巨大的随机数,别以为是抓错了。

3.2 超时重传的 RTO 是怎么算出来的

重传要等多久?等太短,网络只是稍微抖一下你就重传,白白浪费带宽;等太长,真丢包了应用就卡着。TCP 的做法是动态估算 RTT,用的是 Jacobson/Karels 算法,核心是维护两个平滑量:

SRTT = (1 - 1/8) * SRTT + (1/8) * RTT_sample RTTVAR = (1 - 1/4) * RTTVAR + (1/4) * |SRTT - RTT_sample| RTO = SRTT + 4 * RTTVAR

翻译成人话:SRTT 是往返时延的滑动平均,RTTVAR 是时延波动的滑动平均,RTO 取“平均值加上四倍波动”。网络越不稳定,RTTVAR 越大,RTO 就越保守,这个设计很符合直觉——抖动大的链路,你就该多等一会儿再判断是不是真丢了。

这里必须提 Karn 算法:对重传过的报文,不采样它的 RTT。原因很朴素——你收到一个 ACK,根本不知道它对应的是第一次发的包还是重传的包,算出来的 RTT 是错的。所以重传发生时跳过采样,同时把 RTO 翻倍(指数退避),避免在已经拥塞的链路上继续加压。

Linux 上的 RTO 有上下界,TCP_RTO_MIN默认 200 毫秒,TCP_RTO_MAX是 120 秒。这解释了一个经典现象:弱网环境下第一次重传至少要等 200 毫秒。如果你的业务对延迟极度敏感,一次丢包就意味着 200 毫秒的卡顿,那这个代价是 TCP 的固有成本,只能靠应用层冗余或者换协议来解决。

3.3 三个重复 ACK 触发的快速重传

等 RTO 超时太慢了。如果接收方连续收到乱序的报文,每收到一个就回一次“我还在等那个缺失的字节”,发送方连着收到三个重复 ACK,就能推断“中间大概率丢了,但后面的还在到”,于是不等超时立刻重传。这就是快速重传。

它和超时重传的分工很明确:快速重传处理“零星丢包”,超时重传兜底“连续丢包或 ACK 全丢”。如果丢包严重到后面一个包都过不去,接收方根本发不出重复 ACK,那就只能等 RTO。

这套机制有个前提:乱序不能太严重。如果链路本身乱序很多,接收方频繁发出重复 ACK,发送方就会误判为丢包而做无谓的重传。真实网络里这个度不好把握,Linux 有tcp_reordering之类的参数来调节容忍度,但一般不建议手动改,让内核自适应就行。

4. 连接的生命周期:状态机视角下的握手与挥手

三次握手、四次挥手这两段,网上讲得太多了,但大部分人记住的是“SYN、SYN+ACK、ACK”这个流程,而不是“为什么必须是这样”。一旦遇到 TIME_WAIT 堆积、CLOSE_WAIT 不释放、SYN 队列溢出这些线上问题,光背流程是救不了火的,得从状态机的角度去看每一步存在的意义。

4.1 为什么握手是三次而不是两次

教科书的标准答案是“防止已失效的连接请求突然又传到服务端”。这个答案对,但不够透彻。更本质的原因是:握手要完成的是双向的序号同步和能力协商。

  • 第一次 SYN:客户端告诉服务端“我的初始序号是 x,我想建立连接”。
  • 第二次 SYN+ACK:服务端回“我收到了,你的序号是 x,我的初始序号是 y”。
  • 第三次 ACK:客户端回“我收到你的 y 了”。

如果只有两次,服务端发完 SYN+ACK 就认为连接建立了,但它无法确认客户端是否收到了自己的初始序号。假如这个 SYN+ACK 在网络里丢了,客户端根本不知道连接存在,服务端却已经开始分配资源、进入 ESTABLISHED,这些资源就白占着。第三次 ACK 的存在,是让服务端确认“双方对序号的认知是一致的”。

顺带把 SYN 攻击的原理说清楚,这有助于理解防护参数。攻击者大量发 SYN 但不回最后的 ACK,服务端的半连接队列会被这些“半成品”占满,正常用户的 SYN 进不来。防护手段里最常见的是 SYN Cookie:服务端不立刻分配资源,而是把连接信息编码进自己的初始序号里,等收到第三次 ACK 再重建状态。代价是丢失了部分 TCP 选项(比如窗口缩放),所以只在队列压力大时才启用。

参数作用常用取值
net.core.somaxconnaccept 队列上限1024 到 4096
net.ipv4.tcp_max_syn_backlog半连接队列上限1024 到 8192
net.ipv4.tcp_syncookiesSYN Cookie 开关1(压力大时启用)
net.ipv4.tcp_abort_on_overflow队列溢出时是否回 RST0(默认,静默丢弃)

4.2 四次挥手和 TIME_WAIT 的真正价值

TCP 是全双工的,两个方向的关闭是独立的,所以需要四次:主动关闭方发 FIN,对方回 ACK(这算两次,因为对方可能还有数据要发),对方数据发完再发 FIN,主动方回 ACK。如果对方恰好也没有数据了,ACK 和 FIN 可以合并,看起来像三次,但那是优化,不是协议规定。

重点说TIME_WAIT。主动关闭的一方在发完最后一个 ACK 后进入 TIME_WAIT,等待 2MSL(Linux 上大约是 60 秒)才真正关闭。为什么不能直接关?两个理由:

第一,保证最后的 ACK 能到达。如果这个 ACK 丢了,对方会重传 FIN,此时如果主动方已经彻底关闭,收到 FIN 会回 RST,对方就会认为连接异常终止。留着 TIME_WAIT,就能重新发一次 ACK。

第二,让这条连接上的残留报文在网络中自然消亡。四元组复用是必然的,如果旧连接的延迟报文还在路上,新连接用了同样的四元组,这些旧报文会污染新连接。等 2MSL,足够让所有残留报文过期。

所以 TIME_WAIT 不是 bug,是特性。真正的问题在于大量短连接导致 TIME_WAIT 堆积,占满临时端口。常见的处理方式有几种,效果和风险差别很大:

# 允许复用 TIME_WAIT 状态的连接发起新连接(仅对客户端有效,且需要时间戳支持) sysctl -w net.ipv4.tcp_tw_reuse=1 # 缩短 FIN_WAIT_2 的超时,尽快回收半关闭连接 sysctl -w net.ipv4.tcp_fin_timeout=15 # 扩大临时端口范围,缓解端口耗尽 sysctl -w net.ipv4.ip_local_port_range="10000 65000"

特别提一句:早年流传的tcp_tw_recycle参数早就在新版内核里被移除了。它在 NAT 环境下会因为时间戳判断不一致而丢弃正常连接,坑过不少人。看到老文章还在推荐这个参数,直接跳过。

4.3 CLOSE_WAIT 堆积是代码问题,不是内核问题

TIME_WAIT 是主动关闭方产生的,属于正常现象。CLOSE_WAIT 是被动关闭方在收到对方 FIN、自己还没调 close 时停留的状态,如果这个状态堆积成千上万,那一定是应用层代码有问题:要么是异常分支里忘了关闭连接,要么是连接池的回收逻辑有漏洞,要么是业务逻辑卡在处理上迟迟不释放。

排查思路很直接:

# 按状态统计连接数 ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn # 找出 CLOSE_WAIT 的连接属于哪个进程 ss -tanp state close-wait # 从 /proc 里看进程持有的 fd 数量,确认是否泄漏 ls /proc/<pid>/fd | wc -l

ss比netstat快得多,在连接数上万的时候差别非常明显,这个工具值得养成习惯。

5. 流量控制和拥塞控制,是两个完全不同的东西

这两个概念被混为一谈的频率,比三次握手被背错的频率还高。一句话区分:流量控制是接收方在说“我吃不下这么多”,拥塞控制是发送方在猜“网络扛不住这么多”。前者有明确的信息来源(接收窗口字段),后者只能靠丢包和延迟来推测。

5.1 接收窗口告诉发送方“我这还有多少空位”

TCP 头部有个 16 位的窗口字段,接收方在每个 ACK 里填上自己接收缓冲区还剩多少空间,这就是rwnd(接收窗口)。发送方据此限制在途数据量,保证不会把对方的缓冲区撑爆。

16 位最大表示 65535 字节,在高带宽高延迟的链路上完全不够用。所以有窗口缩放选项:在握手阶段协商一个移位因子,实际窗口 = 头部字段值 << 移位因子,最大能到 1GB 级别。这个选项只在 SYN 包里协商,连接建立后就不能改了,所以中间设备如果吞掉了这个选项,吞吐量会被死死限制在 64KB 每 RTT 的水平。这解释了为什么有些链路明明带宽很大,单连接却只能跑到几 Mbps——瓶颈不在带宽,在窗口。

当接收窗口降到 0,发送方就停下来,定期发零窗口探测包,防止“窗口更新通知丢了导致双方互等死锁”。

5.2 拥塞窗口是发送方自己维护的网络容量估计

发送方还维护一个cwnd(拥塞窗口),它没有字段在网络上传,纯粹是发送方的本地状态。真正决定能发多少的是:

发送窗口 = min(cwnd, rwnd)

拥塞控制就是 cwnd 的调整算法。经典的四段式流程:

  1. 慢启动:连接刚开始,cwnd 从较小值(Linux 上是 10 个 MSS 左右)起步,每收到一个 ACK 就加一个 MSS,效果是指数增长,一个 RTT 翻一倍。
  2. 拥塞避免:cwnd 涨到 ssthresh(慢启动阈值)后改为线性增长,每个 RTT 加一个 MSS,避免撞墙。
  3. 快重传:收到三个重复 ACK,立刻重传丢失的段,不等超时。
  4. 快恢复:把 ssthresh 设为当前 cwnd 的一半,cwnd 也降到这个值附近,然后直接进入拥塞避免,而不是退回慢启动。

超时重传的处理更狠:ssthresh 减半,cwnd 直接回到初始值,重新慢启动。因为超时通常意味着严重拥塞,得退得彻底一点。

触发事件cwnd 变化ssthresh 变化
收到新 ACK(慢启动阶段)每 ACK 加 1 MSS不变
收到新 ACK(拥塞避免阶段)每 RTT 加 1 MSS不变
三个重复 ACK降到一半左右(快恢复)降到当前 cwnd 的一半
RTO 超时回到初始值降到当前 cwnd 的一半

现在 Linux 默认的拥塞控制算法是 CUBIC,用三次函数代替线性增长,在长肥管道上恢复得更快。想看当前系统用的是哪个:

sysctl net.ipv4.tcp_congestion_control # net.ipv4.tcp_congestion_control = cubic sysctl net.ipv4.tcp_available_congestion_control # 可用的算法列表

5.3 Nagle 算法和延迟 ACK 的经典组合坑

这个坑我踩过不止一次,值得单独拿出来说。

Nagle 算法的逻辑是:如果还有小数据没被确认,就先攒着,等到凑够一个 MSS 或者收到 ACK 再发。目的是减少小包数量。延迟 ACK 的逻辑是:收到数据不立刻确认,等一会儿看能不能捎带确认,Linux 上这个等待时间通常是 40 毫秒。

两个机制单独看都挺合理,碰在一起就出事了:发送方等 ACK 才发下一批小数据,接收方等 40 毫秒才发 ACK,于是每个小请求都要多等 40 毫秒。在“请求小、响应小、来回多次”的 RPC 场景下,这个延迟叠加起来非常夸张。

解决方式很直接,业务代码里把这个连接设成不延迟发送:

import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) s.connect(("127.0.0.1", 9000)) s.sendall(b"ping") print(s.recv(1024)) s.close()

TCP_NODELAY关掉的是 Nagle,不是延迟 ACK。但在实际测试中,关掉 Nagle 之后发送方不再攒包,接收方也就没什么可等的,问题基本消失。要注意的是,这个选项不是无脑开——如果你在做大文件传输,关掉 Nagle 会让小写入直接变成小包,反而增加开销。判断标准是:交互型、请求响应型业务开,批量传输型业务不开。

6. 把结论落到命令行:一次可复现的验证过程

光看原理容易飘,传输层的东西必须动手看一遍,印象才深。下面这套流程不需要什么特殊环境,本机就能跑。

6.1 用 ss 和 tcpdump 观察握手过程

先起一个最简单的服务端,然后抓包看三次握手的字段:

# 终端一:监听端口 nc -l -p 9000 # 终端二:抓包,只看这个端口 tcpdump -i any -nn -S port 9000 # 终端三:发起连接 nc 127.0.0.1 9000

抓到的前三个包,重点看 flags 和 seq/ack 的关系。第一个 SYN 里 seq 是随机值,ack 是 0;第二个 SYN+ACK 里 ack 等于第一个 seq 加 1,自己的 seq 也是随机值;第三个 ACK 里 ack 等于第二个 seq 加 1。加 1 是因为 SYN 本身占用一个序号,虽然它不带数据。同理,FIN 也占一个序号,这就是为什么挥手过程中 ack 号也会出现加 1 的现象。搞懂这一点,抓包时那些看起来对不上的序号就都能解释了。

看状态分布用:

ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

看某个端口上到底谁在连:

ss -tnp 'sport = :9000'

6.2 一个最小可用的服务端和客户端

想验证粘包、验证 TCP_NODELAY、验证窗口变化,用脚本比用 nc 更灵活:

import socket import threading def handle(conn, addr): try: while True: data = conn.recv(1024) if not data: break # 故意回显,方便观察 RTT 和窗口 conn.sendall(data) finally: conn.close() server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 9000)) server.listen(128) print("listening on 9000") while True: conn, addr = server.accept() threading.Thread(target=handle, args=(conn, addr), daemon=True).start()

跑起来之后,用ss -tni能看到每条连接的细节:cwnd、rtt、retrans、发送队列和接收队列的字节数。这个命令的输出信息量极大,比抓包更直观地反映拥塞控制的状态。

ss -tni 'sport = :9000'

输出里会看到类似cubic wscale:7,7 rto:204 rtt:0.05/0.02 cwnd:10 bytes_sent:...这样的字段,rtt后面斜杠分开的是平滑 RTT 和 RTTVAR,能直接印证前面讲的算法。

6.3 粘包不是你想象的“包粘在一起”

这是传输层最容易被误解的概念。TCP 是字节流,它根本不保留消息边界,所以谈不上“粘”,而是应用层错误地假设了“一次 recv 就是一条完整消息”。

假设客户端连续 send 了两条 100 字节的消息,服务端一次 recv(1024) 可能读到 200 字节,也可能先读到 100 再读到 100,还可能读到 150 加 50。这三种情况都是合法的,取决于内核缓冲区和网络状况。唯一的解法是应用层自己定义边界:要么用长度前缀(前 4 字节表示消息体长度),要么用分隔符(比如换行),要么用固定长度。

长度前缀是最稳的方案,实现也简单:

import struct def send_msg(sock, payload): header = struct.pack(">I", len(payload)) sock.sendall(header + payload) def recv_exact(sock, n): buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("peer closed") buf += chunk return buf def recv_msg(sock): header = recv_exact(sock, 4) length = struct.unpack(">I", header)[0] return recv_exact(sock, length)

recv_exact里的循环是关键,一次 recv 可能读不满你想要的字节数,必须循环到凑够为止。很多人写网络代码出问题,根源就在这——以为 recv 一定返回请求的长度,然后在解析时数组越界或者数据错位。

7. 几个真实踩过的坑,以及背后对应的传输层机制

理论讲完,说几个我在实际项目里撞过的具体问题。这些坑的共同特点是:现象看起来像“网络不稳定”,根因却都能精确对应到传输层的某一项机制上。

第一个坑:长连接在空闲一段时间后突然断掉,报连接重置。排查下来,中间的设备(负载均衡、防火墙)有会话老化时间,比如 300 秒没流量就把表项清掉,之后再来的数据包被视为非法连接,直接回 RST。传输层本身的 TCP 是可以在空闲状态下存活很久的,问题出在中间设备的状态表。解决办法是在应用层加心跳,间隔要明显小于设备的老化时间,比如 60 秒一次。光开SO_KEEPALIVE往往不够,因为默认的空闲探测时间是 7200 秒,等它触发早就断了。可以通过参数调:

sysctl -w net.ipv4.tcp_keepalive_time=60 sysctl -w net.ipv4.tcp_keepalive_intvl=10 sysctl -w net.ipv4.tcp_keepalive_probes=3

但更推荐在应用层做,因为应用层心跳还能顺便确认对端业务逻辑是否正常,而 TCP keepalive 只能确认内核活着。

第二个坑:跨机房传输大文件,吞吐量卡在某个固定值上不去。一开始怀疑带宽,测下来带宽充裕;怀疑磁盘,IO 也没瓶颈。最后算了一下:RTT 是 40 毫秒,窗口被限制在 64KB,那理论吞吐就是 64KB / 0.04s = 1.6MB/s。这就是典型的窗口瓶颈。检查发现中间设备没有正确传递窗口缩放选项,导致窗口退化成 16 位最大值。这种情况要么换掉设备,要么在应用层用多连接并行来绕过单连接的限制——多开几个连接,每个连接都有自己的窗口,总吞吐就能上去。

第三个坑:小数据包频繁发送,CPU 占用高但吞吐很低。抓包一看,满屏都是几十字节的小包,每个包 20 字节 TCP 头加 20 字节 IP 头,有效载荷只有十几个字节,头部开销占了七成以上。这是典型的“写得太碎”。解决办法是在应用层攒批,把多次小写入合并成一次 send,或者用带缓冲的写包装。传输层能做的优化有限,TCP_CORK可以强制攒包,但控制粒度不好把握,容易引入额外延迟,不如在业务代码里做批量。

第四个坑:服务端重启后立刻 bind 失败,报 Address already in use。这就是前文说的 TIME_WAIT。连接是被服务端主动关闭的,所以 TIME_WAIT 留在了服务端这一侧,端口被占着。加SO_REUSEADDR立刻解决。顺带说一句,如果在同一台机器上跑多个 worker 且想共享端口,SO_REUSEPORT会让每个进程都能 bind 成功,但要注意它和SO_REUSEADDR的语义差别,两个都开的时候行为取决于内核版本,别想当然。

第五个坑:HTTP 客户端偶发超时,服务端日志里完全没记录。这种情况八成是连接进了 accept 队列但应用来不及取,或者半连接队列溢出被静默丢弃。查一下netstat -s里的溢出计数就能确认:

netstat -s | grep -i -E "listen|overflow|retrans"

times the listen queue of a socket overflowed这个计数在涨,就说明 backlog 不够或者 accept 太慢。加somaxconn只是治标,真正的解法是让 accept 循环足够快,或者把业务处理挪到线程池里,别阻塞在 accept 线程上。

传输层这一层的知识,看的时候觉得都是老生常谈,真出问题时才发现每一条都能对上号。我个人的体会是,与其反复背那几张状态转换图,不如把ss -tni和tcpdump当成日常工具用起来,遇到性能问题先看一眼 cwnd 和 rtt,遇到连接问题先看状态分布和队列溢出计数,很多所谓的“疑难杂症”在这两个命令面前撑不过五分钟。至于那些参数调优,我的建议是先别动,默认值是大规模验证过的,只有在明确知道瓶颈在哪、并且能说清楚调这个参数会带来什么副作用的时候,再去改它。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进