☰
TCP/UDP协议选型、内核调优与抓包排障实战
2026/10/27 12:17:37 网站建设 项目流程

1. 把 UDP/TCP 放回它们该在的位置

先讲一个我遇到过的真实场景:一个内部服务上线后,监控上偶发出现几十毫秒的延迟尖刺,运维第一反应是"网络抖动",网络那边查了一圈说没问题,最后定位到的是服务端在写小包时开了 Nagle,而客户端又开了延迟确认,两个机制一撞,40 毫秒就凭空冒出来了。这件事给我的教训很直接——UDP 和 TCP 这两份协议,如果你只记住"一个可靠一个不可靠",那你在遇到问题时基本只能靠猜。它们决定了你的程序在内核里被怎么排队、怎么切包、怎么重传、怎么算超时,这些细节全都会以"偶发卡顿""连接数异常""带宽上不去"的形式,出现在你的监控面板上。

这篇内容想做的事,是把 UDP/TCP 从课本上的表格里拽出来,放到实际编码、抓包、调参、排障的语境里重新讲一遍。它适合三类人:写过 socket 但没深挖过内核行为的开发者,正在做协议选型或自研传输的工程师,以及被连接数、丢包率、TIME_WAIT 折腾过的运维同学。哪怕你目前只会写一个最简单的 TCP 回显服务,看完之后也应该能回答几个具体问题:为什么我的连接一多就卡、为什么 UDP 明明发了但收不到、为什么带宽跑不满、为什么短连接压测一上来就报端口耗尽。

我不会按"TCP 三次握手、四次挥手、UDP 头部八个字节"这种教材顺序平铺,而是按"设计取舍—内核账本—实证抓包—线上排障"这条线走。原因很实际:协议本身是死的,真正难的是判断在什么条件下应该选哪条路,以及出了问题该往哪一层看。这两件事,教材给不了,只能在踩坑里积累。

1.1 一次"偶发丢包"引出的分层视角

很多人排查网络问题时,习惯从"网络"两个字开始找原因,其实第一步应该是确认自己站在哪一层。以太网帧、IP 包、TCP 段或 UDP 数据报,这三个东西在抓包里长得很像,但它们的边界和语义完全不同,搞混了就会得出错误结论。

举个具体例子。以太网的标准 MTU 是 1500 字节,这是链路层能承载的最大载荷。IP 头占 20 字节(不带选项),剩下的 1480 字节交给传输层。TCP 头又占 20 字节(不带选项),所以MSS 的经典值就是 1460 字节;而 UDP 头只有 8 字节,载荷理论上能到 1472 字节。这个 1480/1460/1472 的数字差异,是后面所有分片问题的起点。

为什么强调这个?因为当你在应用层一次性 write 了 3000 字节,TCP 会帮你切成多个段分开发,接收端按序号重组,你永远看不到中间过程;而 UDP 如果一次 send 了 3000 字节,IP 层会把它切成两个分片发出去,只要有一个分片在路上丢了,整个数据报在接收端就会被直接丢弃,内核连个错误都不一定报给你。同样一条链路,同样的丢包率,TCP 只是慢一点,UDP 可能是数据直接消失。

所以分层视角的实际价值是:它告诉你故障的"责任边界"在哪。链路层看 MTU 和双工模式,IP 层看路由和分片,传输层看连接状态和缓冲队列,应用层看读写逻辑。排查永远是从上往下确认现象、从下往上找根因,两条线在中途对上,答案就出来了。跳过任何一层去猜,基本都在浪费时间。

1.2 设计取舍:可靠性和实时性不能同时最大化

TCP 和 UDP 的差别,本质不是"功能多少"的差别,而是对不确定性的处理策略不同。TCP 选择把不确定性全部吃掉:丢包就重传,乱序就重排,拥塞就降速,代价是延迟不可预测、头部开销更大、连接状态要占内存。UDP 选择把不确定性原样交给上层:发出去就不管,收到什么算什么,代价是可靠性、顺序、流量控制全部得自己实现。

