连着抓了两天包,最终发现的问题居然出在TCP全连接队列上。做开发和运维这些年,传输层的UDP和TCP属于那种“看着都懂,一查就懵”的内容。面试时能背出三次握手四次挥手,但真到了线上服务延迟高、连接被重置、丢包严重的时候,往往不知道怎么从协议层面定位。这篇就把传输层这两个协议从原理到实战完整拆一遍,包括TCP连接管理、可靠传输、拥塞控制,UDP的特性、场景和自定义可靠方案,以及最实在的抓包排障和内核参数调优。
1. 先搞清楚传输层到底在干什么
1.1 一次网页请求里,传输层演了什么角色
简单拆一个访问网页的动作:浏览器发起HTTP请求,DNS查询属于应用层行为,它可能走UDP;建立了TCP连接之后,HTTP请求和响应都被拆成一个一个有编号的TCP段,落在IP层变成IP报文,经过路由器、交换机,最后到达服务器。整条链路里,IP层负责“把包送到哪台机器”,传输层负责“把数据交给这台机器上的哪个进程”。
进程之间的区分靠的就是端口。“IP地址+端口号”四元组,在服务器端唯一定位到一个socket。TCP和UDP都维护了自己的一套端口分配与多路复用机制,一个网卡上可以同时跑成千上万的连接,靠的就是这个解复用能力。
这个分层模型看起来简单,但很多人忽略了一个前提:IP层本身是“尽力而为”的,它会丢包、会乱序、还会重复。这些脏活累活到底是交给上层自己处理,还是交给传输层处理,直接决定了TCP和UDP完全是两种物种。
1.2 传输层解决的三类核心问题
第一个是复用与分用。一台机器上几十个进程同时在收发数据,如果没有端口和协议头部的类型字段,数据到了主机之后根本不知道该交给谁。TCP和UDP头部都有源端口和目的端口字段,这正是复用分用的基础。
第二个是端到端的语义保证。这里的“端到端”指的是从进程到进程,不是从设备到设备。TCP能保证有序、不重复、不丢失,UDP则明确表示自己只负责把数据从一端扔到另一端,能不能收到、顺序对不对,一概不管。
第三个是连接管理。TCP把连接当作一个有状态的对象来管理,建立、传输、释放都有严格的状态机;UDP则完全没有连接概念,发送方只需要知道对方的IP和端口就可以发,不需要提前建立任何状态。
1.3 为什么是这两个极端,而不是一个折中方案
TCP追求可靠,靠的是确认、重传、排序、拥塞控制,代价是复杂度和时延。UDP追求简单和低时延,把可靠性问题完全上抛给应用层,代价是应用层要自己处理丢包和乱序。
可以类比成寄快递:TCP是邮政挂号信,有回执、可追踪、丢了会补发,但需要提前办理一系列手续,时效未必最好;UDP是塞进门缝的便签,写完之后就能走,对方看不看得到取决于有没有风把纸吹走。两个极端各自适配了不同的业务需求,这也是传输层设计里最有意思的地方:它不是做一个“又可靠又低时延”的万能方案,而是把选择权交给了开发者和应用层。
2. TCP的本质:一段连接的起起落落
2.1 三次握手:为什么必须刚好是三次
面试必问题,实际排障也绕不开。三次握手的本质是客户端和服务端各自确认“对方的接收能力没问题,自己的发送能力也没问题”。
过程不复杂:客户端发送SYN段,携带一个初始序列号ISN;服务端收到后回复SYN+ACK,确认号是客户端ISN加1,同时带上自己的ISN;客户端再回复ACK,确认号是服务端ISN加1。此时两端的收发通道都验证过了,进入ESTABLISHED状态。
为什么不是两次?假设只有SYN和SYN+ACK,服务端在收到SYN后就会立即建立连接并分配资源。如果这个SYN是网络中残留的旧连接请求,客户端早已不打算通信了,服务端却白忙一场,还会一直维护着这条半死不活的状态。三次握手让客户端最后回复一个ACK,如果客户端从来没打算建立连接,服务端根本收不到这个ACK,自然不会创建无效连接。
实际排障中要关注SYN重传次数。net.ipv4.tcp_syn_retries默认是6,如果服务端长时间不能完成握手,客户端会反复重发SYN。抓包时看到大量SYN发出却一直没有SYN+ACK回应,通常不是服务端没收到,而是服务端的半连接队列或者全连接队列满了,例如accept队列积压导致内核直接丢包。
这里也提一句SYN泛洪。服务端收到SYN后进入SYN_RCVD,维护半连接状态。如果攻击者只发SYN不完成握手,半连接队列会被挤满。内核的syn cookie机制可以在一定程度上缓解,但生产环境更应该把net.core.somaxconn和net.ipv4.tcp_max_syn_backlog一起调大,并且让应用尽快执行accept,否则队列溢出之后客户端表现就是“连不上”。
2.2 可靠传输:序号、确认与重传之间的博弈
TCP能实现可靠传输,核心武器是“序号+确认号”。每个字节都有一个虚拟序号,发送方基于序号将数据切片并发送,接收方根据序号重组数据,然后通过确认号告诉发送方“我期望下一个字节是什么”。
这个过程看起来天经地义,但有两个细节值得展开。
一是累计确认的使用。接收方不需要对每个包单独回ACK,它只需要回一个“我下一个要的序号”,前面所有小于这个序号的字节都算已经收到。也就是说,连续收到多个包时可以只确认最后一个,大幅减少ACK包数量。
二是重传策略。传统的超时重传依赖RTO(重传超时时间),而RTO是根据历史RTT采样动态计算的。网络抖动大的时候RTO经常估算不准,等超时还没等到确认,性能就很差。于是有了快速重传:发送方连续收到3个重复ACK时,基本可以确定序号对应的数据丢了,不再等待超时,直接重传。
但在真实网络中,重复ACK和乱序产生的重复ACK很难区分。如果只靠快速重传,网路里轻微乱序就会导致发送方误判。SACK(选择性确认)机制就是来救场的:接收方在ACK里额外说明哪些连续的段丢了,哪些已经收到,发送方只重传真正丢掉的段,而不是把整个窗口都推倒重来。
这也是为什么现代内核里SACK默认开启,线上TCP重传效率高不高,先看看两端是否都启用了SACK。sysctl net.ipv4.tcp_sack设置为1才是正常状态。
2.3 滑动窗口、流量控制与拥塞控制,别混为一谈
很多人把流量控制和拥塞控制当成一回事,其实这两个控制的是完全不同的东西。
流量控制是“接收方告诉发送方我自己的缓冲区还剩多少”,用的是TCP头部里的窗口字段,单位是字节。发送方发送窗口不能超过接收方通告的窗口大小,否则接收方缓冲区一满,数据来了也装不下,只能丢。抓包时看到窗口值缓缓变小,就要意识到接收方处理不过来了,不只是网络带宽的问题。
拥塞控制则是“发送方自行推测网络路径上是否拥堵”。经典的四个阶段是慢启动、拥塞避免、快重传和快恢复。慢启动阶段拥塞窗口从一个很小的值开始指数增长,达到慢启动阈值后进入拥塞避免,改为线性增长。一旦发生丢包,阈值减半,不断向网络的实际承载力收敛。
现代TCP已经有很多改进版,比如BBR就不再以丢包作为拥塞信号,而是通过监测带宽和RTT来估算瓶颈。很多云服务器默认使用BBR,在高带宽长链路上效果显著。如果在自己的服务器上还看到传统的cubic默认值,可以评估一下要不要切换。
排障时看拥塞控制相关指标,重点盯重传率、RTT抖动和窗口为零的次数。ss -i可以直接查看每条连接当前的拥塞窗口、RTT和重传数,比抓包分析更高效。
2.4 四次挥手、TIME_WAIT和CLOSE_WAIT的坑
TCP连接关闭为什么是四次挥手,而不是三次?因为TCP是全双工的,每一方向的关闭都需要单独确认。A方说“我发完了”(FIN),B方回复ACK;B方也可能还有数据要发,发完之后再给A发FIN,A再回ACK。如果B方收到FIN后立刻没有数据要发,理论上FIN和ACK可以合并,看起来像三次,但协议设计上允许两个方向独立关闭。
实际运维中最头疼的是TIME_WAIT。主动关闭方在发出最后一个ACK之后,必须进入TIME_WAIT状态,持续2MSL(最大报文段生存时间,通常是2分钟)。这个状态有两个作用:一是万一最后一个ACK丢了,对端会重发FIN,TIME_WAIT状态让主动关闭方还能再回一个ACK;二是保证网络中残留的旧报文段都自然消亡,不会污染同四元组的新连接。
线上服务TIME_WAIT连接数量多,经常被当作“异常指标”来报警。我的经验是,这个状态本身是TCP正常工作的结果,高并发的短连接服务必然产生大量TIME_WAIT,真正要关注的是TIME_WAIT是否占满了端口资源导致无法发起新连接。可以用ss -tan state time-wait | wc -l统计个数,如果少于几十万,通常不用处理。
另一个更值得警惕的状态是CLOSE_WAIT。CLOSE_WAIT出现在被动关闭方:对端发了FIN,自己还没有调用close关闭本地FD。如果应用代码里没有正确释放连接,CLOSE_WAIT会一直累积,最终导致文件句柄耗尽。遇到大量CLOSE_WAIT时先不要调内核参数,老老实实查代码里哪里打开连接后没有关闭。
至于net.ipv4.tcp_tw_reuse,只有把timestamps开启时才有效,而且它只对“发起连接的一方”生效,本质是复用TIME_WAIT连接的四元组来发起新连接。tcp_tw_recycle这个参数在老内核上配合NAT会引发严重问题,现在已经不建议也不需要在生产环境开启。
3. UDP的“简单”其实不简单
3.1 UDP到底做没做事
UDP头部只有8个字节:源端口、目的端口、长度、校验和。发送方把数据交给内核,内核封装好之后直接发出去,没有三次握手,没有序号,没有确认,没有重传,没有流量控制。从协议职责的角度讲,UDP确实“做了最少的事”。
但恰恰是这种极简,让它拥有TCP无法比拟的低时延和低开销。没有队头阻塞,一个数据报丢了不会影响后续数据报的处理;没有连接状态,服务端不需要为每个客户端维护独立的连接对象,可以轻松支撑海量终端;不需要握手,客户端随时可以发送数据。
很多人一提到UDP就只想到“不靠谱”,这是没理解场景。在实时音视频场景里,一个视频帧晚到300毫秒比丢一批数据更致命。TCP为了保证有序,可能会因为一个旧包的重传延迟了后续所有包,这种现象就是队头阻塞。UDP没有这个问题,丢掉的帧直接跳过,下一帧按时到达,观感反而更好。
还别忘了DNS。我们每天用的域名解析就是UDP,一个请求一个响应,几十毫秒内完成,根本不需要建立连接。如果DNS也走TCP,解析延迟会高好几倍。
3.2 实时对战场景中,UDP的实际取舍
游戏开发是UDP主要应用领域之一。拿一个典型的实时对战平台举例:客户端每秒钟发送几十帧操作数据,服务器需要尽快处理并广播给所有玩家。如果走TCP,一旦某个客户端有一帧数据丢失导致重传,后续帧全部要等在它后面,整个对战画面都会卡顿。这正是完全无法接受的。
于是对战平台通常会把帧同步类消息走UDP发送。为了保证关键数据不丢,应用层自己实现一个简易的可靠性机制:给每个数据包编号,接收方发现有缺口后通知发送方重传;或者干脆采用“最新状态覆盖旧状态”的方案,不需要重传旧帧,只需要请求对端发送当前最新状态快照。
这两种思路本质上就是“回退N步重传”和“丢弃旧状态,只保最新”,它们都不是TCP那样完备的可靠性,但对实时性要求高的场景更合适。
也有现成的类KCP方案。KCP这类协议是在UDP之上做一层可靠传输,用ARQ模型提供比TCP更快的重传响应,同时允许开发者配置“最大延迟”和“带宽占用”的平衡。很多RPG游戏、动作游戏在弱网环境下使用这一类方案,效果立竿见影。QUIC也是类似思路的标准化成果,它砍掉了TCP的队头阻塞,用UDP承载HTTP/3,本质就是在UDP上重新实现了可靠、有序、加密、多路复用的传输能力。
3.3 UDP的常见坑:分片、NAT与无拥塞控制
UDP无流量控制和拥塞控制,这是优点也是隐患。一个应用如果不管网络状况拼命发数据,会占满路由器队列,导致其他人体验下降,也让自己持续丢包。后来不断出现基于UDP的应用被运营商优先级限制的传闻,其实是这类协议没有保护机制,容易被网络设备识别并限速。
第二个常见坑是IP分片。以太网MTU通常是1500字节,去掉IP头和UDP头,应用数据超过1472字节时就需要IP层分片。UDP本身不负责重组丢失后的重传,一旦任何一个分片丢了,整个UDP数据报都被丢弃,但发送方无感知,对应用层来说就是偶发性的“一坨数据突然消失”。
因此,实际项目中UDP数据报长度要严格控制。常规配置下建议应用层负载不超过1400字节,视频流更要自己做好RTP分包逻辑,把大帧拆成多个符合MTU的小包。
第三个坑是NAT会话老化。UDP没有连接状态,NAT设备只能靠超时机制维护内部地址和外部流量的映射关系,很多设备的UDP NAT映射在30到60秒后就会过期。客户端一段时间不发数据,服务器主动下行推送就找不到目标了。解决思路通常是应用层发送KeepAlive心跳,或者在每次上行数据时携带一个序列号让服务器重新找到映射。
4. TCP与UDP选型,不要看性能要看语义
4.1 一张表把差异摆清楚
| 维度 | TCP | UDP |
|---|---|---|
| 连接状态 | 有连接,状态复杂 | 无连接,无状态 |
| 可靠性 | 可靠、有序、不重复 | 尽力而为,可能丢包乱序 |
| 流量控制 | 滑动窗口,接收方通告窗口 | 无 |
| 拥塞控制 | 有,多种算法可选 | 无,应用自行控制发送速率 |
| 延迟特性 | 握手+队头阻塞,延迟较高 | 无握手无阻塞,延迟低 |
| 头部开销 | 20字节以上,带选项更长 | 8字节 |
| 传输模式 | 字节流,无消息边界 | 数据报,有消息边界 |
| 典型场景 | 网页、文件传输、数据库、消息队列 | 音视频、游戏同步、DNS、广播 |
| 编程接口 | 可靠stream语义,复杂度高 | datagram语义,实现简单 |
选型时很多人纠结“TCP比UDP快还是慢”,这个命题本身不成立。关键是有序性带来的队头阻塞是否会影响业务,以及是否可以接受应用层自行处理丢包。
4.2 四步判断法,把业务需求翻译成协议选择
第一步问自己:数据丢了,业务能不能忍?绝大多数的API请求、转账指令、数据库事务都忍不了,选TCP,除非你有能力在应用层弥补。
第二步问自己:延迟是否比“偶尔丢一个包”更敏感?视频通话、游戏操作指令、多人互动,延迟超过阈值就会让用户感觉卡顿,而丢一两帧还能接受,这类业务优先考虑UDP。
第三步问自己:消息是连续的流,还是独立的报文?文件流、日志流用TCP很自然;RPC、信令、状态同步这类天然有消息边界的,用UDP加应用层协议也完全合理。
第四步问自己:是否要做NAT穿透或广播?UDP在NAT穿透时更容易,很多P2P场景必须用它。
用这个框架看真实业务,边界就非常清楚。比如某流量转发网关,内部需要对下游系统转发大量带超时限制的请求,用UDP加应用层超时重发,比TCP维护成千上万条连接要轻量得多。
4.3 混合组网:一个连接里同时存在TCP和UDP
实际系统里经常是“控制走TCP,数据走UDP”。典型如某实时对战平台:账号注册、好友列表、房间匹配、商城交易,这些操作必须精确可靠,全部放在TCP通道上;对战时每帧更新的玩家位置、操作指令,全部放在UDP通道上。两种协议互不干扰,各取所长。
这种做法要求通信双方都维护两套连接管理逻辑,调试时也要多抓一个通道的包。但收益非常明显:关键数据不会因为网络波动而丢,实时体验也不会因为重传而卡住。
更细一点,还可以在同一套UDP通道上做分优先级处理。心跳包、控制指令用“必须到达”的可靠性机制,普通位置更新的数据包用“丢了就丢了”的不可靠机制,这样既保证了核心逻辑可靠,也避免了所有数据都走重传链路的延迟放大。
4.4 某跨平台系统的工程案例:从需求到协议的完整决策
之前做一个跨平台的消息推送网关,刚接手时大家都默认“推送数据必须可靠,那就全走TCP”。结果压测时发现单机连接数超过预期后,维护连接状态就占用了大量的CPU和内存,而且推送高峰时大量慢客户端把服务端的发送缓冲区堵住,影响其他正常客户端。
后来把方案调整为:客户端和网关之间默认保持UDP通道,应用层增加序列号和确认机制,只有登录、退订这类关键控制消息要求确认重发;实时消息采用“最新偏移量同步”的方式,客户端发现自己落后了就请求增量同步。实测下来,单机承载的连接数提升了一个数量级,消息到达率虽然达不到100%,但核心消息有应用层兜底,普通推送可以容忍少量丢失。
这个案例的核心经验是:可靠并不意味着必须依赖TCP,只要你能定义业务自己的“可靠性边界”,UDP加上应用层补偿机制往往能带来更优的整体效果。
5. 抓包排障实录:从现场分析到内核调优
5.1 先用tcpdump把现场留下来
不管是TCP还是UDP问题,第一步永远是抓包取证,靠猜没有任何意义。常用的命令是:
tcpdump -i eth0 -nn -s 0 'tcp port 8080' -w /tmp/tcp8080.cap-nn表示不反解域名和服务名,-s 0抓完整包,写入cap文件后用Wireshark分析。如果只想看握手过程,可以过滤:
tcpdump -i eth0 -nn 'tcp[tcpflags] & tcp-syn != 0'抓到包之后,Wireshark里最常用的三个过滤表达式:
tcp.flags.syn == 1 && tcp.flags.ack == 0只看SYN包tcp.analysis.retransmission把所有重传包标出来tcp.window_size == 0找出接收窗口为零的连接
分析UDP时,建议直接看包大小分布和到达间隔。用统计里的IO Graph按时间画点,能直观看出是不是周期性突发。
5.2 常见问题速查:一条条对上号
| 现象 | 可能原因 | 最直接的处理 |
|---|---|---|
| 连接建立缓慢,SYN重传多 | 半连接队列溢出 | 调大tcp_max_syn_backlog和somaxconn,优化accept频率 |
| 大量TIME_WAIT | 短连接服务正常现象 | 确认端口没耗尽,一般不处理 |
| 服务端大量CLOSE_WAIT | 应用未正确关闭连接 | 检查代码,而不是调内核 |
| 重传率高,传输慢 | 网络拥塞、发送窗口太小 | 开启BBR或加大发送缓冲区 |
| 接收窗口持续为零 | 应用读取太慢 | 优化处理逻辑,提高消费速度 |
| UDP偶发整包丢失 | IP分片被丢弃 | 限制UDP包长度,应用层分包 |
| UDP时延高但CPU正常 | NAT映射超时,路径路由变化 | 应用层心跳,丢包后重发最新状态 |
| 连接数一高就大量TIME_WAIT导致新连接失败 | 本地端口耗尽 | 打开tcp_tw_reuse,调整端口范围 |
5.3 内核参数的“该调与不该调”
TCP侧的参数调整要克制。net.ipv4.tcp_fin_timeout调低确实可以缩短TIME_WAIT的停留时间,但如果设得太小,可能导致旧连接的重复报文干扰新连接,不建议低于15秒。
UDP侧的缓冲区是更常见的瓶颈。默认的net.core.rmem_default对高吞吐UDP来说太小,接收方数据到达速度超过应用读取速度时,内核直接丢包,应用层根本感知不到,表现为“偶尔丢数据”。
稳妥的做法是把读写缓冲区都提高:
sysctl -w net.core.rmem_max=16777216 sysctl -w net.core.rmem_default=10485760 sysctl -w net.core.wmem_max=16777216 sysctl -w net.core.wmem_default=10485760同时调整单个socket的接收缓冲区:
int rcvbuf = 8 * 1024 * 1024; setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));要记住,提高缓冲区只是给应用留出处理时间窗口,真正解决问题还是要让应用尽快把数据从内核缓冲区读走。
5.4 踩过的三个真实坑
第一个坑是UDP分片。某网关服务在发送超过1600字节的报文,线上偶发客户端报“收到不完整数据”。抓包发现大报文被分成了两个IP分片,第二个分片经常在弱网下丢失,整个UDP数据报被丢弃。后续把所有报文控制在1400字节以内,问题消失。
第二个坑是Nagle算法和延迟确认的叠加。一个强交互的服务端向客户端发送大量小包,实测时延不稳定。查下来发现TCP_NODELAY没有打开,再加上对端延迟确认,一个几十字节的心跳包被硬生生拖了几十毫秒。需要在发送小包前设置TCP_NODELAY,或者干脆对低延迟场景考虑UDP。
第三个坑是误信“UDP不需要调优”。某视频业务通过UDP发送H.264帧,某个版本突然出现大面积花屏。排查后确认是发送端没有做码率自适应,单纯把数据灌进网络,路由器队列溢出后开始丢包,画面质量雪崩。后来在UDP通道上加了基于丢包率的动态码率控制,问题才稳定解决。UDP的上层协议必须自备流量控制,不能指望内核帮你兜底。
在这两个协议之间做选择,其实是在给自己的业务定语义边界:哪些数据必须稳定到位,哪些数据必须尽快落地。我在实际项目中始终保留一套“上层补偿”的思路——不管是调TCP参数、还是在UDP上做轻量可靠机制,核心都是理解业务的容忍度,然后用协议去匹配它。技术方案没有绝对的对错,只有适合不适合。