资讯动态

TCP与UDP的底层原理、Linux实践与线上排查技巧

发布时间:2026/10/17 3:15:20 来源:尧图企业网站定制
能发出去的包不一定靠谱能建立起来的连接也不一定高效。这个问题在 Linux 网络开发里几乎绕不开。你写一个 UDP 服务端监听端口用nc -u发几个包过去好像一切正常但一旦流量上来、丢包开始出现你会发现 UDP 的真实性格跟 TCP 完全不在一个世界里。反过来看 TCP三次握手、确认重传、拥塞控制每一层机制都在为“可靠”买单但这个成本不是白付的关键看你愿不愿意用延迟和吞吐去换那点确定性。这篇东西主要面向在 Linux 上做服务端开发、网络调试、或者刚起步学网络编程的读者。我会把 TCP 和 UDP 的原理差异落到实际操作层面讲清楚它们在内核里是怎么被处理的、各自的坑在哪、用哪些命令能直观观测再结合 C 和 Python 的套接字示例讲一遍实现套路。看完之后你至少能明白为什么 UDP 会丢包丢得莫名其妙为什么 TCP 会有大量的 TIME_WAIT以及线上出问题的时候应该先查哪里。1. TCP 与 UDP 的底层差异不只是“有没有连接”先说最根本的。TCP 和 UDP 都跑在 IP 之上内核里走的是同一套收包路径网卡中断 - 驱动 - 协议栈 - socket。但再往下两者的处理模型就是两条路线了。1.1 一个是有状态机一个只是数据报翻译TCP 在内核里是一个完整的状态机。你调用connect()之后内核会维护这个连接从 CLOSED、SYN_SENT、ESTABLISHED 一直到 FIN_WAIT、TIME_WAIT 的完整状态迁移。每一个收到的包都要经过校验、去重、排序、确认还有窗口管理、拥塞控制算法。这意味着同一个四元组源 IP、源端口、目的 IP、目的端口天然就是一条独立逻辑链路内核要给它分配一套发送队列、接收队列、拥塞状态变量成本是实打实的。UDP 就简单得多。它没有连接概念socket 只是绑定一个本地端口内核收到 UDP 包之后直接查绑定表丢给对应 socket 的接收队列完事。不排序、不确认、不重传收到什么就上送什么。因此 UDP 包头只有 8 字节TCP 头至少 20 字节这个差值在密集小包场景下就是一笔可观的带宽成本。1.2 可靠性的代价头部字段与状态维护TCP 头里那几个字段值得背一下序列号seq保证字节流的顺序确认号ack表示期望对端发送的下一个字节编号窗口字段做流控SYN/FIN/RST/ACK 这些标志位推动状态迁移。每一个字段都不是白设的它们对应的是确认重传机制里的一环。UDP 头则只有四样东西源端口、目的端口、长度、校验和。校验和还是可选 strong 校验IPv4 下不校验也没人拦你。所以 UDP 真正保证的最小语义是“我帮你把这个数据报按原样从 A 端口搬到 B 端口”至于过程中包有没有丢、有没有乱序它一概不负责。注意UDP 的校验和虽然在 IPv4 里可关但 IPv6 里是强制的。如果哪天你发现 IPv6 环境下 UDP 收发不太一样先检查这个。1.3 从系统调用看两者的姿势区别写代码时感受最明显。TCP 服务端要 socket - bind - listen - accept每一步都有明确语义客户端要 connectconnect 不成功等于没建立连接。收发数据用的是 send/recv 或者 read/write因为 TCP 是字节流消息边界全靠你自己定。UDP 那边是 socket - bind然后直接 recvfrom/sendto。recvfrom 一次返回的就是一个完整数据报带源地址信息天然保留消息边界。这也是很多自定义协议选 UDP 的原因——不用自己在字节流里切消息省一层封装工夫。2. Linux 下 UDP 的典型应用与性能问题排查UDP 在 Linux 里的使用场景其实比很多人想的多DNS 查询、DHCP、NTP、RTP 音视频流、游戏同步、日志采集还有大量工业场景里的私有协议。它的特点决定了它适合“大部分消息及时送到就行丢个别也无所谓”的业务。2.1 先搞懂 UDP 接收缓冲区再谈丢包UDP 最经典的问题进程明明在收后台看netstat -su里却不断有 receive buffer errors。原因是 UDP 没有流控。发送方只管发接收方的内核缓冲区一旦满了新来的包直接丢弃。TCP 的话满了会触发接收窗口收缩发送方会停一停UDP 没有这个机制只能靠丢包来“背压”。查的时候别只盯着应用日志先看内核统计netstat -su输出里重点看这两行Udp: 123456 packets received 78 packets to unknown port received 456 packet receive errorspacket receive errors增长基本可以判定接收队列溢出。这时你可以放大 socket 接收缓冲区// Python import socket s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)但要注意setsockopt 设置的缓冲区上限会被内核参数限制。默认情况rmem_max只有 208KB 左右你设 4MB 根本没用。得先改内核参数sudo sysctl -w net.core.rmem_max8388608 sudo sysctl -w net.core.wmem_max8388608改完之后再来设 socket 缓冲区才生效。这个坑我踩过不止一次很多资料只让你 setsockopt不提内核上限结果用户态怎么调都白搭。2.2 多播与广播UDP 独有的特权TCP 是一个对一个是连接UDP 天然支持一对多。Linux 下用多播要设置组播地址和 TTL以 C 语言为例核心就这么几句struct ip_mreq mreq; mreq.imr_multiaddr.s_addr inet_addr(239.0.0.1); mreq.imr_interface.s_addr htonl(INADDR_ANY); setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, mreq, sizeof(mreq));接收端必须加入组播组否则内核不会把组播包往上送。发送端要记得设 TTLunsigned char ttl 8; setsockopt(sockfd, IPPROTO_IP, IP_MULTICAST_TTL, ttl, sizeof(ttl));多播场景里 Linux 默认的rp_filter反向路径过滤经常是个隐形杀手。如果服务器有多块网卡内核发现源 IP 的路由入口不对会把包直接丢在协议栈外面。遇到组播收不到先sysctl net.ipv4.conf.all.rp_filter看一眼必要时调成 0再逐个网卡确认。2.3 一个小而完整的 UDP 收发代码示例不用 C 也不用 Python 里太复杂的框架Python 标准库就够跑通基本逻辑。服务端import socket s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind((0.0.0.0, 6000)) print(UDP server listening on 0.0.0.0:6000) while True: data, addr s.recvfrom(1500) print(recv from %s:%d - %s % (addr[0], addr[1], data.decode())) s.sendto(back: data, addr)客户端import socket import time c socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server (127.0.0.1, 6000) for i in range(5): msg (hello udp %d % i).encode() c.sendto(msg, server) data, addr c.recvfrom(1500) print(recv:, data.decode()) time.sleep(0.5)跑起来以后 tcpdump 抓包可以看到每个 UDP 包之间没有握手也不存在确认发完就算完事。这种“不可靠感”在实时音视频里反而是优点因为没人愿意等一个迟到的老画面。3. Linux 下 TCP 的实践细节从握手到状态迁移TCP 的可靠性是它的招牌但这块招牌需要无数机制去维护。你把它部署在 Linux 上如果不理解状态机、不了解超时重传和拥塞控制的影响就很容易遇到线上连接卡死或者端口耗尽的问题。3.1 握手不是“多一次交互”那么简单三次握手客户端发 SYN服务端回 SYNACK客户端再回 ACK。这个流程很多教程讲了无数遍但落到 Linux 上有个细节容易被忽略——握手队列。内核里每个监听 socket 维护两个队列半连接队列SYN Queue和全连接队列Accept Queue。前者保存收到 SYN 还没完成握手的请求后者保存已经完成握手、等待 accept() 取走的连接。队列长度由listen(fd, backlog)决定但实际值会受到内核参数somaxconn限制。查全连接队列状态ss -lnt看Send-Q那一列如果 Recv-Q 一直是满的说明用户态程序 accept 速度跟不上连接建立的节奏。此时客户端表现就是 connect 成功但服务端处理不过来严重时大量连接建立失败。这个跟网络没关系是典型的应用层吞吐问题。3.2 TIME_WAIT 和它带来的“端口不够用”主动关闭连接的一方最后要进入 TIME_WAIT 状态保持 2MSL 时长Linux 默认 60 秒左右。这个状态是为了让迟到的包在网络里自然消失避免影响下一次使用相同四元组的连接。但高并发短连接场景下TIME_WAIT 会积累成一堆。你ss -tan一看成千上万个 TIME_WAIT 挂着四元组里的本地端口被占住最终导致无法创建新连接。缓解办法分为几级第一级应用层用好连接复用和长连接避免频繁短连接。第二级设置SO_REUSEADDR让监听 socket 可以重用 TIME_WAIT 状态的端口。第三级谨慎调整内核参数sudo sysctl -w net.ipv4.tcp_tw_reuse1 sudo sysctl -w net.ipv4.ip_local_port_range1024 65535tcp_tw_reuse只能用于客户端发起新连接时复用 TIME_WAIT 连接服务端场景基本不适用。很多人一看到 TIME_WAIT 多就开这个参数这是不对的。正确顺序永远是先优化业务连接模型再动内核。3.3 TCP 发送缓冲区和 Nagle 算法的相爱相杀TCP 攒数据再发这个概念应该不陌生。发送端为了减少小包数量默认开启 Nagle 算法一个连接上只能有一个未被确认的小包后续小包得攒一攒再发。在高实时交互场景比如远程操作、游戏协议这会引入明显延迟。解决办法是关闭 Nagle也就是设置TCP_NODELAY。以 Python 为例s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)代价是可能多出一些 TCP 小包。这种取舍跟业务模型强相关没有绝对的对错。同时在服务端不要忽略另一个配合项发送缓冲区。UDP 丢包看接收侧TCP 性能问题往往看发送侧。如果业务一次性写入大数据而 socket 缓冲区太小write/ send 会被卡住表现为系统调用耗时暴涨。3.4 基于 select 的 TCP 服务端骨架还是用标准库写一个能跑的 TCP 服务器突出 socket 的关键调用顺序import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 7000)) server.listen(128) print(TCP server listening on 0.0.0.0:7000) while True: conn, addr server.accept() with conn: print(accept from, addr) data conn.recv(1024) if data: conn.sendall(back: data)SO_REUSEADDR务必放在 bind 之前不然服务端重启后端口可能起不来因为上一次遗留的 TIME_WAIT 还占着地址。这是写 Linux 网络服务最基础的一个小细节。4. 选型判断什么时候必须 TCP什么时候可以 UDP技术选型永远是看业务而不是看谁更“高级”。传输层的选择直接决定你后续要对丢包、乱序、延迟做多少补偿工作。4.1 适合用 TCP 的场景一切对数据完整性有强需求的应用基本逃不开 TCP。文件传输、网页请求、数据库操作、远程登录、消息队列这些场景不允许丢字节。哪怕是 IP 电话的信令也建议走 TCP 或 TLS保证控制消息不乱、不丢。另一个判断标准是你的应用能否容忍重传带来的延迟抖动。如果业务要求“最终一致就行中间慢一点没关系”TCP 的确认重传机制完全能接受。4.2 适合用 UDP 的场景实时性要求高、可容忍少量丢包的场景通常更倾向 UDP。直播流、实时语音、多人游戏位置同步、监控视频传输它们在意的是低一路延迟而不是每个字节都对。UDP 发给你的每一帧如果晚到了对播放器毫无意义不如丢掉赶紧播下一帧。这类业务通常还会在应用层加 FEC前向纠错或者用 QUIC 这类基于 UDP 的可靠协议来弥补 UDP 的不足。4.3 不要忘了“可靠性可以自己造”很多人一说 UDP 就觉得“不靠谱”但可靠是一个向上层可迁移的属性。你可以用 UDP 做底层在应用层实现序列号、确认、超时重传、拥塞控制。这就是 QUIC 的理念。QUIC 在一个 UDP 连接上实现了 TLS 加密、多路复用、连接迁移和类似 TCP 的可靠性机制而且把重传粒度从 TCP 的字节流改成独立流头阻塞问题大幅缓解。所以更准确的说法不是“用 TCP 还是 UDP”而是“你能不能接受在内核态用现成的 TCP还是愿意在用户态再造一套可靠性”。TCP 省心但控制不了内部算法UDP 灵活但一切自己扛。附一张简单的对照表方便收藏维度TCPUDP连接状态有完整状态机无状态可靠性确认、重传、排序不保证消息边界字节流需要自己分包数据报天然保留头部开销至少 20 字节8 字节流量控制滑动窗口无典型场景文件、数据库、网页音视频、游戏、DNS注意这份表格说的是“通常情况”。技术方案总是有例外比如你可以用 TCP 传音视频也可以用 UDP 封装一切可靠逻辑关键看你对质量、延迟、成本三者怎么排列优先级。5. 用 Linux 运维命令直观对比 TCP 与 UDP理论说再多不如自己动手抓包看一次。Linux 下有几个命令对排查这两种协议非常有用而且基本都是系统自带或者常规软件源里直接能装的。5.1 ss看连接状态的瑞士军刀ss读取内核 socket 信息比 netstat 更快更准。常用几条# 查看所有 TCP 连接带状态 ss -tan # 查看所有 UDP 监听端口 ss -lun # 按目标端口过滤 ss -tan sport :80 # 只看套接字内存占用 ss -tmss -tan可以看到 TIME_WAIT、ESTABLISHED、LISTEN 等各状态的连接数。如果你发现某个状态堆积严重通常可以直接定位到问题方向。比如SYN_SENT多代表客户端连不上服务端可能是防火墙丢包CLOSE_WAIT多说明服务端程序没正确关闭连接。UDP 用ss -lun看的是端口监听情况。它不会显示连接状态因为 UDP 没有状态你只能确认端口在不在听。5.2 tcpdump亲眼看到协议差异抓包是最直观的对照方式。示例# 抓和端口 6000 相关的 UDP 包 sudo tcpdump -i any -n udp port 6000 # 抓 TCP 三次握手全过程 sudo tcpdump -i any -n tcp port 7000抓 TCP 握手时你能看到明显的三次交互SYN、SYN-ACK、ACK。抓 UDP 时每一次 sendto 就是独立的一个包没有额外的确认包存在。这种差异看过一次就忘不掉。生产环境抓包时建议加-w保存文件之后拿分析工具慢慢看避免在终端里刷掉现场信息sudo tcpdump -i eth0 -n -w /tmp/capture.pcap host 192.0.2.10 and tcp port 4435.3 nc快速模拟协议交互ncnetcat是手边最轻量的协议调试工具。# 测试 TCP 端口是否可达等效于手动完成一次握手后立即关闭 nc -vz 192.0.2.10 7000 # 模拟 UDP 客户端发一段文字到目标端口 echo hello udp | nc -u 192.0.2.10 6000 # 本地起一个 TCP 监听端口手动回显收到的内容 nc -l 7000TCP 场景里nc -vz的成败能立刻反映路由和防火墙是否放行UDP 场景里nc -u发完包没有反馈因为 UDP 没有应答机制得配合服务端日志判断包有没有到达。这些命令组合起来能让你不做任何开发也能直观对比两种协议的行为差异。6. 线上问题排查技巧与避坑实录最后这部分是我实际运维中积攒下来的经验不一定出现在教科书里但遇到问题时非常救命。6.1 一张表速查常见故障现象可能原因排查命令与思路UDP 收不到包rp_filter 反向路径过滤sysctl net.ipv4.conf.all.rp_filter检查UDP 接收丢包socket 缓冲区满内核丢弃netstat -su看 receive buffer errorsTCP 连接超时防火墙丢 SYN 包ss -tan state syn-sent看是否卡死端口耗尽TIME_WAIT 过多占用本地端口ss -tan state time-wait统计数量大量 CLOSE_WAIT应用未关闭连接lsof 查进程 fd检查代码连接释放逻辑TCP 吞吐上不去窗口或缓冲区太小ss -tm看发送/接收队列积压6.2 为什么我总是强调先看内核统计排查网络问题时第一反应应该是到内核统计里找证据而不是凭经验瞎猜。UDP 丢包这种问题很多开发者优先怀疑自己的代码逻辑查来查去没有结果最后发现只是缓冲区满了。Linux 在/proc/net/snmp和/proc/net/udp里保留了很详细的计数器也可以直接看cat /proc/net/snmp | grep Udp里面RcvbufErrors和SndbufErrors直接告诉你发送或接收侧由于缓冲区不足丢了多少包。这些数字默默增长的时候应用层可能还在正常运行但延迟和丢包率已经开始恶化。养成一个习惯任何网络性能问题处理前先拍一张内核计数器的快照。6.3 一个真实的排查思路演示假设你收到告警某个 UDP 日志采集服务的丢包率突然升高。按这套思路走一遍第一步ss -lun确认服务监听正常。第二步cat /proc/net/snmp | grep Udp看 RcvbufErrors 有没有增长。有增长就是接收队列溢出。第三步ss -ulnp找到进程看一眼它有没有频繁调用 recvfrom。可能问题是单线程处理来不及也可能 socket 缓冲区确实太小。如果是处理能力不足先加消费者线程再考虑调大 SO_RCVBUF。第四步调整后观察netstat -su里的错误计数字段确认走势稳定。整个过程不需要重启服务也不需要动通过代码逻辑之外的东西。这里头最容易犯的错是一上来就怀疑发送方过快然后给发送端加了限速其实是接收端缓冲区太小。没有内核计数器的支撑方向就容易跑偏。6.4 我个人的几条避坑习惯第一任何 socket 的 recv 缓冲区默认值都很小生产环境务必根据流量预分配。尤其在容器化环境里内核参数的默认值不比物理机很容易踩到小缓冲区导致的隐性丢包。第二TCP 的tcp_nodelay不是什么时候都要开。如果你的服务端在一个高速局域网里、包处理很快开着没问题跨公网传输时完全关闭 Nagle 反而可能让网络里堆满小包吞吐反而下降。第三写 TCP 服务端时永远记得处理半关闭状态。对方调了 shutdown 只关写你的 recv 会返回 EOF但 read 返回 0 不代表连接已经销毁。socket 资源和 fd 的清理一定要彻底否则就是 CLOSE_WAIT 堆积的温床。第四UDP 场景里包大小尽量控制在 MTU 以内。以太网常见 MTU 是 1500减掉 IP 头 20 字节和 UDP 头 8 字节实际 payload 建议在 1400 以内。超过这个值IP 层就要分片一旦中间某个路径不支持分片包就直接丢了。这是很多人设计 UDP 协议时没注意的低级陷阱。我在实际项目里最深刻的感受是TCP 和 UDP 不是好与坏的对比而是不同责任边界的取舍。TCP 把可靠性封装在内核里代价是你必须接受它的握手成本和拥塞控制行为UDP 把控制权交还给你代价是网络质量、丢包恢复、消息时序全都得自己操心。理解这层逻辑之后你再去看各种网络框架和协议设计思路会瞬间清晰很多。最后再分享一个小技巧在你的测试环境里同时起一个 TCP 服务和 UDP 服务用 tcpdump 各抓一次包保存下来闲下来对比看看比读十篇原理文章都管用。

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

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

免费获取报价 →
↑