这个取舍在什么场景下会翻转?我总结成三个判断维度。

第一是延迟下限是否比平均延迟更重要。语音和视频通话里,一个 200ms 前丢失的音频帧即使重传成功也毫无意义,用户只听到卡顿。这类场景里,"宁可丢也不要等"是硬需求,UDP 是唯一选择。

第二是连接数规模。TCP 每条连接在内核里都要占一份 struct sock、收发缓冲区、定时器,十万连接就是实打实的内存和 CPU。UDP 是无状态的,一个 socket 就能接几万个对端,所以大规模广播、服务发现、监控上报这类场景,用 UDP 或者"UDP + 应用层会话表"更划算。

第三是是否需要多播或广播。TCP 是点对点的,物理上就不支持一对多;UDP 天然支持组播,这是做局域网发现、集群心跳广播时的唯一选项。

反过来,什么场景必须用 TCP?凡是"数据完整性不可妥协且延迟可以容忍"的,比如配置文件下发、数据库连接、文件传输、HTTP 请求。这些场景里少一个字节都是事故,重传带来的几百毫秒完全可以接受。

提示:不要用"TCP 慢、UDP 快"这种说法做决策依据。在链路质量好的内网,两者的吞吐差距远小于很多人想象;在链路质量差的公网,UDP 的裸吞吐反而可能更低,因为丢包后没有任何退让机制,会和别人的流量硬碰硬。

1.3 四种典型场景的第一判断

我把常见场景粗略归成四类,每类的"第一判断"列在下表里。这张表不是让你背,而是给你一个起点——真正落地时还要继续往下追问细节。

场景类型典型例子第一判断关键理由
请求-响应、数据不可丢配置拉取、数据库访问TCP完整性优先,延迟可容忍
实时流媒体音视频通话、直播UDP延迟下限优先,旧数据无价值
大规模短消息监控上报、日志采集UDP 或应用层批量 TCP连接开销 vs 可靠性需权衡
一对多分发局域网发现、集群广播UDP 组播TCP 物理上不支持

拿第三类展开说,因为它最容易选错。假设你要做一件事:几千个节点每隔几秒上报一次心跳。用 TCP,每个节点一条长连接,服务端要维护几千个 socket,还要处理断线重连的惊群;用 UDP,服务端一个 socket 收所有包,节点掉线靠"多久没收到"来判断,逻辑简单得多,代价是丢几个心跳无所谓——反正下一次还会来。

但如果上报的是计费日志,丢一条就是钱的问题,那 UDP 就不能用了,得走 TCP 长连接或者应用层批量确认。同一个业务里,心跳和日志的正确选择可能完全相反,这就是为什么不能一刀切地说"我们全部用 UDP"。

2. TCP 的内核账本:那些参数不是随便填的

TCP 之所以让人头疼,是因为它有大量状态和参数散落在内核里,而这些参数平时看不见,出问题时才冒出来。我习惯把 TCP 理解成一本"内核账本":连接是账户,缓冲区是余额,窗口是信用额度,拥塞窗口是风控上限。你要调优,本质上是在调这本账本的规则。不理解账本逻辑就照抄网上的 sysctl 配置,大概率是负优化。

2.1 状态机到底在防什么

TCP 的状态机有十来个状态,很多人背过就忘。我建议换个角度记:每个状态存在的意义,都是为了解决一个具体的"消息可能丢失"问题。

三次握手解决的是"双方都确认自己和对方的收发能力正常"。客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK。为什么不能两次?因为两次握手时,服务端无法确认客户端能收到自己的包,而且历史上滞留的重复 SYN 会让服务端白白建立连接。三次握手的最后那个 ACK 就是让服务端确认"对方确实收到了我的 SYN"。

四次挥手解决的是"双向关闭"。TCP 是全双工的,A 不想发了不代表 B 也不想发,所以要分两次 FIN 各自关闭一个方向。主动关闭方进入 FIN_WAIT_1,收到对方 ACK 后进 FIN_WAIT_2,收到对方 FIN 后回 ACK 进 TIME_WAIT,等 2MSL 后进 CLOSED。

