1. 先把传输层这层“皮”剥开:TCP与UDP到底在解决什么问题
做网络开发这些年,我遇到最多的困惑不是应用层怎么调接口,而是很多人把TCP、UDP挂在嘴边,却说不清传输层在整条网络链路里到底干了一件什么事。传输层是TCP/IP协议栈里承上启下的那一层,往上承接HTTP、FTP、DNS这些应用协议,往下把数据丢给IP层去路由转发。没有这一层,你的浏览器不知道页面数据有没有收全,你的视频通话也不知道声音画面该按什么节奏播放。这篇文章想把TCP和UDP的核心原理讲透,并且把那些平时文档里不会写的实战细节一并整理出来,适合刚接触网络编程的学生、做嵌入式通信的工程师,以及后端开发中经常要排查连接问题的同学。
先说结论:TCP和UDP解决的是同一个问题——如何让数据从一台主机的某个程序,准确送到另一台主机的某个程序。但它们的实现哲学完全不同。TCP像打电话,要拨号、等待对方接听、确认双方都在线,然后一句一句说,说完还要确认对方听清楚了才挂断;UDP像发快递单,把包裹贴上地址扔进快递柜,寄出去就不管了,对方收没收到,快递有没有破损,全凭运气和上层应用自行处理。这两种思路没有绝对的高下之分,只看你的业务能不能接受丢包或延迟。
我在实际项目中见过不少选型错误,有人为了图省事全部用UDP,结果应用层得自己补一堆重传和排序逻辑,代码量比直接用TCP还大;也有人不管什么场景都TCP,实时音视频卡得没法看。所以开篇先把定位搞清楚,后面每个机制的细节才有着落。
1.1 传输层的角色:给数据包装上“收发地址”
如果你抓过包,会看到IP层负责的是从一台机器到另一台机器的路由,但一台机器上同时跑着浏览器、游戏、微信,IP层把数据包送到这台机器之后,该交给哪个程序?这就是端口号的作用。传输层的核心工作之一,就是通过源端口和目的端口,把数据精准投递给对应的进程。
TCP和UDP头部都包含源端口、目的端口这两个字段,各占16位,取值范围0到65535。其中0到1023是知名端口,比如HTTP用80、HTTPS用443、DNS用53,这些端口通常需要管理员权限才能绑定;1024到49151是注册端口,像MySQL的3306、Redis的6379都在这个区间;49152到65535是动态端口,客户端发起连接时,操作系统会从这段里自动挑一个作为源端口。
理解端口这个概念,很多问题就迎刃而解了。比如你在CentOS上部署服务,明明进程起来了、防火墙也关了,但外部就是连不上,十有八九是端口没监听对地方。用ss -lntp看一眼监听地址,是127.0.0.1还是0.0.0.0,差别非常大。前者只接受本机回环请求,后者才对外开放。这个坑我踩过不止一次,所以先放在前面提醒。
1.2 TCP和UDP的分水岭:一个打电话,一个发快递
选协议之前先搞明白“面向连接”和“无连接”的区别。TCP面向连接,通信前必须通过三次握手建立会话,通信过程中有确认、重传、排序、流量控制,结束后还要四次挥手释放连接。好处是可靠,坏处是开销大、有延迟。
UDP无连接,发送前不需要建立会话,直接把数据封装成数据报扔出去。好处是快、协议头小、支持广播和组播,坏处是不保证送达、不保证顺序、不保证不重复。
我见过一个很形象的类比:TCP是顺丰快递,有回执、有保险、丢了重发;UDP是平邮寄明信片,寄出去能不能到听天由命。不过这个类比有个不准确的地方,UDP并不是“不努力送达”,IP层本身会尽最大努力转发,只是传输层不负责纠错而已。所以在局域网这种网络质量很好的环境里,UDP完全够用;反而是跨公网传输时,丢包率上来了,UDP的短板才暴露出来。
判断该用哪个协议,我总结了一个简单的三步法:第一,数据能不能容忍丢失,不能容忍就TCP;第二,实时性要求是不是极高,高到宁可丢一帧也不愿意等重传,那就考虑UDP;第三,是否需要一对多通信,UDP天然支持组播和广播,TCP要实现类似效果得多花不少功夫。
2. TCP核心机制拆解:三次握手、四次挥手与可靠性保障
这一节是重头戏,面试考、工作用、排查问题绕不开。我尽量把每个机制背后的“为什么”讲清楚,而不是让你死记硬背流程。
2.1 三次握手为什么必须是三次,而不是两次或四次
三次握手的过程大家应该都背得出来:客户端发SYN,服务端回SYN+ACK,客户端再回ACK,然后连接建立。但为什么必须是三次?
核心原因是为了防止“历史重复连接请求”干扰正常的通信。考虑一种情况:客户端发送了一个SYN,因为网络拥塞迟迟没到达服务端,客户端超时后重发了一个新的SYN。如果第二次SYN先被服务端接收并建立连接,通信结束后双方关闭连接,这时候第一个迟到的SYN才到达服务端。如果只有两次握手,服务端收到这个迟到的SYN,会误以为客户端又要建立新连接,于是分配资源、返回SYN+ACK,但客户端根本不会理会这个确认,因为它压根没想再发连接,结果服务端的资源就被白白占用了。
三次握手之所以能解决这个问题,关键在于客户端收到服务端的SYN+ACK后,有能力判断这个确认对应的到底是哪一次SYN。如果客户端发现自己没有发起新的连接请求,就直接发RST把这个历史连接重置掉。而服务端只有在收到客户端的ACK或RST之后,才能确认这个连接是有效还是无效。
还有一个非常实际的原因:TCP需要初始化双方序列号。序列号是TCP可靠传输的基石,发送方用序列号标记每一字节数据的编号,接收方用确认号告诉发送方“我期望收到哪个序列号”。三次握手的过程正好让双方各自通告自己的初始序列号ISN,并确认对方的ISN已经收到。两次握手只能让一方知道另一方的初始序列号,对方却无法确认你收到了,后面可靠传输就无从谈起。
实际抓包时你会发现,TCP连接的建立速度比想象中快,局域网内通常在毫秒级完成。但如果你在弱网环境,看到大量SYN重传,就要检查是不是防火墙把SYN包丢了,或者服务端的半连接队列backlog满了。
2.2 四次挥手的细节与TIME_WAIT状态
四次挥手比三次握手复杂,因为TCP连接是双向的,每一方向都要单独关闭。客户端发送FIN表示“我没有数据要发了”,服务端回ACK表示“我收到了”,但服务端可能还有数据没发完,所以等服务端也发完数据后,再发一个FIN,客户端再回ACK,连接才算彻底关闭。
这里面有一个状态容易让人困惑:TIME_WAIT。主动关闭连接的一方在发出最后的ACK之后,会进入TIME_WAIT状态,默认等待2个MSL(Maximum Segment Lifetime,报文最长存活时间),在Linux上通常为60秒。很多人不理解,连接都关了,为什么还要等这么久?
原因有二。第一,防止最后一个ACK丢失。如果服务端没收到这个ACK,它会重发FIN,这时候客户端如果已经彻底关闭,就收不到这个重发的FIN,服务端会一直处于LAST_ACK状态。等2MSL,是给可能丢失的ACK一个重发的机会。第二,让本连接产生的所有报文在网络中自然消失。因为网络中可能存在延迟到达的重复数据包,如果客户端立刻关闭并用同一个四元组(源IP、源端口、目的IP、目的端口)建立新连接,旧连接的残留报文可能会污染新连接的数据。
TIME_WAIT太多是高性能服务端常见的困扰。用netstat -ant | grep TIME_WAIT | wc -l看一眼,如果数量上万,可能说明短连接过于频繁。优化思路有很多,比如开启net.ipv4.tcp_tw_reuse让内核在安全条件下复用TIME_WAIT连接,或者调整net.ipv4.tcp_fin_timeout缩短等待时间,但要注意这些参数不是万能的,改了之后得压测验证,否则可能引发连接异常。
2.3 可靠性保障:滑动窗口、确认重传与拥塞控制
TCP的可靠性不是一个单一机制完成的,而是多个机制协作。最容易理解的是确认与重传:发送方发出数据后,等待接收方回ACK;如果超过重传超时时间还没收到ACK,就重新发送这段数据。这个过程看似简单,但超时时间怎么定是个技术活。如果定得太小,网络稍微慢一点就疯狂重传,加剧拥塞;如果定得太大,丢包后要等很久才能发现,用户体验很差。
所以TCP引入了自适应重传算法,根据历史RTT(Round-Trip Time,往返时间)动态计算超时时间RTO。早期用简单的加权平均,后来演进到更精细的算法。内核里都可以通过sysctl查看和调整相关参数,比如net.ipv4.tcp_rto_min、net.ipv4.tcp_rto_max。
滑动窗口解决的是流量控制问题。接收方在ACK里带上自己还能接收的字节数,这个数值就叫窗口大小。发送方不能一口气把数据全发出去,必须保证“已发送但未确认的字节数”不超过对方的接收窗口。这个机制避免了发送方太快把接收方缓冲区塞满。如果你看Wireshark的Timeline,能看到窗口大小的变化曲线,当接收方处理不过来时,窗口会逐渐缩小,甚至窗口为0,这时候发送方就得停下来等待窗口更新。
拥塞控制解决的是“不要压垮网络”的问题。经典的四个阶段大家应该都听过:慢启动、拥塞避免、快速重传、快速恢复。慢启动时拥塞窗口从初始值指数增长,到慢启动阈值后转为线性增长,出现丢包时认为网络拥塞,阈值减半,窗口回退,再重新开始。这些机制保证了TCP在网络不稳定时能自己适应,不至于因为发送太快导致整个网络雪崩。
不过要泼一盆冷水,在特定场景下,特别是跨运营商网络或者弱网环境,TCP的拥塞控制算法可能过于保守,导致吞吐量上不去。这也是为什么很多新协议(比如QUIC,基于UDP)会尝试在应用层自带更激进的拥塞控制算法。
2.4 粘包/拆包问题:TCP“流”特性带来的经典坑
这个坑几乎每个写过TCP通信的人都会踩。TCP是流协议,它只管把字节流从一个端传到另一个端,并不关心你发送的是不是完整的一条“消息”。发送方调一次send()写进去的数据,接收方可能要调好几次recv()才能读完;反过来,发送方连续调好几次send()写入的内容,接收方可能一次recv()就全读出来了。这种现象就叫粘包或拆包。
很多新手第一反应是“那我在send和recv之间加个sleep不就好了”,这是典型的治标不治本。sleep改了时机,但TCP的分段行为由内核和网络状况决定,不能用业务层的延时去猜。正确做法是给每个消息定义一个边界,常见方案有三种。
最基础的是定长协议,每条消息固定N字节,不够就补零,收满N字节就解析一条。实现简单,但空间浪费大,不适合长度变化大的业务。第二种是分隔符协议,消息末尾加特定字符如\r\n或\0,接收方读到分隔符就认为一条消息结束了。HTTP的头部就是这种思路。第三种最常用,也是我推荐在生产环境用的:消息头+消息体结构,头部固定几个字节,前4字节存总长度或者消息体长度,接收方先读够头部,解出长度,再按长度读取消息体。
用C#写TCP客户端时尤其要注意这个问题。NetworkStream.Read()并不保证一次能读到你期望的字节数,必须循环读,直到累计字节数满足条件。我见过有人在Read()外面套一层while不做长度判断,结果数据一多就错位,解析出来的全是乱码。
3. UDP实战解析:轻量、无连接但绝非“低级”
很多教材把UDP描述成“不可靠的传输协议”,导致新手对它有一种偏见,觉得用UDP就是低级、不专业。实际上,UDP在实时音视频、在线游戏、物联网、组播通信这些领域是绝对的主力。理解UDP的设计哲学和应用边界,比单纯会背UDP和TCP的区别有价值得多。
3.1 UDP头部与无连接特性
UDP头部只有8个字节:源端口(2字节)、目的端口(2字节)、长度(2字节)、校验和(2字节)。对比TCP最少20字节的头部,UDP省了不少开销。这里的“长度”字段很关键,它指UDP数据报的总长度,包括头部和数据。因为IP层会把UDP数据报完整地递给上层,所以接收方不需要像TCP那样处理粘包,每个recvfrom()调用拿到的就是发送方一次sendto()发出去的一整条数据报。
UDP的无连接特性体现在API使用上。TCP通信要先connect()建立连接,而UDP的sendto()每次都要指定目标地址,接收端则用recvfrom()带出源地址信息。有意思的是,UDP其实也支持connect(),但这里的connect和TCP的connect完全不同,它不会真正建立连接,只是在内核里把目标地址固定下来,后续可以简化为send()/recv(),同时内核可以过滤掉非目标地址发来的包,减少系统开销。
还有一个经常被忽视的地方:UDP校验和。IPv4下UDP校验和是可选的,如果源端关闭了校验和,接收端可能无法检测数据在传输过程中是否被破坏。IPv6则强制要求必须计算校验和。对于关键业务,建议在应用层再加一层校验,比如每个数据报带一个CRC或者消息摘要,防止数据静默损坏。
3.2 UDP适合哪些场景:实时音视频、组播、IoT
先讲实时音视频。WebRTC底层用的就是UDP,因为视频通话这种场景,偶尔丢掉一帧画面,比等一帧数据重传导致后续所有帧都卡住要好得多。用户感知上,短暂的马赛克远没有长时间的延迟卡顿那么让人崩溃。语音同理,中断100毫秒和延迟300毫秒,前者还能忍,后者基本就是灾难。
组播是UDP的一个独门绝技。TCP是点对点的连接,要实现一对多分发必须由发送方维护N个连接,资源消耗很大。UDP支持组播组,发送方只要往组播地址发一份数据,网络中的路由器会负责复制转发给组内所有成员。这在局域网视频分发、股票行情推送、设备发现等场景非常实用。不过要注意,组播在跨网段时需要路由器支持IGMP协议,很多云服务器默认不支持,所以公网组播基本不可行,只能局限在可控的局域网内。
IoT物联网场景选UDP也很多,尤其是功耗敏感的传感器设备。比如低功耗设备通常用CoAP协议,CoAP就是基于UDP的请求/响应协议,一次通信只交换两个报文,关闭连接免了握手的开销,设备可以快速进入休眠省电。我自己在嵌入式设备上跑过类似方案,用ESP8266通过UDP定时上报传感器数据,实测整体功耗比用TCP低一个量级。
3.3 调试上手:两台电脑用网络调试助手跑通UDP通信
很多初学者在开发环境里不知道怎么验证自己的UDP代码,其实最简单的方法是用网络调试助手这类工具,快速完成两端的联调。
准备两台电脑,假设A和B在同一个局域网。先在A上打开网络调试助手,协议类型选UDP,本地IP填A的IP地址(比如192.168.1.100),本地端口填A要监听的端口(比如8000),点击“连接”按钮进入监听状态。然后在B上同样打开网络调试助手,协议选UDP,本地端口空着或者填一个B自己的端口,然后在“目标IP”和“目标端口”里填A的IP和8000。B发送一条消息,A那边应该立刻就能收到。
验证完单向通信,再验证双向。A回复消息时,目标IP填B的IP,端口填B的端口,B就能收到。这个过程看着简单,但它能帮你确认几件事:两台机器网络是否互通,防火墙有没有拦截UDP流量,你写的UDP绑定逻辑是否正确。如果B发消息A收不到,先ping一下A的IP确认网络通不通;如果ping通但UDP不通,重点查防火墙,很多系统默认防火墙会拦截未经允许的UDP端口。
UDP有个特点让调试很痛苦:包丢了没有任何提示。所以调试时一定要做双向验证,并且最好在抓包工具里看数据是否真的发到了线上。我在Windows上用网络调试助手,在Linux上更多用nc -u命令,一条命令就能起一个UDP收发端点,比图形界面还方便。
3.4 UDP的“应用层可靠性”补课
既然UDP不保证可靠,那业务层就得自己想办法。常见做法是应用层实现ACK机制:每条消息带一个自增序号,接收方收到后回一个ACK,发送方超时未收到ACK就重发,接收方根据序号处理乱序和去重。这套机制基本就是TCP可靠传输的简化版,但是放到应用层之后,优势在于你可以完全控制重传策略,比如游戏里只重传关键状态,不重传高频位置更新。
KCP是这方面非常典型的开源项目,一个基于UDP的可靠传输库,实现了快速重传、选择性确认等机制,比TCP在高延迟、高丢包网络上表现更好。很多游戏公司的通信层都在用KCP。如果不想自己造轮子,直接集成这类成熟库是性价比很高的选择。
不过要提醒一点,应用层做可靠性,复杂度并不低。序号管理、超时重传、滑动窗口、拥塞控制,这些你在TCP里躲掉的坑,全部要在应用层重踩一遍。所以做技术选型时,先问自己一个问题:你的业务对延迟的敏感度,真的高到无法接受TCP的开销吗?如果不是,老老实实用TCP,省下来的时间可以做更多有价值的事。
4. 工程实践中的协议栈问题:抓包、打流、端口与防火墙
这一节全是干活时用得上的东西。理论归理论,上了生产环境,你会遇到各种莫名其妙的现象,这里把最常见的问题整理出来。
4.1 Wireshark筛选UDP时间间隔与TCP状态分析
Wireshark是排查网络问题最趁手的工具,但很多人只会打开看一眼,不知道怎么高效筛选。先说一个常见需求:如何筛选出UDP前后两包的时间间隔。
Wireshark本身没有一个直接的“时间间隔”筛选字段,但可以用显示过滤器的计算功能。在过滤栏输入udp && frame.time_delta是不合法的,因为frame.time_delta不是过滤字段,只能在列里显示。正确做法是先把frame.time_delta加为一列:右键任意包,选择Column Preferences,添加新列,Field填frame.time_delta。这样就能看到每个包和上一个包之间的时间差。
如果要筛选出时间间隔大于某值(比如大于1秒)的包,Wireshark在新版本里支持了一些计算字段,但不是所有版本都支持直接对列做比较筛选。更通用的一种做法是导出CSV,用脚本分析每包的时间戳差。我写过一个简单的思路:tshark -r capture.pcap -Y "udp" -T fields -e frame.time_epoch -e udp.srcport -e udp.dstport导出时间和端口,再用awk或Python算前后差值。对于需要精确定位的抖动问题,这个办法比肉眼盯着看高效得多。
TCP状态分析是另一个高频需求。用tcp.flags.syn==1能筛出所有SYN包,tcp.flags.fin==1筛出FIN包。看三次握手是否成功,关注是否有tcp.analysis.retransmission标记,这个标记说明有包发生了重传,如果大量出现,基本可以断定网络有丢包或者延时抖动。tcp.analysis.zero_window则提示接收方窗口为零,说明对方处理不过来,是性能瓶颈的信号之一。
抓包有个关键技巧:一定在两端都抓。只在一侧抓,看到的现象容易误导你判断问题在哪一段链路。比如客户端抓包看到SYN发出去了但没收到回应,可能是服务端根本没收到,也可能是服务端回了但路由丢了,还可能是服务端回了但被客户端防火墙挡了。两端对比抓包,一眼就能看出数据是在哪一段断的。
4.2 iPerf3跑TCP/UDP性能测试的正确姿势
iperf3是打流测带宽的第一选择,好用但容易用错。最常见的错误是不加参数直接跑,测出来的数据只能证明TCP能工作,无法反映真实性能。
TCP测速的标准姿势:服务端启动iperf3 -s,客户端执行iperf3 -c <服务端IP> -t 60 -P 4。-t指定持续时间,建议至少30秒,太短受慢启动影响测不准;-P 4用4个并发流,模拟真实应用的并发连接。实测下来,单流和多流差距可能很大,尤其在跨公网场景,单流吞吐量往往不如多流,这和TCP拥塞控制的公平性问题有关。
UDP打流的参数更讲究。常见误区是直接跑默认参数,因为iperf3默认UDP带宽只有1Mbps,你看到的吞吐量其实是被限速了。正确的做法是用-b显式指定带宽,比如iperf3 -c <服务端IP> -u -b 100M -t 60,意思是压100Mbps的UDP流。测完终端会报告实际接收带宽和丢包率。这里有个关键点:发送端报告的吞吐量和接收端报告的往往不一样,接收端显示的才是真正到达对端的速率,两个差值就是丢弃的流量。
还有几个参数值得一提。-R做反向测试,测下行带宽;--pkt-size调整UDP包大小,默认1470字节可以避开IP分片,如果你想测大包对小包的性能差异,改成9000字节的巨型帧试试,局域网内差距非常明显。-i 1让iperf3每秒打一个报告,用来观察带宽的波动曲线,对排查间歇性拥塞很有用。
4.3 端口冲突、防火墙配置与常见连接错误
端口冲突是最常见的一个坑,错误提示通常是bind: only one usage of each socket address。这个错误的中文意思是,某个socket地址只能被绑定一次,你尝试绑定的IP加端口组合已经被占用了。我用Windows开发时遇到过,代码里端口写死8080,跑起来直接崩,用netstat -ano | findstr 8080查一下,果然有别的进程在监听。解决办法很简单,换端口或者把那个进程处理掉。但如果代码里端口是动态分配的,冲突概率会小很多,这也是为什么服务端程序经常支持通过配置文件指定端口,而不是写死。
Linux上查看端口占用用ss -lntp,比netstat信息更全,直接显示PID和进程名。如果显示的进程名是-,说明当前用户没权限查看,加sudo即可。
防火墙配置是CentOS用户绕不开的坎。CentOS 7以后用的是firewalld,不再推荐用iptables命令行直接管理。开放一个端口的命令是firewall-cmd --permanent --add-port=8080/tcp,然后firewall-cmd --reload生效。注意--permanent决定了规则是否写入永久配置,不加的话重启防火墙后就丢了。查端口是否放行用firewall-cmd --query-port=8080/tcp。
还有个我见过很多次的错误:服务已经监听在0.0.0.0了,本机能连,外部连不上,最后发现是云服务商的安全组没放行端口。云服务器和本地环境不一样,云平台的安全组规则是独立于系统防火墙的,两边都得配好才行。排查网络问题时,先看云控制台的安全组,再看系统防火墙,别一上来就怀疑程序代码。
4.4 Socket编程要点与C# TCP客户端实战
写TCP/UDP程序时,Socket API的细节决定了程序能不能扛住生产环境。先讲TCP服务端的标准骨架:socket()创建套接字,bind()绑定地址和端口,listen()开始监听,然后进入accept()循环。这里有个设计要点,accept()返回的客户端连接要交给独立线程或异步任务处理,否则第一个客户端占住循环,后面来的客户端全部排队等待。我在C#里一般用TcpListener加异步AcceptTcpClientAsync(),配合Task处理每次接入的连接,代码干净且不容易阻塞。
C#的TcpClient封装了底层Socket,对很多开发者来说是主力工具。一个基础的TCP客户端类,核心步骤是:构造时传入服务端IP和端口,Connect()连接,获取NetworkStream,然后循环读写。注意读数据时一定要用循环,不能指望一次Read()拿到完整消息,要声明一个缓冲区,循环读到0说明对端关闭了连接,读到空说明暂时没有数据,但连接还活着。
TCP的另一个大坑是接收缓冲区大小。默认缓冲区在Windows上通常是8192字节,在Linux上可以更大。如果你要传输大文件,缓冲区太小会导致频繁的Read调用,性能很差。解决方案是TcpClient.ReceiveBufferSize调大,或者干脆用自定义协议把数据分块传输,每块控制在合理大小。
UDP编程相对简单:UdpClient直接绑定端口,Receive()返回一个UdpClient,注意它返回的是数据报的内容,没有流的概念。Send()发送时指定目标IP和端口即可。UDP的Receive和Send天然是一对一的,不会出现TCP那种半包问题,但也正因为如此,UDP应用必须自己处理消息完整性的校验,别指望内核帮你拼包。
5. 常见故障排查速查表(附独家避坑心得)
做了这么多年网络相关的开发,我把高频问题归类整理成了一张速查表,你在实际工作中遇到类似现象,直接对照查,可以省不少排查时间。
5.1 高频问题一表搞定
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 本机连服务正常,外部连不上 | 服务监听在127.0.0.1而非0.0.0.0 | 修改监听地址为0.0.0.0,重启服务 |
| 本机连服务正常,外部连不上(云服务器) | 云安全组未放行端口 | 登录云控制台,添加入方向规则开放对应端口 |
| 端口冲突,bind报错 | 目标端口已被其他进程占用 | netstat -ano或ss -lntp查占用进程,换端口或杀进程 |
| TCP连接超时,抓包无SYN-ACK回包 | 防火墙丢弃了SYN包,或服务端对端不可达 | 检查防火墙规则,确认目标IP端口可达性 |
| 大量TIME_WAIT | 短连接频繁建立关闭 | 开启tcp_tw_reuse,或改用长连接复用 |
| 传输速度上不去,抓包有重传 | 网络丢包率高,或拥塞窗口受限 | 检查链路丢包率,尝试多流并发或调整TCP参数 |
| UDP收不到数据 | 防火墙拦截UDP端口,或源端没绑定目标地址 | 先ping通,再用nc -u双向测试,检查防火墙 |
| Wireshark看不到预期的包 | 抓包过滤条件写错,或接口选错 | 确认抓包接口为实际通信网卡,简化过滤条件 |
| iperf3 UDP只有1Mbps | 忘了用-b指定带宽 | 加-b 100M等参数,重新打流 |
| 客户端connect报10061(Windows) | 目标端口没有进程在监听 | 确认服务进程已启动,且监听地址和端口无误 |
| 服务端Read一直收不到数据的末尾 | TCP流没有消息边界,对端没关连接 | 应用层定义协议长度,或使用消息终止符 |
这张表不可能覆盖所有问题,但大部分我遇到的排查场景都在这张表的框架内。如果你遇到的是表外问题,我的建议是先抓包,再定位,不要靠猜。抓包能看到数据包在哪一段丢失和延迟,比任何日志都直接。
5.2 最后再分享几条实战心得
第一,写网络程序时,日志一定要带上四元组信息(源IP、源端口、目的IP、目的端口)。出了问题和别人协作排查时,没有四元组日志,你连问题发生在哪条连接上都说不清楚。这个习惯帮我节省了大量排查时间。
第二,测试网络通信时,不要在同一台机器上起客户端和服务端测试,因为回环网络和真实网络的特性差异很大。我第一次写TCP长连接程序时,本地测试一切正常,部署到跨机房环境就频繁断连,折腾了半天才发现是网络中间设备空闲超时把空闲连接掐了。重要提示:跨网络的长连接一定要有心跳机制,否则中间节点静默丢弃连接,你完全察觉不到。
第三,UDP调试时,如果一个包丢了让你怀疑人生,先确认对方有没有在监听正确的端口。UDP的ICMP端口不可达消息往往会被防火墙吞掉,导致发送方完全无感知。用Wireshark抓包时如果看到ICMP Destination Unreachable,就是端口没监听。
第四,不要轻易修改TCP内核参数。Linux默认的TCP栈经过大量调优,适合绝大多数场景。我见过有人把tcp_max_syn_backlog调得很大,结果SYN洪水攻击时直接把服务打垮;也见过盲目开启tcp_tw_reuse导致连接复用时序号错乱的问题。修改内核参数前,一定要理解每个参数的机制和风险,最好在测试环境验证。
第五,多看协议本身的RFC和内核实现,少看二手博客。TCP/IP详解卷一是经典,但比较老了;新一点的可以看《TCP/IP Illustrated Volume 1》的第三版,结合现代网络环境更新了很多内容。UDP和TCP的设计哲学,吃透一遍能少走很多弯路。
如果你正在做一个网络相关的新项目,我建议搭建环境的时候就把抓包工具、打流工具和端口排查命令都提前准备好。网络问题排查的黄金法则就一句话:先确认数据包在哪一段、哪个方向丢了,再问为什么丢。抓包前别猜,抓包后别急,逐跳分析,问题总能查得出来。