线上服务半夜告警,监控面板上连接数一路往上顶,应用日志里刷着各种超时,运维同学第一反应是加机器,开发同学怀疑是数据库慢查询,结果一圈排查下来,问题出在最底下那层——传输层协议 TCP 的连接状态不对,大量连接卡在半开或者等待回收的状态里出不去。这种场景我经历过不止一次,每次都让我更加确信一件事:不管上层框架封装得多好,TCP 这门基本功不扎实,线上问题就只能靠猜。这篇内容我想聊聊 TCP 协议本身,从连接怎么建立、数据怎么保证不丢、流量怎么控制,到实际调优和抓包排查,把我这些年踩过的坑和验证过的做法整理出来。适合刚学网络基础的同学建立完整认知,也适合写了几年业务代码、但一直没认真看过传输层细节的开发者补课。看完你至少能做到:看到连接状态异常知道往哪查,遇到吞吐上不去知道调哪个参数,抓包抓到一堆报文能看懂它在说什么。
1. TCP 到底在解决什么问题
1.1 从 IP 层的不靠谱说起
要理解 TCP 的设计,得先看它脚下踩的是什么。IP 层提供的是"尽力而为"的投递服务,翻译成人话就是:我尽量帮你送,但送不到、送重了、送乱了,我都不负责。网络中的路由器会因为队列满了丢包,链路会因为拥塞导致报文乱序到达,中间任何一跳出问题都不会通知两端。这个模型的好处是简单、便宜、可扩展,坏处是对绝大多数应用来说根本不够用——你没法让一个"转账请求"承受丢包,也没法让一个"文件下载"接受乱序。
TCP 就是在 IP 之上补上这一层的可靠性。它把 IP 的"尽力而为"包装成"字节流",向上层承诺四件事:数据不丢、不重、不乱序、有流量控制。注意这里的关键词是字节流,不是消息流。TCP 只保证字节按顺序到达,它完全不知道你发的是一个"请求"还是半个"请求",这个边界要应用层自己划。很多人对 TCP 的误解就从这个点开始,后面讲粘包的时候会详细展开。
我习惯用一个类比来解释分层:IP 层像邮政系统,只负责把信封从 A 城市运到 B 城市,路上丢了不赔;TCP 像在两端各安排了一个负责任的秘书,负责编号、登记、催收、补发,直到确认对方完整收到为止。应用层则是坐在办公室里写内容的人,只管把文件交给秘书,不用操心路上发生了什么。
1.2 TCP 的四项承诺与实际代价
TCP 的可靠性不是白来的,每一项承诺背后都有明确的机制和成本,理解这个对应关系,才能在实际选型时做出判断。
| 承诺 | 实现机制 | 代价 |
|---|---|---|
| 数据不丢 | 序号 + 确认 + 超时重传 | 需要缓存未确认数据,占用内存 |
| 数据不重 | 序号去重 | 接收端要维护期望序号 |
| 不乱序 | 序号排序 + 接收缓冲区重组 | 乱序时要等待,增加延迟 |
| 不压垮对方 | 滑动窗口流量控制 | 窗口探测带来额外报文 |
| 不压垮网络 | 拥塞控制 | 慢启动阶段吞吐爬坡慢 |
| 连接语义 | 三次握手 / 四次挥手 | 建连有 RTT 开销 |
这张表我建议每个写服务端代码的人都过一遍。你会发现,TCP 为了保证可靠性,付出了不少延迟和内存的代价。所以它并不是万能解,在实时音视频、游戏帧同步这类"宁可丢一帧也不能等"的场景里,UDP 加自定义可靠性策略反而更合适。我见过有团队在游戏里硬用 TCP,结果一次丢包引发重传阻塞,整个画面卡成幻灯片,最后还是改成 UDP 加应用层序号。
选择 TCP 的判断标准其实很简单:你能不能接受数据丢失或乱序。不能接受,就用 TCP;能接受,或者有自己的重传策略,就用 UDP。中间态的 QUIC 属于另一个话题,这里不展开。
1.3 一次请求里 TCP 究竟做了什么
拿最常见的 HTTP 请求举例,一次完整的交互在传输层大致经历这些步骤:
- 客户端调用 connect,内核发送 SYN,进入 SYN_SENT;
- 服务端回复 SYN+ACK,客户端回复 ACK,连接建立,双方进入 ESTABLISHED;
- 客户端发送请求数据,数据被切成 MSS 大小的段,每段带序号;
- 服务端收到后回复 ACK,同时把接收窗口大小告诉对方;
- 如果某个段丢了,服务端重复 ACK 相同序号,客户端收到三个重复 ACK 触发快速重传;
- 数据传输期间,发送端根据拥塞窗口和接收窗口取最小值决定能发多少;
- 请求结束,一方发送 FIN,经过四次挥手断开。
这七步里,真正容易被忽视的是第 4 步和第 6 步。接收窗口是接收方告诉发送方"我还能接多少",拥塞窗口是发送方自己估计"网络还能承受多少",实际发送量取两者较小值。很多吞吐上不去的问题,根源就在这两个窗口之一被卡住了——要么是接收端应用读得太慢导致窗口收缩,要么是网络丢包导致拥塞窗口被打回原形。
提示:排查吞吐问题时,先看是 rwnd 受限还是 cwnd 受限。前者是应用层消费慢,加机器或优化业务逻辑;后者是网络路径有问题,调参数往往治标不治本。
2. 连接管理:三次握手和四次挥手的真实细节
2.1 三次握手每一步在干什么
教科书上的三次握手图大家都见过,但真正理解每一步存在的意义,得从"双方各自要确认什么"这个角度切入。
第一次:客户端发送 SYN=1,seq=x。这一步客户端表达了两个意思——我想建连,我的初始序号是 x。此时客户端不知道服务端是否在线、是否愿意接受连接,所以进入 SYN_SENT 状态等待。
第二次:服务端回复 SYN=1,ACK=1,seq=y,ack=x+1。这一步服务端同时做了两件事:确认收到了客户端的 SYN(ack=x+1),并且发出自己的 SYN(seq=y)。这里有个容易忽略的细节:SYN 会消耗一个序号,所以确认号是 x+1 而不是 x。服务端此时进入 SYN_RCVD。
第三次:客户端回复 ACK=1,ack=y+1。这一步客户端确认收到了服务端的 SYN。到这里,双方都确认了对方的发送能力和自己的接收能力,连接正式建立。
我经常问身边同学一个问题:为什么是三次不是两次?答案藏在"防止历史连接"这个点上。假设网络里滞留了一个很久以前客户端发出的 SYN,服务端收到后回复 SYN+ACK 并直接认为连接建立,然后傻等着数据;而客户端根本没打算建连,收到 SYN+ACK 会回 RST。这两次握手的情况下,服务端白白创建了一个连接资源。三次握手让客户端多了一次"我确认我要建这个连"的表态,服务端就不会被历史报文骗到。
2.2 半连接队列与全连接队列
服务端处理握手不是一次一个串行来的,内核用了两个队列来管理:半连接队列(SYN queue)存放收到 SYN 但还没完成三次握手的连接,全连接队列(accept queue)存放已完成握手、等待应用层 accept 取走的连接。
这里有两个极易踩坑的点。第一个是全连接队列满了会怎样。Linux 的默认行为是:如果tcp_abort_on_overflow=0(默认值),服务端会丢弃客户端的 ACK,让客户端以为握手没完成而重传,相当于把问题延后;如果设成 1,则直接回 RST。前者表现为偶发的连接超时,后者表现为偶发的 connection reset,两种现象都很迷惑人。
第二个是backlog参数的真正含义。应用层调用listen(fd, backlog)传的那个数字,并不是全连接队列的精确大小,内核实际取的是min(backlog, net.core.somaxconn)。而半连接队列的大小由net.ipv4.tcp_max_syn_backlog控制,同时还受somaxconn影响。我见过线上把 backlog 传成 5 的服务,平时没问题,一到流量高峰就开始丢连接,日志里全是超时,查了半天才发现是这个数字太小。
# 查看当前全连接队列溢出情况 ss -lnt # Recv-Q 表示当前全连接队列长度,Send-Q 表示队列上限 # 如果 Recv-Q 长期接近 Send-Q,说明应用 accept 太慢 # 内核统计 netstat -s | grep -i listen # 关注 "times the listen queue of a socket overflowed"这条命令我建议加入日常巡检。Recv-Q持续增长是明确信号:要么应用 accept 线程被阻塞了,要么单机连接数已经超过处理能力。
2.3 四次挥手与 TIME_WAIT 的真实坑
挥手比握手多一次,原因很直白:TCP 是全双工的,两个方向要各自关闭。主动关闭方发 FIN 表示"我没数据要发了",但这不代表"我不能收数据",所以被动方要先回一个 ACK,等自己数据发完了再发 FIN,这就形成了四次。
完整状态流转是这样的:
- 主动方发 FIN,进入 FIN_WAIT_1;
- 被动方回 ACK,进入 CLOSE_WAIT;主动方收到后进入 FIN_WAIT_2;
- 被动方数据发完,发 FIN,进入 LAST_ACK;
- 主动方回 ACK,进入 TIME_WAIT,等待 2MSL 后关闭。
实际工作中,两个状态最让人头疼:TIME_WAIT和CLOSE_WAIT。它们的成因完全不同,处理方式也完全不同,但很多人会搞混。
TIME_WAIT 出现在主动关闭方,作用是两条:一是保证最后一个 ACK 能到达对方,如果对方没收到会重发 FIN,此时还能再回一次 ACK;二是让本次连接中滞留的旧报文在网络中消散,避免它们干扰使用相同四元组的新连接。这两个作用决定了 TIME_WAIT 必须存在,不能简单粗暴地取消。
TIME_WAIT 的数量在主动发起大量短连接的场景下会迅速堆积,比如反向代理服务器、爬虫客户端。Linux 默认net.ipv4.tcp_max_tw_buckets有一定上限,超过后会直接清理并打日志。处理思路有几个层次:
- 最优先:改成使用长连接,从源头减少短连接数量;
- 其次:让服务端主动关闭连接,把 TIME_WAIT 转移到服务端(服务端通常资源更充足,且可以配合连接池);
- 再次:开启
net.ipv4.tcp_tw_reuse=1,允许复用 TIME_WAIT 状态的连接发起新连接(仅对客户端出向连接生效); - 最后:调整
net.ipv4.ip_local_port_range扩大可用端口范围。
CLOSE_WAIT 则是另一个故事。它出现在被动关闭方,表示"我收到了对方的 FIN,但我还没调用 close"。如果 CLOSE_WAIT 堆积,几乎百分之百是应用层代码问题——要么忘了关连接,要么关连接的逻辑在异常分支里被跳过了。这个不是调内核参数能解决的,只能改代码。
注意:网上流传的
tcp_tw_recycle参数在新版本内核中已经被移除,它的设计在处理 NAT 场景时存在缺陷,不要再照抄老教程去开。
2.4 SYN Flood 的防护思路
SYN Flood 是利用握手第一步的经典攻击:攻击方伪造大量源地址发 SYN,服务端每收到一个就分配资源、回 SYN+ACK,然后傻等那个永远不会来的 ACK,半连接队列很快被撑满,正常连接进不来。
防护手段分几层。最基础的是开启net.ipv4.tcp_syncookies=1,原理是在 SYN+ACK 的序号里编码连接信息,服务端不为半开连接分配资源,只有收到合法 ACK 才真正建连。这招在队列快满时自动触发,平时不影响正常流程。更上层的手段包括增大tcp_max_syn_backlog、缩短tcp_synack_retries、在边界设备做限速。
需要提醒的是,syncookies 会让一些 TCP 选项(如窗口缩放)在建连初期无法协商,属于应急手段而非常态方案。真正扛得住的架构,还是在前面有一层能吸收和清洗流量的能力,再加上合理的队列配置。
3. 可靠性机制:序号、确认与重传
3.1 序号和确认号到底怎么算
TCP 把应用层交下来的字节流看成一个连续的长数组,每个字节都有一个编号,这个编号就是序号(sequence number)。但实际报文头里填的不是字节编号本身,而是一个 32 位的值,初始序号 ISN 是随机生成的,之后按已发送字节数递增。
为什么初始序号要随机?两个原因:一是安全,防止别人猜测后续序号伪造报文;二是避免和历史连接混在一起。ISN 的生成在现代内核里是基于时钟的,大约每 4 微秒加 1,绕一圈要很久,足够保证新旧连接的序号空间不重叠。
确认号(acknowledgment number)表示"我期望收到的下一个字节编号",是累积确认语义。比如收到 ack=1001,意思是我已经完整收到了 1 到 1000 号字节,下一个我要 1001。这种设计的好处是简单,一个 ACK 可以确认一大段数据;坏处是一个丢包会导致后面所有数据都无法被确认,也就是常说的队头阻塞。
这里有个实操中很实用的点:抓包时看相对序号比看绝对序号舒服得多。主流抓包工具都支持把首个包的序号显示为 0,后续显示相对偏移,排查时一眼就能看出哪段数据丢了。
3.2 超时重传的 RTO 是怎么算出来的
超时重传的核心问题是:等多久没收到 ACK 就认为丢了?等太短会误判,本来没丢也重传,浪费带宽还加重拥塞;等太长则恢复慢,用户体验差。TCP 的做法是动态估算。
RFC 6298 给出的算法是这样的:维护两个变量,平滑往返时间 SRTT 和往返时间偏差 RTTVAR。每测到一个新的 RTT 样本,按下面的公式更新:
RTTVAR = (1 - beta) * RTTVAR + beta * |SRTT - RTT_sample| SRTT = (1 - alpha) * SRTT + alpha * RTT_sample RTO = SRTT + 4 * RTTVAR其中 alpha = 1/8,beta = 1/4。初始时 RTO 取 1 秒,第一次测量后按公式更新。Linux 里 RTO 有下限和上限,下限默认 200ms,上限 120 秒。
这个公式的含义值得琢磨:SRTT 是平均值,RTTVAR 是波动程度,用 4 倍偏差作为安全边际。网络越不稳定,RTTVAR 越大,RTO 就越保守。这是典型的工程智慧——在不稳定的环境里宁可多等一会儿。
还有一个经典问题:重传后的 RTT 样本还能用吗?答案是不能。因为你不知道收到的 ACK 是对原始报文还是对重传报文的确认,算出来的 RTT 会偏大或偏小。这就是Karn 算法的结论:重传过的报文不采样 RTT。同时,只要发生超时重传,RTO 就按指数退避翻倍,1 秒、2 秒、4 秒……直到收到确认。
3.3 快速重传与 SACK 的配合
等超时太慢了。如果发送端连续收到三个针对同一序号的重复 ACK,说明后面的包已经到了,唯独中间那个丢了,这时候不用等 RTO 到期,直接重传,这就是快速重传。为什么是三个?因为网络轻微乱序也会产生一两个重复 ACK,三个是个经验阈值,能在及时性和误判率之间取得平衡。
快速重传解决了"什么时候重传",但没解决"重传多少"。假设发了 1 到 10 号段,2 号丢了,3 到 10 都到了,发送端只知道 2 号丢了,但不知道 3 到 10 已经收到,按原始 TCP 的累积确认语义,它只能重传 2 号,然后等确认推进。如果一次丢了多个包,这个过程会反复多轮。
SACK(选择性确认)就是来解决这个问题的。接收端在 ACK 里附带"我已经收到了 3 到 10"这样的区间信息,发送端一看就知道只需要重传 2 号,不用重复发已经到达的数据。SACK 在 Linux 里默认开启,抓包时能在 TCP 选项里看到 SACK permitted 和 SACK 块。
# 查看 SACK 是否启用 sysctl net.ipv4.tcp_sack # 1 表示启用 # 查看重传统计 netstat -s | grep -i retrans # 关注 segments retransmitted 与 total 的比例重传率是一个非常好的健康度指标。内网环境正常应该在万分之一以下,公网跨地域在千分之几,如果超过 1%,基本可以断定链路有质量问题或者拥塞严重。
3.4 重传相关的调优参数
有几个参数在特定场景下很有用,我列一下实际验证过的配置思路:
| 参数 | 默认值 | 调整场景 | 注意事项 |
|---|---|---|---|
net.ipv4.tcp_retries1 | 3 | 一般不改 | 底层重传次数,影响路由失效判断 |
net.ipv4.tcp_retries2 | 15 | 移动弱网可适当降低 | 决定连接多久后放弃,默认约 13-30 分钟 |
net.ipv4.tcp_syn_retries | 6 | 建连超时敏感场景降到 3 | 约 1+2+4+8+16+32=63 秒 |
net.ipv4.tcp_synack_retries | 5 | 同上 | 服务端重发 SYN+ACK 次数 |
net.ipv4.tcp_thin_linear_timeouts | 0 | 短连接多可考虑开 | 部分内核版本已移除 |
tcp_retries2这个参数值得多说两句。它决定了在已经建立连接的情况下,重传多少次后放弃并通知应用。默认 15 次加上指数退避,累计时间超过十分钟。如果业务侧有 3 秒超时,这个内核参数根本不会触发,应用层先放弃了。所以调整它之前,先确认应用层超时设置是什么,别做无用功。
4. 流量控制与拥塞控制
4.1 滑动窗口是怎么工作的
如果没有流量控制,发送端会以自己能发出的最快速度狂发,接收端处理不过来只能丢,丢了又要重传,恶性循环。滑动窗口的作用就是让接收端告诉发送端:"我还能接多少,你悠着点。"
接收端在 TCP 头的窗口字段里填入当前接收缓冲区的剩余空间,这个值叫 rwnd。发送端维护一个发送窗口,大小是 min(cwnd, rwnd),只有落在窗口内的数据才能发出去。随着 ACK 回来,窗口左边沿右移,新的数据进入可发范围,这就是"滑动"的由来。
几个细节容易被忽略。第一,窗口缩放选项。TCP 头里窗口字段只有 16 位,最大 65535 字节,在长肥管道(高带宽高延迟)上完全不够用。窗口缩放选项允许把窗口值左移最多 14 位,理论最大约 1GB。这个选项只能在握手阶段协商,连接建好后不能改,所以那些跑长连接的服务,握手时一定要确保双方都支持。
第二,零窗口。如果接收缓冲区满了,接收端会通告一个 0 窗口,发送端就停止发送,并启动零窗口探测定时器,定期发一个探测包询问窗口是否恢复。这个探测包的序号是窗口左边沿减一,接收端会回一个当前窗口值。如果探测逻辑出问题,连接会假死,表现为双方都还连着但数据不动。
第三,窗口更新通知。窗口从 0 恢复时,接收端会主动发一个窗口更新 ACK。但这个 ACK 有可能丢,所以发送端还是得靠探测定时器兜底。
4.2 拥塞控制四个阶段的实际表现
流量控制管的是接收端,拥塞控制管的是网络。发送端并不知道网络中间发生了什么,只能通过丢包和延迟变化来推测,然后调整拥塞窗口 cwnd。经典算法分四个阶段:
慢启动。连接刚建立时,cwnd 从初始值开始,每收到一个 ACK 就加一个 MSS,效果是指数增长——1、2、4、8、16……Linux 现在的初始窗口是 10 个 MSS(RFC 6928),相比早期的 1 个 MSS 已经快很多了。慢启动的名字有点误导,它其实一点都不慢,只是起点低。
拥塞避免。当 cwnd 达到慢启动阈值 ssthresh 后,改为每个 RTT 增加一个 MSS,变成线性增长。这个转折点的判断在早期是基于丢包,现在多基于延迟或带宽估计。
快速重传。收到三个重复 ACK,判定丢包,立即重传,不等超时。
快速恢复。重传后不回到慢启动(那太保守了),而是把 ssthresh 设为当前窗口的一半,cwnd 设为 ssthresh,然后直接进入拥塞避免。
超时重传的处理更狠:ssthresh 减半,cwnd 直接回到 1 个 MSS,重新慢启动。这就是为什么一次超时会让吞吐断崖式下跌,恢复要好几秒。
4.3 CUBIC 和 BBR 的取舍
Linux 默认的拥塞控制算法是 CUBIC,它是基于丢包的。核心思路是用一个三次函数来描述窗口增长,让它在远离拥塞点时增长快、接近时增长慢,同时具备较好的 RTT 公平性。CUBIC 在大多数场景下表现稳定,但有两个明显短板:一是在有随机丢包的链路上(比如无线),它会把随机丢包误判为拥塞,不必要地降低窗口;二是它依赖丢包作为信号,缓冲区大的链路上容易把 buffer 填满,造成 bufferbloat,延迟飙升。
BBR 走的是另一条路:不把丢包当拥塞信号,而是持续测量瓶颈带宽和最小 RTT,建立网络模型,按模型决定发送速率。它的优势在高丢包、长距离链路上非常明显,能把吞吐提升数倍,同时因为不填满缓冲区,排队延迟也更低。
但 BBR 不是银弹。它在多流竞争时公平性存在争议,v1 版本对 loss-based 流不友好;在浅缓冲区场景下优势不明显;而且它需要较准确的带宽估计,起流阶段会有探测开销。
实际选择我的建议是:默认用 CUBIC,稳定可靠;如果是跨地域长距离传输、或者链路上有明显非拥塞丢包,可以试 BBR,但要配合真实业务指标验证,别只看单流压测数据。
# 查看当前算法和可用算法 sysctl net.ipv4.tcp_congestion_control sysctl net.ipv4.tcp_available_congestion_control # 临时切换 sysctl -w net.ipv4.tcp_congestion_control=bbr # 持久化写入 echo "net.ipv4.tcp_congestion_control = bbr" >> /etc/sysctl.conf4.4 缓冲区参数怎么定
net.ipv4.tcp_rmem和net.ipv4.tcp_wmem是两个三元组,分别表示最小、默认、最大值。自动调优开启时(tcp_moderate_rcvbuf=1),内核会在 min 和 max 之间动态调整。
这里有个经典的计算公式,用来估算单条连接需要的缓冲区大小:
缓冲区 >= 带宽 × RTT比如跨地域专线带宽 100Mbps,RTT 40ms,那 BDP = 100Mbps × 0.04s = 4Mbit = 500KB。缓冲区至少要能装下 500KB,才能让窗口充分打开、跑满带宽。如果 max 设得比这个小,单连接速度就被卡死了。
很多发行版默认的tcp_rmem最大值是 6MB 左右,对大多数场景够用。但在高带宽高延迟链路上,可能要调到 16MB 甚至更大。调大之前想清楚内存开销:最大缓冲是 per-connection 的,连接数上万时内存占用不容忽视。
提示:
tcp_wmem的第三个值影响发送缓冲,它决定了单连接最多能缓存多少未发送数据。如果应用是大量小写入,过大的发送缓冲会导致数据在本地排队,增加延迟。tcp_notsent_lowat可以限制未发送数据量,适合延迟敏感的服务。
5. 应用层最容易踩的三个坑
5.1 粘包不是 TCP 的错
"TCP 粘包"这个说法其实不准确,TCP 是字节流协议,它压根没有"包"的概念,自然谈不上粘。所谓粘包,本质是应用层没有定义消息边界。
举个例子:客户端连续调用了两次 send,分别写入 "hello" 和 "world"。这两次调用可能触发两个 TCP 段,也可能合并成一个段发出去,还可能被拆成 "hel" 和 "loworld"。接收端 read 的时候,一次可能读到 "helloworld",也可能读到 "hello" 然后第二次读到 "world"。这些组合完全取决于内核缓冲、MSS、Nagle 算法和网络状况,应用层无法预测。
解决办法只有三条路,选一条就行:
定长消息。每条消息固定长度,接收端按长度切。简单但有浪费,适合消息大小高度一致的场景。
分隔符。用特殊字符(如换行符)分隔,接收端一直读到分隔符为止。HTTP 头、Redis 协议都这么干。缺点是分隔符不能出现在正文里,需要转义。
长度前缀。消息头里先写长度,接收端先读长度再读正文。这是最通用的方案,绝大多数 RPC 框架都用这个。实现时要注意长度字段本身的字节序和大小端约定。
import struct def send_message(sock, payload: bytes): # 4 字节大端长度前缀 + 正文 header = struct.pack('>I', len(payload)) sock.sendall(header + payload) def recv_exact(sock, n: int) -> bytes: buf = b'' while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError('连接在读取过程中关闭') buf += chunk return buf def recv_message(sock) -> bytes: header = recv_exact(sock, 4) length = struct.unpack('>I', header)[0] return recv_exact(sock, length)这段代码的关键在recv_exact这个循环。recv返回的数据长度是不确定的,可能少于请求的字节数,必须循环读满。我见过太多新手直接sock.recv(1024)然后假设读到了完整消息,在本地测试永远没问题,一上生产就偶发解析失败。
5.2 Nagle 算法与延迟确认的 40ms 叠加
Nagle 算法的初衷是减少小包。规则是:如果还有未确认的数据在飞,就把新的小数据攒起来,等确认回来再发。这在小包密集的场景下能显著减少报文数。
延迟确认是接收端的行为:收到数据后不立刻回 ACK,而是等一小会儿(通常 40ms 到 200ms),期望能捎带在反向数据里一起发,减少纯 ACK 报文。
这两个机制单独看都很合理,放在一起就出问题了。经典的交互场景是这样的:客户端发了小包 A,服务端收到了但延迟确认;客户端有数据 B 要发,但 Nagle 说"前面还有没确认的,等着",于是 B 卡住了;服务端等到 40ms 超时才回 ACK;客户端收到 ACK 后才发 B。一次交互多出 40ms,一个页面有十几次这种交互,白屏时间就多出半秒。
解决办法很简单:对延迟敏感的服务设置TCP_NODELAY,关闭 Nagle。同时应用层尽量把小的写操作合并成大块发送,从源头减少小包数量。
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sock.connect(('127.0.0.1', 8080))需要注意,TCP_NODELAY不是什么场景都该开。批量传输大数据的场景,Nagle 合包反而能提高效率。判断标准是:你的业务对单次交互延迟敏感吗?敏感就开。
5.3 长连接的假死与保活设计
中间设备(负载均衡、防火墙、NAT 网关)为了节省资源,会主动清理长时间空闲的连接。清理通常不发任何通知,两端都以为连接好好的,直到下一次发数据才发现对面早就没了。这就是"连接假死"。
TCP 自带的 keepalive 能缓解这个问题,但默认参数非常保守:空闲 7200 秒才开始探测,间隔 75 秒,探测 9 次才判定断开。这意味着最坏情况下要两个多小时才能发现连接已死,对线上服务完全不可接受。
# 缩短 TCP keepalive 探测 net.ipv4.tcp_keepalive_time = 600 net.ipv4.tcp_keepalive_intvl = 30 net.ipv4.tcp_keepalive_probes = 3 # 应用层单独设置但更推荐的做法是在应用层做心跳。原因有三:一是不依赖内核参数,跨语言跨平台一致;二是心跳包可以携带业务信息,比如版本号、负载情况;三是心跳周期可以灵活调整,不受全局内核配置约束。
应用层心跳的设计有几个要点。周期要小于中间设备的最短空闲超时,通常取 30 到 60 秒比较稳妥;心跳要有超时判定,连续丢几个就算断开;心跳失败后要能主动重连,而不是等下一次业务请求才发现。我踩过的一个坑是心跳只做了发送没做响应校验,结果对端进程卡死但内核还在回 ACK,心跳包正常发出,连接看起来健康,业务请求却全部超时。后来补了双向确认才解决。
6. 抓包排查实录与常见问题速查
6.1 抓包环境怎么搭
排查 TCP 问题,抓包是绕不开的。我的习惯是先在出问题的机器上抓,如果能看到两端就更好。常用的命令组合如下:
# 抓指定端口的所有流量,写入文件 tcpdump -i any -s 0 -w /tmp/tcp.pcap 'tcp port 8080' # 只看握手和挥手过程 tcpdump -i any -n 'tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0' # 实时显示,带序号和窗口 tcpdump -i any -n -S -vv 'host 10.0.0.1 and port 8080'几个实操经验。-s 0是抓完整包,不截断,不然分析时可能缺数据;-i any会抓到所有网卡,但可能产生重复包,分析时注意去重;生产环境抓包要限制文件大小和数量,-C和-W参数配合使用,避免把磁盘写满。
抓下来的文件用图形化工具打开看会更直观。重点看几个东西:有没有 RST、有没有重复 ACK、窗口是不是持续为 0、重传间隔是不是符合指数退避规律。这些特征基本能定位大部分问题。
6.2 三类典型故障的排查路径
故障一:连接建立慢,偶发超时。
先看握手耗时。如果 SYN 发出后很久才收到 SYN+ACK,说明服务端处理不过来或者中间链路有问题。查服务端的半连接队列和全连接队列是否溢出,同时确认 backlog 配置。如果 SYN+ACK 发出后客户端没回 ACK,通常是客户端侧的问题。
排查顺序我一般这样走:查ss -lnt看队列、查netstat -s看溢出计数、查服务端 accept 线程是否被业务阻塞。有一次定位到的问题就是 accept 之后立刻做了个同步数据库查询,导致队列消费速度跟不上。
故障二:传输过程中卡顿,吞吐上不去。
先分清是 cwnd 受限还是 rwnd 受限。抓包看窗口字段,如果窗口一直是满的但数据不动,可能是拥塞控制把 cwnd 压下来了,去看有没有重传。如果窗口本身就在变小,那是接收端应用读得慢,缓冲区积压。
再往下查,可以看ss -ti的输出,里面有 cwnd、rtt、retrans 等详细指标:
ss -tin dst 10.0.0.1 # 输出里关注: # cwnd:10 拥塞窗口 # rtt:0.5/0.2 平滑 RTT / 偏差 # retrans:0/12 当前重传数 / 总重传数 # send 1Mbps 发送速率故障三:连接大量堆积在某个状态。
用一条命令就能看清分布:
ss -ant | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rnTIME_WAIT 多,看是不是短连接用太多;CLOSE_WAIT 多,几乎肯定是应用没关连接,去代码里找异常路径;ESTABLISHED 多且不降,可能是连接池泄漏或者下游响应慢导致的堆积;SYN_RECV 多,考虑是不是有攻击或者服务端处理能力不足。
6.3 常见问题速查表
| 现象 | 最可能的原因 | 排查动作 | 处理方向 |
|---|---|---|---|
| 偶发连接超时 | 全连接队列溢出 | ss -lnt看 Recv-Q | 调大 backlog 和 somaxconn |
| 大量 CLOSE_WAIT | 应用未关闭连接 | 查代码异常分支 | 补 finally 或 try-with-resources |
| 大量 TIME_WAIT | 短连接过多 | 统计连接生命周期 | 改长连接或连接池 |
| 传输 40ms 卡顿 | Nagle + 延迟确认 | 抓包看小包间隔 | 开 TCP_NODELAY |
| 吞吐远低于带宽 | 窗口太小或丢包 | ss -ti看 cwnd 和重传 | 调缓冲区或排查链路 |
| 连接假死无响应 | 中间设备清理了会话 | 长时间空闲后发探测包 | 加应用层心跳 |
| 数据解析错乱 | 消息边界未定义 | 检查收包逻辑 | 用长度前缀协议 |
| 单连接速度上不去 | 缓冲区小于 BDP | 计算带宽乘 RTT | 调大 tcp_rmem/tcp_wmem |
6.4 几个我踩过的坑
第一个坑是关于tcp_tw_reuse的。这个参数只对主动发起连接的一方生效,服务端作为被动方是不受影响的。当年有个服务端 TIME_WAIT 堆积的问题,我信心满满地开了这个参数,结果毫无效果,白白浪费了半天。后来才想明白,服务端要减少 TIME_WAIT,得让客户端主动关闭,或者改用长连接。
第二个坑是 Nagle 和延迟确认的叠加。这个前面提过,但实际排查时的难度在于,40ms 这个数字太有迷惑性了,看着像 GC 停顿、像锁竞争、像磁盘 IO,唯独不像网络。我是靠抓包看到每次请求间隔精确的 40ms 才锁定方向的。所以记住这个特征值:如果是稳定的、精确的几十毫秒延迟,优先怀疑 Nagle。
第三个坑是 SACK 的误判。有一次线上重传率异常高,我第一反应是链路质量差,查了半天运营商和设备都没问题。后来抓包细看,发现是中间某台设备的 TCP 选项处理有问题,把 SACK 块给丢了,导致发送端只能按累积确认的方式反复重传已经到达的数据。这种中间设备的问题最难查,因为你在两端抓包都是对的,问题出在中间。
第四个坑是缓冲区调优的方向搞反了。有段时间某服务单连接吞吐上不去,我直接把tcp_wmem最大值拉到 64MB,结果毫无改善还占了内存。后来用ss -ti一看,cwnd 只有 10,根本没打开,说明是丢包导致的拥塞窗口压制,跟缓冲区一点关系都没有。这个教训是:调参之前先测量,别凭感觉。
再补一个实践中的小技巧。看ss -s的汇总输出能快速掌握全局,它按状态统计了所有 socket 数量,还能看到内存占用和 TCP 各状态计数。我习惯把它做成定时采集,配合历史曲线看趋势,比单次快照有用得多。有一次就是靠趋势图发现某个服务每半小时 CLOSE_WAIT 涨一截,对上了下游服务定时重连的周期,很快就定位到对方重连逻辑里没关旧连接。
TCP 这套机制设计于几十年前,到现在依然是绝大多数业务的默认选择,本身就说明了它的工程质量。但它不是黑盒,那些藏在协议细节里的坑,只会在你最不希望的时候冒出来。我个人的体会是,与其等问题发生了再去翻文档,不如平时就把ss、tcpdump这些工具用熟,把重传率、连接状态分布这些指标纳入日常监控。等到真出故障的时候,你手里有数据,心里才有底。