TIME_WAIT 是最容易被误解的状态。它存在的两个理由:一是保证最后那个 ACK 能到达对方,如果 ACK 丢了,对方会重发 FIN,此时主动关闭方还在 TIME_WAIT 就能重发 ACK;二是让这条连接上"迷路"的旧报文在网络中自然消亡,避免它们被新连接误收。所以 TIME_WAIT 不是 bug,是保护机制。

但保护是有代价的。Linux 上 2MSL 默认是 60 秒,意味着一条主动关闭的连接要占着一个本地端口 60 秒。如果你的服务是短连接模式,每秒几千次请求,端口就会迅速耗尽,报出 "Cannot assign requested address"。这时候的解法不是简单调小tcp_fin_timeout(那个参数只影响 FIN_WAIT_2),而是要么改成长连接,要么调大ip_local_port_range,要么让服务端做主动关闭方把 TIME_WAIT 压到服务端去。

还有一个被忽视的状态是 CLOSE_WAIT。它表示"对方已经关了,我还没关"。如果 CLOSE_WAIT 大量堆积,百分之百是应用层代码没调用 close,不是内核问题,调任何 sysctl 都没用。这一点我见过太多次误判。

2.2 窗口与拥塞控制:带宽跑不满的真正原因

带宽跑不满,最常见的三个原因是:接收窗口太小、拥塞窗口上不去、单连接受限于 RTT。这三个要先分清,不然会乱调。

先算一笔账,这是每个做传输的人都该会的计算。带宽时延积(BDP)= 链路带宽 × 往返时间。假设一条链路是 200Mbps,RTT 是 60ms,那么 BDP = 200 × 10^6 / 8 × 0.06 = 1.5MB。这个数字意味着:要让这条链路跑满,发送方在收到第一个 ACK 之前,必须能连续发出 1.5MB 的数据。如果接收窗口只有 64KB,那实际吞吐上限就是 64KB / 0.06s ≈ 1.07MB/s ≈ 8.5Mbps,无论带宽多大都只有这么点。

这就是为什么跨地域传输时,"带宽明明够但速度上不去"是常态。解决办法有两个:一是开窗口缩放(TCP Window Scale 选项,把 16 位窗口字段左移最多 14 位,最大支持 1GB),二是调大内核的收发缓冲区net.ipv4.tcp_rmem和tcp_wmem。

拥塞控制这一侧,cwnd从慢启动开始指数增长,到ssthresh后转线性增长,遇到丢包就下降。经典算法是 CUBIC,现在很多场景用 BBR。CUBIC 靠丢包判断拥塞,在有随机丢包的链路上会误判、过度降速;BBR 靠测量带宽和 RTT 建模,在有一定丢包率的长肥链路上表现更好。选哪个取决于你的链路特征,不是越新越好:纯内网低丢包环境下两者差别不大,高丢包的跨境链路上 BBR 往往优势明显。

注意:开 BBR 需要内核支持,且要确认中间设备不会因为非 CUBIC 的拥塞行为把它当成异常流量。上线前一定在灰度环境实测,不要直接全量切。

2.3 常用内核参数与它们的真实代价

网上流传的 TCP 调优清单,一大半是过时的或者有副作用的。我把真正常用、且我确认过效果与代价的几条列出来。

参数作用调大的收益潜在代价
net.core.somaxconnaccept 队列上限高并发下减少握手被丢弃需应用 listen 的 backlog 同步调大
net.ipv4.tcp_max_syn_backlogSYN 队列上限抗突发握手洪峰内存占用略增
net.ipv4.ip_local_port_range本地端口范围缓解短连接端口耗尽与目标端口冲突概率略升
net.ipv4.tcp_rmem接收缓冲区提高大 BDP 链路吞吐内存占用显著上升
net.ipv4.tcp_wmem发送缓冲区同上同上
net.ipv4.tcp_congestion_control拥塞控制算法长肥链路吞吐提升需实测,可能与他人不公平

