首页 / 资讯中心 / 文章详情

TCP与UDP传输层协议深度解析:从设计原理到选型实战

TCP与UDP传输层协议深度解析:从设计原理到选型实战 ★ FEATURED ARTICLE
传输层这块内容我前后给不同基础的人讲过不下十遍每次讲完都有人问同一个问题TCP 和 UDP 到底该选哪个。问的人里有刚接触网络编程的也有写了几年业务代码但一直停留在“调库”层面的。这个问题看似简单实际上牵扯到可靠性语义、连接状态管理、拥塞控制、报文边界、头部开销这一整串东西任何一个点没想清楚选型就会变成拍脑袋。这篇内容我打算把传输层这两个核心协议从设计动机到落地细节完整拆一遍重点放在“为什么这么设计”和“实际写代码时哪里会踩坑”而不是把教科书上的定义再抄一遍。适合正在学网络基础的人、需要做协议选型的后端开发者以及想搞清楚抓包里那些字段含义的运维同学。读完你应该能做到两件事看到业务场景能判断该用哪个协议以及遇到连接异常、粘包、丢包这类问题时知道从哪下手排查。1. 传输层到底在解决什么问题1.1 从“两台机器能通”到“两个进程能对话”网络层负责把数据包从一台主机送到另一台主机但它只管送到机器不管送到机器上的哪个程序。一台主机上同时跑着浏览器、聊天软件、数据库客户端IP 包到了网卡之后操作系统怎么知道这个包该交给谁这就是传输层存在的第一个理由用端口号做进程到进程的寻址。IP 地址定位主机端口号定位进程两者合起来构成一个通信端点也就是常说的 socket 地址。但端口寻址只是最表层的作用。真正让传输层变得复杂的是它要在一层不可靠的、尽力而为的网络上向上提供不同质量的服务。底层链路会丢包、会乱序、会重复网络层对此不做任何承诺。传输层要做的是在这个“什么都不保证”的基础上根据上层应用的需求决定要不要补上可靠性、要不要控制发送节奏、要不要保证报文边界。TCP 和 UDP 就是这条光谱上的两个典型代表一个把能做的都做了一个几乎什么都不做。理解这一点很关键因为后面所有的机制差异本质上都是这个定位差异的延伸。你把传输层想成一个“服务适配层”TCP 是精装修全包UDP 是毛坯房选哪个取决于你愿不愿意自己搞装修。1.2 复用与分用一个 IP 地址背后的多路并发一台主机只有一个或少数几个IP 地址但同时可能有几百个连接在跑。传输层通过复用把多个应用进程的数据统一交给网络层发送通过分用把收到的数据按端口交付给正确的进程。这个机制听起来平淡但它直接决定了端口号的设计约束。端口号是 16 位范围 0 到 65535。其中 0 到 1023 是知名端口绑定着固定服务1024 到 49151 是注册端口49152 以上是动态端口通常由操作系统在客户端发起连接时临时分配。这里有个实操中经常被忽略的点服务端监听一个端口但每条已建立的连接在服务端侧占用的是“本地端口 远端地址 远端端口”这个四元组而不是单纯一个端口。所以一个监听 8080 的服务可以同时处理上万条连接端口并不会被“占满”。很多人第一次看到netstat里同一个本地端口对应一堆不同远端地址时会困惑原因就在这里。提示判断端口是否被占用看的是四元组是否冲突而不是端口号本身是否重复。写服务端程序时不要误以为“一个端口只能服务一个客户端”。1.3 传输层给上层留下的两套语义从使用者的角度看传输层最终暴露给应用的是两套截然不同的语义。TCP 提供的是字节流你往里写多少字节对面按顺序收到多少字节但没有任何边界信息写两次和写一次在接收端可能完全一样。UDP 提供的是数据报每次发送是一个独立单元接收端要么完整收到一个报文要么什么都收不到边界严格保留。这个差异是后面所有讨论的地基。粘包问题、消息分帧、重传策略、超时处理全都建立在这两套语义之上。我在带新人的时候会反复强调一句话先想清楚你的数据有没有边界语义再谈选协议。很多选型错误根源就是没搞明白自己的业务数据到底是“流”还是“消息”。2. TCP 的连接管理三次握手与四次挥手背后的取舍2.1 三次握手不是仪式是在同步什么TCP 是面向连接的通信前必须先建立连接。三次握手的流程大家都背过客户端发 SYN服务端回 SYNACK客户端再回 ACK。但为什么是三次而不是两次这个问题能筛掉一大批“背过但没懂”的人。核心在于双方都要确认对方的初始序列号并且都要确认对方已经收到了自己的序列号。两次握手只能让客户端确认服务端的接收能力服务端无法确认客户端是否收到了自己的 SYNACK。如果只有两次一个延迟到达的旧 SYN 可能让服务端单方面建立连接并分配资源而客户端根本不认这条连接资源就白白浪费了。三次握手让双方都完成“我发了、你收到了、我知道你收到了”这个闭环序列号同步也在这个过程里完成。序列号的作用不只是排序。它是 TCP 实现可靠传输、去重、流量控制的基础。初始序列号不是从 0 开始而是随机生成的这一点在现代实现里很重要能降低连接被伪造和序列号被猜测的风险。抓包时看到 seq 是个大数别觉得奇怪那是正常的。2.2 四次挥手与 TIME_WAIT 的真实意义关闭连接需要四次挥手因为 TCP 是全双工的两个方向要分别关闭。主动关闭方发 FIN对方回 ACK等自己数据发完再发 FIN主动方回 ACK。这里最容易被误解的是TIME_WAIT 状态主动关闭方在发完最后一个 ACK 后要等待 2MSL最大报文段生存时间的两倍才真正关闭。为什么要等两个原因。第一保证最后一个 ACK 能到达对方如果这个 ACK 丢了对方会重发 FIN此时主动方还在 TIME_WAIT 就能重新应答如果直接关了对方会收到 RST关闭过程就异常了。第二让这条连接上残留的旧报文在网络中自然消亡避免它们干扰后续使用相同四元组的新连接。实操中大家最常遇到的是服务端出现大量 TIME_WAIT。这里要分清是谁在主动关闭。如果是服务端主动关连接比如短连接服务TIME_WAIT 就会堆在服务端占用端口资源。常见的处理思路是让客户端主动关闭或者调整内核参数但调整参数要谨慎缩短等待时间会削弱上面说的两个保护作用。我的经验是优先从连接模型上解决比如改用长连接或连接池而不是一上来就改内核。2.3 连接状态机里那些容易看错的迁移TCP 的状态迁移有十几种抓包或者看ss输出时经常见到 ESTABLISHED、CLOSE_WAIT、TIME_WAIT 这几个。其中CLOSE_WAIT 堆积是代码层面的典型信号它表示对方已经发了 FIN本端收到了但本端应用还没调用 close。大量 CLOSE_WAIT 基本可以断定是程序里漏了关闭连接的逻辑比如异常分支没走 finally或者连接池回收有问题。这跟 TIME_WAIT 的性质完全不同前者是 bug后者是正常机制。状态出现位置常见含义处理方向ESTABLISHED双方连接正常可收发数据正常状态CLOSE_WAIT被动关闭方收到对方 FIN本端未 close检查代码是否漏关连接TIME_WAIT主动关闭方等待 2MSL保证可靠关闭正常机制量大时优化连接模型SYN_RECV服务端收到 SYN等待最后 ACK量大可能是 SYN 洪泛或握手异常这张表我建议直接贴在排查手册里。看到状态异常先对号入座比盲目翻日志快得多。3. TCP 的可靠传输确认、重传与流量控制的协作3.1 累积确认与滑动窗口如何配合TCP 的可靠性靠三件事撑起来确认、重传、排序。接收方收到数据后回 ACKACK 里的确认号表示“这个序号之前的所有字节我都收到了”这就是累积确认。累积确认的好处是简单一个 ACK 能确认一大段数据坏处是如果中间丢了一个包后面的包即使收到了也只能一直回同一个确认号发送方会误以为后面都没到。为了解决这个问题发送方维护一个滑动窗口窗口内的数据可以连续发送而不必等逐个确认窗口左边界随 ACK 前移。窗口大小由接收方通过 TCP 头部的窗口字段通告表示“我还能接收多少字节”。这个机制把“发多快”和“收得下多少”解耦了发送方不会因为发太快把接收方缓冲区撑爆。实际调优时窗口大小是个关键参数。窗口太小带宽再高也跑不满因为发送方发一批就得停下来等确认这就是所谓的“带宽时延积”问题。窗口大小至少要能覆盖一个往返时间内链路上能容纳的数据量否则吞吐上不去。这也是为什么长肥管道高带宽、高时延场景下需要窗口缩放选项。3.2 超时重传与快速重传的触发逻辑丢包之后怎么补两条路。第一条是超时重传发送方为每个已发未确认的段设一个定时器超时没收到 ACK 就重发。超时时间RTO不是固定的而是根据往返时间RTT的动态测量值估算出来的会随网络状况自适应调整。RTO 设太小会误重传设太大会导致恢复慢。第二条是快速重传接收方收到乱序的段时会立刻重复发送最后一个按序收到的确认号。发送方如果连续收到三个重复 ACK就判断中间那个段丢了不等超时直接重传。这比等超时快得多是提升恢复速度的关键机制。这里有个容易混淆的点快速重传触发后TCP 不一定只重传一个段。现代实现里通常配合选择确认SACK来告诉发送方“我到底收到了哪些段”从而只补真正缺的部分避免把已经收到的又重传一遍。抓包时看到 SACK 选项说明这条连接在用更精细的重传策略。3.3 流量控制与拥塞控制不是一回事这两个概念经常被混为一谈但作用对象完全不同。流量控制是端到端的防止发送方发太快把接收方缓冲区填满靠接收窗口字段实现。拥塞控制是面向网络的防止过多数据注入导致中间链路拥塞靠拥塞窗口cwnd实现。发送方实际能发多少取接收窗口和拥塞窗口的较小值。拥塞控制有几个经典阶段慢启动窗口指数增长、拥塞避免窗口线性增长、快重传快恢复。慢启动这个名字有误导性它其实一点都不慢是指数增长的只是起点小。理解这套机制的意义在于当你看到传输速度忽快忽慢、或者大文件传输前期慢后期快很可能就是拥塞控制在起作用而不是网络本身不稳定。注意调优时不要只盯着接收窗口拥塞窗口往往才是瓶颈。尤其是在跨地域、高时延的链路上拥塞控制算法的选择对吞吐影响很大。4. UDP 的极简哲学与它真正的用武之地4.1 去掉一切之后剩下什么UDP 的头部只有 8 个字节源端口、目的端口、长度、校验和。没有连接状态没有确认没有重传没有排序没有流量控制。发出去就不管了收不收得到、顺序对不对全看网络和应用自己。这种极简不是偷懒而是一种明确的设计取舍把控制权完全交给应用层。因为没有连接状态UDP 服务端不需要为每个客户端维护上下文一个 socket 就能处理海量客户端资源占用极低。因为没有握手第一个包发出去就开始传数据省掉一个往返时间。因为没有重传和拥塞控制的强制约束应用可以自己决定发送节奏。这些特性在特定场景下是巨大优势而不是缺陷。我见过不少人一上来就认为 UDP “不可靠所以不好”这是典型的用 TCP 的价值观去评判 UDP。选协议要看业务需求不是看谁功能多。4.2 无连接带来的并发优势UDP 的无状态特性在高并发场景下优势明显。一个 UDP 服务端用单个 socket 就能接收来自任意数量客户端的数据报每个数据报自带源地址处理完即走不需要维护连接表。相比之下TCP 服务端要为每条连接维护状态、缓冲区、定时器连接数上去之后内存和 CPU 开销都很可观。这也是为什么很多实时性要求高的系统偏爱 UDP不是因为它可靠而是因为它轻。轻意味着单机可以扛更多并发延迟也更可控。当然代价是可靠性要自己补这就引出下一节。4.3 在 UDP 之上自建可靠性的常见套路很多实时音视频、游戏、实时通信系统会在 UDP 之上自己实现一套轻量可靠性机制。常见做法包括给每个数据报编号接收端据此检测丢包和乱序对关键数据做选择性重传对非关键数据比如已经过时的帧直接丢弃不补自己做前向纠错用冗余数据抵抗丢包自己做拥塞控制根据丢包和延迟反馈调整发送速率。这套思路的核心是按需可靠不是所有数据都值得重传。一帧已经过时的视频画面重传回来也没用了不如把带宽留给新数据。TCP 的可靠是“全有或全无”的要么保证所有字节按序到达要么连接出问题UDP 之上自建方案可以做到“重要的保不重要的丢”这在实时场景里是刚需。自己实现这套东西工作量不小所以实际项目中往往会用成熟的传输库而不是从零写。但理解原理仍然必要否则调参和排障时完全抓瞎。5. 选型决策什么场景该用哪个5.1 一张对照表看清本质差异维度TCPUDP连接面向连接需握手无连接直接发可靠性确认、重传、排序保证按序到达不保证到达、不保证顺序报文边界字节流无边界保留数据报边界头部开销20 字节起含选项更多8 字节流量/拥塞控制内置无需应用自建传输速度受控相对保守无约束可更快适用场景文件、网页、数据库、需完整性的业务实时音视频、游戏、DNS、广播这张表不是让你背而是让你在选型时逐项对照自己的需求。重点看两列可靠性要求和实时性要求。两者往往此消彼长。5.2 典型场景的选型逻辑网页浏览、接口调用、文件传输、数据库连接这类场景数据完整性是第一位的丢一个字节都可能出问题天然适合 TCP。HTTP/1.1、HTTP/2 都跑在 TCP 上就是这个道理。实时音视频、在线游戏、实时位置上报这类场景延迟比完整性更重要。一帧画面晚到 200 毫秒就失去意义不如丢掉。这类场景用 UDP 更合适配合应用层的按需重传和抗丢包策略。DNS 查询是个有意思的例子默认用 UDP因为查询报文小、要求快一次一问一答丢了重发一次成本很低。但当响应数据超过一定大小或者需要可靠传输时会切换到 TCP。这说明选型不是非黑即白同一个业务的不同环节可以用不同协议。广播和多播场景只能用 UDP因为 TCP 是点对点的没法一对多。这是协议能力决定的没有选择余地。5.3 那些“看起来该用 UDP 其实该用 TCP”的误区有个常见误区是“要快就用 UDP”。快不快取决于整体设计不是单看协议。如果业务需要完整数据用 UDP 之后自己补可靠性补出来的东西复杂度可能远超直接用 TCP而且容易补出 bug。我见过有团队为了“性能”把本可以用 TCP 的业务改成 UDP 自建可靠传输结果重传逻辑写得漏洞百出最后性能没提升稳定性还下降了。判断标准其实很简单如果你的数据丢了必须补而且补的代价和直接用 TCP 差不多那就直接用 TCP。只有当你能明确说出“这部分数据丢了可以不补”或者“我需要比 TCP 更激进的发送策略”时UDP 才是合理选择。6. 实操中绕不开的坑与排查思路6.1 粘包不是 TCP 的 bug是字节流的必然用 TCP 的人几乎都遇到过“粘包”发了两次数据接收端一次全收到了或者一次只收到半条。严格说这不是粘包是字节流没有边界。TCP 只保证字节顺序不保证你 write 几次对面就 read 几次。发送端有缓冲区接收端也有缓冲区合并和拆分随时可能发生。解决办法是在应用层定义消息边界。常见三种固定长度、分隔符、长度前缀。长度前缀最通用先发 4 个字节表示消息体长度再发消息体接收端先读长度再按长度读体。这个方案对二进制数据友好也不怕消息内容里出现分隔符。写的时候要注意处理“半包”读长度时可能只读到 2 个字节得继续读满 4 个读体时同理。这是新手最容易漏的地方。# 长度前缀分帧的接收端示意伪代码风格 def read_message(sock): header recv_exactly(sock, 4) # 必须读满 4 字节 if not header: return None length int.from_bytes(header, big) body recv_exactly(sock, length) # 必须读满 length 字节 return body def recv_exactly(sock, n): buf b while len(buf) n: chunk sock.recv(n - len(buf)) if not chunk: return None buf chunk return buf这段代码的关键在recv_exactly的循环recv返回的不一定是你请求的字节数必须循环读满。很多人直接sock.recv(n)就以为拿到了 n 个字节这是粘包问题的头号来源。6.2 UDP 的报文大小限制与分片陷阱UDP 数据报有大小上限。理论上 UDP 长度字段是 16 位最大 65535 字节减去 IP 头和 UDP 头实际能承载的数据更少。但真正的问题不在协议上限而在链路层的 MTU。以太网 MTU 通常是 1500 字节减去 IP 头和 UDP 头单个 UDP 数据报的安全载荷大约在 1472 字节以内。超过这个大小IP 层会分片而分片只要丢一片整个数据报就废了重传成本很高。所以用 UDP 时应用层要主动控制单包大小通常建议控制在 MTU 以内避免 IP 分片。如果业务数据本来就大就在应用层自己分片和重组这样丢了哪片补哪片比 IP 层分片可控得多。这个细节在实时传输里特别重要很多“莫名其妙丢包”的问题追下去都是分片惹的祸。6.3 排查连接问题的下手顺序遇到连接异常我一般按这个顺序查先看状态ss或netstat确认连接处于哪个阶段CLOSE_WAIT 多查代码SYN_RECV 多查握手和防火墙再看抓包确认握手是否完成、有没有 RST、重传是否频繁最后看应用日志确认是不是应用层主动关的。这个顺序能覆盖大部分问题比一上来就翻代码高效。抓包时重点关注几个信号大量重传说明链路质量差或拥塞频繁 RST 说明某一端异常关闭窗口字段长期为 0 说明接收方处理不过来。这些信号比逐包看内容有用得多。7. 我个人在实际操作中的几点体会传输层这块知识光看不动手很难真正理解。我的建议是自己写一个最简单的 TCP 客户端服务端故意制造粘包场景然后亲手用长度前缀解决它再用 UDP 发几个超过 MTU 的包抓包看看分片长什么样。这些实验花不了多少时间但比看十篇文章印象深。另外一个体会是选型时不要被“性能”两个字带偏。TCP 的可靠性机制确实有开销但在绝大多数业务场景下这个开销完全可以接受真正拖慢系统的是糟糕的架构和不合理的数据库查询而不是 TCP 那点握手和确认。反过来UDP 省下的那点开销往往要用应用层复杂度和潜在 bug 来还。想清楚自己的数据到底需不需要可靠、需不需要保序答案自然就出来了。最后提一个容易被忽略的点无论用哪个协议都要给 socket 设置合理的超时。TCP 连接可能卡在某个状态很久UDP 的 recv 可能永远等不到数据。没有超时的网络代码在生产环境里就是定时炸弹。这个坑我踩过希望你别再踩。
阅读完成 · 觉得有帮助?
咨询建站