资讯动态

TCP协议工程实践:从三次握手到拥塞控制与粘包排查

发布时间:2026/10/17 14:22:39 来源:尧图企业网站定制
很多人把 TCP 当成八股文在背三次握手、四次挥手、拥塞控制、滑动窗口每个概念都能说出个大概但一到线上排查就抓瞎连接为什么一直处于 FIN_WAIT_2重传率突然飙升收到的数据总是粘成一片。说实话TCP 是个非常“现实”的协议它要解决的根本不是“能不能把包发出去”而是“在不可靠的网络上怎么让双方像面对面聊天一样按顺序、不丢、不重地拿到完整数据”。这篇文章不打算堆砌状态转移图我想从实际工程的角度把 TCP 拆开讲清楚适合那些已经入行一段时间、想真正理解协议背后取舍的后端开发、网络运维也适合准备系统性过一遍计算机网络基础的同学。理解 TCP 最好的方式不是先背字段而是先搞清楚它在替应用层扛什么活。只有理解了它的设计动机你再去看报文格式、状态机、各种定时器才会觉得每一条都有来历而不是一堆需要死记的规则。1. 先搞明白 TCP 到底在解决什么问题1.1 一条数据从发送到接收要过几道关网上有很多通俗类比比如把 TCP 比作打电话UDP 比作寄明信片。这个类比方向是对的但它忽略了一个关键点TCP 不是帮你“接通”就结束了它还要负责整段对话的完整性和秩序。你想想日常打电话时如果对方没听清你会说“刚才那句再说一遍”TCP 做的就是这件事而且做得更加细致。应用层调用一次发送接口数据并不会像点对点光纤那样直接出现在对端。它要经过发送缓冲区、操作系统协议栈、网卡、路由器、交换机中间任何一环都可能出问题路由丢包、链路拥塞、对端处理不过来。TCP 要保证的是无论底层网络怎么折腾接收方最终拿到的字节流和发送方写进去的字节流完全一致连顺序都不能乱。所以 TCP 协议栈内部其实维护了四个核心机制缺一不可连接管理负责建立连接、维持连接、释放连接让双方知道“这段对话开始了”“这段对话结束了”。可靠传输用序号、确认号、重传机制保证数据不丢、不重、不乱序。流量控制让发送方不要超过接收方的处理能力避免把对端缓冲区挤爆。拥塞控制让发送方不要超过网络中间设备的承载能力避免把整个链路堵死。这四个机制不是独立的它们共享同一个报文结构、同一个窗口字段只是从不同维度限制了发送行为。这也是初学者最容易懵的地方为什么 TCP 有这么多窗口和定时器因为它在同时协调两端能力和整条链路状态。1.2 为什么说 TCP 是“有状态”的协议UDP 处理数据报的方式非常“无脑”来一个包根据端口扔给对应应用不记录任何上下文。TCP 则完全相反它在通信过程中维护了大量状态包括但不限于发送序号、接收序号、发送缓冲区大小、接收窗口大小、拥塞窗口大小、重传超时时间、连接当前所处的状态。这些状态分散在两端操作系统内部。所以 TCP 协议在教科书里常被描述成“有状态的字节流协议”这句话的工程含义是想要真正通过抓包看懂一段 TCP 通信你不能只看单条报文而要把同一连接的所有报文放到一起来读像看一条流水线一样。你可以这样理解TCP 连接不是一条“物理线路”而是双方内核里共同维护的一个虚拟会话对象。实际数据走的还是 IP 网络TCP 只是在 IP 之上加了一层“记账”的能力——每个字节都有账可查每个包都有确认要回每个异常都有补偿动作。这也是为什么 TCP 性能调优比 UDP 复杂得多你调的不是某一个参数而是这整条流水线上所有状态之间的配合。2. 连接管理三次握手与四次挥手的真实语义2.1 三次握手为什么不是两次也不是四次三次握手的本质是让通信双方各自确认一件事我发的包你能收到你发的包我也能收到。第一次握手客户端发 SYN服务端收到后知道了“客户端的发送能力是好的”。第二次握手服务端回 SYN ACK客户端收到后知道了“服务端的接收和发送能力都是好的我发的包对方能收到”。第三次握手客户端再回一个 ACK服务端收到后知道了“我发的包客户端也能收到”。如果只做两次握手服务端无法确认自己的发送路径是否畅通。举个例子客户端发 SYN因为网络拥塞超时重传服务端收到了重传的 SYN回了 SYN ACK此时服务端认为连接建好了开始等待数据但客户端可能因为久久没等到回应已经放弃了这次连接。这次残留在服务端的半开连接就会占用资源直到超时才能被回收。三次握手的好处是连接是否成立以“最后一次 ACK 是否到达服务端”为最终判定ACK 丢了双方都不会进入可用状态。四次握手理论上当然可以做但没有必要。三次已经让双方完成了最基本的双向可达性验证再多一次只是语义上的重复。还有一个常见的细节如果客户端在握手之后立即发送数据第三次握手的 ACK 可以和数据包一起捎带发送这也是 TCP 减少往返次数的一个常见优化抓包时经常会看到“PSH, ACK”一起出现。2.2 四次挥手的关键细节以及 TIME_WAIT 这件事挥手比握手复杂因为 TCP 连接是双工的双方各自要独立关闭自己的发送方向。四次挥手的具体过程是主动关闭方先发 FIN表示“我不再发数据了”被动关闭方回 ACK表示“收到你的 FIN”被动关闭方处理完剩余数据后也发 FIN表示“我也不再发了”主动关闭方最后回 ACK双方彻底关闭。这里最容易出问题的是被动关闭方的 ACK 和 FIN 为什么要分开。因为被动方在收到 FIN 后可能还有数据需要继续发送它不能立刻关闭发送方向必须等应用层把剩余数据处理完。如果应用层迟迟不 close连接就会卡在 CLOSE_WAIT 状态。线上如果出现大量 CLOSE_WAIT基本可以断定是服务端应用代码没有正确关闭连接而不是协议本身的问题。TIME_WAIT 是另一个高频话题。主动关闭方在发出最后一个 ACK 后并不会立即进入关闭状态而是会等待一段时间通常是 2 个最大报文段生存时间。这个设计的目的是如果最后一个 ACK 丢了被动方会重发 FIN主动方需要能响应这个重发同时防止旧连接中的延迟报文出现在新连接里。TIME_WAIT 过多会占用端口但这是代价是为了保证协议正确性不建议一开始就粗暴地把 TIME_WAIT 时长调小。2.3 在抓包里看到的握手挥手长什么样实际观察 TCP 交互最能建立直觉的方式是抓包。在一台 Linux 机器上可以使用系统自带的抓包工具采集端口数据也就是类似下面的命令tcpdump -i eth0 -nn port 8080 -c 20抓包结果里你会看到一条连接完整的生命周期先是 SYN然后 SYNACK然后是 ACK之后是正常的 PSHACK 数据交互最后 FIN、ACK、FIN、ACK。你可以重点观察这几个东西握手阶段看三次握手的报文长度一般很小SYN 包通常只有几十字节不携带应用数据。挥手阶段看 FIN 包之后对端是否立即回 FIN如果隔了很久才回说明对端应用层处理完数据后才 close。重传抓包过程中如果看到相同序号的包出现多次说明发生了重传这些重传在抓包软件里通常会用特殊颜色标出。零窗口如果对方的 ACK 里窗口大小是 0说明接收方缓冲区已经满了发送方必须停下来等待窗口更新。抓包是一种“眼见为实”的学习方式。很多人理论背得滚瓜烂熟但从来没有亲眼看一个连接如何建立、如何关闭、如何被重传所以遇到问题时缺乏现场感。我建议你动手抓一次本机回环的连接再抓一次跨公网的连接对比一下 RTT 和重传行为很快就能体会到协议栈到底在背后做了多少事。3. 可靠性序号、确认与重传3.1 序列号与累积确认如何工作TCP 把应用数据看成一个连续的字节流每个字节都有唯一的序号。发送方发送数据时报文段中会写明“这个段的第一个字节的序号是多少”接收方收到后回 ACK 时会在确认号字段里填上“我期望收到的下一个字节序号”。这就是累积确认确认号 N 表示“序号小于 N 的所有字节我已经都收到了”。举个例子。如果发送方发了 1 到 1000 字节确认号返回 1001接收方就表明前 1000 字节完整到达。如果发送方同时发了三个段分别是 1-500、501-1000、1001-1500但中间的 501-1000 丢了接收方收到 1001-1500 时因为前面缺了一段只能还是回 ACK 1001同时把这些后续数据暂时缓存起来。累积确认的优点是实现简单不需要为每个字节单独记状态缺点是如果中间丢了一个段后续所有数据即使已经到达也无法推进确认号发送方只能重传缺失部分。理解了累积确认就能明白一个常见问题为什么一个小的丢包可能导致整条传输链路速度骤降因为确认号推不上去发送方的发送窗口即使还有空间可以继续发包但接收方会因为数据不连续而触发快速重传机制发送方也会在等不到 ACK 后进入超时重传。拥塞控制的算法又会因为重传而缩小窗口于是看起来只是丢了一个包带宽却可能掉一个量级。3.2 超时重传与快速重传的取舍有了序号和确认还需要一个判定机制发送方什么时候认为包丢了TCP 用两个手段配合判断超时重传和快速重传。超时重传是最朴素的方案。发送方给每个报文段设定一个超时定时器如果在超时时间内没收到对应 ACK就重新发送。这个超时时间不是固定的它要根据网络的实时 RTT 动态调整。实际算法中RTO 通常会根据平滑后的 RTT 以及 RTT 的偏差来计算并且采用指数退避策略如果这次超时重传后又超时下一次 RTO 会翻倍避免在网络严重拥塞时反复重传把链路打得更死。快速重传是对超时重传的改进。如果发送方连续收到三个重复的 ACK说明接收方确实收到了某个序号之后的数据只是中间缺了一段这时候可以不等超时立即重传缺失的段。快速重传的优势是反应快不需要等待一个完整的 RTO而且它的出现本身就暗示网络还在通只是某个段丢了所以不需要像超时重传那样大幅缩减拥塞窗口。这里有个工程上容易踩的坑重复 ACK 不一定是真丢包也可能只是网络路径上发生了乱序。比如三个报文段走不同路径后发的先到接收方就会因为顺序不对而回重复 ACK。所以实现上一般要等收到 3 个重复 ACK 才触发快速重传就是为了降低误判概率。3.3 SACK 和 D-SACK 在现实网络中的作用传统的累积确认在丢包较多时效率很低发送方一次发了 5 个段中间丢了 3 个接收方只能持续回同一个确认号发送方只能疯狂重传不知道哪些数据其实已经到达了。为了解决这个问题TCP 增加了选择性确认选项也就是 SACK。SACK 在返回确认时可以额外携带“我已经收到的不连续数据块范围”。比如发送方发了 1-500、501-1000、1001-1500、1501-2000 四个段只有 501-1000 丢了接收方在 ACK 里除了确认号仍为 1001还可以通过 SACK 告诉发送方“1001-1500 我收到了1501-2000 我也收到了”。发送方就能精准地只重传 501-1000而不是把后面全部重发一遍。D-SACK 是更进一步的优化它用来告诉发送方“你重传的那个段我其实早就收到了或者你重传的数据是重复的”。这有助于发送方判断是不是自己的超时时间设得太小或者把乱序误判成了丢包。对于现代网络环境SACK 基本是必须开启的遇到高丢包率高带宽的场景它的效果非常明显。你可以检查系统参数里 SACK 是否被禁用很多老系统默认是开启的但某些安全配置会把它关掉导致传输效率断崖式下降。4. 流量控制与拥塞控制别把网络堵死4.1 滑动窗口与接收方通告窗口流量控制的思路很直观接收方告诉发送方“你最多还能再发多少字节”这个信息放在报文头的窗口字段里。发送方会把“已发送未确认的数据量”控制在接收方通告的窗口范围内。窗口字段是一个 16 位的值最大能表示 65535 字节这对于高速网络显然不够所以后来引入了窗口缩放选项让窗口可以按比例放大。实际抓包时你看到的窗口大小往往不是原始字段里的值而是经过缩放后的有效值。这是一个很容易迷惑新手的地方为什么抓包里写的是 65536 甚至更大而协议头只有 16 位因为有缩放因子在起作用。滑动窗口的另一个重点是窗口更新机制。接收方处理完应用数据后窗口会重新变大它会发送一个带新窗口大小的 ACK 来通知发送方。如果接收方应用迟迟不读数据窗口就会逐渐变小最后变成零窗口。这时发送方必须停止发送并周期性地发送探测包询问窗口是否已经变大。线上如果看到零窗口说明接收方应用处理能力不足问题常常出在应用层消费速度而不是网络。4.2 拥塞控制的四个阶段拥塞控制和流量控制的区别在于流量控制关注的是接收端能力拥塞控制关注的是网络中间节点的承载能力。发送方自己不知道整条链路现在有多挤只能靠丢包和 RTT 变化来反推网络状态。拥塞控制大体可以分成几个阶段来理解实际实现中不同算法有差异但思想是共通的。慢启动阶段发送方从很小的拥塞窗口开始每收到一个 ACK窗口就增加一个最大报文段大小所以窗口大小是指数增长的。这里“慢”指的是起点低不是增长速度慢。慢启动会一直持续到窗口达到慢启动阈值或者出现丢包。拥塞避免阶段窗口不再指数增长而是每个往返周期只增加一个最大报文段大小表现为线性增长。这个阶段让发送速率缓慢逼近链路容量。发生丢包时如果走超时重传拥塞窗口会被重置得很小重新回到慢启动如果走快速重传通常会执行快速恢复把窗口减半而不是归零然后进入拥塞避免阶段继续线性增长。这种“减半而不是清零”的处理方式保证了网络在轻微拥塞时不会一下子降到低谷能更快恢复到可用带宽。实际工程中很少有人去手动调拥塞算法但理解这些阶段对排查性能问题很有帮助。比如你看到一份监控数据发送速率呈现“迅速爬升、陡然下跌、再爬升”的锯齿形波动那就是典型的慢启动和拥塞窗口被重置的痕迹说明链路已经接近饱和或者存在间歇性丢包。4.3 高带宽高延时的链路窗口要特别注意有一个概念叫带宽延迟积它表示“数据从发送端到接收端在这条链路上能同时容纳的数据总量”。简单算一下带宽是 100MbpsRTT 是 100ms那么带宽延迟积大约是一秒能发 100Mbit也就是约 12.5MB在两个往返内能飞的字节量就是一个很大的数字。如果发送窗口小于这个容量链路就会处于“没塞满”的状态带宽再高也跑不满。我们常说的“长肥网络”指的就是带宽大、延迟也大的网络。在这种网络里默认的窗口大小和缓冲区设置往往成为瓶颈。你以为是程序写得慢或者服务器 CPU 不行实际上可能只是 TCP 窗口不够大。调优时要同时关注发送缓冲区、接收缓冲区和窗口缩放是否开启几者必须一起配合。另外RTT 的测量对高带宽链路特别敏感。如果 RTT 忽高忽低计算出来的 RTO 也会不稳定超时重传可能频繁发生。不要一看到重传就怀疑丢包先确认一下是不是网络抖动导致 RTT 估算不准。这个排查顺序很多人会搞反白浪费了大量时间。5. 粘包、拆包与低延时调优工程里真正天天打架的地方5.1 粘包、半包的本质与常见解法TCP 是字节流协议它不管应用层消息边界。你在应用层调用两次发送分别发了“hello”和“world”接收方可能一次就收到“helloworld”也可能分几次收到“hel”“loworld”。这不是协议出错了恰恰是协议的设计特性。粘包和半包只是应用层在基于字节流解析消息时产生的现象不是 TCP 本身的缺陷。想要在 TCP 上承载有边界的消息必须由应用层自己定义消息格式。常见方案有三种一是定长消息每条消息固定长度不足部分补齐接收方按长度切分简单但浪费带宽二是特殊分隔符比如按换行符或自定义分隔符切分适合文本协议但要防止消息内容里出现同样的分隔符三是在消息头部带上长度字段接收方先读长度再按长度读取完整消息。第三种最常用几乎所有应用级协议和 RPC 框架都是这么做的。实现长度字段时有个细节很关键头部长度本身也可能被拆开。接收方必须先完整读到一个头部解析出长度再循环读取直到消息体读完。所以处理缓冲区时要维护“半包状态”不能简单地认为“收到一次读事件就是一个完整消息”。我给自己的团队定过一个简单规则所有 TCP 消息处理代码必须能处理“一个字节一个字节到达”的最坏情况写出来要正确也便于事后排查。5.2 Nagle 算法与延迟 ACK 的相互作用Nagle 算法的初衷是减少小包数量。它的规则大概是如果发送方还有未确认的小数据包新产生的小数据不会立刻发送而是先缓存起来等到之前小包被确认或者积累了足够的数据再一起发送。这种策略对当时低速网络很有意义但在现代交互式应用里它常常和延迟 ACK 机制互相打架产生明显的延迟。延迟 ACK 是接收方的策略不立即回 ACK而是等一小段时间期望能把 ACK 和响应数据一起捎带返回以减少 ACK 包数量。问题来了如果发送方启用了 Nagle发送的小数据要等 ACK 才发下一批接收方启用了延迟 ACKACK 又要等一段时间再回。两头等就形成了大约 40ms 到 200ms 的“确认延迟”。这个现象在抓包时会看到明明数据早就到了对端却迟迟不回 ACK。解决这个问题的常见做法是在交互式小包场景里关闭 Nagle 算法也就是设置 TCP_NODELAY 为真。大部分现代 RPC 框架默认都会关闭 Nagle因为它们往往自己实现了消息聚合批量发送不需要协议栈级别的小包优化。但要注意TCP_NODELAY 不是万能的它只是让“小包立刻发”如果你的应用疯狂发送大量小消息反而会造成网络小包泛滥应该从应用层做消息合并和批量写入而不是依赖 Nagle 来兜底。5.3 关于 TCP_NODELAY、KeepAlive 等调优参数的注意事项TCP 调优参数很多但真正日常会用到的也就几个而且它们的影响必须放在具体场景里判断。TCP_NODELAY上文已经说过低延迟交互场景建议开启批处理场景可以考虑关闭。但很多开发者只记住了“开启可以降低延迟”却没意识到这不代表整体吞吐更高。大块数据传输场景关闭 Nagle 可能反而增加了大量小包重传的概率。KeepAlive 是用来检测空闲连接是否还存活的机制。TCP 的 KeepAlive 默认是关闭的即便打开默认探测间隔也相当长。它只能起到“兜底清理无效连接”的作用不适合作为业务存活检测手段。如果应用需要确认对端是否还活着应该在应用层设计心跳而不是依赖 TCP 的内置保活。重新设置缓冲区大小也经常出现。接收缓冲区和窗口大小直接相关发送缓冲区则限制了发送方能积压多少未确认数据。如果缓冲区设置太小即使带宽很大吞吐也提不上去设置太大又可能让大量数据滞留在内核里对内存和延迟都不友好。正确做法是先计算带宽延迟积再结合业务可接受的延迟来确定缓冲区大小而不是凭感觉把值调大。还有一个被忽略的点系统级的 TCP 参数调优不是全局通用的。同一台机器上可能同时跑着低延迟的 RPC 服务和批量传输任务全局关闭 Nagle 或者统一改大缓冲区会对不同业务造成互相影响。更合理的做法是在 socket 级别依据连接类型分别设置参数。6. 排查与观察不能只看抓包软件的颜色6.1 重传率、RTT 和 RTO 不正常意味着什么线上网络问题排查最常用的三个指标是重传率、RTT 和 RTO。重传率是所有重传报文与总报文数的比值这个值如果持续超过一定比例说明链路质量已经不可接受。但要先区分重传是快速重传还是超时重传快速重传占比高说明链路存在少量丢包但整体还通可能是线路质量问题超时重传占比高说明网络可能出现了较长时间的堵塞或中断要么是路径不通要么是经过的业务节点负载过高。RTT 是数据从发送到收到确认的往返时间。正常该是平滑的小幅波动。如果 RTT 呈现周期性锯齿状大概率是缓冲区排队导致当瞬时流量接近上限时数据在路由器缓存里排队RTT 会持续走高直到某个丢包触发拥塞窗口收敛RTT 才开始下降。这种图在监控系统里非常典型。RTO 是根据 RTT 和 RTT 抖动计算出来的超时时间。如果 RTO 频繁变动意味着链路本身不稳定。排查时可以先分三路一是看服务器负载和 CPU 中断确认不是本机协议栈处理不过来二是看中间链路的丢包率确认是不是运营商线路问题三是看对端应用处理时长排除对方延迟 ACK 或应用迟迟不读缓冲区导致的假 RTT 升高。三者都看过之后再下结论说“TCP 有问题”也不迟。绝大多数情况下协议栈本身是没毛病的问题在环路的某个具体环节。6.2 几种常见的可疑模式线上出了问题抓包看什么我自己整理过一份快速判断清单。第一种情况大量零窗口。这说明接收方应用进程没有及时读走数据发送方被暂停。排查方向是应用消费能力和内存分配不是网络。有些服务会把接收缓冲区设置得很大以为能缓解业务压力结果数据堆积在内核缓冲区里应用层延迟反而更高。第二种情况持续的重复 ACK 和快速重传。可以先看是不是路径上存在对称的拥塞点或者中间设备的流量整形策略导致数据段被丢弃。如果同一连接的重复 ACK 比例高且乱序明显还需要怀疑数据包是不是被负载均衡设备分散到了不同路径。第三种情况连接堆积在 FIN_WAIT_2 或 CLOSE_WAIT。FIN_WAIT_2 大量出现往往与对端不主动关闭有关比如客户端发起了 FIN但服务端程序异常一直不返回 FIN。CLOSE_WAIT 大量出现基本可以确定是本端应用在处理完请求后没有调用关闭接口这是代码问题跟协议无关。第四种情况RST 包异常增多。RST 用于异常终止连接正常情况下应该很少见。出现大量 RST常见原因是端口未监听、连接已超时被本端强行关闭、以及应用层写入一个已经被对端关闭的连接。后端开发遇到“Connection reset by peer”十有八九是对方已经关闭了 socket而你的流程还继续写数据。这些模式只要见过一次以后排查就很快。但如果你没有抓包经验第一次遇到可能会懵很久。我的建议是先养成“看时序图”的习惯把可疑连接过滤出来逐个看时序再结合服务端日志判断是应用层问题还是网络层问题。6.3 一些实践层面的观察建议排查 TCP 问题单靠抓包软件的颜色判断是很不可靠的。抓包工具只是把协议栈看到的报文显示出来它并不会告诉你为什么丢包、为什么乱序、为什么重传。想要真正定位还要结合两端操作系统的统计信息。例如Linux 下可以查看网络栈的相关统计判断是接收队列溢出、发送队列积压还是校验错误。这些数据比抓包更接近问题源头。抓包也有不少要注意的细节。比如抓包本身可能漏包特别在高流量场景下样本一丢得出的结论就容易偏。抓包时要优先在两端同时采集而不是只在一端抓。只在一端抓你看不到另一端实际发送了什么很多问题就很难判断。对比两端的时间轴能帮你判断丢包是发生在上行还是下行这个信息很关键。如果你刚开始接触 TCP 排查不要一上来就尝试各种复杂的抓包过滤和统计脚本。先在可控环境里复现一次正常的连接完整看一遍握手、数据交互、挥手把所有状态记录下来。再人为模拟一次丢包观察快速重传如何触发再模拟一次拥塞观察超时重传到来的节奏。等你熟悉了正常和异常的对比再上线上环境看真实流量就会从容得多。我个人在这几年里最大的体会是TCP 没有那么多“魔法”所有现象背后都有清晰的原因。你看到的问题大概率不是协议栈不行而是某个环节的配置、某段应用代码、某台网络设备的行为不太对。把 TCP 的机制吃透再配合正确的观测手段绝大多数网络疑难杂症都能在一个小时内定位到八分以上。剩下的两分很多时候取决于你有没有耐心去翻那些被忽略的参数、内核计数器和两端时间戳。把这套基本功练扎实了比背十遍握手挥手的状态转移图管用得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