关于somaxconn有个经典坑:很多人只改内核参数,忘了应用层的 listen backlog。以 Python 为例,s.listen(128)这个 128 会被内核取min(128, somaxconn),你内核调到 65535 但应用还写 128,等于白调。Nginx 的backlog配置同理。

缓冲区这条也要说清楚。tcp_rmem和tcp_wmem都是三元组(最小、默认、最大),内核会在最大值的范围内自动调节。直接把最大值拉到几百 MB 是危险的:每条连接都可能吃到上限,几千连接就能把内存吃穿。稳妥做法是先算 BDP,把最大值设成"单连接最坏情况下需要的两倍",然后观察ss -m里的实际占用再微调。

至于tcp_tw_reuse和已被移除的tcp_tw_recycle,我的态度很明确:前者在客户端侧可以谨慎开启,后者在任何场景下都不要碰。recycle 依赖时间戳,在 NAT 环境下会造成大量连接被误丢,早就在新内核里删掉了,看到还在推荐它的文章可以直接跳过。

3. UDP 的"不作为"才是它的特性

很多人对 UDP 的印象停留在"不可靠、会丢包、不保证顺序",然后就没了。这个认知太浅。UDP 真正的价值是它把控制权完整地交回给了应用层,让你可以按业务需求定制每一条策略。你在 TCP 上没法决定"这个包丢了不用重传",在 UDP 上可以;你在 TCP 上没法做到"晚到的包直接扔掉不阻塞后续",在 UDP 上可以。理解了这一点,你才会明白为什么 HTTP/3 要跑到 QUIC 上,为什么实时音视频宁可自己写重传逻辑也不用 TCP。

3.1 八个字节里藏着的坑

UDP 头部只有四个字段:源端口、目的端口、长度、校验和,各 16 位。看起来简单,但有两个坑值得单独说。

第一个坑是校验和字段可以是 0。在 IPv4 下,发送方把校验和填 0 表示"我不计算校验和",接收方看到 0 就直接放行。这意味着 UDP 在 IPv4 下可以完全没有完整性校验。IPv6 下这个行为被禁止了,校验和是强制的。所以如果你的服务同时跑在 v4 和 v6 上,别指望 UDP 的校验和一定生效。

还有一个细节:UDP 的校验和计算会用到一个"伪首部",把源 IP、目的 IP、协议号、UDP 长度都算进去。这意味着如果 NAT 设备改写了 IP 地址却没重算校验和,接收端会算出错误并丢包。这是某些网络环境下 UDP 莫名不通的原因之一,排查时容易被忽略。

第二个坑是长度字段只有 16 位。65535 是理论上限,减去 IP 头 20 字节和 UDP 头 8 字节,载荷最大 65507 字节。但这个数字在实际网络里毫无意义,因为超过 MTU 就会被 IP 层分片。

3.2 MTU 分片:UDP 丢包最隐蔽的来源

这是我最想强调的一节。以太网 MTU 1500,IP 头 20,UDP 头 8,所以不分片的最大载荷是 1472 字节。你如果 send 了 2000 字节,IP 层会把它拆成两个分片。接收端要等两个分片都到齐才能重组,任何一个丢了,整个 2000 字节全部作废。

问题在于:大多数 UDP socket 默认不会告诉你这件事。丢包发生在 IP 层重组阶段,应用的 recvfrom 只是"没收到",没有任何错误码。你会在业务层看到"偶发数据缺失",但抓包一看,发送端确实发出去了。

更麻烦的是路径 MTU 发现(PMTUD)。有些链路的中间设备会丢弃带 DF 标志的大包并且不回 ICMP,导致 PMTUD 失效,出现"小包能通、大包不通"的黑洞现象。这种情况下表现非常诡异:ping 通,小请求通,稍大一点的请求就超时。

