TCP与UDP的底层原理与抓包实战:从三次握手到粘包排查 1. 先把传输层的位置摆正为什么TCP和UDP如此重要计算机网络这门课里很多初学者一上来就被七层模型吓住然后死记硬背TCP是可靠的、UDP是不可靠的考试过了就丢。但真到了写代码、排查线上故障、优化网络性能的时候才会意识到这两个协议不是考试题而是天天要打交道的底层逻辑。我见过不少开发者业务代码写得飞起但一问TCP握手为什么是三次、UDP头为什么这么短立刻答不出来。这三脚猫功夫迟早会在某个深夜的故障排查现场还债。先说清楚它们在协议栈里的位置。网络层负责的是“从这台机器到那台机器”的寻址和路由IP协议干的就是这个活。但一台机器上跑着几十上百个进程数据到了机器之后该交给谁这就轮到传输层出场了。传输层的核心工作就是“端到端”的交付靠的是端口号来区分不同的应用进程。HTTP服务听80端口、HTTPS听443、DNS查53、SSH连22这些数字对应的就是传输层的能力边界。TCP和UDP就是传输层的两个代表选手。TCP全称是传输控制协议UDP全称是用户数据报协议。俩名字都带“协议”但气质完全不同。用生活里的例子来打比方TCP像顺丰快递有面单、有签收、有物流跟踪、丢件了还给你补发UDP像在球场边喊一嗓子“把球传过来”对方听没听见、球传不传全看缘分。这篇文章适合三类人看计算机专业的学生、刚入行的后端或者客户端开发者、还有那些写网络程序总感觉“哪里不对”但说不出来为什么的人。我会从原理讲到实操再拿Wireshark抓包、Python写socket程序来验证最后把我在项目里踩过的坑一并列出来。看完之后你对TCP和UDP的理解会从“背过”变成“真的通了”。2. UDP极简主义的信使快但不承诺2.1 UDP到底做了什么UDP的报文头只有8个字节源端口、目的端口、长度、校验和各占2字节。对比TCP最小20字节的头部UDP省掉的不仅仅是空间更是一整套复杂的连接管理机制。发送端把数据扔给UDPUDP加个头部就交给IP层到对端之后解个头、按端口分发就完事了。没有确认、没有重传、没有顺序保证、没有流量控制。这个设计看起来很“简陋”但它换来的东西是低延迟和低开销。你不需要建立连接想发就发你不用等对端确认下一包数据已经在路上了。那么问题来了谁在需要可靠传输的场景下会用它答案是那些“时效性大于一切”的应用。比如实时音视频通话一帧画面晚到100毫秒用户已经明显感觉卡顿但偶尔丢几个包画面花一下也就过去了下一帧马上补上。假如这里强行用TCP丢包触发重传重传的数据到达时那一段画面早就该播完了反而是灾难。再比如游戏里的角色位置同步、操作指令下发要的是当前这一刻的状态不是把十分钟前的数据补发过来。还有DNS查询。你访问一个域名本地DNS服务器发一个UDP查询包出去默认情况下用的就是53端口的UDP。查询没回应客户端自己超时重发或者退而求其次走TCP。为什么不用TCP做首要选择因为大多数DNS查询就一个小包来一次三次握手的开销比查询本身还贵。2.2 用Python写一个UDP收发Demo空谈理论没有感觉我建议你亲手写一个最小的UDP收发程序把整条链路跑通。我找一个生产项目的简化版来演示你先在机器上装好Python3不需要任何第三方库。# udp_server.py import socket server socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server.bind((127.0.0.1, 8888)) print(UDP server listening on 127.0.0.1:8888) while True: data, addr server.recvfrom(65535) print(freceived from {addr}: {data.decode()}) server.sendto(fack: {data.decode()}.encode(), addr)# udp_client.py import socket client socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_addr (127.0.0.1, 8888) for i in range(3): message fhello udp {i} client.sendto(message.encode(), server_addr) data, _ client.recvfrom(65535) print(fresponse: {data.decode()})先起服务端再跑客户端你会看到服务端依次收到三条消息并原样回包。这里的核心点在于sendto和recvfrom都是无连接的服务端不需要事先知道客户端是谁客户端也不需要跟服务端“打招呼”。每个数据报都是独立的服务端只负责从套接字里取数据然后按对方的地址回包。这里有个新手容易踩的坑recvfrom的缓冲区大小。我给的示例是65535这基本是UDP单包能传的极限。如果你的业务包比缓冲区还大recvfrom只会返回缓冲区能装下的那部分剩下的数据直接丢掉了而且你根本不知道有没有丢。所以设计UDP应用时业务包一定要设定一个上限比如4KB、8KB超过就分片或者改用别的方案。不要寄希望于UDP帮你自动分包它只负责把IP层的数据报原样交给你。2.3 UDP的校验和是个容易被忽略的细节UDP头里那个2字节的校验和很容易被忽略但它真的很关键。它计算的范围除了UDP头本身还包含一个伪头部里面有源IP、目的IP、协议号、UDP长度。这个设计的用意是即使IP层把包转错了目标接收方也能通过校验和发现数据不对。不过校验和算错会怎样在大多数系统里收到校验和错误的UDP包会直接悄悄丢弃发送方毫不知情。所以UDP不可靠不只体现在“可能丢包”它连出错无声无息这件事也一并与生俱来。你在设计高可靠性业务时如果选了UDP就必须在应用层自己解决校验、序列号、超时重传这些事。业界有现成的方案比如QUIC协议就是在UDP之上构建了类似TCP的可靠性机制但这已经属于进阶话题了。3. TCP面向连接的可靠传输代价是复杂度3.1 三次握手为什么非得三次TCP的“可靠”不是凭空来的它在一开始就用三次握手建立了连接状态。握手的目的有三个确认双方都能收发、交换初始序号、协商一些窗口参数。很多人问为什么不是两次我换个角度解释。假设只有两次握手客户端发SYN给服务端服务端回SYNACK然后服务端就认为连接建立了开始给客户端发数据。但问题在于如果第一个SYN在网络里滞留了很久客户端等不及超时重发了一个新的SYN服务端回了新的SYNACK旧SYN才姗姗来迟。服务端又回一个旧SYNACK然后傻乎乎地认为这是另一条新连接开始等数据。客户端的序号早就变了两边“状态不一致”旧连接永远悬在那里占资源。三次握手能把这个历史遗留问题暴露出来客户端收到服务端回执后再发一个ACK这个ACK本身就携带了“我知道你在等我序号没问题”的信息。如果服务端收到的是旧请求对应的ACK序号对不上就能干净地丢弃。具体的状态迁移是这样的客户端先发SYN进入SYN_SENT服务端收到后回SYNACK进入SYN_RCVD客户端收到SYNACK再发ACK进入ESTABLISHED服务端收到ACK也进入ESTABLISHED。很有意思的是客户端只要发出ACK就认为自己连接建好了服务端必须等这个ACK收到才算数。这中间的“不对称”其实反映了TCP的容错思想——客户端可以先发数据服务端即使没收到ACK也有超时重传兜底。我建议你亲手抓一次包看看。用Wireshark抓本机回环流量然后随便访问一个本地HTTP服务过滤tcp.port 8080你会清楚看到三条握手包第一条没有载荷、带SYN标志第二条带SYN和ACK标志第三条只有ACK。这个视觉记忆比看十遍概念都管用。3.2 四次挥手和TIME_WAIT断开连接比建立连接更微妙。因为TCP是双工的两边的数据可以独立地流动所以每一方向对方说“我没话说了”都要单独表达一次。A发FIN给B表示A的数据发完了B回ACK表示“我知道了”。但如果B还有数据要发给A它可以继续发直到B也没话说再发FIN给AA回ACK。这就凑成四个包。在实际抓包里你经常会看到第二次和第三次合在一起发也就是B回ACK之后马上跟着发FIN看起来跟三次挥手一样。但这跟网络状况、数据缓存有关不代表“挥手其实是三次”。内核里处理的时候B会先关闭发送方向但接收方向依然开着直到A也关闭。这里最值得讲的是TIME_WAIT状态。主动发起关闭的一方在收到对方FIN并回完ACK之后不会立刻释放连接而是进入TIME_WAIT等2MSL时长通常约1到4分钟Linux默认60秒左右。为什么要等这么久两个原因一是万一最后一个ACK丢了对方会超时重发FIN你这边还在等着回二是让网络中残留的旧数据包自然消亡不至于出现在新连接里。TIME_WAIT多了不是好事。高并发短连接的服务器上你执行netstat -ant | grep TIME_WAIT可能看到成千上万条。每条连接占用一个本地端口端口耗尽之后新连接就建不起来了。当然这不意味着你要粗暴地禁止TIME_WAIT后面我讲故障排查时详细说。3.3 滑动窗口、拥塞控制这些“看不见的机制”握手和挥手只是TCP的开始连接建立之后真正干活的是数据传输。TCP怎么保证不丢、不乱、不把对端压垮靠的是序号、确认号、滑动窗口和拥塞控制。先讲滑动窗口。发送方有一个窗口表示“我能一口气发多少字节而不必等确认”。这个窗口大小不是自己拍脑袋定的而是根据接收方通告的窗口值来动态调整的。你往一个慢速的网络设备狂发数据对方的接收缓冲区会满然后它会在ACK里把窗口通告为0发送方就必须停下来等窗口更新。这个机制保护了接收方不会因为处理不过来而崩溃。拥塞控制针对的则是网络本身的容量。慢启动、拥塞避免、快重传、快恢复这一堆术语本质上是在解决一个问题发送方不知道网络瓶颈在哪只能靠“试探”来逼近上限。慢启动阶段发送窗口从一个小值开始每收到一个ACK就翻倍呈指数增长很快就能探到接近网络的极限一旦出现丢包立刻减半进入拥塞避免模式还有重传定时器兜底。这些机制对应用层是透明的你写业务代码时根本感知不到它们的存在但它们在背后决定了你的接口延迟和吞吐量。我见过有人调HTTP接口慢以为是代码问题最后发现是TCP窗口和拥塞控制导致小包延迟这种问题不看抓包基本找不到方向。3.4 用Python写一个TCP回显服务搞懂TCP最直接的方式还是写代码。我用一个回显服务来演示所谓回显就是收到什么就返回什么简单但足以展示连接管理的全过程。# tcp_server.py import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 9999)) server.listen(5) print(TCP server listening on 127.0.0.1:9999) while True: conn, addr server.accept() print(fnew connection from {addr}) while True: data conn.recv(1024) if not data: # 对端关闭recv返回空字节 break print(freceived: {data.decode()}) conn.sendall(becho: data) conn.close()# tcp_client.py import socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((127.0.0.1, 9999)) for i in range(3): client.sendall(fhello tcp {i}.encode()) response client.recv(1024) print(fresponse: {response.decode()}) client.close()你运行这个程序时会发现TCP的使用方式跟UDP完全不同先connect建立连接然后sendall发数据recv读对端来的数据。更有意思的是如果客户端不发数据服务端的accept会一直阻塞等待新连接。这个“面向连接”的模型决定了你写代码时必须考虑连接的生命周期什么时候建连、什么时候断开、断开后资源怎么释放。代码里有个小细节我在服务端设置了SO_REUSEADDR。如果不开这个服务端程序杀掉之后立刻重启很可能会报“Address already in use”原因就是之前的连接还在TIME_WAIT状态占着端口。这个参数告诉内核“这种状态下也可以复用端口”是写服务端程序的基本功。4. 实操过程抓包验证TCP三次握手和UDP传输4.1 抓包工具的选择与基本用法纸上谈兵没意思抓包才是真正让协议“现原形”的方式。主流工具有Wireshark和tcpdumpWireshark有图形界面过滤规则直观适合初学tcpdump是命令行工具适合在服务器上远程抓包。我个人的习惯是本地调试用Wireshark线上环境用tcpdump抓包再拉回本地分析。tcpdump抓包的命令大概长这样sudo tcpdump -i any -w /tmp/capture.pcap host 127.0.0.1 and port 9999这个命令把回环接口上、目标端口9999的流量都记录到capture.pcap抓完按CtrlC停止然后用Wireshark打开这个文件慢慢分析。4.2 抓TCP握手包看状态迁移跑上面那个TCP回显服务在客户端connect的时候同时开抓包你会看到类似下面这样的过程第一包客户端 - 服务端SYN标志序列号如Seq0。这里的0是相对序列号Wireshark为了可读性把初始序列号显示成0实际是个随机数。第二包服务端 - 客户端SYNACKSeq0Ack1。Ack1表示“我期待你从序号1开始发数据”。第三包客户端 - 服务端ACKAck1。你要注意第二包里Seq0、Ack1第三包里Ack1但已经没有Seq变化承载数据后Seq才会递增。这是TCP里最容易绕晕的点一个包的ACK号表示“我希望你下一个包的序号是从我这里这个数字开始”跟包本身发到哪里是两回事。如果抓包时看到握手迟迟停在SYN_SENT多半是防火墙把SYN包丢了或者服务端根本没监听这个端口。这时候先用netstat -tlnp确认端口在听再检查防火墙规则不要一口咬定是“网络问题”。4.3 抓UDP包看极简风格抓UDP就简单了回到我前面写的UDP服务端起个抓包你会看到客户端发出的每个数据报都是一锤子买卖没有SYN、没有ACK、没有状态标识源端口和目的端口之间就是纯粹的“发和收”。有趣的是你甚至会看到Wireshark在UDP那栏下面显示“IP checksum”等信息但这只是底层IP协议在干活UDP本身不带任何可靠性机制。如果中间有个路由器把包丢了客户端和服务端谁都不会知道。你自己程序里看到的“没有超时重发”不是Bug是协议设计如此。4.4 实操中必会的三条排查命令抓包是细活但日常排障最常用的其实是几条命令。先看端口监听状态netstat -tlnp确认某个端口到底有没有进程在听。加了-n是不做DNS解析加快速度也避免误判。再看当前网络连接全貌ss -ant这个命令比netstat更快更现代一屏能看出ESTABLISHED、SYN_SENT、TIME_WAIT各占多少。如果TIME_WAIT特别多说明服务器上有大量短连接。最后是ping和telnet。ping测的是网络层的连通性Ping不通不代表TCP端口不通反过来也成立telnet ip port则是直接测试TCP端口是否可达如果能通了立刻Ctrl]退出。很多新手拿ping的结果判断“服务器挂了”这是个常见误区。这三条命令配合使用能覆盖80%的连接类故障场景。以后遇到“连不上”“时好时坏”“慢”这类问题先冷静下来跑一遍再决定要不要上抓包。5. 常见问题与排查技巧实录5.1 TCP粘包和拆包应用层该负的责任“TCP粘包”这个词估计每个写网络编程的人都听过。很多人以为这是TCP的Bug其实不是。TCP提供的是字节流它不关心你的业务消息边界。你调用sendall发了10个包对端recv可能一次就收到20个包的内容也可能来3次才收完10个。那怎么办两种常见方案一是固定长度每条消息都定长不足补位二是长度前缀包头用4字节表示后面的内容长度。最怕的就是在socket套接字上直接按“一次recv就是一个完整消息”来写逻辑线上数据一复杂立刻出现错乱。我在模拟项目X里就遇到过这个坑。客户端发的是JSON文本本机回环测试一切正常部署到服务器后偶尔报JSON解析失败。排查半天发现问题出在TCP流被拆分和合并上——服务端一次recv读到的内容是两条JSON拼在一起解析器当然爆炸。后来改成“读取长度前缀再读完整包体”的方案问题才彻底解决。5.2 UDP丢包为什么收不到数据UDP丢包的原因很多不一定是“网络不好”。最容易忽略的是接收缓冲区太小内核把来不及处理的包直接丢了。在Linux上可以执行sysctl net.core.rmem_max看系统接收缓冲区上限程序里自己在recvfrom之前的setsockopt可以调优。除了缓冲区还有一个隐患是多个进程绑定同一个UDP端口Linux默认的负载均衡策略可能导致某些包始终被同一个socket接收其他进程饿死。这涉及到SO_REUSEPORT选项但不太适合新手入门你要做的第一件事永远是确认“服务端有没有真正在收包”加日志就是最快的手段。5.3 网络超时与重传为什么TCP有时很慢TCP有超时重传机制如果确认没回来发送方会等一段时间再重发。这个时间不是固定的系统会根据往返时延动态调整。本地网络环境下握手几乎是瞬时的公网跨区域时你可能经常看到几百毫秒的延迟。如果TCP连接频繁超时常见原因有三个一个是防火墙丢包另一个是链路本身拥塞还一个是服务端线程池耗尽导致accept处理不过来。你抓包看有没有重传就能区分如果看到大量TCP Retransmission基本可以断定是链路质量问题优化代码没用得找网络设施的原因。5.4 实战排查表格我把上面几类问题整理成一张速查表方便你遇到问题的时候对照着来。现象可能原因优先排查动作connect超时端口未监听netstat -tlnp检查端口connect被拒防火墙拦截检查iptables/安全组规则频繁超时重传链路拥塞抓包看Retransmissionping测丢包率大量TIME_WAIT短连接过多复用连接、调整keep-aliveUDP收不到包缓冲区溢出调大rmem打印收包日志应用层消息错乱TCP粘包/拆包加长度前缀协议别小看这张表很多线上问题走一遍这个流程就能定位省去大量瞎猜的时间。6. 我的一些实际经验与建议踩过无数次坑之后我现在的习惯是选型之前先问三个问题。第一个业务能不能容忍偶尔丢数据能就优先考虑UDP比如日志上报、指标采集、广播搜索这类场景UDP的简单高效很占便宜。第二个业务对顺序和完整性有没有硬性要求有那TCP几乎是必须的比如文件传输、转账交易、消息队列的消费者。第三个如果非要UDP应用层能不能接受自己实现可靠性实在不行就上QUIC别硬造轮子。还有一件事我想提醒所有刚入门的朋友不要听风就是雨一看到“TCP可靠UDP不可靠”就觉得UDP一无是处。网络世界的需求千差万别VoIP、视频会议、游戏、DNS、DHCP、QUIC这么多核心服务都站在UDP的肩膀上它一点都不“Low”只是“不同”。如果你想在TCP和UDP的理解上再上一个台阶我建议接下来去做三件事第一抓一次你访问网站时的完整TLS握手包看TCP握手和TLS握手是怎么叠加的第二自己写一个用UDP传文件的程序体会一下什么叫做“应用层重传”第三读一下RFC 793和RFC 768原文没有想象中难读反而能纠正很多流行的二手误解。网络协议这东西只停留在概念层面是最亏的因为大量的知识藏在状态、时序和异常处理里。你能亲手抓到那个SYN包、看到切换状态、知道它下一步要干什么才算真正对这个协议有了体感。后面再去读更复杂的HTTP/3、QUIC、Multipath TCP心里就有底了。