资讯动态

TCP/UDP协议机制、参数调优与选型排查实战

发布时间:2026/10/23 3:41:05 来源:尧图企业网站定制
1. 先从两个协议到底在解决什么问题说起前阵子帮一个做数据采集的团队排查线上问题现象很怪日志上报接口的耗时曲线平时都在几毫秒每隔一段时间就会冒出一批 40 毫秒左右的毛刺业务量越大毛刺越密集。业务代码翻了三遍没找出问题最后抓包一看一个请求的数据被拆成了两个小包发出去中间正好卡了 40 毫秒。这就是典型的 Nagle 算法和延迟确认撞在一起的结果。这件事让我又一次确认了一个判断TCP 和 UDP 的差异从来不只是“一个可靠、一个不可靠、一个快、一个慢”这么简单的一句话真正决定系统表现的是藏在协议头、状态机、缓冲区、内核参数里的那些细节。TCP传输控制协议和 UDP用户数据报协议是传输层的两大支柱。TCP 提供面向连接的、有序的、可靠的字节流服务UDP 提供无连接的、尽力而为的数据报服务。几乎所有需要跨网络通信的系统最终都会落到这两个协议的选择上数据库连接、HTTP 接口、消息队列、音视频通话、游戏同步、DNS 查询、指标上报、日志采集无一例外。很多人对这两个协议的认知停留在面试题层面能背出三次握手和四次挥手但一旦真到选型和调优环节就会发现背下来的东西基本用不上——因为真实系统里没有“标准场景”只有一堆互相牵制的约束条件。这篇文章面向的人群比较明确手上有实际服务在跑、需要做协议选型、被连接数或延迟问题折磨过、或者想搞清楚那些内核参数到底该不该动的开发者和运维同学。如果你只是想知道 TCP 有几个状态那这篇可能有点重但如果你曾经对着ss -s的输出发过呆或者在tcpdump里翻了几百个包还没定位到丢包点那接下来的内容应该能省你不少时间。我会按“协议机制 → 参数影响 → 选型决策 → 实战代码 → 问题排查”的顺序往下走中间会穿插一些我在实际项目里踩过的坑以及那些文档里通常不会写、但真出事时又特别关键的经验。2. 头部字段拆解每个字段都是踩坑的来源2.1 TCP 头部里那些真正会影响业务的字段TCP 头部固定部分 20 字节带选项最多 60 字节。很多人记住了“20 字节”但没意识到这 20 字节在每一个数据包上都要占掉一份也不会去想这个开销在高频小包场景下意味着什么。关键字段逐个过一遍序号Sequence Number32 位标识本报文段数据第一个字节的编号。它是“字节流”语义的核心——TCP 不按“包”计数按“字节”计数。这解释了一个常被误解的现象应用层调用一次send()写了 100 字节对端不一定能一次recv()读到 100 字节因为 TCP 根本没有报文边界这个概念。确认号Acknowledgment Number32 位表示期望收到的下一个字节编号。它是累积确认——确认了 1000就代表 1000 之前的所有字节都收到了。这也是为什么单靠 TCP 的重传机制在长肥管道上效率会明显低于带选择确认SACK的场景。标志位里最值得关注的是这几个SYN建连、FIN关方向、RST强拆、PSH提示尽快上交应用、URG基本没人用、ACK携带确认号有效。RST这一位在实际排查中出现频率极高——你大概率见过“Connection reset by peer”这个错误它的本质就是收到了对端或中间设备发来的 RST 包。窗口Window16 位接收方通告的可用接收缓冲区大小。原始字段只有 16 位最大 65535 字节在今天的带宽下显然不够用所以有了窗口缩放选项Window Scale缩放因子最大 14理论窗口上限约 1GB。这个字段直接决定了 TCP 的吞吐上限后面讲 BDP 时会细说。校验和 16 位覆盖 TCP 头部、数据和伪头部是端到端的完整性校验。注意它只能发现错误不能纠正一旦校验失败接收方直接丢弃靠重传补上。注意TCP 头部字段大多由内核维护应用层能直接干预的地方很少但这不代表应用层无能为力——选项部分、缓冲区大小、超时策略都能通过setsockopt影响。我在实际项目中遇到过一个典型案例一个内部 RPC 框架在长肥管道上跑不满带宽单连接只能跑到 200Mbps 左右而链路设计容量是 1Gbps。排查下来是接收窗口被压得很小因为SO_RCVBUF被业务代码设成了 64KB。64KB 窗口在 RTT 40ms 的链路上理论吞吐上限就是 64KB × 8 / 0.04s ≈ 12.8Mbps看起来业务跑到了 200Mbps 是因为开了多条连接。这个数字一对上问题就很清楚了。2.2 UDP 头部的 8 字节与校验和的取舍UDP 头部只有 8 字节源端口 16 位、目的端口 16 位、长度 16 位、校验和 16 位。结构极简带来的直接结果是开销小——同样 1500 字节的 MTUUDP 能比 TCP 多装 12 字节净荷TCP 头部 20 字节UDP 头部 8 字节。这个差别在高频小包场景下会被放大比如游戏同步、实时指标上报这类每个包只有几十字节的场景头部开销的占比就很可观了。校验和这里有个容易被忽略的历史包袱在 IPv4 下UDP 校验和是可选的发出去填全 0 表示“我不校验”接收方看到 0 就直接放行。IPv6 则强制要求校验和。这个设计导致的直接后果是某些实现会把校验和计算省略掉一旦链路上出现比特翻转数据损坏会被静默地递交给应用层不做任何提示。我做数据采集时曾经遇到过字段值偶尔变成离谱数值的情况查了整整一天最后定位到是某台设备网卡硬件校验和计算的问题而应用层从来没检查过数据合理性就这么一路传上去了。UDP 长度字段是 16 位这意味着单个 UDP 数据报的载荷理论上限是 65535 - 8 - 20 65507 字节IPv4。但千万别把这当成“UDP 可以发 64KB 的包”来用。超过 MTU 的 UDP 包会在 IP 层被分片任一分片丢失都意味着整个数据报作废而且接收端的重组缓冲区会被占用分片越多丢失概率越高。生产环境中建议把单个 UDP 数据报控制在 MTU 以内具体算法后面第 6 节会给出。提示UDP 的“无连接”不代表不能用connect()。对 UDP socket 调用connect()之后内核会记住对端地址性能有提升而且能收到 ICMP 端口不可达之类的错误通知。这是一个很容易被忽略但很有用的小技巧。3. 连接管理握手、挥手与状态机里的隐形陷阱3.1 三次握手的本质是双向序号同步三次握手的流程大家都熟客户端发SYN服务端回SYNACK客户端再回ACK。但为什么是三次而不是两次这个问题值得掰开讲——因为它直接关系到你对连接可靠性的判断。两次不够的核心原因是TCP 是全双工的双方需要各自确认“我的发送序号被对方收到了”和“对方的发送序号我收到了”这是两个独立的信息。服务端发出SYNACK之后它只知道自己的序号被对方确认了通过第三个 ACK但在此之前服务端无法确认客户端是否收到了自己的SYN。少了这一步服务端就可能在客户端并不知情的情况下建立连接然后把资源耗在一个永远不会被使用的连接上。初始序号ISN的选择也是有讲究的。现代内核不会用固定值或简单递增而是结合时间戳做随机化。原因有二一是防止历史连接上的延迟报文被误认为当前连接的数据二是提高伪造连接的难度。老式实现用递增 ISN 时攻击者只要猜出下一个序号就能伪造报文段这个风险在今天的系统里基本已经解决了。握手过程中还有一个生产环境高频出现的问题SYN 队列溢出。服务端收到SYN后会把连接放入半连接队列SYN queue并回复SYNACK等收到最终ACK后才移入全连接队列Accept queue等待应用调用accept()。这两个队列的长度都有上限前者受net.ipv4.tcp_max_syn_backlog控制后者受listen()传的 backlog 参数和net.core.somaxconn中较小者控制。当短连接 QPS 很高时somaxconn太小会直接导致连接被丢弃客户端看到的是超时或者连接重置服务端看不到任何应用层日志。排查这个问题要盯两个计数器# 查看监听队列溢出次数 nstat -az | grep -i -E ListenOverflows|ListenDrops # 查看当前各监听端口的队列情况 ss -lnt # 输出中的 Send-Q 就是当前全连接队列的最大长度 # Recv-Q 是当前排队等待 accept 的连接数ss -lnt输出里的Recv-Q如果持续接近Send-Q说明应用accept()的速度跟不上建连速度这时候调大somaxconn只是治标真正的解法是提高 accept 的并发处理能力或者改用连接复用。提示net.core.somaxconn在很多发行版上的默认值是 4096较新内核或 128老内核。如果你的服务是短连接密集型上线前一定要确认这个值我见过不止一次因为它是 128 导致压测过不去的情况。3.2 四次挥手与 TIME_WAIT 的真实成因挥手比握手多一次因为 TCP 连接是全双工的两个方向要分别关闭。主动关闭方发FIN被动方回ACK此时连接进入半关闭状态——主动方不能再发数据但仍然可以接收。等到被动方也发完自己的数据再发FIN主动方回ACK连接才算真正关闭。主动关闭方最后会进入TIME_WAIT状态等待 2 倍的最大报文段生存时间2MSL。在 Linux 上MSL 的实现取值是 30 秒所以TIME_WAIT默认持续 60 秒。很多人对这个 60 秒耿耿于怀觉得是浪费想方设法要把它干掉。但要先搞清楚它为什么存在。第一个原因是保证最后一个 ACK 能到达对端。如果这个 ACK 丢了对端会重发FIN此时主动方还在TIME_WAIT状态能重新回一个 ACK。如果主动方直接关掉了对端重发的FIN会收到RST对端就会认为连接异常终止而不是正常关闭。第二个原因是让旧连接的延迟报文在网络中自然消亡。同一个四元组在短时间内被复用时如果上一个连接的迟到报文还在路上可能会被新连接误认为是自己的数据。等 2MSL基本能保证这些报文都被丢弃。那么TIME_WAIT大量堆积到底是不是问题答案是要看它堆在谁身上。如果堆在服务端说明服务端是主动关闭方。常见于服务端设置了较短的 keep-alive 超时、或者 HTTP 响应后主动断开。这种情况会占用端口资源但现代内核有tcp_max_tw_buckets做兜底超过上限会直接不复用而是打日志。真正的影响是内存和少量 CPU不一定是瓶颈。如果堆在客户端尤其是做大量短连接调用的场景问题就比较实在了客户端可用的本地端口范围是有限的默认net.ipv4.ip_local_port_range大约 2.8 万个端口如果每个连接都进TIME_WAIT并且持续 60 秒那么理论最大新建连接速率就是 28232 / 60 ≈ 470 条/秒。超过这个速率就会看到“Cannot assign requested address”这类报错。这里有三个常见方案各自的适用条件差别很大方案做法适用条件风险调大端口范围修改ip_local_port_range客户端场景端口确实不够治标端口仍会耗尽开启tcp_tw_reuse允许复用 TIME_WAIT 连接发起新连接仅对主动发起连接的一侧生效且需要开启时间戳需要两端时间戳支持NAT 环境下可能有问题改用连接池 / 长连接从根上减少短连接数量绝大多数场景都适用需要处理连接失效和保活我的经验是优先选第三个。短连接带来的开销远不止TIME_WAIT还有握手 RTT、TLS 协商、慢启动重新爬坡。一个设计良好的连接池收益比调内核参数大得多。另一个高频状态是CLOSE_WAIT。如果这个状态的连接数持续上涨那基本可以确诊应用层没有正确关闭 socket。CLOSE_WAIT是收到对端FIN并回复ACK之后的状态接下来应该由应用调用close()。如果代码路径里有分支漏掉了close()或者在异常处理里没走finally连接就会一直挂在那里直到进程重启。这个问题内核帮不了你只能改代码。4. 可靠传输与流量控制的内核机制4.1 滑动窗口、拥塞控制与带宽时延积TCP 的可靠性靠三件事撑起来序号确认、超时重传、校验和。但光有可靠性不够还得有效率这就轮到滑动窗口和拥塞控制出场。滑动窗口解决了“发多快”的问题。接收方通过窗口字段告诉发送方“我还能收多少”发送方据此控制未确认数据的量。这个机制的核心价值在于不需要每发一个包就停下来等确认可以连续发送一整个窗口的数据把 RTT 对吞吐的影响摊薄。但窗口大小必须和链路的带宽时延积BDP匹配。BDP 的计算很简单BDP 带宽 × RTT举个例子1Gbps 带宽、RTT 40ms 的链路BDP 1_000_000_000 bit/s × 0.04 s 40,000,000 bit 5,000,000 字节 ≈ 4.77 MB这意味着要想在这条链路上跑满 1Gbps发送方在途未确认的数据量必须达到约 4.77MB。如果接收窗口只有 128KB那么吞吐上限就是 128KB × 8 / 0.04s ≈ 25.6Mbps只有链路容量的 2.5%。这就是所谓的“长肥管道”问题——带宽大、延迟高窗口不够就是跑不满。Linux 的 TCP 缓冲区有三个值看net.ipv4.tcp_rmem和net.ipv4.tcp_wmemcat /proc/sys/net/ipv4/tcp_rmem # 输出示例4096 131072 6291456 # 依次是最小值 默认值 最大值字节默认最大值 6MB 左右在普通场景够用但跨地域高延迟链路可能不够。这时候可以适当调大但要注意缓冲区不是越大越好过大的缓冲区会导致缓冲区膨胀bufferbloat排队延迟上升交互类业务的响应时间反而变差。拥塞控制和流量控制是两回事很多人会混淆。流量控制是接收方告诉发送方“我这边处理不过来”是端到端的拥塞控制是发送方自己判断“网络中间堵了”靠丢包或延迟信号来推断。Linux 默认的拥塞控制算法是 CUBIC在高丢包长距离链路上表现不错BBR 则是基于带宽和 RTT 建模不依赖丢包信号在有一定丢包率的链路上通常表现更好。# 查看当前算法 sysctl net.ipv4.tcp_congestion_control # 查看可用算法 sysctl net.ipv4.tcp_available_congestion_control # 临时切换 sysctl -w net.ipv4.tcp_congestion_controlbbr什么时候该换 BBR我的判断标准是如果你的链路有明显的跨地域特征RTT 大于 50ms、存在一定的基础丢包率0.1% 以上、且业务对吞吐敏感那值得试。如果服务基本都在同机房内网RTT 不到 1ms换算法的收益微乎其微别折腾。4.2 Nagle、延迟确认与那个 40 毫秒的毛刺回到开头提到的那个问题。Nagle 算法的目的是减少小包数量当连接上还有未确认的小数据时新产生的小数据先攒着等收到 ACK 或者攒够一个 MSS 再发。这个设计在批量传输场景下非常省事但在“发一个小请求等一个小响应”的交互模式下就会出问题。延迟确认Delayed ACK是另一头接收方收到数据后不立刻回 ACK而是等一小段时间Linux 上默认 40ms看看能不能捎带在反向数据上或者等收到两个包再一起确认。这个设计的初衷是减少纯 ACK 包的数量。两者单独用都没问题凑在一起就是经典死锁发送方攒着等 ACK接收方等着攒够两个包再 ACK于是就有了 40ms 的固定延迟。这就是那个毛刺的来源。解决办法只有两个选一个import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 9000)) # 关闭 Nagle小包立即发出 s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)或者在协议设计上做批量把多个小消息合并成一个较大的写操作让 Nagle 没有攒的机会。注意TCP_NODELAY不是无脑开的。如果你的业务本身就是大量小包顺序写关掉 Nagle 会让网络上的小包数量暴涨头部开销和中断处理成本都上去了。判断标准是有没有请求-响应式的交互延迟要求。有就关纯流式批量写就不用管。还有一个相关但更隐蔽的坑糊涂窗口综合征。如果接收方的缓冲区快满了每次只通告很小的窗口发送方就会被迫发很多小包。这个问题的经典解法是接收方在窗口小于一定阈值通常是 MSS 或缓冲区一半时不通告等腾出足够空间再说。现代内核基本都处理好了一般不用手动干预但当你在抓包里看到大量异常小的段时可以往这个方向想一想。5. 实战选型什么场景该用什么5.1 选型决策表与几个常见的思维误区选 TCP 还是 UDP本质上是在问四个问题能不能接受丢数据、能不能接受乱序、要不要自己管连接、延迟有多敏感。下面这张表是我这些年总结下来的判断依据覆盖了大部分常见场景场景推荐协议关键理由需要额外做的事HTTP/REST API、数据库连接TCP需要有序可靠天然长连接连接池、超时设置文件传输、日志批量上报TCP数据完整性优先延迟不敏感合理设置缓冲区即时通讯的消息通道TCP消息不能丢不能乱序心跳保活、断线重连实时音视频UDP迟到一点的数据不如不要前向纠错、抖动缓冲游戏状态同步UDP位置更新只关心最新值序列号丢弃过期包DNS 查询UDP单次请求响应包小超时重试、截断时改用 TCP指标采集高频小数据UDP量太大丢一点无所谓控制包大小、限速内网服务发现UDP 组播一对多广播处理组播丢包几个常见的思维误区值得单独说。误区一UDP 一定比 TCP 快。这个说法的来源是 UDP 头部小、没有握手、没有拥塞控制。但在稳定网络下一个调优良好的 TCP 连接的吞吐往往和 UDP 相差无几因为拥塞控制本来就是为了充分利用带宽设计的。UDP 快 的真实含义是没有队头阻塞——一个包丢了不影响后面的包而 TCP 会卡住等重传。在实时性敏感的场景下这才是关键差异。误区二用了 UDP 就不用管可靠性。很多团队选 UDP 是因为“丢一点没关系”但真到线上就会发现用户体验下降往往不是因为丢包本身而是因为应用层没做好兜底。UDP 场景必须假设丢包会发生并且设计好降级逻辑比如关键控制消息走重传、非关键状态更新直接丢弃。误区三TCP 的可靠性是免费的。TCP 保证的是“你交给它的字节最终会按顺序完整送到对端或者在失败时告诉你”。但它不保证“在什么时间送到”。一个重传了 5 次的包最终还是会送到但这段时间业务可能早就超时了。所以业务层必须自己设超时不能指望 TCP 帮你兜底。误区四TCP 有粘包问题。严格说这不是 TCP 的问题。TCP 是字节流本来就没有消息边界的概念。所谓“粘包”是应用层的协议设计缺陷——你没有定义好消息边界。解决办法就两个定长消息或者“长度前缀 内容”。后者更通用几乎所有的二进制 RPC 协议都是这么设计的。5.2 在 UDP 上自建可靠性什么时候值得什么时候别碰有一类场景比较特殊既需要 UDP 的低延迟特性又需要一定程度的可靠性保证。典型代表是实时音视频和游戏同步它们的做法通常是在 UDP 之上做一层轻量的可靠性机制。常见的做法有这几种序列号 丢弃过期每个包带一个递增序号接收端发现序号落后超过一定窗口就直接丢弃。这类场景里一个迟到的位置更新没有任何价值直接用新值覆盖就行。选择性重传NACK接收端发现序号不连续主动发 NACK 请求补发。这比 TCP 的累积确认灵活因为只补发真正丢掉的那几个包。前向纠错FEC发送端额外发一些冗余包接收端在丢包时可以用冗余包恢复不需要往返一次。代价是带宽开销通常 10%~20%。自适应码率根据实时测到的丢包率和 RTT 动态调整发送速率和编码质量。这是音视频领域的标准做法。这套机制完整实现下来的复杂度远超多数人的预期。我在一个项目里评估过从零实现一套带拥塞控制、重传、FEC 的 UDP 可靠传输层团队投入大概要 2~3 人月而且后续的调优是无底洞。如果业务不是对延迟极度敏感直接用成熟方案更划算。好消息是现在有现成的中间选择。基于 UDP 的 QUIC 协议在传输层做了多路复用、0-RTT 建连、连接迁移等能力而且避免了 TCP 的队头阻塞。如果你的服务是 HTTP 类场景、又需要在高丢包网络下保持体验用支持 HTTP/3 的组件比自己造轮子要靠谱得多。提示判断要不要自建可靠 UDP 传输层有个很实用的标准——如果你的业务在丢 1% 的包时体验下降明显、但在丢 5% 的包时还能接受那说明你其实需要 TCP。真正需要 UDP 的场景是丢 20% 的包时业务还能正常运转只是质量下降。这个区分很重要能帮你避免大量无效工作。6. 参数计算与可直接抄的实战代码6.1 MTU、MSS 和 UDP 载荷的精确计算这部分是纯算术但算错的人特别多而且算错的后果往往是“本地测试没问题上线就丢包”。先看一个以太网帧的构成标准 1500 MTU以太网头部 14 字节 FCS 4 字节 IP 头部 20 字节IPv4无选项 TCP 头部 20 字节无选项 → TCP MSS 1500 - 20 - 20 1460 字节 → UDP 最大载荷 1500 - 20 - 8 1472 字节IPv6 的话 IP 头部是 40 字节所以 MSS 1440UDP 载荷上限 1452。这些数字看起来是常识但有几个实际情况会让它变复杂情况一PPPoE 或隧道封装。如果链路上有 PPPoEMTU 会掉到 1492MSS 相应变成 1452。如果有 VXLAN 之类的隧道封装每个包还要额外减掉 50~100 字节。这种情况下的经典症状是小请求正常大响应超时——因为大包被分片后丢了而分片包丢了不会重传如果设置了 DF 位就直接丢弃并回 ICMP。情况二路径 MTU 发现失败。中间设备屏蔽了 ICMP 需要分片 消息发送方就一直在用过大 MTU 发包然后一直丢。排查方法是在应用层做 PMTU 探测或者干脆把 MTU 调保守一些。情况三UDP 大包。前面说过UDP 数据报超过 MTU 会在 IP 层分片。假设你要发 1473 字节的 UDP 载荷会分成两个 IP 分片任一片丢失整个数据报作废。而且很多防火墙和 NAT 设备对分片包的处理策略不一致有的直接丢弃有的重组后再校验但超时。所以 UDP 载荷的安全上限应该按 1472 再往下留点余量生产环境建议控制在 1200~1400 字节之间。注意IPv6 环境下路由器不会对包做分片只有发送方可以分片且最小 MTU 要求是 1280 字节。如果你的 UDP 包超过这个值在某些链路上会被直接丢弃。做 IPv6 兼容时这条一定要考虑进去。再来说缓冲区。UDP 的接收缓冲区设置尤其关键因为UDP 没有重传缓冲区满了就是直接丢包而且这个丢包是静默的应用层完全感知不到。import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 设置接收缓冲区为 4MB # 注意Linux 上内核对这个值会翻倍且上限受 net.core.rmem_max 限制 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) sock.bind((0.0.0.0, 9000)) # 查看实际生效的值内核会调整 print(sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF))需要提前确认系统上限cat /proc/sys/net/core/rmem_max cat /proc/sys/net/core/rmem_default如果SO_RCVBUF设的值超过rmem_max会被静默截断到上限不会报错。这个坑我在一个高频数据采集项目里踩过代码里明明设了 8MB实际生效只有 208KB原因是rmem_max没调而应用侧从来没检查过getsockopt的返回值导致高峰期丢包率飙到 3% 才发现。缓冲区大小怎么定给一个可用的估算方法缓冲区 ≥ 峰值包速率包/秒× 单包最大尺寸 × 允许的积压时间秒举例峰值 50,000 包/秒每包 1KB应用处理的最坏延迟 200ms50000 × 1024 × 0.2 ≈ 10,240,000 字节 ≈ 10MB这个数字意味着要调rmem_max到 10MB 以上然后应用侧SO_RCVBUF设 10MB。如果嫌太大另一个方向是提高应用侧消费速度——多线程读、批量处理、把处理逻辑从收包线程里挪出去。6.2 一套可直接跑的 TCP/UDP 服务端与客户端骨架下面这套代码是精简过的去掉了业务逻辑保留了我认为所有服务端都该有的设置。可以直接拿去改。先看 TCP 服务端import socket import struct def run_tcp_server(host0.0.0.0, port9000): srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 允许端口在 TIME_WAIT 状态下被复用绑定重启服务必备 srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 让内核在 accept 队列满时保持连接而不是直接丢弃 # 注意需要在系统层面支持macOS 上行为不同 srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) srv.bind((host, port)) # backlog 建议显式设置不要用默认值 # 实际生效值是 min(backlog, net.core.somaxconn) srv.listen(1024) while True: conn, addr srv.accept() # 关键关闭 Nagle避免小包延迟 conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 开启 keepalive探测死连接 conn.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) # 空闲 60 秒开始探测 conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) # 每 10 秒探一次 conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) # 连续 3 次无响应判定为死连接 conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) # 读超时避免线程被永久挂住 conn.settimeout(30) handle_connection(conn, addr) def handle_connection(conn, addr): 按长度前缀协议读取消息解决消息边界问题 try: while True: # 先读 4 字节长度头 header recv_exactly(conn, 4) if header is None: break (length,) struct.unpack(!I, header) if length 0 or length 16 * 1024 * 1024: # 长度异常直接断开防止被恶意长度拖垮 break payload recv_exactly(conn, length) if payload is None: break process_message(payload) # 业务处理 finally: conn.close() def recv_exactly(conn, n): 必须读满 n 字节处理 TCP 半包问题 buf bytearray() while len(buf) n: chunk conn.recv(n - len(buf)) if not chunk: return None buf.extend(chunk) return bytes(buf)这里recv_exactly是重点。TCP 是字节流recv(1024)返回多少字节完全不确定可能比你要的多也可能比你要的少。所有基于 TCP 的协议解析都必须用这种“循环读满”的写法否则一定会在某个压力场景下出问题。UDP 服务端的骨架import socket def run_udp_server(host0.0.0.0, port9001): sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP 接收缓冲区必须提前设置否则高峰期静默丢包 try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 * 1024 * 1024) except OSError as e: print(f设置缓冲区失败需要调整 net.core.rmem_max: {e}) sock.bind((host, port)) while True: data, addr sock.recvfrom(65535) # UDP 必须做长度检查不能假设对端一定发了合理大小的包 if len(data) 4: continue handle_datagram(data, addr)客户端侧有个容易忽略的点UDP 调用connect()之后能收到 ICMP 端口不可达错误这在调试时非常有用。import socket client socket.socket(socket.AF_INET, socket.SOCK_DGRAM) client.connect((127.0.0.1, 9001)) # 建立虚拟连接 # 发一个探测包如果对端没监听下次 recv 会抛出 ConnectionRefusedError client.send(b\x00\x01\x02\x03) try: resp client.recv(1500) print(收到响应:, resp) except ConnectionRefusedError: print(对端端口无人监听)7. 常见问题排查实录与速查表7.1 排查工具箱先看什么再看什么排查网络问题最忌讳一上来就抓包。正确的顺序是从宏观到微观每一层排除掉一批可能性。第一步看系统级统计。一条命令就能知道是连接问题还是流量问题ss -s # 输出包含总连接数、各状态连接数、各协议统计 # 看 TCP 的异常计数器 nstat -az | grep -E TcpExt|Tcp | grep -v 0$ # 看 UDP 的丢包和错误 netstat -su # 关注packet receive errors、receive buffer errorsnetstat -su里的RcvbufErrors是 UDP 排查的第一指标这个值在涨就说明接收缓冲区不够或者应用处理太慢不需要看别的。第二步看具体连接。TCP 的各种异常状态都在这里暴露# 按状态统计 ss -tan | awk NR1 {print $1} | sort | uniq -c | sort -rn # 只看特定状态 ss -tan state time-wait ss -tan state close-wait # 看某个端口的连接 ss -tn sport :9000CLOSE_WAIT数量多说明应用没关连接SYN_RECV多说明可能有扫描或半连接堆积FIN_WAIT2多说明对端迟迟不发 FIN受tcp_fin_timeout影响。第三步抓包定位。到这一步通常已经有明确怀疑对象了。我常用这几个过滤表达式# 只看握手和挥手包快速判断连接生命周期 tcpdump -i eth0 -nn tcp port 9000 and (tcp[tcpflags] (tcp-syn|tcp-fin|tcp-rst) ! 0) # 只看重传 tcpdump -i eth0 -nn tcp port 9000 and tcp[tcpflags] tcp-ack ! 0 -w retrans.pcap # 看 UDP 是否有分片IP 标志位 MF 或分片偏移非 0 tcpdump -i eth0 -nn udp port 9001 and (ip[6:2] 0x1fff ! 0 or ip[6:2] 0x2000 ! 0)抓到之后用 Wireshark 分析效率更高重点看这几个信号重复 ACK 次数、重传间隔、窗口大小变化、是否有 RST 插入。7.2 问题速查表九个我实际处理过的典型案例下面这些场景基本覆盖了日常工作中 80% 的传输层问题按现象、原因、处置整理成表方便对照现象大概率原因排查命令处置方式小包交互延迟稳定在 40ms 附近Nagle 延迟确认tcpdump看是否有小段延迟发送TCP_NODELAY1或合并写吞吐上不去稳定在某个值窗口太小BDP 不匹配看ss -ti的 cwnd 和 rcv_space调大SO_RCVBUF和tcp_rmemCLOSE_WAIT持续增长应用未 close socketss -tan state close-wait检查代码异常分支和 finallyTIME_WAIT占满端口短连接太多主动关闭ss -tan state time-wait | wc -l改连接池或调端口范围报错 Cannot assign requested address本地端口耗尽ss -s看 timewait 数量同上优先改长连接大量SYN_RECV半连接队列满或 SYN 攻击nstat -az | grep Listen调tcp_max_syn_backlog和somaxconnUDP 间歇性丢包接收缓冲区满netstat -su看 RcvbufErrors调rmem_maxSO_RCVBUFUDP 大包全部丢失超过 MTU 被分片分片被丢tcpdump看 IP 分片标志控制载荷 ≤ 1400 字节连接偶发被重置中间设备超时清理会话抓包看 RST 来源 TTL加应用层心跳缩短空闲时间单独说两个值得展开的。关于“连接偶发被重置”。这个问题的隐蔽之处在于服务端日志看不到任何异常客户端只看到一个Connection reset by peer。原因是中间有设备比如负载均衡、NAT 网关维护了自己的连接表长时间没有流量就把表项清了此时再来数据它不认识这个连接直接回 RST。排查方法很简单在客户端和服务端同时抓包比对 RST 包的 TTL 和 IP ID如果 TTL 明显小于服务端正常的包说明 RST 来自中间设备而不是真实对端。处置方式就是加应用层心跳让连接始终有流量。心跳间隔要小于中间设备的空闲超时一般设 30~60 秒比较稳妥。注意 TCP 自带的 keepalive 默认是 7200 秒2 小时指望它保活是不现实的必须自己在应用层做。关于“UDP 丢包但抓包看不到丢”。这种情况通常说明包已经到达网卡但没进到应用的 socket 缓冲区。判断方法是同时看三处tcpdump抓到了包、netstat -su的RcvbufErrors在涨、应用没收到。三者同时成立就确诊是缓冲区问题。如果tcpdump都没抓到那问题在更前面——网卡 ring buffer 溢出看ethtool -S eth0 | grep drop或者上游设备丢包。提示做 UDP 服务时建议在应用层加一个每秒统计的计数器把“收包数 / 期望包数”的比例上报出来。这个指标比任何内核计数器都直观出问题时一眼就能看出丢了几个百分点。最后分享一个我觉得最有用的习惯任何一次协议选型和参数调优都要留下可复现的验证手段。调了SO_RCVBUF之后用getsockopt读回实际值确认改了拥塞控制算法之后用固定压测场景对比前后吞吐和 P99 延迟加了心跳之后用长连接维持测试跑 24 小时看有没有断连。这些验证动作多花不了十几分钟但能在问题回溯时帮你省掉大半天。我见过太多团队改完参数就上线出问题之后连“改之前是什么状态”都说不清楚只能从头再来一遍。另外一点体会是TCP 和 UDP 的选择往往不是技术问题而是产品问题。同样一个消息推送功能产品要求“消息必达”就得用 TCP 加确认重发要求“延迟优先、允许偶发丢失”就可以用 UDP 加序号丢弃。先把这个前提和产品对齐再谈技术方案能避免很多返工。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