实际工程里的做法是保守取值。QUIC 把初始包限制在 1200 字节,就是为了避开绝大多数链路的 MTU 黑洞。你自己设计 UDP 协议时,也可以照这个思路:应用层主动把单包控制在 1200 字节以内,超出的数据在应用层自己切分和重组,不要指望 IP 分片。

提示:想测某条路径的实际 MTU,可以用带 DF 标志、逐级增大包长的探测方式。但注意很多环境会屏蔽这类探测,结果只能作为参考,最终还是要靠实测业务流量来确认。

3.3 在 UDP 上重建可靠性:一份取舍清单

如果你决定用 UDP 并且需要一定可靠性,那实际上是在重新实现 TCP 的一部分。这时候最关键的不是"怎么实现",而是"哪些要实现、哪些可以不实现"。我的取舍清单如下。

能力是否必做说明
序号 + 重排序必做否则乱序数据无法使用
超时重传必做但超时时间要基于 RTT 动态计算
快速重传建议靠后续包触发,减少等待
拥塞控制建议公网场景必做,否则会挤垮别人也害自己
流量控制可选对端处理能力差异大时需要
校验和建议应用层自己加,不全信底层

重传策略是最容易做错的一块。一个常见错误是固定超时时间,比如统统 200ms。链路 RTT 波动时,固定值要么重传太频繁造成拥塞,要么太慢导致卡顿。正确做法是维护一个 RTT 的滑动估计(类似 TCP 的 SRTT 和 RTTVAR 思路),超时时间取SRTT + 4 × RTTVAR,并设一个下限避免过早重传。

另一个容易忽略的是重传放大效应。丢包率 5% 时,你重传一次能解决大部分问题;但如果重传的包也丢了,再重传一次就变成 3 份流量。在拥塞时这会让情况更糟。所以重传次数必须有上限,且重传时要降低发送速率——这也是为什么自研协议里拥塞控制不能省。

4. 抓包实证:把协议从课本里拽出来

光看文字记不住协议行为,我在带人的时候一律要求先抓一次完整的包。抓包的价值在于把抽象的"握手""重传""窗口更新"变成具体的时间戳和字节,你能亲眼看到 40ms 的延迟尖刺长什么样,看到重传是怎么被触发的。这一节给的是可以直接抄的最小实验环境。

4.1 最小复现环境与抓包命令

先准备一个最简服务端和客户端。TCP 版本我加了TCP_NODELAY,因为默认开着 Nagle 会干扰对时序的观察。

# tcp_server.py import socket srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) srv.bind(("0.0.0.0", 9000)) srv.listen(128) print("listening on 9000") conn, addr = srv.accept() print("accepted from", addr) while True: data = conn.recv(4096) if not data: break conn.sendall(b"echo:" + data) conn.close()
# tcp_client.py import socket c = socket.socket(socket.AF_INET, socket.SOCK_STREAM) c.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) c.connect(("127.0.0.1", 9000)) for i in range(3): c.sendall(b"hello-%d" % i) print(c.recv(4096)) c.shutdown(socket.SHUT_WR) c.close()

UDP 版本更简单,但要注意SO_RCVBUF的调整——默认接收缓冲区往往只有一百多 KB,突发流量下会在内核里直接丢包。

# udp_server.py import socket u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) u.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) u.bind(("0.0.0.0", 9001)) print("udp listening on 9001") while True: data, addr = u.recvfrom(65535) print("recv", len(data), "from", addr)

抓包用 tcpdump,几个参数要在意:-nn不做名称解析(否则 DNS 查询会污染结果)、-s 0抓完整包(老版本默认只抓 96 字节,会截断 payload)、-w写文件留给后续分析。

# 抓 TCP 9000 端口的完整报文 sudo tcpdump -i lo -nn -s 0 'tcp port 9000' -w tcp.pcap # 抓 UDP 9001,并统计每秒包数 sudo tcpdump -i lo -nn -s 0 'udp port 9001' -w udp.pcap # 实时看连接状态分布 ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

4.2 握手与挥手的逐包解读

抓完包用任意图形工具打开,你会看到这样一串。我按顺序讲每一包在说什么。

