1. 为什么说学网络协议,TCP和UDP是绕不开的两扇门
先讲个我自己的经历。大二那年第一次翻开谢希仁的教材,看到TCP和UDP那一章,我以为是两个并列的协议,背一背区别就能过关。结果期末考试第一道大题就让画TCP三次握手的状态迁移图,第二道题直接给了一个UDP报文让拆字段。那会儿我才意识到:TCP和UDP不只是两个协议名字,它们是理解整个IP网络如何为应用提供服务的两把钥匙。
后来工作了,用iperf3给服务器做压力测试、用Wireshark抓包排查线上延迟,再回头看大学教材,才发现当年死记硬背的很多东西,其实在真实环境下全都能一一对照上。这也是为什么我现在逢人就建议:别急着背面试八股,先把TCP和UDP的底层逻辑真正吃透,后面的三次握手、四次挥手、滑动窗口、拥塞控制,全都是顺理成章的事。
这篇文章不打算做成教科书复读机。我会从拆报文的角度出发,讲清楚TCP和UDP各自的设计哲学,然后结合iperf3打流、抓包分析、编程实战这些具体场景,把协议栈里那些一知半解的概念掰开揉碎。无论你是刚学计算机网络的学生,还是准备面试的求职者,又或者已经上手socket编程但老出问题的开发者,这篇文章应该都能帮你把碎片补成整图。
有两件事先说明。第一,我会用大量的生活类比来讲原理,但类比只是拐杖,真正的落脚点还是协议字段和状态机本身。第二,全文会穿插一些实操向的内容,比如用iperf3测UDP丢包、用tcpdump看TCP重传,这些命令你最好亲手跑一遍——看书十遍不如抓包一次。
2. UDP的报文结构拆解——四平八稳的"快件明信片"
2.1 八个字节能说明什么
很多初学者看到UDP报文头只有8个字节,第一反应是"这也太简陋了吧"。但恰恰是这种简陋,成就了UDP的不可替代性。我们先看这8个字节是怎么组成的。
UDP报文头的固定部分是四个字段,每个字段占2个字节:
| 字段 | 字节数 | 作用 |
|---|---|---|
| 源端口 | 2 | 标识发送方的应用程序 |
| 目的端口 | 2 | 标识接收方的应用程序 |
| 长度 | 2 | 整个UDP报文(头部+数据)的字节数 |
| 校验和 | 2 | 检测传输过程中是否出现比特错误 |
这个结构简单到什么程度呢?它甚至连"这个报文属于哪条连接"这样的信息都没有——因为UDP压根不维护连接状态。每个UDP报文都是一个独立的快递包裹,寄出去之后,发件人不会收到签收回执,也不关心包裹中途是否被拆开或者丢掉。
我用明信片来类比UDP再合适不过。你写好地址贴上邮票丢进邮筒,后面的事情就跟你无关了。明信片的好处是什么?寄起来快、成本低、不用等回执。坏处是什么?可能丢、可能乱序、内容被别人看了你也不知道。这就是UDP的全部哲学:能发就发,发完拉倒。
2.2 校验和计算里藏着的一个细节
UDP校验和的计算方式经常被教材一笔带过,但它其实埋了一个特别容易在考试和面试里被挖的坑。计算校验和之前,发送方会在UDP报文前面加一个"伪头部",这个伪头部包含源IP地址、目的IP地址、协议号(UDP是17)和UDP长度。
伪头部的存在,意味着UDP的校验和不仅能检查UDP报文本身有没有比特翻转,还能检查IP地址在传输过程中有没有被篡改。这就是"伪"字的意思——它不是UDP报文的真实组成部分,只参与计算,不参与传输。
我见过不少同学在实现UDP协议栈的时候,因为漏掉伪头部,导致本机发送、本机接收一切正常,一旦跨主机通信就疯狂丢包。排查半天,最后发现是校验和永远算不对,按接收方的视角来看,每个报文都是坏的,直接丢弃。这个坑我在后面讲协议栈实现的时候还会再提一次,因为它真的很容易被低估。
2.3 长度字段为什么明明能算出来还要显式携带
有人会问:UDP长度字段不就摆在IP层已经知道了吗?IP报文头里已经包含了整个IP数据报的长度,而IP头部长度也是已知的,拿总长度减去IP头部长度,不就得到UDP报文长度了吗?何必再传一遍?
答案是:为了让UDP成为一个"自包含"的协议。IP层可能分片,可能填充,UDP层并不完全信任IP层给自己转述的结果。长度字段的存在让UDP接收方可以直接从报文里获知"这段数据有多少字节是属于我的",哪怕IP层做了某些加工,UDP仍然能够正确解析自己的边界。
这种"每层都把自己那点事管清楚"的设计哲学,在TCP里体现得更极致,后面讲到TCP头部时你能看到,TCP的头部字段多得让人头皮发麻。
3. TCP头部的核心字段——从一次抓包开始讲
3.1 一个真实TCP报文里到底藏了什么
如果你用Wireshark抓一个HTTP请求的包,点开TCP段那一层,会看到一长串字段。初学者看到源端口、目的端口、序列号、确认号、数据偏移、保留位、标志位、窗口大小、校验和、紧急指针,再加上一堆可选项,瞬间就懵了。
我当时自己学的时候,用了最笨也最有效的一种方法:只关注六个标志位,即URG、ACK、PSH、RST、SYN、FIN。因为三次握手、四次挥手、连接重置、数据推送,全部靠这六个比特位表达。理解TCP,本质上是理解这套标志位如何组合出完整的连接生命周期。
3.2 序列号和确认号:漏掉任何一点都会绕晕
序列号和确认号是TCP最核心、也最容易让人绕晕的两个字段。我先抛一个结论:TCP传输的不是离散的数据包,而是一个字节流。
什么叫字节流?想象你有一根无限长的水管,发送方把数据一个字节一个字节地灌进去,TCP负责把这个水管里的水切成一桶一桶地运到对端,然后对端按顺序把桶里的水倒进自己的水管。序列号标记的是我这个桶里装的水,是整根水管从第几个字节开始的;确认号表示的是我期望对端下一个桶从第几个字节开始装。
举个具体例子。发送方要发一串"HELLO WORLD",第一个字节是H。那么这个TCP段的序列号可能是1000(不是0,因为初始序列号是一个随机值),数据区装上"HELLO",共5个字节。对端收到后,回复ACK,确认号是1005,表示"我已经正确收到序列号1000到1004这5个字节,你下一个段从1005开始发给我"。
网上很多文章喜欢画三次握手的图,把SYN和ACK两个标志位画得明明白白,但从不解释为什么第二次握手要同时带SYN和ACK。其实道理就在字节流里:第一次握手,客户端发SYN,携带的序列号假设是x;第二次握手,服务端回SYN+ACK,它的SYN表示"我也要建立这条连接,我的初始序列号是y",ACK则表示"我收到了你的x,你的下一个字节该从x+1编号了"。两个信息拼在一个包里,节约一次RTT,这就是TCP的效率。
3.3 窗口大小与滑动窗口:TCP的"手头余粮"思想
TCP头部里有一个2字节的窗口大小字段,最大值65535。这个字段表示的是:接收方当前还有多大的缓冲区能收数据。发送方必须盯着这个数字发数据,不能盲目地把水管里的水全倒给对端。
滑动窗口机制用一句话概括:发送方维护一个可发送窗口,窗口大小取"接收方通告的窗口"和"本地拥塞窗口"中的较小值。接收方的窗口解决的是"对端处理不过来"的问题,拥塞窗口解决的是"整条网络链路可能堵车"的问题。两个窗口是两把尺子,一个量对方,一个量路况。
我在实际排查慢速传输问题时见过太多次这样的情况:延迟很高,带宽很大,CPU占用很低,所有指标看起来都没问题,但传输速率上不去。最后抓包发现接收方通告的窗口值一直在减小,原因是接收方的应用层处理速度跟不上,缓冲区被占满。这时候要解决的压根不是网络问题,而是应用层的消费速度。
3.4 十三个字节的保留区和可选项——别急着跳过
TCP头部除了固定20字节,后面还有可选项区域。最常见的可选项是MSS(最大报文段长度)、时间戳、窗口缩放因子、选择性确认SACK。很多教材把这部分当扩展内容略过,但我想说,这几个可选项恰恰是TCP性能优化的主战场。
举一个例子:窗口缩放因子。前面我说窗口大小字段最大65535,如果不用缩放因子,TCP的单条连接在带宽很高的链路上就达不到理想的吞吐量。想象一下,往返时延是50毫秒,窗口只有64KB,那发送方每秒最多也就传输1.28MB左右的流量,换成百兆网络都不够塞牙缝的。窗口缩放因子扩展让窗口可以按2的N次方缩放,才撑起了今天万兆网卡上的TCP吞吐。
还有个SACK选项,专门处理多个包丢失的恢复。没有SACK的时候,发送方只能通过"累积确认"猜测对端丢了哪几个包,一旦中间有包丢了,可能要从丢包前的位置开始全部重传,效率极低。有了SACK,接收方可以明确告诉发送方:"我这边的数据空洞在哪个区间",发送方只需要精确补发空缺段即可。这个机制在互联网高丢包环境下,是TCP保命的底牌。
4. 三次握手与四次挥手——从状态机角度彻底搞懂
4.1 为什么一定是三次握手,不能是两次
TCP三次握手的流程,几乎每个学网络的人都能背出来:SYN -> SYN+ACK -> ACK。但"为什么不能是两次",很多人其实没有真正想明白。
核心原因有两条。第一条是防止"历史连接"的干扰。考虑这样一个场景:客户端先发起了一个连接请求A,因为网络阻塞,迟迟没到;客户端超时后发起了一个新连接请求B。如果只是两次握手,那么当A最终到达服务端时,服务端会直接同意并建立连接,但客户端已经不要这条旧连接了,这就会造成服务端白白维护一个废连接。三次握手让客户端有机会在第三步确认"这是我当前发起的那条连接",一旦发现序列号对不上,可以立刻发RST把这条历史连接清掉。
第二条原因是同步双方的初始序列号。握手阶段的核心目的之一是交流ISN(初始序列号),这个序号一旦不一致、不互通,后续的字节流确认就是空中楼阁。三次握手确保双方都确认了彼此的初始序列号,谁也不会误解数据编号。
这两年考研408的题目里,我注意到特别喜欢考查这样一个变体:客户端发SYN后,如果服务端回的是RST,客户端应该如何处理。背后的考点恰恰是"防止历史连接"这一层思想——如果客户端收到一个跟自己的SYN不匹配的SYN+ACK,就直接发RST,拒绝这个连接。
4.2 四次挥手的本质是"双方各自断开"
四次挥手的流程是FIN -> ACK -> FIN -> ACK,很多人背完就忘。我换个角度给你讲:TCP连接是全双工的,数据可以双向独立传输。因此,关连接这件事也必须双向独立。
第一次挥手,主动关闭方说"我不再发数据了",发FIN。第二次挥手,被动关闭方回一个ACK,表示"我收到你不再发数据的通知了"。但此时被动方自己还有可能要继续发数据,所以它不能立即发FIN,要等自己的数据发完。等它把剩余数据发完,再发FIN,这就是第三次挥手。主动关闭方收到FIN后,回ACK,这就是第四次挥手。
这里有一个容易踩坑的状态:TIME_WAIT。主动关闭方发送最后一次ACK之后,并不会立刻进入CLOSED状态,而是要等待2MSL(两倍报文最大生存时间)。为什么等这么久?一是确保最后这个ACK能到达对端——如果这个ACK丢了,对端会重发FIN,如果主动方已经关闭了,重发的FIN就没人应答,对端就会一直卡在LAST_ACK状态。二是让这条连接上所有"迟到的、乱序的报文"在网络中自然消亡,避免污染下一次使用相同四元组的新连接。
4.3 连接建立与关闭中最常见的两个实测问题
实际做服务端开发的时候,我碰到最多的两个问题,都跟握手挥手有关。
第一个是服务端出现大量TIME_WAIT状态的连接。原因通常是短连接服务中,服务端主动关闭连接,每个关闭都要经历TIME_WAIT。解决思路有三个方向:一是让客户端主动关连接,把TIME_WAIT甩给客户端;二是开启SO_REUSEADDR,允许服务端在TIME_WAIT状态下复用端口;三是升级到长连接,减少连接建立和关闭的频率。我在Java后端项目里用过Netty做长连接压测,TIME_WAIT问题的处理直接决定了能支撑多少并发连接。
第二个是客户端连不上的时候,第一时间要看是不是服务端卡在SYN_RCVD状态。SYN_RCVD是半连接状态,服务端发了SYN+ACK,但没收到客户端的最终ACK。如果服务端有很多SYN_RCVD,通常意味着客户端收到SYN+ACK后"失踪"了。原因可能是客户端防火墙拦截了那个SYN+ACK,也可能是服务端开启了SYN Cookie但客户端不支持对应的TCP选项。排查时用netstat -antp看状态分布,一般能快速定位问题出在哪一侧。
5. 可靠性机制的"深水区":重传、拥塞控制与保活
5.1 超时重传与快速重传:别让包白白走冤枉路
TCP的可靠性基石之一是重传。发送方发出去一个段,会启动一个计时器,如果超时还没收到这个段的ACK,就重新发送。这个超时时间(RTO)不能是固定的,因为网络延迟一直在变。TCP用指数加权移动平均来估算RTT,再结合RTT的方差计算出RTO。用大白话说,就是"我根据历史上每次往返时间,估算下一次往返大概多久,再留一些富余量做超时判断"。
快速重传则是另一个机智的设计。如果发送方连续收到三个重复的ACK,就认为某个段丢了,立刻重传,不用等超时。为什么三个ACK能说明问题?因为接收方每收到一个乱序段,都会重复ACK自己最后一个有序接收的段。连续三个重复ACK,说明排在那个段后面的数据已经陆续到了,那个段很可能是真丢了,而不是延迟。
5.2 拥塞控制的四个阶段,用一条山路来理解
拥塞控制是TCP里最考验理解的机制,也是面试官最爱追问的部分。我用一条山路的比喻来帮你建立直觉。
设想你开着一辆货车在山路上送货,路况未知。你一开始不敢开太快,先以1个单位的速率试试水,确认道路通畅后,每过一个往返周期就把速率翻倍,这叫做慢启动——名字虽然叫"慢"启动,但增长速度其实是指数级的。速率涨到一个阈值(ssthresh)附近时,你就不再翻倍了,而是每次只增加一点,这叫拥塞避免。如果你在半路遇到堵车(触发丢包重传),就立刻把速率掉下来,轻则减半(快速恢复),重则直接从1重新开始(超时重传)。
为什么要有慢启动和拥塞避免两阶段?因为慢启动负责在连接刚建立、完全不了解路况的时候快速试探;拥塞避免则负责在接近道路容量上限时"细嚼慢咽"。真正的高手还会关注一个指标:带宽延迟积。当吞吐量接近带宽延迟积时,继续盲目涨窗口不仅没有收益,反而增加排队延迟和丢包风险。
5.3 tcpdump实测:如何抓到一个真实的超时重传
纸上谈兵没意思,我建议你亲手做一次重传观测实验。原理很简单,用tc命令人为给网卡加上丢包和延迟,再用tcpdump抓包看TCP重传标志。
先模拟5%的丢包率和200ms延迟:
sudo tc qdisc add dev eth0 root netem loss 5% delay 200ms然后在两个终端分别起一个服务端和客户端,用iperf3打流:
iperf3 -s iperf3 -c 127.0.0.1 -u -b 10M -t 10同时开Wireshark或者tcpdump抓包:
sudo tcpdump -i eth0 -nn 'tcp port 5201'观察抓包结果时,重点过滤TCP分析选项里的"TCP Retransmission"标记。你会看到,Wireshark会用红色高亮标记重传包,而且重传包的序列号往往和某个已经抓到的段重复。这个实验做完,你对重传的理解会比看十遍教材都深刻。
测试结束后记得清理规则:
sudo tc qdisc del dev eth0 root5.4 应用层心跳 vs TCP Keep-Alive:谁才是保活的正主
TCP头部没有也没办法有"心跳"字段,但TCP确实提供了一个可选的保活机制,叫Keep-Alive。默认情况下,Linux的TCP Keep-Alive每2小时探测一次空闲连接,探测不到回复后,每75秒重试一次,最多重试9次。
看到这组默认参数,你应该明白:TCP Keep-Alive的设计初衷根本不是为应用层服务的。它只是用来清理那些长时间没有数据交换的半死连接,防止系统资源被僵尸连接拖死。真正的高可用系统,几乎都是自己在应用层做心跳包,比如每10秒发一个PING,连续三次没回就判定对端死了,主动重连。
我在做分布式微服务的时候,对这一点体会特别深。服务间的长连接如果只依赖TCP Keep-Alive,一次断网可能要几个小时才能被发现,这在生产环境是不可接受的。后来我们统一在业务层做心跳,配合断线重连,才能做到秒级感知故障。所以说,保活这件事,TCP只能保底线,业务等级的保护要给应用层自己来做。
6. UDP和TCP的实际"脾气"对比——以及选型该听谁的
6.1 从丢包率、乱序、吞吐三个维度实测
UDP和TCP的区别,网上随便一搜就有一堆表格,但表格只告诉你"UDP不保证可靠",却没告诉你"UDP在某些场景下反而比TCP更稳"。我用iperf3实测一次给你看。
在本地回环接口上,先测TCP吞吐:
iperf3 -c 127.0.0.1 -t 10我自己的机器上,TCP打了大概9.5Gbps,几乎接近万兆上限。接着测UDP:
iperf3 -c 127.0.0.1 -u -b 10G -t 10UDP打流的时候不关心对端能不能接住,它只管疯狂往发送缓冲区丢数据报。实测结果很有意思:发送端报告发送了巨量报文,但接收端实际收到的报文远小于发送量,丢包率动辄二三十个点。原因很简单,本地环回虽然路况好,但UDP没有拥塞控制,发得快了照样会把接收缓冲区挤爆,挤不进去的报文就直接被内核丢掉。
再把网络换成真实的局域网,掉包率会更高。为什么?UDP发送端不会因为丢包而降低速率,它一如既往地以10Gbps的速率灌数据,交换机缓存一旦溢出,丢包率会呈雪崩式上升。
6.2 什么时候该用UDP,什么时候该用TCP
这里我给一份基于实际生产经验的选择清单,不限于考试考点,但绝对实用。
| 场景 | 推荐协议 | 原因 |
|---|---|---|
| Web页面、接口调用 | TCP | 完整性优先,页面少传输几KB数据但必须完整 |
| 文件传输、邮件 | TCP | 哪怕慢,也不能丢字节 |
| 域名查询DNS | UDP | 单次查询大小极小,追求低延迟;失败可以重试 |
| 视频直播、语音通话 | UDP | 丢失一帧声音画面可以容忍,但延迟不可容忍 |
| 游戏位置同步 | UDP | 启动快、无重传、实时性优先,丢包靠插值糊弄过去 |
| 物联网传感器上报 | UDP | 数据频繁但单包小,可丢旧保新,避免TCP重传堆积延时 |
有个场景我特别想提醒你:金融交易通道、设备控制指令这种"绝不能丢"但又需要极低延迟的场景,不要一拍脑袋就选UDP。更成熟的思路是"使用UDP传输,但在协议栈上层自己实现序列号、确认、重传和乱序缓冲"。QUIC协议就是这条路线的教科书案例,它把TCP的可靠性逻辑搬到了用户态,同时保留了UDP的低延迟优势,并且解决了TCP队头阻塞的老大难问题。
6.3 TCP的队头阻塞问题:一个常被忽略的软肋
讲TCP的缺点时,如果只盯着"慢"和"重传浪费带宽",那还是浅了。TCP真正被很多人忽略的软肋是队头阻塞。
什么叫队头阻塞?TCP是字节流协议,它要求数据严格按照序列号顺序交付给应用层。假设发送方发了一个窗口内的5个段,第2个段丢了,第3、4、5个段都到了。接收方为了保持有序,会把3、4、5先存在缓冲区,不交付给应用层,同时反复发送ACK,请求重传第2段。即便这个窗口里的其他数据早就到了,应用层也只能干等着。在HTTP/1.1时代,由于每个请求各自占用一条TCP连接,这个问题被掩盖了不少,但在HTTP/2多路复用一条连接传多个资源时,队头阻塞的影响立刻放大——一个包丢了,后续所有资源都得排队。
这也是为什么Google要搞QUIC,因为QUIC在一条连接上划分了多个独立的流,每个流都有自己的序列号空间,一个流的丢包不会阻塞其他流。理解了这一点,你对"为什么TCP有时反而不如UDP适合直播"的理解就是降维级别的了。
6.4 UDP调试工具给排查问题带来的便利
工作中最常被忽略的一类工具是UDP调试工具和打流工具,比如netcat、iperf3配合UDP模式、专门的UDP客户端模拟器。我自己就遇到过线上UDP组播服务收不到数据的问题,排查过程全靠这些工具。
第一步,用nc确认本地端口是否能接收:
nc -u -l 12345另外一台机器上发送:
echo "hello" | nc -u <目标IP> 12345如果收不到,先用tcpdump看UDP报文是否到达网卡:
sudo tcpdump -i eth0 udp port 12345如果网卡能看到包但程序收不到,基本可以断定是防火墙或路由问题。这一套组合拳,能帮你把"程序Bug"和"网络环境问题"快速分离,而不是一头扎进代码里瞎调。这种排查思路,远比记住UDP协议里每个字段更重要。
7. 学习资源配置指南——教材、网课、刷题、实操怎么组合
7.1 教材怎么读:谢希仁、王道、自顶向下三选一
市面上的计算机网络教材琳琅满目,我经常被问到底买哪本。我的看法是,主攻考研408的,王道配合谢希仁是标配。王道把考点归纳得非常清晰,适合应试;谢希仁的教材胜在体系完整,很多概念的原理解释比王道更展开。但如果你是为了理解网络本身而学,不是奔着考试去的,那《计算机网络:自顶向下方法》会更对你的胃口。
自顶向下这本教材的思路是"先用应用,再谈原理"。它从HTTP、DNS这些你实际用过的东西讲起,再一层层往下钻进传输层、网络层,这个路径对自学者极其友好。相比之下,谢希仁教材更偏向"自底向上",从物理层一点点往上讲,虽然严谨,但对初学者就是一座大山。
还要提一句"湖科大教书匠"这位UP主。他的视频以动画演示为主,把三次握手、滑动窗口这些抽象概念用非常直观的动画呈现出来,强烈推荐给任何觉得"光看书看不进脑子"的人。他讲TCP状态迁移的那几期,哪怕你已经工作了,回头看一遍也能把旧知识重新激活。
7.2 八股文与面试题的正确打开方式
各大社区的"计算机网络八股文"合集,我建议你不要一上来就背,而是先问自己三个问题:三次握手为什么不能是两次?四次挥手为什么要等2MSL?TCP为什么需要拥塞控制?如果这三个问题你都能用"自己的话+一个例子"解释清楚,那面试题基本难不倒你。
一个比较好的训练方法是:把面试题当定理,自己去证明。比如有人问"TCP和UDP的区别",先别急着从"面向连接vs无连接"开始背,而是从应用场景反推:视频会议为什么选UDP?因为延迟敏感、可以容忍轻微花屏。网页浏览为什么选TCP?因为资源必须完整加载,哪怕慢几百毫秒都能接受。从场景到协议再到机制,这个链条贯通了,答案自然脱口而出。
7.3 动手实验的三个入门小项目
如果只推荐三个动手项目,我选下面这三个,全部可以用普通电脑完成:
第一个,用Python写一个最简单的UDP聊天室。核心代码不超过50行,用socket库的socket(AF_INET, SOCK_DGRAM)就行。做完你就能真切感受到"发送完不管"的感觉:你把消息发出去了,对端收没收到,你的代码完全不知道。
第二个,用Python写一个TCP回显服务器。先单线程版,再用threading改成多线程版,最后用async IO改成异步版。这个过程你能直观感受到TCP的"字节流"特性:你可能一次recv到半个客户端消息,也可能一次recv到好几个消息拼接在一起,这在UDP里是绝对不可能发生的。
第三个,用Wireshark抓三次握手和四次挥手的包。先别急着记字段,先把那4到7个包的源端口、目的端口、SYN/ACK/FIN标志和序列号变化抄在本子上,再比对着画出完整的序列号变化图。这一个小实验做下来,顶得上背十遍408教材。
7.4 用iperf3和tcpdump构建自己的"实验沙箱"
我真心建议你花一个下午,配置一个推进式的小实验环境。准备两台虚拟机,装好Linux系统,然后在上面做这些实验:
- iperf3 TCP打流,调整窗口大小观察吞吐变化
- iperf3 UDP打流,调整带宽参数观察丢包率变化
- tc人为注入丢包,观察TCP快速重传和超时重传
- nc联合tcpdump抓包,看UDP和TCP发送的报文差异
这些实验需要的只是装好iperf3、tcpdump、tc这几个工具,成本极低,但带来的收益非常大。协议栈是一套工程实现,理念再好,你也得亲手看看它在真实内核里的行为,才会真正"信"它。纸上得来终觉浅,这句老话放到计算机网络领域,比任何真理都真。
8. 协议栈之外的"隐约边界":TCP/UDP与上层应用的一次对话
8.1 不要让协议背锅:先从应用层找问题
带过几次team里的新人排障,我发现一个通病:只要网络有问题,第一反应永远是把罪过推到"TCP/UDP协议"头上。但实际上,绝大多数线上问题,代码层要负主要责任。
举个例子,有个业务说"TCP连接总是断",抓包一看,服务端回了RST。RST可不是TCP的随机故障,它是应用中某段代码主动调用了错误处理逻辑:我只知道一个典型的触发点是向一个已经关闭的socket写数据,内核就会回一个RST。这时候你别说TCP协议不稳定,你该去查业务逻辑里为什么还在往已关闭连接写东西。
8.2 从socket编程看TCP与UDP的现实差异
接触过socket编程的人应该体会过:UDP的代码路径短得感人,创建socket、bind、recvfrom、sendto,完事。TCP则要listen、accept、recv、send,中间还要应对半关闭、粘包、拆包、连接异常。这两种协议给开发者的"心智负担"完全不是一个量级。
我提醒每个做网络编程的新人:TCP粘包问题不是TCP协议的设计缺陷,而是字节流模型带来的天然特性。你应用层要自己定义消息边界,要么用固定长度,要么用长度前缀,要么用分隔符。把"粘包"怪到TCP头上,就好比怪快递员把你的两封信塞进同一个箱子,但你没有在信封上写收件人姓名。
8.3 学会看内核的"尺子":一些立等可取的排查技巧
最后分享几个我常用的快速排障命令,全部来自内核提供的"尺子",非常简单但极其实用:
查看系统TCP连接状态分布:
ss -antp | awk '{print $1}' | sort | uniq -c查看指定端口的连接统计:
ss -antp | grep :8080 | wc -l查看UDP接收缓冲区是否溢出:
netstat -su | grep -i "receive buffer"如果看到UDP接收缓冲区错误数一直在涨,而且你的UDP服务丢包严重,第一件事就是调大系统UDP缓冲区:
sysctl -w net.core.rmem_max=26214400 sysctl -w net.core.rmem_default=26214400这套组合拳,我在处理很多"玄学丢包"问题时都第一时间使用,基本每次都能把问题定位到"用户态消费太慢"或者"内核缓冲区太小"这类实质原因上。不夸张地说,熟练掌握这几个命令,等于给自己配了一副看穿内核行为的显微镜。
8.4 把知识缝进自己的话术里:你应该形成的"TCP/UDP小模型"
当你能够不看任何资料,用不出五分钟的时间,向别人完整地讲清楚下面这几件事,就可以认为TCP和UDP这关真的过了:
- UDP的报文头有哪4个字段,为什么它不需要建立连接,适合什么场景,不适合什么场景
- TCP头部的主要字段各管什么,序列号和确认号如何配合实现字节流的有序交付
- TCP三次握手为什么是三次,四次挥手为什么是四次,TIME_WAIT为什么存在
- TCP是如何通过超时重传、快速重传、滑动窗口、拥塞控制来保障可靠性的
- TCP和UDP各自的软肋在哪里,什么场景必须绕过TCP的队头阻塞、什么场景必须容忍UDP的丢包
我后来带实习生做过一次分享,我把这套话术压缩成了一张A4纸,叫"TCP/UDP五分钟小模型"。每次遇到跟网络协议有关的问题,先套这个小模型,再决定要不要深挖。这个方法帮团队省掉了大量无效的排查时间。
我自己学计算机网络那阵子,最遗憾的就是没有尽早建立一个"协议好奇心"的视角:每遇到一个报错、一次延迟、一次重传,都下意识地想"这是TCP的哪个机制在起作用"。等你开始下意识地用协议栈的语言去解释应用问题,你就会发现,网上那些"网络不好就怪丢包"的模糊论调,渐渐骗不到你了。