TCP/UDP协议选型与排障:Nagle、粘包与TIME_WAIT实战
上周帮一个团队排查他们的消息推送服务压测到三千连接的时候 CPU 才跑到百分之四十出口带宽用了不到三成但端到端延迟已经飙到八百多毫秒而且越压越离谱。翻日志、抓包、看监控折腾了大半天最后发现问题出在一个特别朴素的地方——他们把所有消息都塞在 TCP 长连接上而每条消息只有几十字节还顺手关掉了TCP_NODELAY。Nagle 算法和延迟确认撞在一起每条消息硬生生被拖了几十毫秒。这件事让我又一次确认TCP 和 UDP 的差别从来不是教科书上那张可靠/不可靠、面向连接/无连接的对照表能讲清的真正让人栽跟头的是这些机制在真实网络里的联动效应。这篇内容就是想把这些年我在协议选型和排障上攒下来的东西摊开讲一遍从三次握手到底交换了什么到 TIME_WAIT 为什么会把端口吃干再到粘包、MTU、缓冲区这些一到线上就现原形的细节。不管你是刚学网络编程还是已经写过几套长连接服务应该都能从里面找到能直接用的东西。1. 别急着背三次握手先把这两种协议放回它们真正的位置1.1 它们解决的根本不是同一个问题很多人一开始学网络脑子里会形成一种印象TCP 是好的协议UDP 是差的协议因为一个可靠一个不可靠。这个认知偏差会一直带到工作里导致选型的时候下意识觉得用 UDP 就是偷懒。实际上这两者的定位差异用生活里的东西类比特别清楚。TCP 像寄挂号信你去邮局工作人员给你一个单号信件到达对方手里要签收中途丢了邮局会负责查找甚至重投你也能随时查进度。代价是每一次寄信都得走一遍完整流程手续多、开销大。UDP 像往楼下扔纸条你写好、扔出去至于风把它吹到哪儿、有没有人捡到发送方一概不知也不负责。但它的优势是快扔完就走不用等回执。关键在于挂号信和扔纸条服务的是完全不同的需求。你要传一份合同必须走挂号信你要在球场上喊一句传球扔纸条式的即时性才是核心哪怕偶尔没听清也无所谓下一句就补上了。协议选型本质上就是在回答一个问题这条数据是必须完整到达更重要还是尽快到达更重要。绝大多数选型错误都源于没有把这个问题问清楚就动手写代码。还有一个更底层的差异经常被忽略TCP 提供的是一段字节流它不保留任何消息边界。你调用三次send写了三坨数据对方可能一次recv全收走也可能分五次收完。UDP 提供的是数据报你发一个包对方就收一个包边界天然保留要么整包到达要么整包丢失不会出现半包。这个差异直接决定了应用层要不要自己做消息分帧也是后面粘包问题的根源。1.2 从 socket API 看一条流和一个包的差别如果从编程接口的角度看两者的差异会更加具体。TCP 的连接是有状态的服务端要listen、accept客户端要connect双方各自维护一个四元组源 IP、源端口、目的 IP、目的端口标识的连接内核里为每条连接维护发送缓冲区、接收缓冲区、拥塞窗口、重传队列等一大堆结构。这意味着每多一条连接服务端就多一份内存开销。一万条空闲长连接占用的内核内存可能就到 GB 级别。UDP 没有连接这个概念一个 socket 可以收任意来源的数据报内核里几乎没有per-peer的状态。这就是为什么 DNS 查询用 UDP——客户端发一个几十字节的查询包等服务端回一个响应包一问一答结束不需要维护任何连接状态。全球每天几十亿次 DNS 查询如果用 TCP光是握手和挥手的开销就是天文数字。但无状态也意味着应用层要自己承担更多责任。TCP 帮你处理了顺序、去重、重传、流量控制UDP 把这些全部交还给你。你要做实时音视频就得自己在 UDP 之上实现序列号、抖动缓冲、前向纠错、拥塞控制——这也是为什么 WebRTC 那套东西复杂得吓人。所以在动手之前先问自己一句我有没有能力、有没有必要在应用层重新实现一遍传输层大多数业务系统的答案是没有。2. TCP 的可靠性是怎么造出来的拆开那台看不见的机器2.1 三次握手交换的其实是三个状态不是三次客套面试里问三次握手很多人能背出 SYN、SYN-ACK、ACK但说不出为什么必须三次。真正的理由很简单双方都要确认我能发、你能收和你能发、我能收这两个方向都通。第一次客户端发 SYN携带自己的初始序列号 ISN_c。服务端收到说明客户端能发、服务端能收这一条成立了但服务端还不知道客户端能不能收。第二次服务端回 SYN-ACK携带自己的 ISN_s 并确认 ISN_c1客户端收到说明服务端能发、客户端能收也成立同时客户端确认了自己之前的发送被收到。第三次客户端发 ACK 确认 ISN_s1服务端收到才最终确认服务端发出的东西客户端收得到。两次为什么不行因为如果只有两次服务端无法确认自己发出的 SYN-ACK 是否被对方收到也无法排除历史连接残留下来的重复 SYN 包。三次是能同时满足双向确认的最小次数多一次是浪费少一次不成立。这里有个实战细节值得记住初始序列号不是从 0 或 1 开始的而是基于时钟和四元组哈希随机生成的。这个设计是为了防止旧连接的报文混进新连接同时也让外部难以猜测序列号从而伪造报文。你在抓包时看到那些巨大的随机数字就是这个原因。握手还牵扯到一个非常实际的性能问题每一次新建连接都至少需要一个 RTT。跨地域访问RTT 可能是 30 毫秒到 200 毫秒如果你的业务是短连接模式每个请求都要握手一次那光是建立连接就吃掉了大量时间。这就是连接池、长连接、keep-alive 存在的意义也是 HTTP/2 多路复用、HTTP/3 用 0-RTT 恢复的动机所在。还有一个容易踩的坑SYN 队列和 accept 队列是两回事。服务端收到 SYN 后先放进半连接队列SYN queue握手完成后移入全连接队列accept queue等应用调用accept取走。如果应用处理慢全连接队列满了新的握手完成连接会被丢掉或者拒绝客户端表现就是连接超时但服务端看起来没压力。Linux 上net.core.somaxconn和tcp_max_syn_backlog控制的正是这两个队列的长度后者还受tcp_syncookies影响。2.2 丢包之后序号、确认号与重传定时器TCP 保证可靠的核心是三样东西序号、确认、重传。发送方给每个字节编号接收方收到后回 ACK 告诉对方我期望的下一个字节是 X发送方据此判断哪些数据已经被确认哪些还在路上。重传的触发主要有两种。一种是超时重传发送方为已发送未确认的数据启动一个定时器超时还没收到 ACK 就重发。这个超时值RTO不是固定的而是根据往返时间动态估算的标准做法是采样 RTT 并做平滑处理同时设置上下界避免过度激进或过于保守。另一种是快速重传当发送方连续收到三个重复 ACK说明接收方收到了乱序的数据中间缺了一块不等定时器超时就立即重发缺失的那一段。这就是所谓的三次重复 ACK机制。再往后还有 SACK选择性确认让接收方告诉发送方我收到了哪些不连续区间发送方可以只重传真正丢失的部分而不是把整段重发。在高丢包率环境下SACK 对吞吐的影响非常明显。现代操作系统基本默认开启你可以在/proc/sys/net/ipv4/tcp_sack里确认。实际排查中重传率是一个非常有价值的指标。用netstat -s或者ss -ti能看到每条连接的 retrans 计数和 rtt 采样。如果某条连接的重传率超过 1%基本可以断定链路有问题要么是中间设备丢包要么是缓冲区被打爆。我遇到过一次线上延迟抖动最后定位到是某个中间网络设备的队列满了在随机丢弃重传率从 0.1% 涨到了 3%而应用层只能看到偶尔慢一下。2.3 窗口与拥塞控制带宽够却传不快的原因很多人有个误解觉得带宽够大传输就该快。但真实情况是TCP 的发送速率受两个窗口的约束取两者中较小的那个接收窗口rwnd和拥塞窗口cwnd。接收窗口是接收方告诉发送方的我还能接收多少字节。如果接收方的应用层读得慢接收缓冲区被填满rwnd 就会缩小到 0发送方必须停下来等窗口更新。这就是为什么下游处理慢会反压到上游网络层。我们开头那个案例部分原因也是这个——推送服务在处理消息时做了同步写磁盘处理速度跟不上接收缓冲区被填满窗口缩小发送方只能等。拥塞窗口是发送方自己维护的代表我认为网络还能承受多少在途数据。它的增长遵循慢启动和拥塞避免刚开始指数增长每收到一个 ACK 就把 cwnd 加一一个 RTT 翻一倍到达慢启动阈值后转为线性增长一旦检测到丢包就把 cwnd 砍半甚至降到 1重新开始。这套逻辑的本质是试探网络容量撞到墙就往回退。不同的拥塞控制算法在丢包场景下表现差异极大。传统的基于丢包的算法比如 CUBIC在有一定随机丢包的链路上会过于保守吞吐上不去而 BBR 这类基于带宽和 RTT 建模的算法则不那么敏感。如果你的服务跨公网、跨运营商拥塞控制算法的选择可能比服务器配置带来的差异还大。Linux 上可以用sysctl net.ipv4.tcp_congestion_control查看和切换但要注意对端和中间链路的兼容性不能想当然。还有一个非常常见的问题带宽延迟积BDP。一条链路的理论最大吞吐 窗口大小 / RTT。假设 RTT 是 100 毫秒窗口只有 64KB那吞吐上限就卡在 640KB/s 左右哪怕你有千兆带宽也没用。这就是为什么跨洋传输一定要开窗口缩放window scaling把窗口扩到 MB 级别。2.4 四次挥手与 TIME_WAIT2MSL 到底在防什么断开连接需要四次交互因为 TCP 是全双工的两个方向要分别关闭。主动关闭方发 FIN被动方回 ACK此时主动方不再发送数据但还能接收被动方处理完剩余数据后再发 FIN主动方回 ACK连接才算彻底关闭。真正让人头疼的是 TIME_WAIT 状态。主动关闭的那一方在发完最后一个 ACK 之后还要等 2 倍的 MSL报文最大生存时间才真正释放连接Linux 上通常是 60 秒。为什么要等两个原因一是保证最后一个 ACK 如果真的丢了对方重发的 FIN 还能被正确处理不至于让对方卡在 LAST_ACK二是让这条连接上残留的旧报文在网络中自然消亡避免它们混进之后复用相同四元组的新连接。问题在于如果是服务端主动关闭连接短连接 HTTP 服务就是典型大量连接会进入 TIME_WAIT每条占着一个本地端口。端口范围默认是 32768 到 60999也就两万多个一旦 QPS 上去端口很快被吃光新连接直接报Cannot assign requested address。常见的应对方案有几种需要按场景选。开tcp_tw_reuse允许在安全条件下复用 TIME_WAIT 连接发起新连接这个相对温和调小tcp_fin_timeout只影响 FIN_WAIT_2对 TIME_WAIT 无效把服务端改成不主动关闭让客户端先关或者干脆用长连接和连接池才是治本的办法。至于那些关掉 TIME_WAIT 的极端参数在 NAT 环境下极易引发连接混乱我不建议在生产上动。3. UDP 的不可靠是一种设计选择不是缺陷3.1 八字节头部省掉的是状态不是能力UDP 头部只有八个字节源端口、目的端口、长度、校验和。对比 TCP 最少二十字节的头部加上各种选项差距非常直观。但更关键的差别不在头部大小而在于 UDP 不在内核里维护连接状态。没有状态意味着几件事。发送方不需要等待握手就能直接发数据一个 RTT 就省下来了不需要维护重传队列和窗口内存占用极低不需要处理拥塞控制的退让逻辑发送速率完全由应用决定。DNS 之所以快很大程度上是因为一次查询一个 RTT 就搞定不用先握手。但没有状态也意味着内核不会帮你做任何保障。包丢了就丢了乱序了就乱序了重复了就重复了这些全部由应用层自己处理。所以当你听到UDP 不可靠这个说法时正确的理解是UDP 不承诺可靠性但也不阻止你在它之上构建可靠性。QUIC 就是活生生的例子——它在 UDP 之上重新实现了可靠传输、拥塞控制、多路复用同时避开了 TCP 在内核里的很多历史包袱。顺带说一句校验和。UDP 的校验和是可选的IPv4 下可以置 0 表示不校验IPv6 下则强制。有些场景比如已经在上层做了完整性校验的隧道会关掉它来省一点 CPU但在普通业务里没必要校验和的计算开销早就被硬件卸载掉了。3.2 组播、广播与实时性UDP 真正的主场UDP 有几个 TCP 根本做不到的能力这是选型时必须考虑的。组播multicast一份数据同时发给多个接收者网络设备负责复制。TCP 只能一对一要做一对多就必须为每个接收者建一条连接发送端带宽和 CPU 都扛不住。视频会议、行情推送、局域网发现协议都依赖组播。广播broadcast则是发往同一网段的所有设备常见于设备发现和服务宣告。低延迟实时传输语音、视频、游戏状态同步这类场景对延迟的敏感度远高于对丢包的敏感度。一个 200 毫秒前的声音帧就算完整到达也失去了意义丢一个包顶多是短暂的杂音用户的感知远好于卡顿。用 TCP 反而糟糕因为 TCP 的队头阻塞会让后续所有数据都等着那个丢失的包重传延迟越积越大。这就是为什么实时音视频几乎清一色跑在 UDP 上。无连接的一问一答DNS、NTP、监控探针这类请求-响应模式一次交互就结束用 TCP 建连接纯属浪费。这类场景用 UDP一个包去一个包回简单直接。需要说明的是实时音视频在 UDP 之上并不是裸奔。RTP 提供时间戳和序列号RTCP 做质量反馈抖动缓冲负责重排和抗抖动FEC 和前向纠错在丢包时做补偿。整套体系相当复杂这也是为什么自己从零实现一套实时传输协议通常不是明智选择直接用成熟方案更省事。3.3 关于UDP 一定丢包的误解经常听到UDP 会丢包所以不能用这个说法有一定道理但不准确。丢包的原因通常是三类链路拥塞时中间设备的队列满了这是 TCP 也会遇到的接收端 socket 缓冲区满了应用层读得太慢或者发送速率超过了链路容量因为 UDP 没有拥塞控制发送方会一直猛发直到丢包。第一类和第三类本质上是同一个问题没有拥塞控制的发送方会把网络和自己都打爆。这也是 QUIC 在 UDP 之上必须重新实现拥塞控制的原因。第二类则完全是应用层的问题接收缓冲区设得太小或者处理逻辑太慢导致内核直接丢弃新到的包。这个坑下面会专门讲。所以正确的说法是UDP 本身不制造丢包它只是不帮你隐藏丢包。如果你把发送速率控制在合理范围接收端处理得足够快UDP 在局域网和优质链路上的丢包率可以低到万分之一以下。4. 选型的本质先给业务的完整和即时排个序4.1 一张对照表把决策过程钉死光讲原理不够实际做决定的时候需要一张能落地的表。我一般会按业务特征往下筛几个关键维度列出来是这样的业务特征优先 TCP优先 UDP判断依据数据完整性要求强订单、支付、配置下发弱音视频帧、状态同步丢失一条能否靠后续数据补偿延迟敏感度一般百毫秒级可接受高几十毫秒即影响体验超过阈值的数据是否还有价值消息大小与频率大块、低频小块、高频头部开销占比是否显著是否需要一对多不需要需要组播或广播TCP 无法原生支持网络环境内网、优质链路弱网、移动网络弱网下拥塞控制表现差异大团队实现能力直接可用需自建可靠性层自研传输层的成本往往被低估这张表的用法不是逐行打分求平均而是先看完整性要求这一行如果答案是强基本就锁定 TCP 了后面的维度只是优化空间。反过来如果完整性要求弱、延迟敏感度高UDP 才有讨论的必要。举个具体的例子。即时通讯的消息收发看起来很像实时但消息不能丢丢了一条用户就会投诉所以底层必须是 TCP或者基于 UDP 的可靠传输如 QUIC。而同一款应用里的语音通话丢一帧用户听不出来卡一下用户立刻感知那就该走 UDP。同一个 App 里两套协议并存是常态不是什么奇怪的设计。4.2 那些反直觉的选型案例有几个场景的选型结论和直觉相反值得单独说。文件传输用 TCP 而不是 UDP。这个看起来没争议但总有人觉得UDP 快所以传文件也用 UDP 加自己重传。问题在于自建重传你就得同时实现拥塞控制、流控、乱序重排、丢包检测任何一个环节做得不如内核整体性能就会掉到 TCP 以下。内核的 TCP 栈经过几十年优化还有各种硬件卸载支持不是几周能追上的。游戏状态同步用 UDP但登录和商城走 TCP。这是很典型的组合。游戏里位置同步每秒几十次丢一帧下一帧就补上了用 UDP而充值、道具购买这类操作绝对不能丢也不能重复走 TCP 或者基于 TCP 的可靠通道。同一个游戏客户端里同时维护两种连接是常规做法。内网服务间调用优先考虑够用就好。很多团队在内网 RPC 上纠结用 TCP 还是 UDP其实内网丢包率极低TCP 的那些重传、拥塞控制在正常情况下几乎不触发性能差异微乎其微。真正该优化的是序列化格式、连接复用、批量合并协议选择带来的收益远小于这些。把精力花在协议选型上往往是本末倒置。监控探针和轻量查询用 UDP但要有重试。一次查询丢了就重发一次简单粗暴。但要注意重试次数要有上限否则对端挂了你会一直重发反而放大故障。4.3 在 UDP 上自建可靠传输为什么多数团队最后回到 TCP 或 QUIC这个坑我见过太多团队踩。起因往往是TCP 太慢了我们要自己搞一套快的然后花几个月实现了一个带序列号、确认、重传、拥塞控制的协议最后发现性能还不如原生 TCP因为人家在细节上打磨了三十年。自建可靠传输的真正难点不在发出去确认一下这么简单。你要处理RTT 估算、超时重传的定时器管理、拥塞窗口的慢启动和退避、快速重传和恢复、乱序缓存的内存上限、接收窗口的流控、Nagle 类似的合并策略、路径 MTU 发现、以及对各种中间设备行为的兼容。每一项都有大量边界情况线上一个罕见的丢包模式就能让你排查一周。如果确实需要 UDP 上的可靠性和低延迟正确的做法是直接用 QUIC 而不是自己造。QUIC 在用户态实现绕开了内核协议栈的一些限制支持 0-RTT 恢复、连接迁移切换网络不断连、多路复用无队头阻塞而且有成熟的库可用。前提是你的场景确实需要这些特性并且能接受它带来的调试复杂度——加密传输意味着抓包看不到明文排查问题要额外花功夫。5. 六个真实踩过的坑与完整排查链路5.1 粘包把 TCP 当消息边界用这是 TCP 编程里最常见的问题没有之一。表现是客户端发了两条消息服务端一次读到两条拼在一起的内容解析直接报错或者串数据。根因很明确TCP 是字节流没有消息边界的概念。发送方的多次send可能被合并成一个 TCP 段发出去Nagle 算法会干这事也可能被拆成多个段接收方的缓冲区里可能同时躺着多条消息的部分或全部。任何一次 recv 对应一条消息的假设都是错的。排查这类问题的第一步是抓包确认。用 tcpdump 或者 Wireshark 看实际发出的段你会清楚看到两条消息被合并在一个包里或者一条消息跨了多个包。看到这个现象问题就定位了。解决方案有两种主流思路。固定长度消息最简单但浪费空间只适合定长指令。更通用的是长度前缀消息头部固定几个字节记录正文长度接收方先读头部解析出长度再按长度读正文不够就继续等下一批数据。这个方案实现简单、解析快、支持任意长度是绝大多数私有协议的选择。还有分隔符方案比如以\n分隔适合文本协议但要处理转义问题正文里出现分隔符会引发歧义。实现长度前缀的时候有个容易忽略的细节读循环必须处理只读到半个头部的情况。头部四个字节可能第一次只来了两个这时候不能直接解析要缓存起来等凑齐。同理正文也可能分批到达。正确的做法是维护一个接收缓冲区循环尝试从中解析完整消息解析出一条就消费掉对应字节直到剩余数据不足以构成完整消息为止。这个循环逻辑写错的概率极高建议直接封装成可复用的解码器并写单元测试覆盖各种拆分组合。5.2 Nagle 撞上延迟 ACK40 毫秒的诡异卡顿这个坑就是开头那个案例的真凶。现象是小消息发送延迟稳定在 40 毫秒左右偶发但规律压测时更明显。Nagle 算法的逻辑是如果还有未被确认的小数据就先攒着等凑够一个 MSS 或者收到 ACK 再发。它的初衷是减少小包数量、提高带宽利用率对批量传输有好处但对交互式的小消息是灾难。延迟确认则是接收方的优化收到数据后不立即回 ACK而是等一小会儿通常 40 到 200 毫秒看能不能捎带在反向数据里一起确认。这两个机制一组合就出现了经典的糊涂窗口发送方等 ACK 才发下一包接收方等一会儿才回 ACK双方互相等待硬生生造出几十毫秒延迟。更坑的是这个延迟和网络状况无关你查带宽、查路由都查不出问题。解决办法是给这类连接设置TCP_NODELAY关掉 Nagle让每条消息立即发出。代价是小包变多但在交互式场景下这点开销完全可以接受。设置方式很简单import socket sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sock.connect((127.0.0.1, 9000))判断要不要开的经验是消息小、频率高、要求低延迟就关掉 Nagle大批量连续写数据保持默认反而更好。不要无脑全局关闭那会让大量小包涌入网络反而增加中间设备的负担。5.3 MTU 与 IP 分片大包静默丢失有一次做 UDP 服务小包测试全过一上大包就出问题而且是有时能收到有时收不到这种诡异状态。抓包发现发送端明明发出去了接收端内核的统计里也没看到丢包记录。问题出在 MTU 上。以太网的标准 MTU 是 1500 字节减去 IP 头 20 字节和 UDP 头 8 字节UDP 载荷的安全上限是 1472 字节。超过这个大小的包会被 IP 层分片分成多个片传输。只要其中任何一片丢失整个数据报就废了接收端不会交付给应用层而且这个丢失在应用层完全无感知——你只会看到有的时候收不到。更麻烦的是路径 MTU。从发送端到接收端中间可能经过 MTU 更小的链路比如某些隧道、PPPoE 环境下的 1492这时分片会更碎丢一个片的概率更高。如果设置了 DFDont Fragment位包会被中间设备直接丢弃并回一个 ICMP 差错但这个 ICMP 在某些网络环境下会被过滤掉表现就是发出去石沉大海。处理方式有三层。应用层控制单包大小把 UDP 载荷限制在 1200 字节以内给隧道封装留足余量需要传大块数据就自己分片并加序号。系统层开启路径 MTU 发现让内核自动探测链路能承受的最大包。排查层用ping -M do -s size逐级试探真实 MTU找到丢包临界点。# 测试路径 MTU从 1472 开始往下试 ping -M do -s 1472 -c 2 192.168.1.1 ping -M do -s 1400 -c 2 192.168.1.1TCP 侧也会遇到类似问题只是内核会自动做 MSS 协商通常不用操心。但如果你在隧道里跑 TCP封装层的额外开销会让有效 MSS 变小协商不当就会出现连接建立成功但大数据传不动的怪现象。5.4 TIME_WAIT 堆积导致端口耗尽这个问题的典型症状是服务跑一段时间后新建连接开始大量失败报错是Cannot assign requested address但服务端和客户端都没崩。排查路径很清晰。先看当前的连接状态分布ss -s ss -tan state time-wait | wc -l如果 TIME_WAIT 数量达到几万基本可以确诊。然后看是谁在主动关闭——ss -tan输出里 TIME_WAIT 状态那一侧的本地地址就是主动关闭方。短连接客户端是最常见的元凶。造成堆积的原因通常是客户端发起大量短连接每次用完就关服务端处理快所以立即回 FIN客户端就成了主动关闭方每个连接留 60 秒 TIME_WAIT。假设每秒 500 个请求60 秒就是三万个连接占着端口本地端口范围根本不够分。解决的优先级我一般这样排。第一优先是用连接池或长连接从根上减少短连接数量收益最大且没有副作用。第二是服务端配置成主动关闭让服务端承担 TIME_WAIT前提是服务端有足够的端口资源并且统一处理更可控。第三才是调tcp_tw_reuse让内核在安全前提下复用处于 TIME_WAIT 的连接发起新连接这个在客户端侧效果明显但要注意它只影响出站连接。至于关掉 TIME_WAIT 的那些激进参数在 NAT 或负载均衡后面极易造成串包坚决不要用。5.5 UDP 接收缓冲区溢出UDP 服务在高负载下出现莫名其妙丢包应用层日志一切正常发送端也确认发出去了但接收端就是少了一部分包。先看内核统计netstat -su | grep -i receive buffer errors # 或者用 nstat nstat -az | grep UdpRcvbufErrors这个计数如果持续增长说明接收缓冲区满了内核在丢包。UDP 没有流控接收缓冲区满了不会通知发送方减速直接静默丢弃。根因通常是两个缓冲区太小或者应用层读得太慢。默认的net.core.rmem_default通常是 208KB 左右高并发下很快就不够。可以调大net.core.rmem_max然后在应用层用SO_RCVBUF单独设置import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) sock.bind((0.0.0.0, 9000))注意内核实际分配的值会是设置值的两倍有内部开销而且受rmem_max限制所以系统参数要一起调。但调参数只是缓解真正的解决是提升消费速度。常见做法是把收包和处理解耦一个线程专门recvfrom收包塞进无锁队列工作线程池并行处理。收包线程只做拷贝不做业务逻辑速度能提上来。另一个思路是用多个 socket 加SO_REUSEPORT让多个线程各自绑同一个端口内核按哈希分流避免单线程成为瓶颈。5.6 心跳设计不当造成的假在线长连接服务里有个特别隐蔽的问题连接看起来还在但实际已经不可用了。用户表现为消息发出去没反应要重连才好。根因在于 TCP 连接在半开状态下的检测。如果一端断电、断网或者进程被杀而没有走正常关闭流程另一端的内核完全不知道连接会一直保持在 ESTABLISHED。应用层如果只看连接未报错就认为可用就会往一个死连接里写数据。TCP 本身有 keepalive 机制但默认的空闲时间是 7200 秒探测间隔 75 秒探测次数 9 次加起来两个多小时才发现问题完全不可用。可以调小sysctl -w net.ipv4.tcp_keepalive_time120 sysctl -w net.ipv4.tcp_keepalive_intvl15 sysctl -w net.ipv4.tcp_keepalive_probes3但这只解决了内核层的连接保活还有几个细节要处理。一是 keepalive 只在连接空闲时触发如果一直在写数据探测不会发出去。二是很多中间设备负载均衡、NAT 网关有自己的空闲超时通常是几分钟到几十分钟超时会静默丢弃连接表项此时两端内核都以为连接还在。所以应用层心跳几乎是必需的。设计要点有几个心跳间隔要小于中间设备的最短空闲超时我一般按 30 到 60 秒设置比常见网关的超时保守心跳要有超时判定发出去后一段时间没收到响应就主动断开重连不能一直等心跳要能探测单向故障所以应该用请求-响应的形式而不是单向发送。单向发送只能证明我还能发证明不了我还能收。6. 可以直接抄的参数与代码清单6.1 socket 选项速查这些选项在调优时用得上我按场景整理了一下选项适用场景说明TCP_NODELAY交互式小消息关闭 Nagle避免 40ms 级延迟SO_REUSEADDR服务端重启允许绑定 TIME_WAIT 状态的端口SO_REUSEPORT多进程同端口内核按哈希分流提升收包吞吐SO_RCVBUF高吞吐 UDP调大接收缓冲区避免静默丢包SO_SNDBUF大块数据发送配合 BDP 调整发送缓冲TCP_KEEPIDLE长连接覆盖系统级 keepalive 空闲时间SO_LINGER需立即释放连接谨慎使用可能造成 RSTSO_LINGER那个要特别提醒。设置 linger 为 0 并关闭连接会直接发 RST 而不走正常挥手连接立即释放。这在某些场景下有用比如快速回收资源但会导致对端收到 RST 而不是 FIN可能丢掉还在路上的数据。生产环境用之前想清楚别为了省几个 TIME_WAIT 而丢数据。6.2 长度前缀帧的设计与实现这是私有协议里最通用的分帧方案。设计上我建议头部固定 4 字节大端表示的正文长度后面跟正文。4 字节上限是 4GB够用大端保证跨平台一致如果担心超大包攻击再加一个最大长度校验。解码器的核心逻辑是维护一个 bytearray 缓冲区每次收到数据就追加然后循环尝试解析import struct class FrameDecoder: HEADER_SIZE 4 MAX_BODY 16 * 1024 * 1024 # 16MB 上限防止恶意大包 def __init__(self): self._buf bytearray() def feed(self, data: bytes): self._buf.extend(data) messages [] while True: if len(self._buf) self.HEADER_SIZE: break body_len struct.unpack(I, self._buf[:self.HEADER_SIZE])[0] if body_len self.MAX_BODY: raise ValueError(fframe too large: {body_len}) total self.HEADER_SIZE body_len if len(self._buf) total: break body bytes(self._buf[self.HEADER_SIZE:total]) del self._buf[:total] messages.append(body) return messages这段代码有几个值得注意的点。del self._buf[:total]在缓冲区很大时会有内存搬移开销高频场景可以换成维护一个读偏移量加定期压缩的方式。异常处理上超过上限直接抛错并关闭连接不要尝试宽容处理那往往会给攻击者留下可乘之机。还有就是feed要支持一次传入任意长度的数据内部循环保证解析出所有完整帧调用方不需要关心每次读到的字节数。6.3 系统级参数与压测验证最后的清单按用途分类# 连接队列应对高并发建连 sysctl -w net.core.somaxconn65535 sysctl -w net.ipv4.tcp_max_syn_backlog65535 # TIME_WAIT 复用客户端侧 sysctl -w net.ipv4.tcp_tw_reuse1 sysctl -w net.ipv4.tcp_fin_timeout30 # 缓冲区高吞吐场景 sysctl -w net.core.rmem_max16777216 sysctl -w net.core.wmem_max16777216 sysctl -w net.ipv4.tcp_rmem4096 87380 16777216 sysctl -w net.ipv4.tcp_wmem4096 65536 16777216 # 长连接保活 sysctl -w net.ipv4.tcp_keepalive_time120 sysctl -w net.ipv4.tcp_keepalive_intvl15 sysctl -w net.ipv4.tcp_keepalive_probes3调完一定要压测验证别改完就上线。验证的指标至少包括建连成功率和 P99 建连耗时、端到端延迟分布、重传率、接收缓冲区溢出计数、TIME_WAIT 和 ESTABLISHED 的数量变化。压测时要模拟真实业务的消息大小和发送频率用大包跑出来的结论套到小包场景上会完全失真反过来也一样。我在实际项目里的体会是协议层的调优收益往往是有限的大多数性能问题的根因在应用层的设计——同步阻塞、无谓的拷贝、锁竞争、处理逻辑太重。先把这些捋顺再看内核参数顺序反了容易白折腾。另外任何参数调整都要留下明确的记录和回滚方案网络参数的影响面很大出问题时能快速恢复比什么都重要。