Linux TCP/UDP协议实战:socket参数与内核排错
做后端的人大概都遇到过这种场面:一个服务上线时跑得好好的,流量一上来延迟曲线就开始抽风,TCP连接数卡在某个数字上不去,或者UDP那边的收包速率怎么调都突破不了瓶颈。第一反应通常是加机器、换框架、调线程池,但排查到最后,问题往往落在最底层那几行socket代码和几个内核参数上。标题叫"Linux之udp/tcp协议",听起来像教科书目录,实际上它覆盖的是Linux网络编程里最容易被"会用但不懂"掩盖掉的一整片区域:TCP的状态机怎么被内核托管、UDP的报文边界谁来负责、缓冲区到底是谁在丢包、抓包看到的现象和代码逻辑对不上时该信谁。
我把这篇当成一份实战笔记来写。适合三类人看:刚写完第一个socket demo、对三次握手只停留在八股文层面的新手;线上出过网络问题、靠重启和扩容糊过去但没搞明白根因的开发者;以及需要给团队做技术选型、得说清楚"为什么这里必须用UDP而不是TCP"的负责人。文中涉及的参数取值、命令输出、内核行为都基于通用Linux发行版的常见默认配置,不同内核版本会有细微差异,实操时请以自己机器上的sysctl和ss输出为准。
1. 协议选型最终会落到几个socket参数上
1.1 先搞清楚"报文边界"这件事归谁管
新手最容易混淆的一点是:TCP和UDP最本质的区别不是"可靠"和"不可靠",而是报文边界由谁维护。TCP是字节流,内核只保证字节按序到达,不保证你一次send对应对方一次recv;UDP是数据报,一次sendto对应一次recvfrom,边界清晰,但长度、顺序、是否到达全都不管。
这个差异会直接决定你的应用层代码长什么样。我见过太多TCP服务端写成这样:recv(fd, buf, 4096, 0)然后默认"这一包就是完整的一条消息"。本地测试永远正常,因为数据量小、走回环、一次就收全了;一上真实网络,半个JSON被解析、粘包导致两条指令被当成一条,各种诡异bug就来了。根因不是网络不稳,是你在用UDP的思维写TCP的代码。
正确的做法是在应用层自己划边界,常见三种:
- 长度前缀:先发4字节大端长度,再发正文。二进制协议首选,解析最简单。
- 分隔符:比如
\r\n或\0。文本协议常用,但要处理正文中出现分隔符的转义问题。 - 定长包:每条消息固定长度,简单粗暴,浪费带宽。
UDP这边则相反,你需要主动关心单个数据报的大小。一次sendto超过MTU会触发IP分片,任何一片丢失整个数据报就废了,而UDP没有重传。所以UDP业务里"单包控制在1400字节以内"几乎是条铁律,后面第3节会细算这个数。
1.2 内核里TCP和UDP各自站在什么位置
从代码往下看,你的send()调用会依次穿过:应用缓冲区 → socket层 → 传输层(TCP/UDP)→ 网络层(IP)→ 设备层(驱动/网卡)。TCP在这条链路上做了大量你看不见的事:分段、编号、重传、流控、拥塞控制;UDP基本就是加个端口号和校验和就往下丢。
理解这个分层对排错极其关键。举个例子:UDP发送端sendto返回成功,只代表数据进了内核发送缓冲区,不代表对方收到,甚至不代表包已经出了网卡。所以"我明明send成功了为什么对方没收到"这类问题,答案永远不在返回值里,得去下层找。
反过来,TCP的send返回值是"拷贝进内核缓冲区的字节数",也不是"对方已确认"。真正的确认发生在后续的ACK里,而你通常感知不到。想感知,就得用ss -ti看unacked和retrans字段。
| 关注点 | TCP | UDP |
|---|---|---|
| 报文边界 | 应用层自己划 | 内核按数据报保留 |
| 可靠性 | 内核重传保证 | 完全不保证 |
| 单次发送大小建议 | 受MSS影响,可任意大 | 建议 ≤ 1400 字节 |
| 主要可调项 | 缓冲区、Nagle、backlog | 缓冲区、分片、GRO |
| 排错主战场 | 状态机 + 重传计数 | 缓冲区 + 丢包计数 |
1.3 一个反直觉的结论:大多数"协议问题"其实是缓冲区问题
我带过几个新人,遇到"连接卡住""收不到数据",第一反应都是怀疑协议、怀疑网络。实际统计下来,八成是下面三类:接收方应用读得太慢导致接收缓冲区满;发送方缓冲区太小导致吞吐上不去;或者连接根本没有被accept出去,卡在半连接队列里。
这三类问题的共同点是:用ss看一眼就能定位,但不知道看什么就永远在猜。第5节我会把完整的排查顺序列出来。
2. TCP连接是被内核状态机托管出来的
2.1 三次握手对应代码里的三个动作
很多人把三次握手背得很熟,但不知道它在服务端代码里对应哪几步。拆开看其实很清楚:
- 客户端
connect()发出SYN,进入SYN_SENT。 - 服务端收到SYN,回SYN+ACK,同时在半连接队列(SYN队列)留一条记录,状态
SYN_RECV。 - 客户端回ACK,服务端把这条记录从半连接队列移到已完成连接队列(accept队列),状态
ESTABLISHED。 - 你的
accept()从accept队列里取出一条,返回一个新的fd。
关键点在第4步:连接在accept()之前就已经建立完成了。这意味着服务端进程即使卡住不accept,内核也会帮你把三次握手走完。于是就有了两个队列、两个上限、两种溢出表现,这也是2.3节要展开的内容。
还有一个经常被忽略的细节:accept()返回的新fd不继承监听fd上设置的某些属性,比如O_NONBLOCK在部分实现上需要重新设置。写高并发服务时如果监听fd设了非阻塞,记得对每个新fd也处理一遍。
2.2 四次挥手、TIME_WAIT和CLOSE_WAIT完全是两回事
这两个状态在面试题里经常被混着问,但线上处理方式截然不同。
主动关闭方进入TIME_WAIT,持续2MSL(Linux固定为60秒)。它存在的意义有两个:一是保证最后那个ACK能到达对方(如果丢了,对方的FIN重传还能被响应);二是让这条连接上的迷途报文在网络中自然消亡,避免污染下一个复用相同四元组的连接。TIME_WAIT是协议正常行为,不是bug。
被动关闭方收到FIN后进入CLOSE_WAIT,此时它已经知道对方不再发数据了,但自己还没调close()。这个状态如果大量堆积,说明你的代码有fd泄漏——某个分支忘了关连接。这是纯粹的代码问题,调内核参数一点用都没有。
区分方法很直接:
# TIME_WAIT 数量(正常现象,短连接多的服务会很多) ss -tan state time-wait | wc -l # CLOSE_WAIT 数量(异常现象,堆高就是泄漏) ss -tan state close-wait | wc -l短连接服务TIME_WAIT堆到几万是常态,真正要担心的是本地端口耗尽。客户端每发起一次连接占用一个本地端口,Linux默认范围是32768-60999,约2.8万个。如果你的服务作为客户端高频调用下游短连接接口,很快会撞到Cannot assign requested address。
解法按推荐顺序:第一优先改用连接池/长连接,这能把端口占用直接降两个数量级;第二是扩大net.ipv4.ip_local_port_range;第三才是开net.ipv4.tcp_tw_reuse。注意tcp_tw_reuse只对主动发起连接的方向生效,而且依赖时间戳,不要把它当成万能药,更不要碰那个早已被移除的tcp_tw_recycle。
2.3 accept队列和SYN队列:listen的第二个参数到底控制什么
listen(fd, backlog)这个backlog,绝大多数人以为是"最大连接数"。实际语义是已完成连接队列的长度上限,而且内核会取min(backlog, net.core.somaxconn)。所以你在代码里写listen(fd, 1024),机器上somaxconn是128,那实际只有128。
半连接队列(SYN队列)的长度是另一套逻辑,受net.ipv4.tcp_max_syn_backlog影响,并且和tcp_syncookies联动。队列满了之后,内核的默认行为是静默丢弃新的SYN,客户端表现为连接超时——注意是超时,不是拒绝。这个区别很重要:ECONNREFUSED说明端口没监听,超时多半说明队列满了或有防火墙丢包。
看队列水位:
ss -lnt # State Recv-Q Send-Q Local Address:Port # LISTEN 0 128 0.0.0.0:8080对LISTEN状态而言,Recv-Q是当前已完成握手、等待accept()取走的连接数,Send-Q是队列上限(也就是生效的backlog)。如果Recv-Q长期贴着Send-Q,说明应用accept太慢或者根本没在accept,这时候加机器没用,得看你的accept循环里是不是塞了阻塞操作。
我踩过的一个坑:某服务在accept之后立刻做了一次同步的鉴权RPC调用,下游一抖动,accept循环就卡住,几秒钟内队列打满,新连接全部超时。后来把鉴权改成异步,问题消失。accept循环里不要做任何可能阻塞的事,这条经验值不少钱。
3. UDP的简单是有代价的
3.1 一次sendto在内核里到底发生了什么
ssize_t n = sendto(fd, buf, len, 0, (struct sockaddr *)&addr, sizeof(addr));这一行背后:校验长度、查路由、必要时分片、加UDP头、加IP头、交给网卡队列。整个过程没有握手、没有确认、没有重传。n == len只说明数据被塞进了发送缓冲区。
这带来一个实际影响:UDP的发送速率不受接收方任何约束。你发得越快,中间任何一环撑不住就直接丢,发送端毫无感知。所以做UDP业务必须自己在应用层设计反馈机制,否则就是"跑得挺欢,数据丢了一半没人知道"。
另一个高频坑是ECONNREFUSED。如果UDP socket调过connect()绑定到对端,对端端口没人监听时会回ICMP端口不可达,下一次sendto或recvfrom会返回-1且errno = ECONNREFUSED。这个错误看起来像TCP的错误,实际是ICMP带回来的。没调connect()的socket则收不到这个通知。
3.2 MTU和分片:为什么UDP大包总在关键时刻失败
以太网标准MTU是1500。减去IP头20字节、UDP头8字节,应用层能塞的理论最大值是1472字节。如果中间有PPPoE、VXLAN、IPSec隧道,MTU还会更小——VXLAN封装后常见是1450甚至1400。
超过这个数的UDP包会被IP层分片。分片的致命问题是:只要丢失其中任意一片,整个数据报就得丢弃,而UDP不会重传。一个1500字节的包分成2片,丢包率从1%变成接近2%的整包失败率;分成10片,失败率接近10%。这就是"小包好好的,大包一压测就崩"的根本原因。
所以UDP业务的经验值是:单包应用层数据控制在1400字节以内,最稳妥是1200。需要传大数据就自己在应用层做分片和重组,配上序号和超时重传,别指望IP层。
验证路径MTU可以用带DF标志的ping:
# 1472 + 8(UDP) + 20(IP) = 1500,成功说明路径MTU >= 1500 ping -M do -s 1472 -c 3 目标地址 # 逐步缩小找到实际MTU ping -M do -s 1400 -c 3 目标地址如果1472失败、1400成功,那路径上就存在MTU削减,你的UDP单包上限得跟着降。
3.3 收包缓冲区与丢包计数:从内核计数器倒推问题
UDP丢包有四个常见环节,从外到内:
- 网卡ring buffer满:
ethtool -S eth0 | grep -i drop,看rx_dropped/rx_missed_errors。 - 内核收包队列满:
net.core.netdev_max_backlog,用/proc/net/softnet_stat第二列看溢出。 - socket接收缓冲区满:
SO_RCVBUF不够或者应用读得慢。 - 应用层处理不过来:
ss -uan看Recv-Q是否长期堆积。
协议栈层面的统计在/proc/net/snmp的Udp:行:
cat /proc/net/snmp | grep -A1 '^Udp:' # InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrorsInErrors增长通常对应RcvbufErrors,也就是缓冲区满导致的丢弃;NoPorts增长说明有包打到了没人监听的端口,一般是无用的广播或端口配错。
缓冲区调优有个细节:setsockopt(SO_RCVBUF)设置的值和getsockopt读回来的值经常不一样,内核会做对齐、加倍或者抬到最小值,这属于正常行为。判断是否生效别看getsockopt,直接看ss -uanm里的skmem字段更准:
ss -uanm # skmem:(r0,rb212992,t0,tb212992,f0,w0,o0,bl0,d0) # ^^^^^^^^^^ 接收缓冲区实际分配rb是实际分配的接收缓冲,r是当前队列中的字节数。r长期接近rb就是明确的丢包前兆。
4. 写代码阶段最容易埋雷的几处
4.1 阻塞、非阻塞、多路复用的实际取舍
三种模型没有优劣,只有场景匹配。
阻塞IO适合连接数少、每个连接逻辑独立的场景,比如内部工具、命令行客户端。代码最短,出错最少。
非阻塞IO + epoll适合高并发、单连接流量不固定的场景。要注意的是epoll的边缘触发(ET)模式必须一次读到EAGAIN为止,否则事件不会再通知,连接会"假死"。我见过几个线上事故都是ET模式下只读了一次就返回。
**IO线程池(阻塞 + 每连接一线程)**在连接数几百以内完全够用,别被"高并发必须epoll"的说法带偏。线程上下文切换的成本在几百量级根本感知不到,而阻塞代码的可维护性远高于回调。
一个实用建议:先用最简单的模型把功能跑通,压测到瓶颈再换。我见过团队一上来就写Reactor+回调,结果业务逻辑散在十几个回调里,一个超时处理要改五处,维护成本远超性能收益。
4.2 SO_REUSEADDR和SO_REUSEPORT解决的是不同问题
这两个选项名字像,用途完全不同,经常被混用。
SO_REUSEADDR解决的是TIME_WAIT状态下的端口重用。服务重启时,旧连接还在TIME_WAIT,不加这个选项bind会报Address already in use。所有服务端监听socket都应该加上,这是标配。
SO_REUSEPORT解决的是多进程/多线程同时监听同一端口,内核在传输层做负载均衡,把新连接按四元组哈希分发到各个监听者。它是多核扩展的利器,能让每个worker进程各自accept,避免惊群。
| 选项 | 主要用途 | 典型设置位置 |
|---|---|---|
| SO_REUSEADDR | 绕过TIME_WAIT占用,快速重启 | 所有服务端 |
| SO_REUSEPORT | 多进程共享端口做负载均衡 | 多worker服务端 |
| TCP_NODELAY | 关闭Nagle,降低小包延迟 | 交互型TCP客户端/服务端 |
SO_REUSEPORT有个坑:它按四元组哈希分发,如果客户端连接数少而worker多,可能出现分发不均。另外进程重启时,老进程持有的连接还在,新进程能立刻绑定同一端口,这既是优点(无缝重启)也是风险(流量被新老进程分走)。
4.3 TCP_NODELAY:小包延迟的经典来源
Nagle算法的逻辑是:如果还有未确认的小数据,就先攒着,等确认回来或者攒够一个MSS再发。这对批量传输很友好,对交互型应用则是灾难。
典型场景:客户端发一条指令,服务端分两次send回响应头和响应体。第一次send发出后,第二次send被Nagle攒住,等服务端收到ACK才发——而客户端的ACK可能被延迟确认(delayed ACK)拖住40ms。于是每条指令多出40ms延迟,QPS上不去,看起来像"服务端处理慢",实际是协议层在等你。
解法:
int flag = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));交互型业务(RPC、游戏、即时通讯、终端)都应该开。反过来,大文件传输、批量同步这类场景可以不开,让Nagle发挥合并小包的正面作用。判断标准很简单:你的业务是否对单次往返的延迟敏感。
顺带提一句:TCP_CORK是Nagle的加强版,可以手动控制攒包时机,配合sendfile做静态文件服务时效果明显。但记得在合适的时机取消CORK,否则数据会一直压着不发。
5. 排错现场:工具、指标和判断顺序
5.1 ss比netstat多给的那些信息
netstat已经被淘汰,ss才是主力。它直接读内核的socket表,快且信息全。
# 所有TCP连接及状态 ss -tan # 只看监听端口,含队列水位 ss -lnt # 查看单个连接的详细指标:RTT、拥塞窗口、重传 ss -tin # 按状态过滤 ss -tan state established # UDP 缓冲区使用情况 ss -uanmss -ti的输出里几个关键字段:
rtt:平滑后的往返时延。突然抬升说明链路拥塞或对端处理慢。retrans:重传次数。持续增长说明有丢包,可能是链路问题也可能是接收端缓冲区满。cwnd:拥塞窗口。掉到很低说明内核判定网络拥塞了。unacked:已发出未确认的字节数。长期不降,基本可以确定对端没在读。rcv_space/snd_wnd:接收窗口,掉到0就是对方缓冲区满了。
5.2 tcpdump抓什么、不抓什么
抓包的核心原则是先明确你要验证什么假设,否则抓下来的几万行只会让你更晕。
# 抓某端口的往来包,只抓100个,别抓太多 tcpdump -i eth0 -nn -c 100 'tcp port 8080' # 保存下来用wireshark慢慢看 tcpdump -i eth0 -nn -w /tmp/cap.pcap 'host 10.0.0.1 and port 8080' # 只看握手和挥手 tcpdump -i eth0 -nn 'tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0'几个典型现象对应的结论:
- 客户端发SYN,服务端不回任何东西 → 服务端队列满或有中间设备丢包。
- 服务端回RST → 端口没监听,或
backlog溢出后内核按tcp_abort_on_overflow配置回RST。 - 大量重复ACK(Dup ACK)→ 中间有丢包,触发快速重传。
- 窗口大小一直是0 → 接收方应用没读数据。
- 同一序号反复重传 → 链路丢包,或对端接收缓冲不够导致丢弃。
抓包时有个常被忽略的点:tcpdump本身会影响性能。在高流量机器上长时间抓包,可能直接把业务拖垮。生产环境务必加-c限制包数,或者用-s截断长度。
5.3 一份可以直接照着走的排查顺序
我整理了一套从外到内的顺序,遇到"网络问题"时按这个走,基本不会漏:
- 看服务是否在监听:
ss -lntp | grep 端口。 - 看连接状态分布:
ss -tan | awk '{print $1}' | sort | uniq -c,重点看TIME_WAIT、CLOSE_WAIT、SYN_RECV的数量。 - 看队列水位:
ss -lnt的Recv-Q是否贴近Send-Q。 - 看单连接指标:
ss -ti里的retrans、unacked、rtt。 - 看协议栈统计:
netstat -s或/proc/net/snmp,找listen queue overflow、RcvbufErrors这类计数。 - 看网卡层:
ethtool -S eth0的drop计数、ip -s link的errors。 - 最后才抓包,用具体假设去验证。
这个顺序的设计逻辑是从成本最低、信息量最大的手段开始。前四步全是读内存里的计数器,毫秒级完成;抓包是最后手段,成本最高。
6. 选型收口:TCP和UDP到底怎么选
6.1 业务特征到协议的映射
别被"UDP快"这种说法误导。UDP快的前提是丢得起。真实的选型逻辑应该围绕三个问题:能不能容忍丢包、延迟要求有多严、消息之间是否独立。
| 业务特征 | 推荐 | 理由 |
|---|---|---|
| 请求-响应,需严格有序 | TCP | 内核对序,不用自己管 |
| 实时音视频、游戏状态同步 | UDP | 旧帧丢了就丢了,重传没意义 |
| 配置下发、指令控制 | TCP 或带ACK的UDP | 必须确保到达 |
| 服务发现、指标上报 | UDP | 周期性发送,丢一次无所谓 |
| 大文件传输 | TCP | 需要拥塞控制和重传 |
| 高频小包推送 | UDP + 应用层序号 | 避免队头阻塞 |
有一个判断标准很实用:如果你的业务在丢一个包之后,更希望"重发那个旧包"而不是"直接发新数据",那就用TCP。视频通话丢一帧,你会希望直接跳到下一帧,而不是把旧帧补上,所以用UDP。数据库写入丢一个事务,你必须把那个事务补上,所以用TCP。
6.2 在UDP之上自建可靠性,成本比想象中高
很多团队一开始想"UDP快,我们自己加个ACK和重传不就可靠了"。真做下去会发现要处理的清单很长:
- 序号与重组:乱序、重复、丢失都要处理。
- 超时重传:RTO怎么估、退避策略怎么定。
- 拥塞控制:不做的话,一拥塞就自伤,全网受害。
- 流量控制:接收方处理不过来要能反压。
- 连接管理:什么时候算建立、什么时候算断开。
- MTU探测:路径MTU变化时怎么自适应。
这套东西TCP已经做了四十年。除非你的场景确实有TCP解决不了的问题(典型是队头阻塞、或者要在不可靠链路上跑),否则自建可靠传输层大概率是负收益。真需要的时候,考虑成熟的用户态方案比自己造轮子稳妥得多。
反过来说,如果只是偶尔丢一点没关系、或者消息天然独立,那UDP就是正确选择,没必要为了"可靠"硬套TCP,最后反而被队头阻塞拖累。
6.3 一个折中思路:混合使用
实际系统里,TCP和UDP经常并存,各管一段。比如:控制信令走TCP保证可靠,媒体数据走UDP保证实时;或者心跳走UDP省资源,业务数据走TCP保正确。
这种分层的价值在于:让每种协议只承担它擅长的那部分。我在一个项目里就是这么做的,控制通道用TCP长连接,状态同步用UDP广播,两者互不干扰。控制通道卡住不影响状态更新,状态丢一两个包也不影响整体一致性。
设计时的关键是两套通道的数据要能对齐。比如UDP的状态更新带一个版本号,TCP的控制指令也带版本号,接收端按版本号决定是否应用。否则会出现"控制指令改了配置,但旧的状态包后到把它覆盖回去"这种时序问题。
最后分享一个我反复验证过的小技巧:在服务启动日志里打印出实际生效的关键参数——somaxconn、本地端口范围、默认收发缓冲区、拥塞控制算法。这几个值在不同发行版、不同云厂商镜像上差异很大,出问题时第一时间能看到基线,能省掉大量"我本地是好的"式的扯皮。