TCP/UDP选型与延迟抖动排查:Nagle、粘包与抓包调优 1. 一次延迟抖动排查TCP/UDP 的选型为什么总在出问题之后才被想起前阵子帮一个做实时协作的团队看线上问题现象很典型接口 P99 延迟平时 30 毫秒每隔几分钟就冒出一批 200 毫秒以上的毛刺监控上看不出带宽打满CPU 和内存也都很正常。团队一开始怀疑是下游服务抖动加了一堆日志折腾两天没结论。后来抓了一次包才看明白问题出在自己写的长连接网关上——业务代码每次发完一个小包就等着对端回执而 Nagle 算法把小包攒着不发对端的延迟 ACK 又在等着攒够数据再确认两边互相等硬生生把一个本该几十毫秒的往返拖成了两百多毫秒。这类故事在做过网络编程的人身上几乎都发生过。TCP 和 UDP 这两个词面试里背得滚瓜烂熟真到了写代码、调参数、查线上问题的时候很多人对它们的理解还停留在“TCP 可靠、UDP 不可靠”这一句话上。而实际情况是TCP 的“可靠”是有代价的代价藏在内核的缓冲区、拥塞窗口、重传计时器里UDP 的“不可靠”也不是缺陷而是一种把控制权交还给应用层的设计选择用得好可以做到比 TCP 更低的延迟用得不好就是一堆丢包和乱序。这篇内容想聊的不是教科书上的三次握手四次挥手而是把这些协议知识落到真实工程里的那部分为什么握手阶段协商的选项会影响后面的吞吐粘包到底该怪谁心跳间隔凭什么定成 30 秒内核参数改了之后为什么有时候反而更慢。适合已经写过 socket 代码、但被线上问题教育过的后端和客户端开发者也适合正在做协议选型的技术负责人。文中涉及的内核参数和具体数值都以常见 Linux 发行版为参考不同内核版本存在差异实际以本机sysctl输出为准。2. 把 TCP 拆成一条“有记忆的水管”连接、确认、重传的真实逻辑2.1 三次握手协商的不只是“能不能连”很多人把三次握手理解成“确认双方都在线”这个理解不算错但漏掉了重点。握手阶段真正在做的事情是双方交换各自的初始序列号并且顺带协商一批影响后续传输行为的选项。这些选项包括最大报文段长度 MSS、窗口缩放因子、选择性确认 SACK、时间戳有些实现还会带上快速打开的能力。MSS 决定了对端一次最多能发多大的数据段窗口缩放因子决定了接收窗口最大能通告到多大——原始 TCP 头里窗口字段只有 16 位最大 65535 字节乘以缩放因子才能撑到更大的值。这里有个特别容易踩的坑中间经过某些网络设备时MSS 协商可能被改写或者丢弃选项导致实际生效的 MSS 比预期小很多表现出来就是同样的数据量下包数变多、头部开销变大、吞吐上不去。所以当遇到“带宽够但速度就是起不来”的情况抓包看握手包里的 MSS 选项往往比看应用日志更直接。另一个常被忽略的点是半连接队列和全连接队列。服务端收到 SYN 后会把连接放进 SYN 队列握手完成后移到 accept 队列等待应用调用 accept 取走。如果应用 accept 的速度跟不上或者 accept 队列长度设置得太小新连接就会被丢弃或者被内核直接重置。现象是客户端偶发连接超时服务端却看不到任何业务日志。这类问题的排查方向很明确看net.core.somaxconn和 listen 时传入的 backlog 参数两者取较小值生效。我见过不少项目在代码里写了个很大的 backlog但系统参数还是默认的一百多最后生效的是小的那个。2.2 序列号、确认号与重传可靠性到底靠什么撑起来TCP 的可靠性建立在三个东西上序列号、确认号、重传计时器。发送方给每个字节编号接收方用确认号告诉对方“这个序号之前的我都收到了”。注意这里是累积确认一个确认号等于确认了之前所有字节这带来了效率也带来了一个经典问题如果中间丢了一个包后面的包都收到了接收方也只能反复确认丢失位置之前的部分发送方看起来像是“卡住了”。解决这个问题靠的是快速重传和选择性确认。发送方连续收到三个重复的 ACK就判定中间那个包丢了不等超时计时器到期直接重传这就是快速重传。SACK 则让接收方能够告诉发送方“我收到了哪些不连续的区间”避免发送方把已经到达的数据再重传一遍。这两个机制配合起来才能在有丢包的链路上维持可用吞吐。如果抓包发现大量重复 ACK 却没有对应的重传很可能就是 SACK 在中间某段链路上被剥掉了这种情况在跨运营商或者经过某些老设备的链路上并不罕见。超时重传的时间 RTO 也不是固定值内核会根据往返时延的测量值动态估算并且带有指数退避。这意味着一旦发生连续超时重传间隔会成倍拉长表现出来就是连接“僵住”十几秒甚至更久。业务侧如果只设置了 3 秒超时就会看到大量超时报错而实际上底层还在努力重传。理解这一点很重要应用层的超时时间和 TCP 的重传节奏是两套独立的东西前者不可能“催快”后者。2.3 四次挥手之后TIME_WAIT 和 CLOSE_WAIT 说明了什么主动关闭方在发完最后一个 ACK 之后会进入 TIME_WAIT持续 2 倍报文最大生存时间。这个状态的作用有两个一是保证最后那个 ACK 如果真的丢了对端重发的 FIN 还能被正确处理二是让这条连接上残留的旧报文在网络中自然消亡不至于串到新连接上。TIME_WAIT 本身不是问题问题是数量太大导致本地端口不够用。判断方法很简单ss -s或者netstat -an | grep TIME_WAIT | wc -l。如果 TIME_WAIT 集中在服务端说明是服务端主动关闭连接常见于短连接模式的 HTTP 服务。这时候可以开启net.ipv4.tcp_tw_reuse让新连接复用处于 TIME_WAIT 的端口但要注意这个选项只对 outgoing 连接有效而且依赖时间戳选项开启。至于把tcp_fin_timeout调小它缩短的是 FIN_WAIT_2 的时长跟 TIME_WAIT 没直接关系很多文章把这两个搞混了。CLOSE_WAIT 则是另一回事。它表示对端已经发了 FIN本端内核回了 ACK但应用层还没有调用 close。如果 CLOSE_WAIT 数量持续上涨几乎可以断定是代码里有连接没被正确关闭——要么异常分支漏了 defer要么连接池回收逻辑有漏洞。这个问题不会自己好只会越积越多直到文件描述符耗尽。我在排查这类问题时习惯直接看/proc/pid/fd的数量变化曲线再对照代码里所有建立连接的地方通常十几分钟就能定位到。3. 滑动窗口与拥塞控制吞吐量上不去的锅该谁背3.1 发送窗口等于接收窗口和拥塞窗口中的较小值TCP 能发多少数据取决于两个窗口的较小值接收方通告的接收窗口 rwnd和发送方自己维护的拥塞窗口 cwnd。接收窗口反映的是对端缓冲区还有多少空位拥塞窗口反映的是发送方对当前网络承载能力的估计。前者是“对端能不能收”后者是“网络能不能扛”。这个区分在实际调优中非常关键。如果抓包看到窗口字段很小且对端应用读取速度慢那就是接收缓冲区不够或者应用处理不过来调大SO_RCVBUF或者优化消费逻辑才有意义。如果窗口很大但发送速率还是上不去问题就在 cwnd 上也就是网络路径上存在丢包或者时延过大触发了拥塞控制降速。这两种情况的处理方向完全相反搞反了就是白折腾。还有个隐蔽的点接收缓冲区大小不等于窗口大小。内核会预留一部分空间实际通告的窗口往往小于缓冲区配置值。所以把缓冲区调成刚好等于期望窗口通常达不到效果得留出余量。长肥管道场景下带宽时延积可能达到几 MB默认缓冲区根本填不满这时候必须显式调大否则单连接吞吐永远上不去。3.2 慢启动、拥塞避免与不同算法下的体感差异新建连接的 cwnd 从一个较小的初始值开始每收到一个 ACK 就增加这就是慢启动增长是指数级的。到达慢启动阈值后转入拥塞避免变成线性增长。一旦检测到丢包传统的基于丢包的算法会把 cwnd 砍半甚至降到最小这就是为什么轻微丢包会让吞吐断崖式下跌。基于时延的算法思路不同它不看丢包而是看往返时延的变化趋势来判断是否接近瓶颈尽量在不丢包的前提下逼近带宽上限。在有随机丢包但带宽充足的链路上这类算法表现通常更好但在缓冲区很深的链路上它可能比其他算法更早退让实际吞吐反而不占优。所以“换算法能提速”这句话不能一概而论得看链路特征。切换算法在 Linux 上很简单sysctl net.ipv4.tcp_congestion_control查看当前值写入新值即可前提是内核模块已经加载。但我一般建议先在单台机器上做对照测试用相同的客户端和相同的数据量跑几轮观察吞吐和重传率两个指标别只看吞吐。有些场景下吞吐上去了重传率也跟着涨整体体验未必更好。3.3 Nagle 算法和延迟 ACK 的经典互掐回到开头那个案例。Nagle 算法的初衷是减少小包数量如果还有未确认的数据在途就把新的小数据攒起来等确认回来或者攒够一个 MSS 再发。延迟 ACK 的初衷也是减少 ACK 数量收到数据后不立刻回等一小段时间看看有没有更多数据可以一起确认这个等待窗口常见配置是 40 毫秒。单独看都没问题凑到一起就出事了。发送方等着 ACK 才发下一包接收方等着更多数据才发 ACK双方僵持直到延迟 ACK 的计时器到期。表现出来就是每次小请求都多出几十毫秒。这个问题在“请求-应答”模式下的小包交互中最明显比如某些 RPC 框架的控制消息、游戏里的状态同步。解决方案不是简单粗暴地关掉 Nagle。正确的做法是分场景对延迟敏感且每次发送都是完整逻辑消息的连接设置TCP_NODELAY关掉 Nagle对吞吐优先的大批量数据传输保留 Nagle 反而能减少包数。至于延迟 ACK它是接收方的行为发送方控制不了只能靠关闭自己的 Nagle 来打破僵局。我曾经见过一个团队在所有连接上统一关掉 Nagle结果批量导出场景的包数量翻了三倍虽然功能没坏但网络开销明显上升。协议选项从来不是越激进越好。4. UDP 的“不可靠”是设计选择不是缺陷4.1 8 字节头部里藏着的边界语义UDP 头部只有 8 个字节源端口、目的端口、长度、校验和。没有序列号没有确认没有窗口没有连接状态。内核收到一个 UDP 报文直接根据目的端口找到对应的 socket把数据放进接收队列一次sendto对应一次recvfrom边界被完整保留。这个“保留边界”的特性是 UDP 和 TCP 最本质的差别之一也是很多选型讨论里被低估的一点。用 UDP 传输时接收方如果缓冲区不够大报文会被截断剩余部分直接丢掉不会像 TCP 那样让你慢慢读。所以用 UDP 必须约定好单个报文的最大长度业务层通常控制在 MTU 减去 IP 头和 UDP 头之后的安全范围内常见做法是不超过 1200 字节左右避免被分片。校验和是可选的IPv4 下可以置零表示不校验IPv6 下则是强制的。有些高性能场景会关掉校验和来省一点 CPU但我不建议在有真实丢包或者不可信链路的场景这么做一个比特翻转导致的错误数据可能引发比性能损失严重得多的问题。UDP 还天然支持广播和组播这是 TCP 做不到的。局域网内的服务发现、配置下发这类需求用组播 UDP 非常自然一份数据发给一组接收者不需要维护连接列表。当然组播在网络设备上的支持情况参差不齐跨网段时尤其要看交换机配置。4.2 在 UDP 之上自建可靠层要补哪几块如果决定用 UDP 承载需要可靠性的业务就得自己补齐几块能力消息编号与去重、确认与重传、乱序重排、流量控制或者拥塞控制。每一块都有坑。消息编号看似简单但编号位宽要仔细选。位宽太小在高吞吐下很快就绕回绕回之后新旧报文如何区分就成了问题。常见做法是配合时间戳或者滑动窗口来判定窗口外的旧包直接丢弃。确认机制也有讲究逐包确认开销大累积确认省带宽但对丢包敏感折中的方案是确认加选择性重传接收方定期上报自己收到的区间。重传超时的设定比 TCP 更难因为没有内核帮你测量往返时延。比较稳妥的做法是维护一个平滑的 RTT 估计超时时间取估计值加上若干倍偏差并且带退避。这里特别提醒一点不要用固定超时值。网络状况变化很大固定值要么在好的时候浪费带宽要么在差的时候疯狂无效重传反而加剧拥塞。拥塞控制是自建可靠层最容易漏掉的部分。没有拥塞控制的可靠传输在有丢包的链路上会表现得极其糟糕因为它会不断重传把链路越堵越死。这也是为什么一些自研传输协议在大规模上线后会遇到“单机测试很好一上量就崩”的情况。4.3 四类典型场景的选型判断下面这张表是我在实际项目里总结的判断依据遇到选型问题会先对着它过一遍场景特征倾向选择主要原因请求小、响应小、要求强顺序和完整TCP边界清晰、天然有序、可靠性免开发高频小包、延迟敏感、可容忍少量丢失UDP无队头阻塞不存在等待重传的停顿单向大流量、实时性优先于完整性UDP丢掉旧帧比等待重传更有价值需要广播或组播UDPTCP 是端到端的点对点模型长连接、双向交互、业务逻辑复杂TCP 或基于 UDP 的可靠传输取决于是否有成熟协议栈可用这个表只能定大方向落到具体项目还要看团队能力。自建基于 UDP 的可靠传输开发和调试成本远高于直接用 TCP没有足够的测试手段和线上可观测能力很容易做成一个“看起来更快的定时炸弹”。5. 写代码时才会撞上的细节粘包、心跳、超时分层5.1 粘包和半包的根本原因是应用层没有划边界“TCP 粘包”这个说法其实不太准确TCP 本来就是字节流它从来没承诺过“一次发送对应一次接收”。发送方连续调用两次写接收方可能一次读到两段拼在一起的数据也可能只读到半段。这不是 bug是字节流的定义。解决办法只有一个应用层自己定义消息边界。常见三种做法。定长消息简单但浪费空间只适合消息长度固定的场景。分隔符用一个不会在正文中出现的特殊字节序列做结束标记实现简单但正文里如果出现该序列就得转义处理不当会有安全风险。长度前缀先写消息体长度再写消息体接收方先读固定长度的头部拿到长度再读对应字节数这是最通用也最稳妥的方案。用长度前缀时要注意两点。一是长度字段本身也要能处理“半包”也就是读头部的时候也可能只读到一部分需要循环读满。二是要对长度做上限校验防止恶意或者错误数据导致分配超大缓冲区。我见过一个服务因为没做上限校验被一个异常的长度字段直接撑爆内存。下面是一个 Go 里读长度前缀消息的写法核心是把“读满指定字节数”这件事封装掉func readFull(conn net.Conn, buf []byte) error { _, err : io.ReadFull(conn, buf) return err } func readMessage(conn net.Conn) ([]byte, error) { var header [4]byte if err : readFull(conn, header[:]); err ! nil { return nil, err } length : binary.BigEndian.Uint32(header[:]) if length 0 || length 120 { return nil, fmt.Errorf(invalid message length: %d, length) } body : make([]byte, length) if err : readFull(conn, body); err ! nil { return nil, err } return body, nil }io.ReadFull会一直读到缓冲区填满或者出错正好对应“半包”的处理。自己写循环conn.Read的话一定要判断返回值 n 和期望长度别想当然认为一次就读满了。5.2 心跳该放在哪一层间隔怎么定TCP 本身有 keepalive 机制但默认间隔是两个小时对绝大多数业务来说太长了而且它只能检测连接是否还“活着”检测不到对端应用是否卡死。所以业务层心跳基本是必需品。心跳间隔的设定要和多个因素挂钩中间网络设备负载均衡、防火墙的连接空闲超时时间、业务能容忍的故障发现时长、心跳本身的资源开销。常见做法是取空闲超时时间的几分之一。比如中间设备 60 秒没流量就断连接那心跳间隔设成 20 到 30 秒比较稳妥。心跳还要配合超时判定。只发心跳不判定没有意义通常连续丢失两到三次心跳就认为连接不可用主动断开重连。判定次数太少会误杀网络抖动一下就断次数太多则故障发现太慢。我的经验是三次间隔乘以次数大致等于可接受的最长故障发现时间。还有个细节心跳包应该走和业务数据相同的连接而不是单独开一条。单独开一条连接看似干净实际上多占资源而且两条连接的存活状态可能不一致反而增加复杂度。5.3 连接超时、重传超时、业务超时是三层不同的东西这三层经常被混为一谈。连接超时管的是“建立连接这个动作最多等多久”业务超时管的是“整个请求从发出到拿到结果最多等多久”重传超时是传输层自己的事应用层看不到也控制不了。关键在于它们的包含关系。业务超时必须大于连接超时加上至少一次完整传输的时间否则会出现“连接刚建立好业务就超时了”的浪费。反过来如果业务超时设得过大上游服务的线程或者协程会长时间挂住导致级联的雪崩。我的习惯是在配置里显式写清楚这三个值并且加一条启动校验业务超时小于连接超时的配置直接报错拒绝启动。这种校验看起来多余但真的能拦住配置事故。另外要注意设置读超时之后超时触发返回的错误通常是特定的超时错误类型代码里要区分对待不能和“对端关闭连接”混在一起处理否则重试逻辑会出问题。6. 抓包验证你的每一个假设6.1 抓包命令与常用过滤表达式协议知识再熟不抓包验证都是猜。抓包工具的基本用法很朴素但过滤表达式用得好能省大量时间。# 抓指定端口的流量-nn 不做域名和端口名解析-s 0 抓完整包 tcpdump -i any -nn -s 0 port 8080 -w /tmp/cap.pcap # 只抓 SYN 包用于观察连接建立过程 tcpdump -i any -nn tcp[tcpflags] tcp-syn ! 0 # 抓 UDP 的 DNS 流量 tcpdump -i any -nn udp port 53用抓包文件在图形工具里打开后几个过滤条件值得记下来看重传用tcp.analysis.retransmission看零窗口用tcp.analysis.zero_window看重复 ACK 用tcp.analysis.duplicate_ack看握手时延用tcp.time_delta配合排序。这几个组合起来基本能覆盖大部分连接层面的异常。有个注意事项抓包本身有开销在高流量机器上长时间抓全量包可能影响性能也可能把磁盘写满。建议用-c限制包数或者用环形缓冲的方式写文件。另外容器环境下-i any可能抓不到某些虚拟网卡上的流量需要指定具体接口。6.2 一次慢请求的完整抓包复盘说一个我自己遇到的具体案例。某个接口偶发 500 毫秒以上延迟日志显示服务端处理逻辑只花了 5 毫秒。抓包之后按时间线排出来是这样的客户端发出请求后约 200 微秒服务端就回了 ACK但响应数据过了 400 多毫秒才发出来。这就排除了网络问题和服务端处理慢问题在数据从应用层写出到内核发出的这一段。继续看服务端响应数据被拆成了两个包第一个包带了一部分数据第二个包隔了 400 毫秒才发出。结合那段时间的代码改动记录找到了原因响应数据分两次写入第一次写了头部第二次写业务体而 Nagle 在等第一个包的 ACK对端又在延迟确认。两边一卡就是几百毫秒。修复方式是在这个连接的 socket 上开启无延迟选项同时把两次写合并成一次写。改完之后 P99 直接降到了 40 毫秒以内。这个案例说明一个道理抓包看到的时间线比任何猜测都有说服力。看到“数据什么时候进入内核、什么时候离开网卡”大部分争议都能当场结束。7. 内核参数与踩坑速查把协议知识落到线上稳定性7.1 连接队列和端口相关的几个关键参数线上服务经常遇到“连不上”的问题八成和这几个参数有关。net.core.somaxconn控制 accept 队列的最大长度应用 listen 时传的 backlog 会和它取较小值。高并发短连接场景下这个值偏小会导致握手完成但连接被丢弃客户端侧表现为连接超时或者被重置。net.ipv4.tcp_max_syn_backlog控制半连接队列主要影响遭受大量未完成握手时的表现。net.ipv4.ip_local_port_range决定客户端可用端口范围短连接密集的服务如果范围太窄会频繁出现端口耗尽报错信息通常是“无法分配请求的地址”。这个范围的上限还受限于net.ipv4.ip_local_reserved_ports保留的端口。net.ipv4.tcp_rmem和net.ipv4.tcp_wmem是三个值一组的数组分别表示最小、默认、最大缓冲区大小。自动调优开启时内核会在这三个值之间动态调整手动设置SO_RCVBUF会关闭自动调优这点要特别注意。很多人调完应用层缓冲区发现性能反而下降就是因为无意中关掉了内核的自适应能力。改这些参数的原则是一次只改一个改完做压测对比记录改动前后的关键指标。我见过一次性改十几个参数然后出问题的最后花了两天回滚定位得不偿失。7.2 常见现象与可能原因的对照现象优先排查方向常用确认手段偶发连接超时服务端无日志accept 队列满、backlog 过小查看队列溢出计数、ss -lnt的 Send-Q大量 CLOSE_WAIT 堆积应用未关闭连接观察 fd 数量曲线、审查连接释放路径大量 TIME_WAIT服务端主动短连接关闭ss -s统计、评估连接复用吞吐上不去但带宽充足窗口、MSS、拥塞算法抓包看窗口字段和重传率小请求延迟异常高Nagle 与延迟 ACK 叠加抓包看两次写之间的时间差UDP 收包截断或丢失单包超过 MTU、缓冲区不足抓包看分片、检查SO_RCVBUF客户端端口耗尽本地端口范围过窄查看错误码和端口占用统计这张表不是万能答案但它能帮你把排查范围快速收窄到两三个方向。真正的定位还是得靠抓包和指标症状相似但根因不同的情况太多了。最后分享一个我在实际项目里养成的小习惯任何涉及网络的新模块上线前先在测试环境做一次完整的抓包把正常流程的包序列存档。等线上出问题时拿异常抓包和这份基线对比差异往往一眼就能看出来。这个做法的成本很低但省下来的排查时间相当可观。协议本身不会骗人它只是需要你愿意去看。