1. 从一次线上告警说起:Send/Recv 速率为什么突然掉到十分之一
那天下午,监控面板上一条曲线突然像被人按了暂停键——某服务的网络吞吐从稳定的 800 Mbps 直接跌到 70 Mbps 左右,持续了将近二十分钟。CPU 不高,内存正常,磁盘 IO 也没抖动,唯独send和recv的速率双双趴窝。这种“系统看起来哪儿都没坏,就是网络慢”的场景,是后端和运维同学最头疼的一类问题。
Socket 的send和recv速率慢,本质上不是一个“网络带宽不够”的问题,而是一个从应用到内核、从内核到网卡、再从网卡到对端的完整链路上,某一环出现了阻塞或低效。它可能出在你的代码里(比如每次只发几十字节),可能出在内核参数上(比如缓冲区太小),也可能出在协议栈行为上(比如 Nagle 算法和延迟确认撞车)。这篇文章就是把这十几年里我踩过的、帮别人排查过的 Send/Recv 慢的问题,系统地拆开讲一遍。
适合谁看?如果你写过 TCP 服务端或客户端,遇到过“明明带宽够、机器也不忙,但吞吐就是上不去”的情况,那这篇内容基本能覆盖你 80% 的排查场景。我会从整体思路讲到具体参数,再到实际抓包和调优,尽量让刚接触网络编程的同学也能跟着一步步定位问题。
2. 先搞清楚“慢”到底慢在哪:整体排查思路拆解
2.1 速率慢的四种典型表现,对应完全不同的病因
很多人一上来就说“socket 慢”,但“慢”和“慢”差别很大。我习惯先把它分成四类,因为每一类的排查方向完全不同。
第一类是单次 send/recv 调用耗时长。比如一次send卡了几百毫秒才返回。这通常和缓冲区满、对端不收、或者 Nagle 算法有关。
第二类是单位时间吞吐低。调用本身很快返回,但每秒传不了多少数据。这往往是每次发送的数据块太小,或者循环里做了太多无用功。
第三类是速率忽高忽低、周期性抖动。这种最典型的就是 Nagle 和延迟确认(Delayed ACK)互相等待,形成 40ms 左右的周期性停顿。
第四类是连接建立后速率正常,跑一段时间后骤降。这通常和拥塞控制、缓冲区耗尽、或者对端处理能力下降有关。
把这四类分清楚,排查就不会像无头苍蝇。我一般会先问三个问题:是延迟高还是吞吐低?是持续慢还是周期性慢?是单向慢还是双向都慢?这三个问题的答案,基本能把范围缩小一半。
2.2 排查顺序:从应用层往下,而不是从网卡往上
很多教程一上来就让你调内核参数,我个人的习惯恰恰相反——先看应用层,再看内核,最后看物理链路。原因很简单:应用层的改动成本最低,而且大部分“慢”的问题根源都在应用层。
具体顺序是这样的:先确认发送/接收的数据块大小是否合理,再检查是否频繁创建销毁连接,然后看 socket 选项设置(TCP_NODELAY、SO_SNDBUF、SO_RCVBUF),接着才是内核参数(tcp_wmem、tcp_rmem、拥塞控制算法),最后才考虑网卡、交换机、MTU 这些。
这个顺序背后的逻辑是:越靠近应用的层,越容易改,也越容易成为瓶颈。我见过太多案例,最后发现只是代码里每次send只发了 10 个字节,却花了两天调内核参数。
2.3 一个必须建立的认知:send 返回不等于数据已送达
这是新手最容易误解的一点。send返回成功,只代表数据被拷贝进了内核的发送缓冲区,不代表对端收到了,甚至不代表数据离开了本机。真正决定速率的,是内核什么时候把缓冲区里的数据推出去、对端什么时候 ACK、发送窗口还剩多少。
所以当你看到send很快返回,但吞吐上不去,问题往往不在send本身,而在“数据推出去的速度”。理解这一点,后面的很多现象就顺理成章了。同理,recv返回的数据量小于你请求的量,也不代表出错,只是当前缓冲区里就这么多。
3. 应用层最常见的五个坑:数据块、连接、循环与选项
3.1 每次只发一点点:小包问题比你想的严重
我排查过的吞吐问题里,至少一半和“每次发送的数据块太小”有关。假设你每次send只发 100 字节,即使网络带宽是 1 Gbps,理论上每秒也只能发 100 万次左右,而每次系统调用的开销(用户态到内核态切换、协议栈处理)可能就要几微秒。算下来,实际吞吐可能只有几十 Mbps。
这里有个经验公式:单次发送的数据块越大,协议栈和系统调用的摊销成本越低。一般建议单次发送在 4KB 到 64KB 之间,具体取决于你的业务。如果是文件传输,用 64KB 甚至更大;如果是消息推送,至少也要把多条小消息合并成一个批次再发。
但要注意,不是越大越好。超过 MTU(通常 1500 字节)后,数据会被分片,分片本身也有开销。所以更准确的说法是:在不超过 MSS 太多、且不造成明显延迟的前提下,尽量批量发送。
3.2 频繁短连接:三次握手和四次挥手的隐性成本
有些实现为了“简单”,每次请求都新建一个 TCP 连接,用完就关。这种模式在低并发下看不出问题,一旦 QPS 上来,速率会断崖式下跌。原因在于:每个连接的建立需要三次握手(至少 1 个 RTT),关闭需要四次挥手,而且连接初期还会经历慢启动,拥塞窗口从很小开始增长。
我做过一个粗略测算:在 RTT 为 20ms 的链路上,如果每个连接只传 10KB 数据就关闭,那么有效吞吐可能只有理论带宽的 5% 到 10%。因为大部分时间都花在握手、慢启动和挥手上了。
解决办法就是连接复用。用长连接 + 多路复用(比如在一个连接上跑多个逻辑流),或者用连接池。这是提升 Send/Recv 速率最立竿见影的手段之一。
3.3 循环里的“隐形杀手”:同步阻塞与无意义拷贝
还有一种慢,是代码结构造成的。比如在单线程里,send之后立刻recv,而recv是阻塞的,对端又没这么快回数据,整个线程就卡住了。这时候你看到的“send 慢”,其实是“recv 阻塞拖累了 send”。
另外就是无意义的多次拷贝。比如数据先从磁盘读到缓冲区 A,再拷贝到缓冲区 B,再send。每次拷贝都是内存带宽的消耗,数据量大时非常可观。能用sendfile、splice这类零拷贝接口的场景,就别手动拷贝。
3.4 TCP_NODELAY:什么时候该开,什么时候别乱开
TCP_NODELAY这个选项,作用是禁用 Nagle 算法。Nagle 算法的本意是减少小包,它会攒够一定数据或等到 ACK 才发送。这在某些场景下会引入延迟,所以很多人习惯性把它打开。
但我要提醒的是:开了 TCP_NODELAY 不等于速率就快了。如果你的数据本来就是大批量发送,Nagle 根本不会造成影响;如果你的数据是小包且对延迟敏感,那确实该开。关键是理解它的行为,而不是无脑设置。
我的一般建议是:交互式、延迟敏感的应用(比如即时通讯、游戏)开启;大批量数据传输的应用,可以不开,甚至配合TCP_CORK做更精细的控制。
3.5 SO_SNDBUF 和 SO_RCVBUF:缓冲区不是越大越好
发送缓冲区和接收缓冲区的大小,直接影响能容纳多少“在途数据”。如果缓冲区太小,发送方很快填满,send就会阻塞或返回 EAGAIN;接收方缓冲区太小,数据来不及被应用读走,就会触发窗口收缩,拖慢发送方。
但缓冲区也不是越大越好。太大的缓冲区会导致“缓冲膨胀”(bufferbloat),延迟升高,而且占用内存。我的经验值是:在带宽延迟积(BDP)的基础上留 2 到 3 倍余量。BDP = 带宽 × RTT。比如 100 Mbps 带宽、50ms RTT,BDP 大约是 625KB,那缓冲区设 1.5MB 到 2MB 比较合适。
4. 内核参数与协议栈行为:那些真正决定速率的开关
4.1 发送/接收缓冲区的内核上限:tcp_wmem 与 tcp_rmem
应用层设置的SO_SNDBUF和SO_RCVBUF,最终受内核参数约束。Linux 下主要有三个:
| 参数 | 含义 | 典型值 |
|---|---|---|
| net.core.wmem_max | 单个 socket 发送缓冲区上限 | 212992 |
| net.core.rmem_max | 单个 socket 接收缓冲区上限 | 212992 |
| net.ipv4.tcp_wmem | 发送缓冲区自动调优范围 | 4096 16384 4194304 |
| net.ipv4.tcp_rmem | 接收缓冲区自动调优范围 | 4096 131072 6291456 |
这里有个关键点:如果应用层设置的缓冲区超过了wmem_max/rmem_max,会被内核静默截断。很多人设了 4MB,结果实际只有 200KB,速率自然上不去。所以调优前先用ss -m或getsockopt确认实际生效值。
另外,tcp_wmem和tcp_rmem的第三个值是自动调优的上限。如果开启了自动调优(默认开启),内核会根据连接情况动态调整缓冲区,通常不需要手动设SO_SNDBUF。手动设置反而会关闭自动调优,这点要特别注意。
4.2 拥塞控制算法:从 cubic 到 bbr 的取舍
拥塞控制算法决定了发送方如何根据网络状况调整发送速率。传统的cubic在丢包时会把窗口砍半,在高延迟、有一定丢包的链路上表现不佳。而bbr基于带宽和 RTT 估计,不依赖丢包信号,在很多场景下吞吐更高、延迟更低。
切换方法很简单:
# 查看当前算法 sysctl net.ipv4.tcp_congestion_control # 临时切换为 bbr sysctl -w net.ipv4.tcp_congestion_control=bbr但要注意,bbr不是万能药。在丢包率极低、链路干净的局域网里,cubic和bbr差别不大;在跨地域、有随机丢包的链路上,bbr优势明显。我一般建议先在测试环境对比,别直接上生产。
4.3 延迟确认与 Nagle 的“死亡拥抱”
这是经典问题:发送方开了 Nagle,接收方开了延迟确认。发送方等 ACK 才发下一批,接收方等 40ms 才发 ACK,结果就是每 40ms 才传一次数据,速率被死死限制住。
判断方法很简单:抓包看是否有规律的 40ms 间隔。如果是,解决方案有两个:发送方开TCP_NODELAY,或者接收方关掉延迟确认(TCP_QUICKACK,但这是每次 recv 后都要重设的)。实际中,开TCP_NODELAY更常见也更简单。
4.4 MTU 与分片:一个容易被忽略的速率杀手
如果链路的 MTU 是 1500,而你发送的数据块是 2000 字节,内核会把它分成两个包。分片本身增加开销,而且只要其中一个分片丢失,整个数据报都要重传。更糟的是,如果路径上某段 MTU 更小,还可能触发 PMTU 发现失败,导致连接卡死。
排查方法是抓包看是否有大量分片,或者用ping -M do -s测试路径 MTU。应用层尽量把单次发送控制在 MSS 以内(MSS = MTU - IP头 - TCP头,通常 1460 字节),或者干脆让内核去分,但要知道分片的代价。
5. 实操:一次完整的 Send/Recv 速率排查与调优过程
5.1 环境与现象复现
假设我们有一个简单的 TCP 服务端和客户端,客户端持续发送 1MB 的数据块,服务端接收。现象是:在千兆局域网内,吞吐只有 120 Mbps 左右,远低于预期。
先看客户端代码的核心逻辑:
import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(('127.0.0.1', 9000)) data = b'x' * 1024 # 每次只发 1KB for _ in range(1024 * 1024): s.send(data) s.close()这段代码每次只发 1KB,而且循环了上百万次。系统调用次数极多,吞吐自然上不去。
5.2 第一步:加大单次发送块
把每次发送改成 64KB:
data = b'x' * 65536 for _ in range(16 * 1024): s.send(data)改完后吞吐立刻从 120 Mbps 提升到 600 Mbps 左右。这一步就验证了“小包是主要瓶颈”的判断。
5.3 第二步:检查并调整缓冲区
用ss -m查看连接的实际缓冲区:
ss -m -t -n输出里会显示skmem信息,包括rb(接收缓冲)、tb(发送缓冲)等。如果发现发送缓冲只有几十 KB,而 BDP 需要几百 KB,就要调整。
临时调整内核参数:
sysctl -w net.core.wmem_max=16777216 sysctl -w net.core.rmem_max=16777216 sysctl -w net.ipv4.tcp_wmem="4096 65536 16777216" sysctl -w net.ipv4.tcp_rmem="4096 65536 16777216"注意,tcp_wmem的第二个值是默认值,第三个是上限。调整后重新测试,吞吐进一步提升到 850 Mbps 左右。
5.4 第三步:切换拥塞控制算法
在跨机房场景下,把算法切到bbr:
sysctl -w net.ipv4.tcp_congestion_control=bbr在有一定丢包的链路上,这一步往往能带来 20% 到 50% 的提升。局域网内提升不明显,但也不会有副作用。
5.5 第四步:抓包验证,确认没有异常行为
用tcpdump抓包,重点看三件事:是否有大量重传、是否有 40ms 周期性停顿、是否有分片。
tcpdump -i eth0 -nn 'tcp port 9000' -w capture.pcap抓完后用分析工具看统计。如果重传率高,说明链路质量差或缓冲区不足;如果有 40ms 间隔,说明 Nagle 和延迟确认在打架;如果有分片,说明发送块超过了 MTU。
5.6 调优前后的对比数据
| 阶段 | 单次发送块 | 发送缓冲 | 拥塞算法 | 实测吞吐 |
|---|---|---|---|---|
| 初始 | 1KB | 默认 | cubic | 120 Mbps |
| 加大块 | 64KB | 默认 | cubic | 600 Mbps |
| 调缓冲 | 64KB | 16MB | cubic | 850 Mbps |
| 换算法 | 64KB | 16MB | bbr | 920 Mbps |
这个表格很直观地说明了:应用层的改动收益最大,内核参数次之,算法切换是锦上添花。所以排查顺序真的不能反。
6. 常见问题速查与避坑经验
6.1 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| send 阻塞很久 | 发送缓冲满、对端不收 | ss -m 看缓冲,抓包看窗口 | 加大缓冲、检查对端 |
| 吞吐低但延迟正常 | 小包、系统调用频繁 | 统计单次发送大小 | 批量发送 |
| 周期性 40ms 停顿 | Nagle + 延迟确认 | 抓包看间隔 | 开 TCP_NODELAY |
| 跑一段时间后骤降 | 拥塞窗口收缩、缓冲耗尽 | 看重传率、窗口变化 | 调拥塞算法、加缓冲 |
| recv 返回数据少 | 接收缓冲小、应用读得慢 | 看 rmem、应用处理速度 | 加大 rmem、优化处理 |
| 跨机房速率远低于带宽 | BDP 大、缓冲不足 | 算 BDP,对比缓冲 | 加大缓冲、用 bbr |
6.2 我踩过的三个坑
第一个坑是盲目调大缓冲区。有一次我把tcp_rmem上限设到 64MB,结果内存占用飙升,而且延迟反而变高,因为数据在缓冲区里排队太久。后来才明白,缓冲区要匹配 BDP,不是越大越好。
第二个坑是忽略了对端。我花了一整天调本机参数,最后发现是对端应用读取太慢,导致接收窗口一直很小。Send 速率再高,对端不收也没用。所以排查一定要双向看。
第三个坑是在容器里调了宿主机的参数。容器有自己的网络命名空间,sysctl调的是容器内的,但实际生效可能受宿主机限制。容器场景下要特别注意参数的作用域。
6.3 几个实用的排查命令
# 查看连接详情,包括缓冲、窗口、重传 ss -t -i -m # 查看内核参数 sysctl -a | grep tcp_wmem sysctl -a | grep tcp_rmem # 抓包并统计重传 tcpdump -i eth0 -nn 'tcp' -w cap.pcap # 用 wireshark 或 tshark 分析 # 测试路径 MTU ping -M do -s 1472 <目标IP>这些命令我几乎每次排查都会用到,建议收藏。
6.4 一个容易被忽略的点:TIME_WAIT 与端口耗尽
高频短连接场景下,大量连接进入 TIME_WAIT 状态,会占用本地端口,导致新连接无法建立,表现为“连接慢”甚至“连接失败”。这时候看到的 Send/Recv 慢,其实是连接建立阶段就卡住了。
解决办法是开启tcp_tw_reuse(谨慎使用)或者改用长连接。根本方案还是连接复用,别频繁建连。
7. 写在最后:速率优化是系统工程,不是调参游戏
我做了这么多年网络相关的开发和排查,最大的体会是:Send/Recv 速率慢,很少是单一原因造成的。它往往是应用层的小包、内核的默认缓冲、协议栈的算法、链路的 RTT 共同作用的结果。你调好了一个,另一个又成了瓶颈。
所以我的建议是:先建立完整的链路视角,从应用到内核再到链路,一层层看。每次只改一个变量,改完立刻测,记录数据。别一次改一堆参数,那样出了问题都不知道是哪个引起的。
另外,别迷信“最佳实践”。别人的最优参数,放到你的场景里可能完全不对。带宽、RTT、业务模式、对端行为,这些都会影响最终结果。多抓包、多测量、多对比,比背参数有用得多。
最后分享一个小技巧:如果你不确定瓶颈在哪,先用iperf这类工具测一下纯网络层的吞吐。如果iperf能跑满带宽,那问题一定在你的应用代码或 socket 选项上;如果iperf也跑不满,那才需要往内核和链路方向查。这一步能帮你省下大量时间。