
凌晨两点被告警电话叫起来某服务的接口超时率十几分钟内从 0.3% 飙到 30%日志里刷屏connection reset by peer抓包一看满屏 RST 和大量重传最后定位到一个中间设备的空闲连接超时策略和业务的长连接心跳没对齐。这种场面只要做过线上服务迟早会碰上一两次。而每一次追到最底层你都会撞上同一个东西计算机网络里的UDP / TCP 协议。这两个协议是所有上层玩意的地基。上面架着 HTTP、gRPC、DNS、数据库连接、消息队列、音视频推流、游戏同步、物联网上报下面踩着 IP 层的尽力而为。框架再花哨、网关再智能、SDK 封装再厚落到网线上跑的无非就是这两种传输层协议之一。我见过不少写了三四年业务的同学能把框架的线程模型讲得头头是道但一问他为什么这个接口偶尔会多出 40 毫秒延迟为什么这条消息偶发丢了没报错为什么服务端 ESTABLISHED 一堆但业务就是不通就答不上来了。原因很简单这些问题不在框架层在传输层。这篇内容我想把它写成一个完整的、可以拿来当手册用的东西从两个协议的报文头逐字节拆到 TCP 的连接管理和可靠性机制怎么堆出来的再到用抓包和代码把协议看见最后是选型决策和线上排障实录。写的过程中我会把参数的计算过程、我踩过的坑、以及那些文档里不会写的经验都放进去。适合谁看写后端业务服务、做客户端和嵌入式、搞音视频或实时同步、以及在准备技术面试的同学都能从中拿到东西。基础弱的可以先把第 1、2 章读透把是什么钉死有经验的可以直接跳到第 4、5、7 章那里全是我在实践中反复验证过的细节。代码示例我统一用 Python 写因为它最接近伪代码你换成 Go、Java、C 都能一一对应。1. 先把两个协议摆到台面上它们到底在解决什么问题1.1 从一次发消息说起传输层到底负责什么任何一次网络通信本质上都是一台机器上的某个进程把一段字节交给另一台机器上的某个进程。IP 层只负责把数据包送到目标主机它不知道什么是进程也不保证送达、不保证顺序、不保证不重复。传输层就是把 IP 层的这个尽力而为服务加工成应用能直接用的东西。这里有两个必须解决的问题UDP 和 TCP 各自给出了不同答案。第一个是进程寻址。一台机器上可能开着几百个进程数据包到了网卡操作系统怎么知道该交给谁答案就是端口。源端口和目的端口各 16 位加起来 65535 个可用端口0 保留这就是传输层最基础的一块砖。第二个是服务质量。要不要保证送达要不要保证顺序要不要控制速度这些问题的答案就拉开了 UDP 和 TCP 的分野。我习惯用两个生活场景来类比讲了无数遍效果最好UDP 像是往对方信箱里塞明信片塞进去就完事对方收没收到、几张、顺序对不对寄件人一概不管但速度快、成本低、想寄几张寄几张TCP 像是打电话先要拨通握手说话要对方嗯一声确认对方没声了你要重说重传对方听不过来你要放慢流控挂电话前还要互相道别挥手。这个类比能解释绝大部分现象。比如为什么 DNS 默认用 UDP因为一次查询就一问一答包很小丢了重发一次成本极低为它建立连接反而是浪费。为什么文件下载用 TCP因为少一个字节文件就废了必须保证完整和有序。1.2 一条流水线对比一张明信片核心差异的全景图把差异铺开成表格看得最清楚。这张表我建议你手抄一遍面试和排障时脑子里能直接调出来。维度TCPUDP连接面向连接三次握手建立四次挥手释放无连接发了就发可靠性确认、重传、排序、去重保证不丢不乱不保证送达不保证顺序不保证不重复数据形态字节流无消息边界数据报保留边界一次发一次收头部开销20 字节起步带选项可到 60 字节固定 8 字节传输方式支持全双工有发送/接收缓冲区无缓冲概念直接交给 IP 层流量与拥塞控制有滑动窗口 拥塞控制四阶段没有应用自己控制节奏广播/组播不支持支持典型场景HTTP、数据库、消息队列、文件传输DNS、音视频、游戏同步、日志上报、DHCP这张表里有个细节经常被讲错UDP 不是不可靠协议而是不提供可靠性保证的协议。它只是不做不是做不到。你完全可以在 UDP 上面自己实现确认、重传、排序——QUIC 就是这么干的在 UDP 之上重建了一套可靠传输还顺手解决了 TCP 的队头阻塞问题。所以选型时不要被UDP 不可靠这句话吓住要问的是我需要的那部分可靠性自己实现划算还是直接用 TCP 划算。还有一个常被忽略的点TCP 的字节流特性意味着它不保留应用层消息边界。你发两次 100 字节对方可能一次 read 到 200 字节也可能分三次 read 到 30/70/100。这就是所谓粘包问题的根源——它根本不是 TCP 的 bug而是你把流当成了包来用。UDP 反而天然保留边界一次 sendto 对应一次 recvfrom代价是单包有大小限制理论上 65507 字节实际受 MTU 限制。2. 报文头逐字节拆解字段设计背后的取舍2.1 UDP 头部8 字节里藏着什么UDP 头部只有 4 个字段各 16 位一共 8 字节简洁到近乎粗暴0 7 8 15 16 23 24 31 -------------------------------- | 源端口号 | 目的端口号 | -------------------------------- | 长度 | 校验和 | -------------------------------- | 数据 ... | -----------------------------------长度字段指的是头部 数据的总长度最小值是 8只有头没有数据最大值 65535。注意因为这个字段只有 16 位UDP 单个数据报的理论上限就是 65535 减去 IP 头20再减去 UDP 头8即 65507 字节。但这是理论值实际跑在以太网上MTU 是 1500IP 头占 20UDP 头占 8你能塞进单个不分片数据报的载荷只有 1472 字节。校验和字段是最容易被忽略的。它覆盖的不只是 UDP 头和载荷还包括一个 12 字节的伪首部——源 IP、目的 IP、协议号、UDP 长度。为什么要拉上 IP 地址一起算因为要防止包被错误地投递到别的主机上而没被发现。这个伪首部只参与计算不实际传输。这里有个坑一定要记住IPv4 下 UDP 校验和是可选的发送方算出来如果是 0要写成全 10xFFFF如果压根不想算填全 0接收方看到全 0 就跳过校验。IPv6 下则是强制的。我见过真实案例某中间设备重算校验和时出了 bug把本该是 0 的结果写成了 0x0000接收端直接判定未计算校验和而放行一个损坏的包被业务当成正常数据处理排查了两天才找到。2.2 TCP 头部20 字节起步的代价与控制力TCP 头部最少 20 字节带选项最多 60 字节。多出来的每一个字节都是在为可靠性、流量控制、拥塞控制买单。0 7 8 15 16 23 24 31 -------------------------------- | 源端口号 | 目的端口号 | -------------------------------- | 序号 (seq) | ----------------------------------- | 确认号 (ack) | -------------------------------- |偏移|保留 | 标志位 | 窗口大小 | -------------------------------- | 校验和 | 紧急指针 | --------------------------------- | 选项可选 | -----------------------------------序号和确认号各 32 位是整个 TCP 可靠性的基石第 4 章会细讲。数据偏移占 4 位单位是 4 字节所以它能表示的最大头部长度是 15 × 4 60 字节——这也是 TCP 头部上限的由来。窗口大小16 位最大 65535 字节这在高带宽高延迟链路上远远不够BDP 一算就知道所以有了窗口扩大因子Window Scale选项移位最大 14 位理论上窗口上限能到 2^30 字节左右约 1GB。标志位里SYN、ACK、FIN、RST 四个是日常打交道最多的SYN 建连、ACK 确认、FIN 正常关闭、RST 强制重置。PSH 提示接收方尽快把数据交给应用URG 配合紧急指针现代实现基本不用了CWR 和 ECE 服务于显式拥塞通知ECN。RST 这个位值得单独说它表示这个连接我不认了立即终止。收到 RST 时缓冲区里未发送的数据会被直接丢弃这也是为什么长连接遇到中间设备超时后业务侧经常看到请求发出去了但没响应然后连接断了。2.3 端口与校验和两个容易被忽略的细节端口这块日常开发最容易踩的是端口耗尽。一个 TCP 连接由四元组唯一确定源 IP、源端口、目的 IP、目的端口。作为客户端去连同一个目标源 IP 和目的端口固定能变的只有源端口Linux 默认的本地端口范围是 32768–60999不到 3 万个。如果业务大量短连接、又没开SO_REUSEADDR、TIME_WAIT 堆积就可能出现Cannot assign requested address的报错。这时候调大net.ipv4.ip_local_port_range到1024 65535、减少 TIME_WAIT 占用比优化业务代码见效快得多。校验和这块TCP 和 UDP 一样带伪首部一样覆盖头部和数据。区别在于 TCP 校验和是强制的没有跳过这个选项。另外现代网卡普遍支持校验和卸载Checksum Offload你在本机抓包看到的校验和可能是错的因为网卡还没算——别急着以为是协议栈 bug先确认是不是卸载导致的假象。这个坑我踩过一次当时以为内核有问题折腾了半天最后ethtool -K eth0 tx off关掉卸载才看到正确值。3. TCP 的连接管理三次握手、四次挥手与状态机3.1 三次握手为什么是三次握手过程本身很好记客户端 服务端 | ---- SYN, seqx ----------- | 客户端进 SYN_SENT | --- SYNACK, seqy,ackx1 | 服务端进 SYN_RCVD | ---- ACK, acky1 --------- | 双方进 ESTABLISHED关键问题是为什么不能两次为什么不能四次两次握手的问题在于服务端无法确认客户端收到了我的 SYN-ACK。如果客户端发了一个 SYN因为网络延迟很久之后才到达服务端客户端早就超时放弃了服务端两次握手就建立连接然后傻等一个永远不会来的数据资源白白占着。三次握手让服务端在收到第三个 ACK 之后才确认对方真的还在、真的收到了我的回应这才算双向确认。从信息论角度看双方各自需要确认两件事我的发送能力对方能收到、对方的发送能力我能收到。客户端通过 SYN-ACK 确认了这两点服务端只有收到第三个 ACK才能确认这两点。两个方向各需要一次交互交叉起来最少就是三次。四次当然也行把 SYNACK 拆成两个包但纯属浪费。序号的选择也有讲究。初始序号 ISN 不是从 0 或 1 开始的而是随机化的。早期实现用时钟驱动现在RFC 6528一般用源 IP、目的 IP、源端口、目的端口四元组加上一个密钥做哈希。为什么要随机防止攻击者预测序号后伪造报文注入数据也防止同一四元组的历史连接残包被误认为当前连接的数据。有个实操细节服务端在 SYN_RCVD 状态时会把这个半连接放进半连接队列syn queue等第三个 ACK 到了才移进全连接队列accept queue。如果应用层 accept 太慢全连接队列满了新完成的连接会被丢掉或者回 RST客户端看到的就是握手成功了但立刻断开。队列长度由net.core.somaxconn和 listen 的 backlog 参数共同决定默认值往往偏小高并发服务记得调。另一个相关参数是net.ipv4.tcp_max_syn_backlog半连接队列满了会触发 SYN cookie 机制。3.2 四次挥手与 TIME_WAIT 的真实困扰关闭比建立复杂因为 TCP 是全双工的两个方向要各自关闭主动关闭方 被动关闭方 | ---- FIN, sequ ------------ | 被动方进 CLOSE_WAIT | --- ACK, acku1 ----------- | | --- FIN, seqw, acku1 ---- | 被动方进 LAST_ACK | ---- ACK, ackw1 ---------- | 被动方进 CLOSED | 主动方进 TIME_WAIT等 2MSL |为什么是四次因为 FIN 只表示我没有数据要发了不代表我不能再收。被动方收到 FIN 后可以先回 ACK然后继续把剩下的数据发完发完了再发自己的 FIN。这两件事不能合并成一个包所以是四次。当然如果被动方此时也没有数据要发FIN 和 ACK 可以合并实际抓包里经常看到三次就结束。TIME_WAIT 是投诉率最高的一个状态。主动关闭方在发完最后一个 ACK 后要等 2MSL 才能彻底关闭Linux 上 MSL 固定为 60 秒所以 TIME_WAIT 持续 60 秒。它存在的理由有两个一是保证最后的 ACK 能到达对方如果 ACK 丢了对方会重发 FIN此时自己还能回一次 ACK二是让这条连接上所有迷路的旧报文在网络中消亡避免污染下一个同四元组的新连接。代价是高并发的短连接服务主动关闭方会积累海量 TIME_WAIT占满本地端口。标准解法有几个让服务端主动关闭服务端端口固定不受本地端口限制影响用连接池复用长连接开启net.ipv4.tcp_tw_reuse1注意它只对出向连接生效而且依赖时间戳tcp_max_tw_buckets调大但要谨慎超限会打日志并直接销毁。tcp_tw_recycle千万不要开它在 NAT 环境下会导致大量连接失败新内核已经把它移除了。比 TIME_WAIT 更值得警惕的是CLOSE_WAIT 堆积。大量连接卡在 CLOSE_WAIT说明对端已经发了 FIN而你的应用没有调用 close() 释放 socket。这是纯粹的代码问题可能是业务逻辑里 return 提前了没走 finally可能是线程池耗尽导致回调没执行也可能是用了连接池但归还逻辑有 bug。看到 CLOSE_WAIT 数量只增不减别调内核参数去查代码。3.3 状态机速查与抓包识别TCP 一共 11 个状态日常排查其实只需要认出几个关键的状态出现位置含义与常见问题LISTEN服务端正常监听数量异常多说明服务开得太多SYN_SENT客户端发了 SYN 没收到回应通常是网络不通或被防火墙丢包SYN_RCVD服务端半连接大量堆积可能是 SYN Flood 或 backlog 太小ESTABLISHED双方连接已建立数量持续增长要警惕连接泄漏FIN_WAIT_1主动关闭方发了 FIN 还没收到 ACKFIN_WAIT_2主动关闭方收到 ACK 但对方还没发 FIN长期卡住说明对端应用没关连接TIME_WAIT主动关闭方正常等 2MSLCLOSE_WAIT被动关闭方应用没调 close()堆积就是代码 bugLAST_ACK被动关闭方发了 FIN 等最后 ACK一般很快消失用ss -tan state time-wait | wc -l这类命令能快速统计各状态数量ss -tanp还能看到对应进程。抓包时状态转换在报文里一目了然看到SYN就是建连开始看到RST, ACK就是异常终止看到FIN, ACK就是正常关闭。有个小技巧抓包文件里筛选tcp.flags.reset 1能一次性把所有异常重置的连接捞出来排障效率极高。4. 可靠性是怎么堆出来的序号、确认、重传与滑动窗口4.1 序号与确认号的计算TCP 把应用层交下来的字节流当成一个连续的字节序列每个字节都有一个编号。序号字段填的是本报文段第一个数据字节的编号。确认号填的是我期待收到的下一个字节编号也就是这个编号之前的所有字节我都收到了。举个具体例子。客户端要发 3000 字节MSS 是 1000假设那么会切成三个段第一段seq1000ISN1长度 1000携带字节 1000–1999第二段seq2000长度 1000携带字节 2000–2999第三段seq3000长度 1000携带字节 3000–3999服务端收到第一段后回ack2000含义是1999 及之前的我都收到了下一个我要 2000。如果第一段和第二段都到了但第三段丢了服务端会回ack3000而不是 4000明确告诉发送方我只到 2999。这里有两个容易搞混的点。第一SYN 和 FIN 各消耗一个序号所以握手时seqx的 SYN对方回ackx1关闭时 FIN 也一样。第二纯 ACK 报文不消耗序号也不携带数据它只是确认。抓包时看到Seq1, Ack1, Len0这种就是纯确认包。序号字段只有 32 位超过 4GB 会回绕。如果链路两端对回绕的处理不一致就会出现序号跳变的诡异问题。现代内核用 PAWSProtection Against Wrapped Sequences机制配合时间戳选项来判断基本能自动处理但如果你在自研协议栈或者做中间件这块必须特别注意。4.2 超时重传与快速重传发出去的段没收到确认怎么办重传。但多久没收到就重传这个时间RTO不能拍脑袋定定短了会疯狂重传加剧拥塞定长了恢复慢。TCP 的做法是动态估算第一次测量SRTT R, RTTVAR R/2 后续测量 RTTVAR (1-β) × RTTVAR β × |SRTT - R| SRTT (1-α) × SRTT α × R RTO SRTT 4 × RTTVAR其中 α 1/8β 1/4。这个公式的意思是维护一个平滑的往返时间 SRTT 和一个偏差估计 RTTVARRTO 取平滑值 4 倍偏差这样在链路抖动时能自动放宽。Linux 上 RTO 有下限通常 200ms和上限通常 120 秒初始值一般是 1 秒。但等超时太慢了。TCP 还有一条更快的路径快速重传。如果发送方连续收到 3 个重复的 ACK说明某个段丢了后面的段都到了就立刻重传丢失的段不用等 RTO 到期。配合 **SACK选择性确认**选项接收方还能告诉发送方我收到了哪些不连续的段发送方就只用重传真正缺的那几段而不是把后面全重传一遍。这里有个参数值得记net.ipv4.tcp_sack默认开启不要关。我见过有人为了省流量关掉 SACK结果丢包率一高重传量翻了三四倍带宽反而被吃满。实际排查中如果你在抓包文件里看到连续多个[TCP Dup ACK]紧接着一个[TCP Retransmission]那就是标准的快速重传现场。如果看到的是[TCP Retransmission]前面没有任何 Dup ACK那就是超时重传说明丢包发现得晚延迟会明显变高。4.3 滑动窗口与流量控制重传解决丢了补上滑动窗口解决别发太快把对方撑爆。接收方在每次 ACK 里带上自己的接收窗口rwnd含义是我当前缓冲区还能接这么多字节。发送方维护一个发送窗口窗口内的数据可以连续发出去不用等确认窗口左边界随着 ACK 前移。这就是滑动的含义。窗口大小直接决定吞吐量上限。公式很简单吞吐量上限 窗口大小 / RTT假设窗口 64KBRTT 40ms那单连接吞吐上限就是 65536 / 0.04 ≈ 1.6 MB/s约 13 Mbps。这就是为什么跨地域传输大文件时单条 TCP 连接怎么都跑不满带宽——不是带宽不够是窗口太小、RTT 太大。解决办法是开窗口扩大因子或者多开几条连接并行。窗口缩到 0 时发送方会停止发送并启动零窗口探测定期发一个小包问现在能收了吗。如果接收方应用一直不 read 数据缓冲区满了就会一直零窗口发送方就一直等着——这也是服务端不读数据导致客户端卡死这类问题的原理。用ss -tmi能看到每个连接的rwnd、cwnd、rtt等详细指标排障时非常有用。4.4 拥塞控制从慢启动到 CUBIC流量控制是照顾接收方拥塞控制是照顾整个网络。发送方额外维护一个拥塞窗口cwnd实际能发的数据量取min(cwnd, rwnd)。拥塞控制分四个阶段慢启动连接刚建立时 cwnd 很小Linux 现代版本初始值是 10 个 MSSRFC 6928 之前的旧实现是 1-4 个每收到一个 ACK 就把 cwnd 加一个 MSS效果是指数增长——1、2、4、8、16一个 RTT 翻一倍。涨到慢启动阈值ssthresh就切换。拥塞避免超过阈值后改成线性增长每个 RTT 大约加一个 MSS谨慎试探网络容量。快速重传与快速恢复收到 3 个重复 ACK 时说明网络还有余量后面的包能到把 ssthresh 设为当前 cwnd 的一半cwnd 也减半然后直接进入拥塞避免不用回到慢启动从头爬。超时重传说明情况更严重直接把 cwnd 打回 1 个 MSS重新慢启动。Linux 默认的拥塞控制算法是 CUBIC它的增长曲线是三次函数在高带宽长距离链路上比传统的 Reno 表现好很多。近些年 BBR 也很流行思路完全不同——它不靠丢包判断拥塞而是测量带宽和最小 RTT 来建模。切换算法很简单# 查看当前算法和可用算法 sysctl net.ipv4.tcp_congestion_control sysctl net.ipv4.tcp_available_congestion_control # 临时切换为 bbr sysctl -w net.ipv4.tcp_congestion_controlbbr我的经验是跨国或跨运营商的长肥链路高带宽高延迟BBR 通常能带来明显的吞吐提升内网短链路CUBIC 和 BBR 差异不大用默认的就行。切换前一定要在灰度环境压测不要直接上生产。5. 实操用抓包和代码把协议看见5.1 环境与工具准备纸上得来终觉浅协议这东西必须亲手抓一次才算真懂。你需要的东西不多一台 Linux 机器云主机或本地虚拟机都行、一个能跑 Python 的环境、抓包工具。命令行抓包我用tcpdump轻量、随处可用图形化分析用带界面的抓包软件看时序图和流追踪更直观。安装和基础用法# 抓本机 8080 端口的 TCP 包不做域名解析直接写文件 tcpdump -i any -nn -s 0 tcp port 8080 -w /tmp/tcp8080.pcap # 只看握手和挥手相关的标志位 tcpdump -i any -nn tcp port 8080 and (tcp[tcpflags] (tcp-syn|tcp-fin|tcp-rst) ! 0) # 实时看 UDP 53 端口DNS tcpdump -i any -nn udp port 53 -c 20几个参数的含义值得记牢-i any抓所有网卡-nn不做 IP 和端口反解避免 DNS 查询干扰抓包-s 0抓完整包老版本默认只抓 68 字节头部以下会被截断这个坑很多人踩过-w写文件供后续分析。生产环境抓包注意加过滤条件并限制包数不然一个高流量服务几分钟就能抓出几十 GB。5.2 TCP 三次握手抓包实录启动一个最简单的服务端然后用 curl 连一下同时抓包。这是最经典的一组报文值得反复看No. Time Source Destination Info 1 0.000000 10.0.0.5 10.0.0.9 54321 → 8080 [SYN] Seq0 Win64240 Len0 MSS1460 SACK_PERM TSval... 2 0.000312 10.0.0.9 10.0.0.5 8080 → 54321 [SYN, ACK] Seq0 Ack1 Win65160 Len0 MSS1460 SACK_PERM TSval... 3 0.000398 10.0.0.5 10.0.0.9 54321 → 8080 [ACK] Seq1 Ack1 Win64240 Len0 4 0.000521 10.0.0.5 10.0.0.9 54321 → 8080 [PSH, ACK] Seq1 Ack1 Len78对着看几点第 1 个包Seq0是抓包工具显示的相对序号真实 ISN 是随机的工具做了偏移处理方便阅读。MSS1460是客户端告诉服务端我这边最多能收 1460 字节的 TCP 载荷——这是 MTU 1500 减去 IP 头 20 再减去 TCP 头 20 得到的。SACK_PERM表示支持选择性确认。Win64240是接收窗口。TSval是时间戳选项用于更精确的 RTT 测量和防回绕。第 2 个包服务端的Ack1因为 SYN 消耗了一个序号。服务端也报了自己的 MSS 和窗口。注意 MSS 是双向协商的各自报各自能接收的最大值实际发送时按较小的那个来。第 3 个包是纯 ACKLen0没有数据。这一步完成后双方进入 ESTABLISHED。第 4 个包才是真正的业务数据PSH标志表示尽快交给应用层。整个握手在 0.4 毫秒内完成因为都在同一个局域网。如果是跨地域第 1 到第 3 个包之间的时间差就是一次 RTT这也是为什么TCP 建连本身就要消耗一个 RTT——HTTPS 还要再多花一两个 RTT 做密钥协商首屏慢的锅有时候真不在业务代码。你可以在抓包工具里看Statistics → TCP Stream Graphs → Time-Sequence能直观看到每个包在时间轴上的分布、重传在哪、窗口怎么变化。这张图我排障时几乎必看比肉眼数报文高效得多。5.3 UDP 与 TCP 的代码对比写两段最小可运行的代码把两者行为差异看得最清楚。先看 TCP 服务端import socket # SOCK_STREAM 表示 TCP server socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 允许端口复用避免重启时报 Address already in use server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 关闭 Nagle 算法避免小包合并带来的延迟 server.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) server.bind((0.0.0.0, 8080)) server.listen(128) # backlog对应全连接队列长度 print(listening on 8080) while True: conn, addr server.accept() with conn: while True: data conn.recv(4096) # TCP 是字节流一次 recv 不保证一个完整消息 if not data: break # 对端关闭 conn.sendall(becho: data)再看 UDP 服务端import socket # SOCK_DGRAM 表示 UDP server socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server.bind((0.0.0.0, 8081)) print(udp listening on 8081) while True: # 一次 recvfrom 对应一个数据报天然保留消息边界 data, addr server.recvfrom(65535) server.sendto(becho: data, addr)两段代码一对比差异全在细节里。TCP 多了accept()这一步说明它是有连接概念的recv()的返回值需要自己判断消息边界因为一个 4096 的缓冲区可能装到半条消息加一条半消息sendall()和send()不同前者会循环直到发完后者只保证发了一部分。UDP 则完全不需要考虑这些一次recvfrom就是一个完整数据报但你得自己处理丢包和乱序。客户端这边UDP 还有个特殊行为值得注意如果你用connect()绑定了一个目标地址之后发往其他地址的包会被静默丢弃同时能找到对方端口不可达的 ICMP 错误。不connect()的话一次性发送大包会频繁触发sendto返回EMSGSIZE因为载荷超过了单包上限。5.4 用系统命令观察连接状态抓包看报文系统命令看状态两者配合才完整。下面这几条我基本天天在用# 按状态统计当前连接数量 ss -tan | awk NR1 {print $1} | sort | uniq -c | sort -rn # 查看某个端口的连接详情含内存和定时器信息 ss -tmn sport :8080 # 查看 TCP 全局统计重传率、握手失败、队列溢出都在这 netstat -s | grep -iE retrans|overflow|listen|reset # 查看内核连接相关参数 sysctl -a | grep -E somaxconn|tcp_max_syn|tcp_tw|tcp_keepalivenetstat -s里的listen overflows和listen drops两个计数特别关键前者表示全连接队列满导致新连接被丢后者表示半连接队列满。这两个值只要在持续增长就说明应用 accept 不过来或者 backlog 配小了跟业务逻辑无关调参数就能解决。6. 选型决策什么场景该用哪个6.1 一张决策表背后的判断逻辑选型不是站队是权衡。我把常见场景整理成下面这张表附上判断依据场景推荐判断依据网页请求、API 调用TCP需要完整、有序且请求量不极端数据库连接TCP一个字节都不能错长连接还能复用文件传输TCP完整性优先顺序错乱无法处理DNS 查询UDP大响应回退 TCP一问一答包小重试成本低音视频实时通话UDPRTP/RTCP宁可丢一帧也不要卡 200ms 等重传游戏状态同步UDP旧的位置数据没有重传价值新的覆盖旧的日志、指标上报UDP 或批量 TCP少量丢失可接受优先低开销局域网服务发现UDP 广播/组播TCP 不支持广播物联网低功耗上报UDP或轻量封装建连开销和功耗都难以承受判断逻辑可以浓缩成三个问题按顺序问自己第一丢了这一份数据业务能接受吗能接受就用 UDP不能就用 TCP。注意是这一份不是任何一份——音视频丢一帧没事但控制信令丢了就有事所以很多实时系统是 UDP 传媒体流、TCP或可靠 UDP传信令的混合架构。第二延迟敏感度有多高TCP 的队头阻塞是硬伤前面一个包丢了后面即使全到了也得在缓冲区里等着应用层收不到。在弱网环境下这个等待可能是几百毫秒。实时性要求高的场景这个代价通常不可接受。第三能不能承担自己实现可靠性的成本基于 UDP 做可靠传输不是加个重传就完事还要处理拥塞控制、RTT 估计、乱序重组、防放大攻击等等工作量以人月计。除非你有明确的性能诉求否则别自己造。6.2 那些看起来该用 UDP 却用了 TCP的案例选型最忌讳教条。我见过几个反直觉但合理的例子值得说说。第一个是某些即时通讯的长连接。直觉上消息推送应该快但实际主流方案走的都是 TCP 长连接。原因是消息必须到达可靠性权重远高于延迟而 TCP 的连接状态让服务端能主动推送UDP 没有连接概念NAT 穿透后还得自己维护会话再加上有心跳和重连机制兜底偶发的队头阻塞影响被摊薄了。第二个是短查询类接口。有人觉得就查一条记录一次往返用 UDP 省掉握手不是更快。理论上一问一答确实 UDP 少一个 RTT但实际收益微乎其微——因为现在普遍是长连接 连接复用握手成本早被摊销掉了。而你需要自己处理丢包重试、请求 ID 匹配、超时判定代码复杂度翻倍收益却是零点几毫秒。这笔账怎么算都不划算。第三个是内网的 RPC。某些高性能 RPC 框架在延迟敏感场景下会提供 UDP 模式但默认还是 TCP。原因还是那个内网丢包率极低万分之一以下TCP 的队头阻塞几乎不发生而 UDP 省下的那点开销在整体链路里占比很小。反过来也有看起来该用 TCP 却用了 UDP的日志采集。某系统每秒产生几十万条日志如果每条都建 TCP 连接光握手就把 CPU 打满了。改成 UDP 批量发送允许在极端情况下丢少量日志——因为日志的价值是统计趋势不是逐条审计丢 0.1% 完全不影响结论。这个决策的关键在于明确业务对数据完整性的真实要求而不是笼统地说日志很重要所以不能丢。6.3 在 UDP 上重建可靠性要补哪些课如果你确实决定走 UDP 自建可靠传输至少要补齐这几块缺一不可。序号与重组。每个数据报带一个递增序号接收端按序号排序、去重、检测空洞。要注意序号回绕的处理以及乱序窗口的大小——窗口太小会误判丢包太大内存扛不住。确认与重传。可以逐包 ACK也可以累积 ACK 加选择性确认。重传要配合 RTO 估算公式可以直接抄 TCP 的 SRTT 4×RTTVAR。别忘了重传次数上限否则网络彻底断了会无限重试。拥塞控制。这是最容易被跳过、也最容易出事的一块。没有拥塞控制你的协议在丢包时会继续猛发把整条链路和其他流量一起拖死。哪怕只做一个简化版的 AIMD加性增、乘性减也比没有强。分片与 MTU。UDP 超过 MTU 会触发 IP 分片任何一个分片丢失整个数据报就废了重传成本极高。正确做法是在应用层做分片单包载荷控制在 1200 字节以内给 IPv6 和隧道封装留余量这也是 QUIC 选 1200 的原因。安全。裸 UDP 极易被用于反射放大攻击——攻击者伪造源 IP 发一个小请求你回一个大响应受害者就被淹了。所以必须做源地址验证比如要求客户端先回一个随机 token并且限制响应与请求的大小比例。QUIC 把这套东西完整实现了一遍还顺带把握手和加密合并0-RTT 或 1-RTT 建连并在应用层解决了队头阻塞。如果你想理解可靠 UDP到底该长什么样去读 QUIC 的协议文档是最快的路径。6.4 MTU、粘包、心跳三个绕不开的工程细节MTU 与分片。以太网默认 MTU 1500这是历史遗留值。路径上任何一跳的 MTU 更小都会导致分片或丢弃而路径 MTU 发现PMTUD依赖 ICMP很多防火墙把 ICMP 全禁了于是出现小包能通、大包不通的经典故障。经验值TCP 的 MSS 协商后一般是 1460IPv4或 1440IPv6UDP 应用层载荷控制在 1200 以内最稳。如果你在容器或隧道环境里MTU 还要减去封装开销比如某些隧道要减 60–80 字节。粘包。前面说过这是把流当包用造成的。解决方式就三种定长消息、分隔符如换行、\r\n、长度前缀先发 4 字节长度再发内容。工程上最推荐长度前缀解析简单、无转义问题、二进制安全。千万不要用每次 recv 正好是一条消息这种假设写代码它在你本机测试时永远是对的一上线就出问题。心跳。TCP 自带 keepalive但默认空闲 7200 秒才探测太长了而且只能探测连接是否还活着探测不到对端进程假死。所以业务层必须自己做心跳客户端每 30 秒发一次 ping服务端回 pong连续 3 次没收到就判定断线重连。心跳间隔要比中间设备负载均衡、NAT 网关的连接空闲超时短常见设备超时是 60 秒或 300 秒心跳间隔取 30 秒比较安全。7. 常见问题与排查实录7.1 高频问题速查表下面这张表是我这些年攒下来的遇到问题先对照它能省掉一大半瞎猜的时间。现象可能原因排查手段偶发多出 40ms 延迟Nagle 算法与延迟 ACK 叠加抓包看是否有小包等待开 TCP_NODELAY大量 CLOSE_WAIT应用没调用 close()ss -tan state close-wait查代码释放路径大量 TIME_WAIT短连接多、主动关闭方在本地改连接池、调整端口范围、tcp_tw_reuse连接不上报 Address already in use端口被占或 TIME_WAIT 未复用开 SO_REUSEADDRss -tanp查占用小包能通大包不通MTU 不匹配、ICMP 被拦ping -M do -s 1472逐段探测间歇性 connection reset中间设备超时、服务端 backlog 满看netstat -s的 overflow 计数传输吞吐上不去窗口太小或 RTT 太大ss -tmi看 cwnd/rwnd考虑开窗口扩大UDP 偶发丢包无报错缓冲区和 MTU 问题调rmem_max控制载荷在 1200 内服务端 ESTABLISHED 很多但业务超时队头阻塞、线程池打满看 recv-q/send-qss -tmn观察队列7.2 一次典型的排障路径说个真实经历的变形版。某服务每天有几个时段出现请求偶发超时超时率约 2%重试一次就好监控上看不出明显规律。第一步先确定问题在哪一层。在客户端和服务端同时抓包对比两端看到的报文。结果发现客户端发了请求服务端过了 800ms 才收到——说明问题在中间链路不在应用。第二步看 TCP 层的行为。抓包里出现了零星的[TCP Retransmission]重传间隔正好是 200ms 左右符合最小 RTO。说明有丢包但丢包率很低。第三步看丢在哪里。用mtr做路径探测发现某一跳有 1%–2% 的丢包。再和运维确认那台设备负载偏高在跑大流量转发。第四步验证假设。把请求大小从 8KB 降到 2KB 以内减少分片和占用时间超时率下降到 0.3%。最终通过扩容那台设备、同时把服务端的tcp_slow_start_after_idle关掉避免空闲后重新慢启动问题彻底消失。这条路子的核心方法论是分层定位逐层收敛。先确定是应用层、传输层还是网络层的问题再往下一层层缩小范围。最忌讳的是一上来就怀疑业务代码改半天没效果最后还是抓包解决了。7.3 几个只有踩过才知道的坑坑一关掉 TCP_NODELAY 以为能省流量结果延迟爆炸。Nagle 算法会把小包攒起来一起发配合接收方的延迟 ACK最多等 200ms在高频小包场景下会出现每个请求卡 40ms的诡异现象。这个现象在本地测试环境几乎复现不出来因为本机 RTT 极低Nagle 攒不到包就发出去了。IO 密集型的 RPC、实时消息推送一律开TCP_NODELAY。坑二以为 UDP 的缓冲区跟 TCP 一样能自动背压。TCP 缓冲区满了会阻塞发送方天然形成背压UDP 缓冲区满了内核直接把包丢掉发送方毫不知情。如果你的 UDP 接收端处理慢就会出现明明发了 10000 条只收到 8000 条且毫无报错。解决办法是调大net.core.rmem_max并在应用层做队列水位监控。坑三TIME_WAIT 数量高就无脑开tcp_tw_recycle。这个参数在 NAT 环境下的行为会导致大量正常连接被拒表现为同一出口 IP 的多个客户端只有部分能连上。较新的内核已经移除该参数老系统上如果开了建议立刻关掉。坑四把 keepalive 当成心跳用。系统级 keepalive 只能探测对端主机是否可达对端进程崩了但主机还在keepalive 是探不出来的除非进程退出导致内核发了 FIN。真正的存活检测必须靠应用层心跳而且要带业务语义能反映服务真的能处理请求。坑五容器环境下忽略 MTU。容器网络通常有 VXLAN 等封装实际可用 MTU 会比宿主机的 1500 小常见 1450 甚至 1400。如果应用不感知这件事大包会被丢弃且现象极其隐蔽小请求都正常一传大文件就卡死。部署到容器集群时务必确认 Pod 内的ip link显示的实际 MTU并据此调整应用层的包大小。我个人在实际操作中的体会是UDP 和 TCP 这两块知识光看文档永远学不透必须抓一次包 写一次代码 排一次障三件套都走一遍脑子里才会长出真正的模型。我自己是被一次线上 RST 风暴逼着把 RFC 从头翻了一遍那之后再看框架的日志、再看监控的曲线很多以前觉得莫名其妙的东西突然就对上了。如果你现在手上的系统有偶发超时、有说不清的重传、有莫名的连接数增长别急着调参数先抓个包答案八成写在报文里。