第一包客户端 SYN,标志位是[S],序列号是一个随机初始值(ISN)。ISN 随机化是安全设计,不是随便取的,避免被预测序列号注入数据。

第二包服务端 SYN+ACK,标志位[S.],带上自己的 ISN 和 ACK=客户端 ISN+1。注意这一包里通常还会携带 MSS、窗口缩放因子、SACK 允许这些选项,这是双方协商参数的唯一机会——窗口缩放因子一旦定了,整条连接都不能改,这也是为什么用 tcpdump 看握手包时一定要展开 TCP 选项。

第三包客户端 ACK,连接建立。从这一包之后,双方就可以互发数据了。

再看挥手。假设是客户端主动关闭:

第一包客户端 FIN,标志位[F.],进入 FIN_WAIT_1。 第二包服务端 ACK,客户端进入 FIN_WAIT_2。 第三包服务端 FIN,同时带上 ACK,客户端进入 TIME_WAIT,回最后一个 ACK。

抓包里你会注意到第二次和第三次挥手之间常常有明显间隔。这不是协议慢,而是服务端的应用层还没调 close——数据可能还在缓冲区里没发完,或者代码里忘了关。这个间隔在实际排障里就是"服务端处理慢"的直接证据。

还有个小细节值得看:TIME_WAIT 那一端在 60 秒内如果再抓到同一个四元组的包,就是旧连接的残留报文。这验证了 2MSL 的作用,也让你明白为什么快速重连同一个四元组有时会收到奇怪的 RST。

4.3 分片与缓冲溢出的现场证据

UDP 这一侧最值得抓的是分片。你在客户端 send 一个 3000 字节的包,tcpdump 里应该能看到两个 IP 分片,第二个的 "frag" 标志位会显示偏移量。如果只看到第一个分片而接收端什么都没收到,那就是分片丢失导致整包被丢。

# 触发分片的发送端 import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.sendto(b"x" * 3000, ("127.0.0.1", 9001))

另一个现场证据是接收缓冲区溢出。当发送速率超过接收端处理速度,内核会在netstat -su的RcvbufErrors里累加计数。这个计数是排查 UDP 丢包的第一站,比在应用层猜有用得多。

# 看 UDP 层的统计 netstat -su | grep -A 5 Udp # 关键指标:InErrors / RcvbufErrors / SndbufErrors # RcvbufErrors 持续增长 = 应用读得太慢,需要调大 SO_RCVBUF 或加消费线程

还有一个容易漏的点:网卡层的 ring buffer。如果ethtool -S eth0里的rx_discards在涨,说明包还没进内核就已经被网卡丢了,这时候调应用和内核参数都没用,得加大 ring buffer 或者上多队列。排查顺序应该是:网卡丢包 → 内核 socket 缓冲区丢包 → 应用读取不及时,一层层往下排。

5. 线上问题排查实录

协议知识最终要落到排障上。这一节我把常见的几类症状整理成可以直接对照的路径,再挑几个典型场景做复盘。这些经验大部分是从错误判断里换来的,写出来是希望你能少走一遍。

5.1 症状对照表:先定位再动手

症状优先排查方向常用命令
连接建立慢或超时握手丢包、backlog 满ss -lnt、netstat -s | grep -i listen
连接数异常多CLOSE_WAIT 堆积(应用未关)ss -tan state close-wait | wc -l
报端口耗尽短连接过多、TIME_WAIT 占用ss -tan state time-wait | wc -l
带宽跑不满窗口太小、BDP 不足ss -m、ss -ti
偶发几十毫秒延迟Nagle 与延迟确认叠加抓包看 ACK 间隔
UDP 偶发丢数据RcvbufErrors、分片丢失netstat -su、抓包
大包不通小包通路径 MTU 黑洞缩小包长验证

这张表的使用要点是:先用命令确认现象,再对照方向,不要反过来。我见过有人一上来就改 sysctl,改完发现问题是应用层连接泄漏,白折腾一场还引了新问题。

5.2 三个典型故障的判断过程

