先说个我经常对新人说的话:学了 IP 地址,不等于懂网络。IP 能解决的问题是“把数据送到某台主机”,但数据到了主机之后,该交给哪个进程?浏览器发的请求和后台服务发的数据报会不会互相串?数据在半路丢了谁来管?顺序乱了谁来重排?这些问题,全部落在传输层肩上。这篇是我计算机网络系列笔记的第五篇,专门把传输层讲透。内容覆盖端口与套接字、UDP 和 TCP 的差异、可靠传输的机制、流量控制、拥塞控制、连接管理,以及我用 Wireshark 抓包验证这些机制的实操过程。适合刚学网络的同学、准备面试的开发者,以及那些线上出现网络问题却不知道从哪入手的运维朋友。
1. 为什么需要传输层:从“主机能通”到“进程能通话”
看每一层协议,最好先问一句:上一层已经解决了什么问题,这一层又在解决什么新的问题?只有把这个问题想清楚,后面记再多的状态机、标志位才不是死记硬背。
1.1 网络层的边界:只负责把包送到主机
网络层(IP)提供的是主机到主机的通信,而且是尽力而为的通信。IP 协议负责寻址和路由,它能把数据报从一台主机送到另一台主机,但有三件事它不管:第一,不保证数据包不丢失,路由器队列满了直接丢,源主机根本不知道;第二,不保证顺序,同一个连接的数据包走不同路径到达,完全可能后发先至;第三,不保证不重复,有些链路层机制会导致重复帧,IP 层不会去重。
更重要的一点是,IP 层不存在“端口”这个概念。IP 头里的地址是给路由器看的,路由器根据目的 IP 转发,它不关心、也不需要关心这个包是 HTTP 还是 DNS,是游戏数据还是视频流。也就是说,网络层把信送到了“公司前台”,但前台不知道信是给技术部还是财务部。
1.2 传输层补上的两块拼图:复用/分用与逻辑通信
传输层补的第一块拼图是端口号。一台主机上同时跑着几十个进程,浏览器、邮件客户端、后台服务各占一个端口。发送方把源端口和目的端口写进传输层首部,数据到达主机后,系统根据目的端口号把数据交给对应的进程。多个进程的应用数据复用到同一个 IP 网络传输,叫复用;接收端再按端口拆开分给不同进程,叫分用。这一下,通信的粒度就从“主机”细化到了“进程”。
第二块拼图是逻辑通信。应用进程之间通信时,TCP 会给上层提供一种“像有一条专线连着对方”的错觉:数据丢了自动重传,数据乱序自动排序,接收方处理不过来时发送方会自动减速。应用完全看不到底层网络抖动带来的丢包和乱序。当然,这是 TCP 提供的服务,UDP 不提供这些,但 UDP 至少也提供端口复用和差错检测。
我理解传输层最常用的一个类比是物流:网络层只负责把包裹从这个城市运到那个城市,至于这个包裹是给小区里哪栋楼哪户人家,以及运送途中保价不保价、丢件赔不赔,都是传输层的事。你要普通快递还是保价快递,对应 UDP 和 TCP 的区别。
1.3 为什么非得是“层”,而不是把端口塞进 IP 头里
有人会问:既然端口这么重要,为什么不直接把它加进 IP 首部,省得单独分一层?答案要从职责边界和扩展性来看。
IP 层要解决的是全球寻址问题,它被运行在网络里每一台路由器上。如果 IP 头里加端口,意味着所有转发节点都要理解端口语义,协议复杂度会急剧膨胀。而且传输层不是只有一种选择——有的应用需要可靠有序,有的应用只在意低延迟。把这些差异放到 IP 之上,用不同传输层协议承载,网络层就只需要关心“把包从 A 送到 B”,技术演进也灵活得多。你看 QUIC 直接在 UDP 之上重新实现了可靠传输和拥塞控制,IP 层完全不需要改动,这就是分层带来的好处。
2. 端口与套接字:定位一条连接的唯一坐标
传输层所有机制的落点,都可以归结到一个问题上:怎么在茫茫网络里唯一确定一条通信关系。答案就是端口和套接字。
2.1 端口号的地盘划分:知名、注册与动态
端口号是 16 位无符号整数,范围 0 到 65535。这个范围被分成了三段:
| 范围 | 类型 | 说明 | 常见例子 |
|---|---|---|---|
| 0-1023 | 知名端口 | 由系统服务使用,一般需要管理员权限才能绑定 | HTTP 80、HTTPS 443、DNS 53、SSH 22 |
| 1024-49151 | 注册端口 | 给用户进程或常见应用使用 | MySQL 3306、Redis 6379、MongoDB 27017 |
| 49152-65535 | 动态/私有端口 | 客户端发起连接时由系统临时分配 | 出站连接的源端口 |
这里面有个应用层常识:服务端的端口要固定,不然客户端不知道找谁;客户端的端口是临时的,每次发起连接时系统从动态范围内挑一个空闲的。服务器监听 80,进来的新连接可能是从客户端 50000 端口发起的,所以服务端的通信对象实际上是“远端 IP + 远端端口”的组合。
如果绑定知名端口时报错“Permission denied”,不是端口被占用,而是权限不足,普通用户端口范围不是所有系统都允许绑定 1024 以下端口。开发环境碰到这种情况,要么用 root 启动,要么换个高位端口。
2.2 套接字四元组:一条连接的身份证
套接字(Socket)最直白的理解就是“IP 地址 + 端口号”的组合。IP 负责找主机,端口负责找进程,两者合在一起就是一个通信端点。
而一条 TCP 连接,是由四元组唯一定位的:源 IP、源端口、目的 IP、目的端口。四元组完全相同的连接只能有一条,任何一个元素不同,就是另一条连接。这意味着“一台服务器最多只能有 65535 个连接”是个典型误区。服务器监听的是 80 端口,但只要客户端来自不同 IP、不同源端口,四元组就不同,连接就可以无限多(当然还会受文件描述符、内存等限制)。
很多人不理解服务端 accept 之后到底发生了什么:accept 返回的是一个新的套接字文件描述符,内核为这个新连接分配了新的四元组。监听套接字始终保持监听状态,新连接和监听连接互不干扰。这也是为什么服务器可以同时保持百万级连接,而监听端口永远只有那一个。
2.3 客户端临时端口耗尽的现象与排查
反过来看,客户端侧确实有可能用完端口。每个出站 TCP 连接都要占用一个本地临时端口,而临时端口范围默认是有限的。Linux 下可以查看:
cat /proc/sys/net/ipv4/ip_local_port_range很多发行版默认是 32768 到 60999 附近,也就是约两万多个端口。短连接高并发场景(比如一个进程每秒发起上千次 HTTP 请求且不复用连接)很可能把临时端口耗尽,报错通常是一句非常典型的:
Cannot assign requested address排查方法很简单,看系统当前连接数量和端口分布:
ss -s ss -tan state time-wait如果发现大量 TIME_WAIT 连接占着临时端口不放,那就不是“再加一个端口”能解决的问题了。优先做的是让客户端复用连接、缩小连接生命周期。改大 ip_local_port_range 只能带来短暂缓解,不解决根本问题,因为可用端口总有上限。
3. UDP 与 TCP:两套截然不同的传输哲学
走到传输层,最常见的灵魂拷问就是 UDP 和 TCP 怎么选。想搞清楚这个问题,不是去背“TCP 可靠,UDP 不可靠”这种结论,而是要理解它们在设计取舍上走了完全不同的路。
3.1 UDP 的无连接意味着什么
UDP(用户数据报协议)先看特点:没有握手过程,不维护连接状态,想发就发。首部只有 8 字节,四个字段——源端口、目的端口、长度、校验和。发送时一次 write 对应一个报文段,接收方 read 时按报文边界取数据,这个特性叫“保留报文边界”。
因为无连接、无状态,UDP 的延迟极低,CPU 开销也小,还能支持广播和组播。TCP 是无法做广播的,因为 TCP 是一条点对点连接,而 UDP 可以把一份数据同时发给多个接收方。
但代价也很直接:丢包没人管,乱序没人管,重复没人管。网络发生拥塞时,UDP 也不会主动减速,它根本没有拥塞控制机制。
我习惯把 UDP 比作“寄普通明信片”:便宜、快、一次一张,但丢了就丢了,没人给你补发。TCP 则是“挂号信”:要登记、要签收、丢了会补寄,代价是流程长、开销大。
3.2 TCP 的“重”和“稳”体现在哪里
TCP(传输控制协议)和 UDP 几乎是两个物种。TCP 是面向连接的协议,这个“连接”是一组内核数据结构,包含序号、确认号、发送窗口、接收窗口、拥塞窗口、重传计时器……收发双方各维护一套。有了这些状态,TCP 才能在这个不可靠的 IP 网络上提供可靠的字节流交付。
可靠之外,还有两个容易忽略的关键特性:
字节流:TCP 不保留应用报文的边界。你 send 了两个 100 字节的数据,对方可能一次 recv 到 200 字节,也可能分成好几次。TCP 只保证字节顺序绝对按序,不保证你的“消息”边界。这是很多新手写 TCP 通信程序时踩坑的地方——必须自己设计消息边界,比如用长度前缀、分隔符,或者固定结构。
全双工:一条 TCP 连接建立后,数据可以同时在两个方向传输。这意味着关闭连接也比建立连接复杂,因为两个方向都需要独立关闭,后面讲四次挥手时会细说。
3.3 选型不是二选一:从 QUIC 看传输层演进
把两者放在一起对比,更清楚:
| 维度 | UDP | TCP |
|---|---|---|
| 连接状态 | 无连接 | 面向连接 |
| 可靠传输 | 不保证 | 确认、重传、排序 |
| 数据组织 | 报文边界保留 | 字节流 |
| 首部开销 | 8 字节 | 20 字节起 |
| 流量控制 | 无 | 有 |
| 拥塞控制 | 无 | 有 |
| 广播/组播 | 支持 | 不支持 |
| 代表性场景 | 音视频、游戏、DNS、NTP | HTTP、文件传输、邮件、数据库 |
但实际选型千万别被这个表框死。UDP 上面完全可以做可靠传输——QUIC 就是最典型的例子,它跑在 UDP 之上,却实现了加密、可靠、有序、多路复用,还自己实现了一套拥塞控制。这说明什么?说明“可靠”不是 TCP 的专利,而是传输机制的设计目标。如果你的实时通信场景需要低延迟,但同时希望少丢数据,完全可以参考 QUIC 的思路在 UDP 上自己设计序列号、确认和重传机制。
我个人的选型习惯是:需要严格一致性的业务数据走 TCP;实时音视频、游戏同步这类容忍少量丢失的走 UDP;弱网环境下的实时通信,宁可花大力气在 UDP 上做 ARQ 和抖动缓冲,也不要直接裸 TCP——TCP 的队头阻塞在弱网下会把延迟放大到无法接受。
4. 可靠传输的底层拼图:序号、确认、重传与滑动窗口
TCP 的可靠传输看起来复杂,拆开来其实就是四个词:序号、确认、重传、滑动窗口。每一个都解决一个具体问题,理解了问题,机制自然就记住了。
4.1 可靠传输的基础机制:序号、确认、超时与快重传
TCP 把要发送的应用数据看成一个连续的字节流,每一个字节都会有一个序号。报文段头部里的“序号(Seq)”字段,表示的是本报文段数据部分第一个字节的序号。
确认机制是累计确认:确认号(ACK)表示“对方期望我下一个发送的字节序号”。收到 ACK=100,意味着序号 99 及之前的字节全部收到了,不用逐字节确认,效率高很多。
但光有确认不行,丢了包要重传。怎么发现丢包?靠两个机制:
- 超时重传:发送方为每个包启动一个重传计时器(RTO)。RTO 不能设死,要基于 RTT 动态估算,业界一般用加权平均 RTT 再加上安全余量。如果超时还没收到 ACK,就重传。
- 快速重传:发送方连收 3 个相同的重复 ACK,大概率说明对方一直在等你缺的那段数据,此时不等超时,立即重传缺失的包。这是为了应对丢包但后续数据仍在流动的情况,比傻等超时快得多。
还有一个经常被忽略的扩展:SACK(选择性确认)。它通过 TCP 选项告诉发送方“哪些乱序的数据我已经收到了”,这样发送方就知道只需要补发真正丢失的那一小段,而不是一把重传所有未确认数据。
快速重传的判据是“3 个重复 ACK”,但出现重复 ACK 不一定代表丢包。路径如果发生乱序,先到的包也可能触发重复 ACK。所以不要把重复 ACK 直接等同于丢包,还要结合前后报文段的时序判断。
4.2 滑动窗口:让传输从“一问一答”变成“流水线”
如果没有窗口,TCP 就是最原始的停等协议:发一个包,等一个 ACK,再发下一个。这样信道利用率极低,尤其在长距离高带宽链路上,大部分时间都在等 ACK 的往返。
滑动窗口解决的就是这个问题:允许发送方在收到确认之前连续发送多个包。假设发送窗口是 4,发送方可以连续发序号 1、2、3、4,然后等 ACK。收到 ACK=5,说明 1 到 4 全部被确认,窗口向前滑动,允许发 5、6、7、8。数据像流水线上一样持续流动,不用每发一个就停下来等确认。
这里有一个和性能直接相关的概念:带宽延迟积(BDP = 带宽 × RTT)。要把一条链路跑满,发送窗口至少要大于 BDP,否则发出去的包还没飞到对方,确认还没回来,发送方已经被窗口卡住了。高带宽长链路下,如果发现吞吐率上不去,先看是不是窗口太小。
4.3 流量控制与 Nagle:别让网络被小包拖垮
流量控制的本质是发送速率不能超过接收方的处理能力。TCP 首部里的“窗口”字段就是接收方通告的接收窗口(rwnd):我能接收多少字节,就在 ACK 里告诉你。发送方的发送窗口取 min(通告窗口 rwnd,拥塞窗口 cwnd)。如果接收方应用层不读数据,缓冲区满了,它就会通告一个零窗口,发送方必须停下来,直到收到新的窗口更新。
“零窗口”会出现一个隐患:如果接收方后来发送了“窗口非零”的更新包,但这个包在路上丢了,发送方会一直等,接收方也在等数据,双方死锁。TCP 为此设计了持续计时器:当收到零窗口时,发送方周期性发一个窗口探测包,逼对方重新通告窗口。这个细节在实际网络故障里经常看到,表现为连接活着但数据不动。
还有一个经典问题叫糊涂窗口综合征:发送方每次只发 1 字节,接收方每次都通告小窗口。结果网络里全是首部 20 字节、数据 1 字节的小包,效率极低。Nagle 算法就是为此设计的:当上一个包还没被确认时,先把小数据攒起来,凑够一个 MSS 或者等到 ACK 再发。延迟确认(一般等 40ms 或者攒两个包)则让接收方尽量合并 ACK。
但水能载舟亦能覆舟:Nagle 算法和延迟确认遇到一起,在交互式请求里可能造成额外 40ms 甚至更高的延迟。实时性敏感的应用通常要显式关闭 Nagle,设置 TCP_NODELAY,但代价是小包变多。
5. 拥塞控制:一条 TCP 流在网络堵车时的行为模式
流量控制管的是“别把接收方撑爆”,拥塞控制管的是“别把网络撑爆”。两者经常被弄混,但问题域完全不同:流量控制是点对点,拥塞控制是整个网络所有流的资源分配问题。
5.1 慢启动与拥塞避免:先试探,再加码
TCP 的拥塞控制不是事前规划,而是实时探测。它维护一个拥塞窗口(cwnd),表示发送方可发送的字节量。出发点很朴素:不知道网络现在有多堵,就先从小窗口开始,没堵就加大,堵了就减小。
这个过程分成两个阶段:
慢启动阶段,cwnd 从初始窗口开始,每收到一个 ACK,cwnd 加一个 MSS(实际效果是每个 RTT 翻倍)。假设初始窗口是 10,第一个 RTT 后变成 20,第二个变成 40……指数增长,直到到达慢启动阈值 ssthresh。这个阶段名字叫“慢启动”,其实一点都不慢,指数增长很快,真正的目的是“探测”。
进入拥塞避免阶段后,cwnd 每个 RTT 只增加 1 个 MSS,线性增长,而不是翻倍。因为此时已经接近网络容量,再指数增长马上就会把网络打满。
然后发生拥塞(通常表现为丢包)时怎么办?这是 AIMD(和式增加、积式减少)的核心:遇到超时,说明网络已经严重拥塞,ssthresh 减半,cwnd 直接降回初始窗口;遇到 3 个重复 ACK,说明拥塞信号较弱,进入快恢复而不是完全从头再来。
5.2 快重传与快恢复:丢了也能快速止损
很多资料把快重传和快恢复分开讲,实际上它们在 RFC 5681 之后是同一个流程。收到 3 个重复 ACK 时:
- ssthresh 设置为当前 cwnd 的一半。
- cwnd 设置为 ssthresh + 3MSS,加 3 是考虑已经收到的 3 个重复 ACK 对应的数据已经从网络中移除了,可以多放 3 个 MSS 进去。
- 之后每收到一个重复 ACK,cwnd 再增加 1 MSS,相当于给“仍在途的乱序包”腾空间。
- 直到收到新的 ACK,说明丢失的包已经补齐,cwnd 降到 ssthresh,进入拥塞避免,线性增长。
为什么要这样设计?因为 3 个重复 ACK 说明“后面的数据其实还在流动”,网络的拥塞程度没有超时那么严重。直接回到慢启动重新指数探测是杀鸡用牛刀,浪费时间;不加控制地继续发又会加重拥塞。快恢复就是在这两者之间找一个平衡点。
拥塞控制本质上不是“拥塞了才控制”,它是一套持续运行的分布式资源分配协议。每一条 TCP 流都在不停地发送探测、观察信号、调整速率,所有流共同竞争带宽。理解这一点,再看 TCP 的窗口曲线,就明白为什么它是锯齿形的了:加一点、减一半,再加一点、再减一半。
5.3 CUBIC 与 BBR:现代拥塞控制的两个方向
经典 Reno 算法在高带宽长链路(高 BDP)下有个问题:丢包后把 cwnd 砍半,恢复速度取决于 RTT,链路越长恢复越慢,带宽利用率惨不忍睹。Linux 默认的 CUBIC 算法就是为了解决这个问题设计的。
CUBIC 的思路是:不再让窗口增长线性跟随 RTT,而是用一个三次函数来控制窗口随时间的变化。丢包发生后,先把窗口快速恢复到丢包前的水平,然后再缓慢探测额外容量。这样即使 RTT 很大,恢复也不会被拖得太慢,所以在高速网络上表现远好于 Reno。
BBR 是另一个思路。它完全不用丢包作为拥塞信号,而是直接测量瓶颈带宽和最小 RTT,估算出链路的带宽延迟积,然后把发送速率稳定在那个值附近。这意味着 BBR 在“有一定丢包率但不严重”的链路上可以维持高吞吐,而 CUBIC 往往因为少数丢包就把速率减半。我自己的实测体验是:跨运营商、跨地区的高带宽链路,丢包率稍高时 BBR 的吞吐经常比 CUBIC 稳很多。但它和 CUBIC 混跑时也会出现公平性问题,所以生产环境切换拥塞控制算法之前,最好先想清楚链路里是否有别的协议族的流量。
6. 三次握手、四次挥手与 TIME_WAIT 的那些坑
连接管理是面试必考,也是线上故障的重灾区。这部分只要把“为什么是这个次数”想清楚,再结合状态机排查问题,基本就稳了。
6.1 三次握手为什么是三次
三次握手的过程大家都会背:客户端发 SYN,服务器回 SYN+ACK,客户端再回 ACK。但为什么要三次?为什么两次不行?
关键原因是,客户端发出的第一个 SYN 有可能在网络中滞留很久,然后在一个已经不存在的连接上重新出现。如果只有两次握手,服务器收到这个迟到的 SYN 就会建立连接、分配资源,但客户端早就放弃了这个连接,不会给它发数据,服务器的资源就白白浪费了。三次握手让服务器必须收到客户端的明确确认,才能确认“客户端收到了我的 SYN+ACK、确实想要建立连接”。
另一个作用是同步初始序号。双方各有一个初始序列号(ISN),三次握手时互相告知。序列号的作用是让双方知道数据传输从哪里开始,也为后面的累计确认提供基准。
线上偶发 SYN 队列满的情况,表现为客户端一直 SYN_SENT,服务器端 ss 能看到大量 SYN_RECV。常见原因有并发建连太高、半连接队列配置太小,或者有异常流量在填满半连接队列。如果确认是正常业务,优先调大队列长度而不是直接拒绝连接。
6.2 四次挥手为什么比握手多一次
TCP 是全双工的,两个方向的数据流是独立的。关闭连接时,每个方向都要单独关闭。
第一次挥手:主动方发 FIN,表示“我的数据发完了,你这边不会再收到我的新数据了”。对方回 ACK,确认收到这个 FIN。 第二次挥手:被动方如果还有数据要发,可以继续发。数据发完后,再发一个 FIN,表示“我的数据也发完了,可以关了”。主动方回 ACK。
所以关闭需要四次:两个方向各一次 FIN + 各一次 ACK。握手的 SYN 和 ACK 可以合并成一次,因为连接建立时服务器没有“先把自己数据发完再同意连接”的需求;但关闭时被动方完全可能还有数据没发完,不能强迫它立刻关。
真正让运维头疼的是 TIME_WAIT。主动关闭的一方在发完最后一个 ACK 后,不会立刻关闭,而是进入 TIME_WAIT,持续 2MSL(MSL 是报文最大生存时间,Linux 下 TIME_WAIT 通常约 60 秒)。两个原因:第一,万一最后一个 ACK 丢了,被动方会重发 FIN,主动方需要能重新响应;第二,保证旧连接上的报文已经在网络中消失,不会干扰到后续使用相同四元组的新连接。
大量 TIME_WAIT 本身不是错误,而是正常的协议行为。短连接服务端主动关闭连接时会有很多 TIME_WAIT,如果数量大到影响新连接,优先该做的是让客户端复用连接,而不是粗暴地改内核参数。内核曾经的 tcp_tw_recycle 因时间戳问题造成过不少严重的兼容性故障,一定不要在生产环境随便开启。
6.3 连接故障现场:SYN_SENT、SYN_RECV 与 RST
排查连接问题,先学会从ss的输出里读状态:
ss -tan状态分布能告诉你在连接的哪个阶段出了问题:
- SYN_SENT 多:客户端发了 SYN,一直没收到 SYN+ACK。可能服务器没监听端口、防火墙丢包、半连接队列满。这时去服务器上看 SYN_RECV 的数量。
- SYN_RECV 多:服务器收到了 SYN,但完成不了握手。通常是半连接队列满了,要么业务建连太快,要么有人在制造大量半连接。
- RST 报文:对方直接拒绝。端口没监听时的拒绝,或者程序显式 abort 连接,都会产生 RST。如果是访问一个未监听端口,立刻收到 RST 是正常现象。
- 大量 ESTABLISHED:连接建好了但业务没进展,要查应用层读写是否卡住,以及 TCP keepalive 是否够灵敏。
TCP keepalive 默认要等 2 小时才开始探测,很多半开连接要挂很久才能被发现。做服务治理时,我更推荐在应用层做心跳,30 秒到 60 秒探测一次,比 TCP keepalive 灵敏一个数量级。
7. 用抓包把传输层机制调出来看一遍
学了这么多协议细节,最终还是要落在“能不能看到”。我一直建议所有做网络相关工作的同学,至少完整地抓一次包,亲眼看一下三次握手、ACK 确认、超时重传到底长什么样。
7.1 抓包前的准备:别被 checksum offload 骗了
本地实验最简单的办法是起一个 HTTP 服务:
python3 -m http.server 8888然后用 tcpdump 在另一个终端抓包:
sudo tcpdump -i any -nn -S tcp port 8888打开 Wireshark,过滤条件直接写:
tcp.port == 8888第一次抓包的看到一片红、一堆 “TCP Checksum Incorrect” 别慌。现代网卡有硬件分载,数据包从协议栈出来时校验和在网卡上才计算,Wireshark 看到的是没来得及填校验和的包,于是报错。这是抓包工具的正常现象,不是线路故障。做本机回环测试时,直接关闭校验和校验或者忽略这个提示就行。
7.2 对着包看握手、确认与重传
找一个 TCP 流,右键选中,过滤tcp.stream eq 0,你就会看到完整的连接过程。
第一个包是 SYN,注意它的 Seq 值是相对序号,显示为 0;第二个包是 SYN+ACK,Seq 是 0,ACK 是 1——这里的 ACK=1 表示“我期望你的第一个字节序号是 1”,也就是已经收到了你的 Seq=0 这个 SYN 包;第三个包是 ACK,Seq 是 1,ACK 是 1。三次握手完成。
接着发 HTTP 请求数据,你会看到真正有意思的对应关系:数据包的 Seq=1,表示“我的数据从序号 1 开始”;服务器回复的 ACK 变成了新值,表示“你这段数据我都收到了,下一个期望从 XX 开始”。这就是序号和确认号最直观的体现——Seq 指向数据起点,ACK 指向下一个期望字节。
想观察重传,可以用 tc 模拟丢包:
sudo tc qdisc add dev eth0 root netem loss 10%再抓包,你会看到两种重传:一种是隔了一段时间才出现的,标着 “TCP Retransmission”,这是超时重传;另一种是短时间内连续出现重复 ACK,然后触发快速重传的。超时重传的间隔能明显看到 RTO 的身影,快速重传则几乎在重复 ACK 之后立刻发生。看完这一遍,前面说的“为什么要有快速重传”就不用再背了,你会直接理解等超时的代价。
7.3 实际优化中的几个 TCP 参数
抓包之外,生产环境里最常调整的几个 TCP 参数,我顺手列一下:
- tcp_tw_reuse:客户端开启后,允许 TIME_WAIT 状态的连接复用本地端口发起新连接。依赖 tcp_timestamps 开启,适合出站连接多的场景。
- SO_REUSEADDR:服务端重启时经常报
Address already in use,打开这个选项可以快速复用处于 TIME_WAIT 的端口。不是性能调优,是解决重启体验问题。 - TCP_NODELAY:关闭 Nagle 算法,降低小包延迟。交互式、实时性强的应用建议开启。
- BBR 拥塞控制:高带宽长链路、有一定丢包率的场景可以切 BBR:
sysctl -w net.ipv4.tcp_congestion_control=bbr切之前确认内核对 BBR 的支持,以及链路里是否存在大量 Reno/CUBIC 流,避免公平性问题。
最后说一句我自己的经验:线上遇到“变慢”“卡顿”,别先怀疑应用代码,先抓包看传输层。把问题分成三类——是不是在重传?是不是窗口打不开?是不是在握手挥手上花了太多时间?三类定位完,绝大部分网络性能问题都能找到方向。传输层这层学扎实了,往后的 HTTP/3、QUIC、内核网络调优,学起来都会快很多。