第一个:CLOSE_WAIT 堆积。现象是服务端连接数缓慢上涨,几天后耗尽。ss -tan state close-wait | wc -l显示几千条。这时候要看的是代码而不是内核——CLOSE_WAIT 意味着对端发了 FIN,本端收到了但没调 close。常见原因是异常分支里return前没关连接、用了连接池但归还逻辑漏了、或者某个阻塞读永远不返回。修法是加资源管理(比如 Python 的with、Go 的defer),同时在池化层加超时回收兜底。

第二个:吞吐上不去但 CPU 和带宽都闲着。用ss -ti看单条连接的cwnd和rtt。如果 cwnd 停在几十 KB 不再增长,同时retrans有少量增长,多半是随机丢包触发了 CUBIC 降速。如果 cwnd 很大但吞吐还是低,那就是接收窗口卡住了,看ss -m里的接收缓冲占用,必要时调大tcp_rmem。这两个方向完全不同,别混着调。

第三个:UDP 服务在压力下大量丢包。先在服务端看netstat -su的RcvbufErrors。如果这个数在涨,说明应用消费不过来,解法是加大SO_RCVBUF(注意 Linux 上有net.core.rmem_max上限,setsockopt会被截断到该值)并增加消费线程。如果这个数不涨但业务还是丢,那就往上一层看网卡rx_discards,或者往下一层看分片——把单包控制在 1200 字节以内再压一遍,往往立刻见效。

这个"自上而下确认、自下而上定位"的顺序,我在前面提过一次,这里再强调:能用一个命令区分开的两种情况,千万不要凭经验合并判断。

5.3 踩过的坑与独家清单

最后几条是我个人反复验证过、但一般文档里不太写的东西。

关于 Nagle 和延迟确认。服务端连续写小包、客户端开了延迟确认时,会出现"最多 40ms 才回 ACK"的现象。原因是 Nagle 在等前一个包的 ACK,而延迟确认在等更多数据一起确认,两边互相等。解法是业务侧对小包敏感的连接开TCP_NODELAY,或者把多次小写合并成一次大写。这件事在本地测试完全看不出来,跨机房一压就现原形。

关于 TIME_WAIT 的数量阈值。不要看到几千条就慌,这个数和你的 QPS 直接相关:短连接场景下,TIME_WAIT 数量大致等于 60 秒内的连接数。真正该关注的是端口消耗速度,用ss -s看总连接数趋势比看单个状态有用。

关于 UDP 的 socket 复用。多个进程同时绑同一个 UDP 端口时,默认行为是"谁最后绑谁收",其他进程收不到。要用SO_REUSEPORT才能让内核做负载均衡。这个坑在做多进程 UDP 服务时几乎必踩,表现是"起了 8 个进程,只有 1 个在干活"。

关于抓包本身的影响。在高流量机器上开全量抓包,tcpdump自己会成为瓶颈,导致丢包并且污染观测结果。正确做法是先按端口和协议过滤,必要时用-c限制包数、用-w落盘再离线分析,不要在线上机器上做实时全量解析。

关于压测环境与生产环境的差异。压测机上往往没有真实网络设备,路径 MTU、NAT 行为、中间设备的连接跟踪表都和生产不同。凡是涉及 MTU、长连接保持、NAT 超时的结论,必须在接近生产的网络路径上验证过才能上线,这一点我在两套环境结论相反的例子上栽过。

关于参数调整的纪律。每次只改一个参数,改完在灰度环境跑够至少一个完整的流量周期(比如覆盖一次高峰),记录前后指标再决定是否全量。一次改十个参数,出了问题是没法归因的,最后只能全部回滚重来。

我在实际工作中形成的习惯是:遇到网络问题,先花五分钟把"现象、范围、时间点、最近变更"四件事写清楚,再动手。这五分钟往往能省掉几个小时的瞎试——因为大部分所谓"网络抖动",最后都指向某次配置变更或者某个没关的连接。协议本身是稳定的,不稳定的永远是使用它的方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询