上个月被拉去看一个线上问题:某业务接口的 P99 从 80ms 一路涨到 1.2s,监控大盘上 CPU、内存、GC 全绿,应用日志里只有零星几条 timeout,看不出规律。最后在出问题的节点上抓了 30 秒包,Wireshark 里一片红色的 Retransmission,还有几个 ZeroWindow 的窗口探测包在那反复试探。问题不在业务代码,在传输层。这件事之后我把TCP 协议这块又系统梳理了一遍——它属于传输层协议里最常被调用、也最常被误解的一层。很多人对它的认知停留在"三次握手四次挥手""可靠传输"这几个词上,真出了问题时,这些词帮不上忙。这篇内容想做的事很具体:把 TCP 从"背概念"拉到"能排查、能调参、能解释抓包里每一个包"的层面,讲清楚它的每个设计为什么长这样、踩坑时现象和根因怎么对上号。适合已经写过网络代码、但一遇到重传、TIME_WAIT、粘包就心里没底的后端和客户端同学,也适合刚学完计算机网络、想把书本知识落地的朋友。
1. TCP 到底是条什么样的管道
1.1 IP 层只保证"尽力而为",TCP 补的是确定性
要理解 TCP,先得知道它替谁擦屁股。它下面的 IP 层提供的服务叫best-effort delivery,翻译成人话就是"我尽量送,送不到我不负责,送重了我不负责,送乱序了我也不负责"。IP 包在路上可能被路由器因为队列满而丢弃,可能因为链路层重传而在上层看来出现了重复,也可能走了不同路径导致后到先至。这些在 IP 层全都属于正常行为,不算故障。
TCP 干的活,就是在这么一条不靠谱的通道上,虚拟出一条"看起来靠谱"的双向字节流。它要补的短板有四块:丢包要能发现并重发(可靠性)、发太快不能把对方淹了(流量控制)、发太快不能把中间链路压垮(拥塞控制)、两端要先确认彼此都在且愿意通信(连接管理)。这四件事分别对应后面章节里的序号确认机制、接收窗口、拥塞窗口和三次握手。
这里有个特别容易被忽略的认知:TCP 的"可靠"是端到端的可靠,它只保证数据按序、无差错、不重复地到达对端 TCP 缓冲区,不保证应用层已经处理了。你的send()返回成功,只意味着数据进了内核发送缓冲区,对方应用有没有读到、读完之后有没有写库,TCP 一概不管。我见过不止一个项目把"send 成功"当成"对方业务处理成功",然后在对端进程崩溃重启时丢了一批数据,回头查了半天怀疑是 TCP 丢包,其实是应用的锅。想解决这个层面的一致性,得靠应用层的确认和幂等,协议栈帮不了你。
1.2 面向连接、字节流、全双工:三个定语定义了后面所有设计
教科书上说 TCP 是"面向连接的、可靠的、基于字节流的、全双工的传输层协议",这串定语不是凑字数,每一条都直接推导出了后面的一大堆机制。
面向连接意味着通信前要先建立状态。两端各自维护一套连接状态机(CLOSED、SYN_SENT、ESTABLISHED、FIN_WAIT_1 等等),这些状态存在内核里,占内存。所以连接数不是无限的,一台机器能撑多少连接,取决于内存、文件描述符上限和端口范围,不取决于"TCP 理论支持多少"。这也是为什么长连接池、连接复用这些优化手段有意义——建连接本身是有成本的。
字节流意味着 TCP 眼里没有"消息"这个概念,只有一串连续的字节。你调三次send(),对端可能一次recv()就全读到了;你调一次send()发 4KB,对端可能分两次读到。这一点直接导致了所谓"粘包"问题——但严格说,粘包不是 TCP 的缺陷,是把流式协议当消息协议用的人自己没划边界。后面第 6 章会专门讲这个。
全双工意味着两个方向的数据流互相独立,各自的序号、各自的窗口、各自的拥塞控制状态。A 方向拥塞不影响 B 方向继续发。理解这一点很重要,因为排查时会遇到"下载很慢但上传正常"这类单向异常,方向搞反了会白折腾半天。
1.3 头部字段速查:排查时真正会盯的那几个
TCP 头部固定 20 字节,带选项最多 60 字节。字段不少,但真正排查问题时高频出现的就那么几个,值得单独过一遍。
| 字段 | 长度 | 排查时的实际用途 |
|---|---|---|
| 源/目的端口 | 各 16 位 | 定位是哪个服务、五元组过滤抓包 |
| 序号 Sequence | 32 位 | 判断重传、乱序、丢包位置 |
| 确认号 Acknowledgment | 32 位 | 判断对端收了多少、窗口是否推进 |
| 标志位 | 9 位 | SYN/FIN/RST/PSH/ACK 组合判断连接阶段 |
| 窗口 Window | 16 位 | 判断接收方缓冲区是否吃紧 |
| 校验和 | 16 位 | 判断是否链路层损坏 |
| 选项 | 变长 | MSS、窗口缩放、SACK、时间戳都在这里 |
其中窗口缩放选项(Window Scale)最容易被忽略。原始 TCP 头里窗口字段只有 16 位,最大 65535 字节,在千兆、万兆网络下这点窗口根本不够用,带宽时延积一大就卡住。所以握手时会协商一个移位因子,最大 14,把实际窗口放大到 65535×2^14 字节。抓包时如果只看到Win=65535就以为窗口很小,很可能是因为工具没展开缩放因子,Wireshark 里需要确认 "Window Scale" 选项解析正确,否则对整个吞吐能力的判断都是错的。
MSS(Maximum Segment Size)也值得一提。以太网 MTU 通常 1500,减掉 IP 头 20 字节、TCP 头 20 字节,MSS 就是 1460。MSS 在握手时协商,取双方较小的值。如果你走了隧道或者 overlay 网络,MTU 变小了但 MSS 没跟着调,就会出现"小包正常、大包全丢"的经典现象——这种现象特别迷惑人,因为 ping 得通、telnet 端口通,就是传文件卡死。
2. 三次握手:连接建立阶段的账要算清楚
2.1 两次握手为什么不行
这个问题面试常考,但很多人背的答案是"防止已失效的连接请求突然到达",背得出但说不透。换一个角度想:TCP 建立连接的目标是双方都确认自己和对方的收发能力都正常,同时双方的初始序号都被对方确认。
两次握手的问题在于,服务端在收到 SYN 并回 SYN-ACK 之后,就已经认为连接建立了,但此时客户端还没确认服务端的序号。如果这个 SYN-ACK 丢了,客户端会重发 SYN,服务端就会为同一个连接请求建两次连接状态,白白占资源。更糟的是,如果网络里有一个延迟很久的旧 SYN 突然到达,服务端两次握手就建连,然后一直等一个永远不会来的数据——这就是"半开连接"的来源。
三次握手之后,双方都拿到了对方的初始序号确认,且都知道对方知道自己的序号,这个认知是对称的。这是三次的下限,不是随意定的数字。
顺带提一句初始序号(ISN)为什么不能从 0 开始。早期实现确实用过固定值,结果导致同一四元组的旧连接报文会被新连接误认为是合法数据。现在 ISN 是基于时钟的随机化生成,Linux 上还混入了密钥哈希,除了避免混淆,也顺带提高了序列号猜测的门槛。
2.2 半连接队列与全连接队列:连上了却连不进去
这是实际运维中最容易撞上的坑之一,现象非常典型:客户端侧连接超时或连接被重置,服务端ss -lnt看监听队列的Recv-Q一直在涨,但 CPU 不高,应用日志里没有请求进来。
根因在于 Linux 把连接的建立拆成了两个队列:
- SYN 队列(半连接队列):收到 SYN、回了 SYN-ACK、还没收到最终 ACK 的连接,长度受
net.ipv4.tcp_max_syn_backlog限制。 - Accept 队列(全连接队列):三次握手已完成、等应用调用
accept()取走的连接,长度受listen()的 backlog 参数和net.core.somaxconn两者较小值限制。
应用侧如果accept()调得慢(比如单线程处理、或者在 accept 之后有阻塞操作),全连接队列就会堆满。队列满了之后的行为由net.ipv4.tcp_abort_on_overflow决定:默认是 0,即静默丢弃客户端最后的 ACK,客户端会认为连接已建立并开始发数据,服务端却当没这回事,于是出现"连接建立了但第一个请求超时重发"的诡异现象。设成 1 则会直接回 RST,客户端立刻收到 connection reset,反而更容易定位。
# 查看监听队列的当前排队情况,第二列 Recv-Q 就是全连接队列里待 accept 的数量 ss -lnt # 观察是否有溢出计数增长,这是最直接的证据 nstat -az | grep -i -E 'ListenOverflows|ListenDrops'我一般会在排查这类问题时先跑上面两条命令,如果ListenOverflows在涨,基本就锁定方向了,接下来要做的不是调内核参数,而是去看应用为什么 accept 得慢——很多时候是业务在 accept 之后同步做了 DNS 解析、配置拉取或者日志落盘这类慢操作。
2.3 握手阶段的时延账本
握手是要花时间的,而且花的时间不是一次,是一个 RTT(严格说是一个半 RTT,因为客户端发完最后一个 ACK 就可以带数据了)。跨地域调用时这个成本很显眼:北京到广州 RTT 约 30-40ms,一次新建连接就是 30-40ms 的纯等待,如果链路上还有 TLS 握手,又要再加一到两个 RTT。这就是为什么 HTTP 长连接、连接池、TLS 会话复用这些优化能带来肉眼可见的收益。
还有一个隐藏成本:新建连接的初始拥塞窗口很小。Linux 从 3.0 开始按 RFC 6928 把初始窗口设成 10 个 MSS,也就是约 14KB。这意味着一个新连接头一个 RTT 最多只能发 14KB 数据,之后才进入慢启动翻倍。对于大响应体来说,前几个 RTT 都在"爬坡"。短连接场景下,连接往往还没爬到合适的速率就关闭了,带宽利用率非常低。这也是长连接在延迟敏感场景下更划算的原因之一。
3. 可靠性不是白来的:序号、确认与重传
3.1 序号编的是字节,不是包
这一点必须掰清楚,因为它决定了你读抓包时怎么理解数字。TCP 的序号是字节流上的偏移,不是"第几个包"。假设初始序号是 1000,你发了 100 字节,下一个包的序号就是 1100,而不是 2。确认号的含义是"我期望收到的下一个字节的序号",也就是"这个号之前的我全收到了"。
这个设计带来一个很实用的推论:累计确认。接收方不需要对每个包单独回 ACK,只要回一个确认号,就表示之前所有字节都收到了。ACK 丢了不要紧,后面任何一个 ACK 都能把这个信息补上。这也是为什么抓包里会看到 ACK 数量比数据包少很多,不是丢包,是合并了。
同时这也解释了乱序和丢包在协议层长得不一样。如果中间的某个段丢了,后面到达的段会让接收方反复回同一个确认号,这就是重复 ACK;如果是乱序但没丢,接收方会先缓存起来,等缺口补上再一次性推进确认号。抓包时看到重复 ACK,先别急着判定丢包,要确认是不是仅仅乱序——尤其在有负载均衡或者多路径的网络里,乱序非常常见,把乱序当丢包去优化,方向就歪了。
3.2 RTO 是算出来的,不是拍脑袋定的
超时重传的时间阈值叫 RTO(Retransmission Timeout)。定太短会大量无谓重传,定太长则丢一个包要等很久才能恢复。TCP 用了自适应估计,公式不复杂:
- 平滑 RTT:
SRTT = (1 - 1/8) × SRTT + (1/8) × 本次RTT - RTT 偏差:
RTTVAR = (1 - 1/4) × RTTVAR + (1/4) × |SRTT - 本次RTT| - 最终:
RTO = SRTT + 4 × RTTVAR
这里的 1/8 和 1/4 是经验系数(对应 α=0.125、β=0.25),来自于对网络抖动长期观测的折中。乘 4 倍的偏差是为了覆盖绝大多数波动情况,让 RTO 不至于因为一次偶发延迟就误判。
Linux 上还有个硬约束:RTO 最小 200ms,最大 120 秒。这带来一个容易被忽视的现象——在低延迟内网里,RTO 的下限 200ms 反而是主导因素。一个 RTT 只有 0.5ms 的机房内部调用,如果发生丢包,重传要等 200ms 才触发,对 P99 的影响远大于丢包本身。这种场景下优化的重点应该是降低丢包率(比如查是不是网卡队列、交换机 buffer 的问题),而不是去调 RTO。
另外要注意指数退避:同一个段连续重传时 RTO 会翻倍(200ms、400ms、800ms……),一直退到上限。这是为了防止网络已经严重拥塞时还猛发。所以抓包里看到重传间隔越来越大,是正常行为,说明协议在主动让路。
3.3 快速重传、SACK 与 D-SACK
光靠超时重传太慢,要等一个 RTO 才反应。所以 TCP 有了快速重传:接收方连续三次发出相同的确认号(三个重复 ACK),发送方就能判定这个段丢了,不必等超时,立刻重传。
三次这个阈值是权衡的结果。设成 1,一次单纯的乱序就会触发不必要的重传;设成 3,多数轻度乱序能被容忍,同时丢包恢复足够快。这也是为什么调整tcp_reordering参数会影响重传行为——有些网络路径乱序严重,超过三次重复 ACK 就会造成大量伪重传,把重传率指标搞得很难看。
SACK(选择性确认)是进一步的优化。没有 SACK 时,发送方只知道"从某号开始没收全",一旦中间丢一段,就得把后面收到的全部重发,这叫"回退 N"。有 SACK 后,接收方可以在选项里告诉发送方"我收到了 X 段和 Y 段,缺的是 Z 段",发送方只补 Z。在大窗口、高丢包率的场景下,两者差距非常大,能把重传量降一个数量级。Linux 默认开启net.ipv4.tcp_sack,不建议关。
D-SACK(重复 SACK)用来告诉发送方"我收到的这段是重复的",主要用于处理伪重传——即发送方以为丢了、其实没丢的情况。当网络里出现大量伪重传时,D-SACK 能帮发送方修正判断,避免继续无谓地收缩拥塞窗口。
3.4 一张表看懂常见重传现象
| 抓包现象 | 常见根因 | 验证方式 |
|---|---|---|
| 单段偶发重传,间隔 200ms 左右 | 链路瞬时丢包,或网卡队列溢出 | 看丢包点是否集中在流量高峰 |
| 重传间隔递增(200/400/800ms) | 连续丢包,已进入指数退避 | 检查是否大面积拥塞或链路故障 |
| 大量快速重传但总量不大 | 路径乱序,伪重传 | 看是否有 D-SACK,调大tcp_reordering |
| 重传率与业务 RT 同涨 | 带宽打满或中间设备限速 | 看链路带宽利用率和排队延迟 |
| 只在特定 MTU 下出问题 | PMTUD 黑洞,MSS 未协商 | 改 MSS clamp 后再试 |
| 重传集中在一台机器 | 单机网卡/驱动/软中断问题 | 对比同机房其他节点 |
4. 两个窗口:流量控制与拥塞控制别混为一谈
4.1 rwnd:接收方的自我保护
接收窗口(rwnd)是接收方告诉发送方"我缓冲区还剩多少,你能发多少"。发送方实际能发在途的数据量不能超过 rwnd 与 cwnd 的较小值。它的作用是防止快发送方撑爆慢接收方,属于端到端的机制,接收方完全掌控。
接收窗口的大小取决于应用读取的速度。如果应用一直不recv(),内核接收缓冲区被填满,窗口就降到 0,发送方必须停下来。这时候发送方会发窗口探测(Zero Window Probe),隔一段时间问一次"现在能发了吗",直到窗口重新打开或者超时断开。那些抓包里反复出现的、间隔规律的 1 字节包,很多就是窗口探测。
这里最容易踩的坑是:只调大了内核缓冲区,但应用读取逻辑没优化。调net.ipv4.tcp_rmem能让窗口变大,看起来吞吐上升了,但如果应用本身处理得慢,缓冲区只是变成了更大的"缓冲区",治标不治本,反而让延迟变高——因为数据在缓冲区里排队更久了。真正该做的常常是增加读线程、把处理逻辑异步化,或者用SO_RCVBUF明确表达意图。
4.2 cwnd:发送方对网络的试探
拥塞窗口(cwnd)和 rwnd 完全不同,它是发送方自己维护的、对网络承载能力的估计。发送方不知道中间链路有多少带宽、有多少 buffer 被共享,只能靠试探和丢包信号来推断。这个机制是整个 TCP 里最"玄学"的部分,也是各种拥塞控制算法差异的来源。
基本过程是三步:
- 慢启动:从初始窗口(约 10 MSS)开始,每收到一轮 ACK,窗口翻倍。名字叫"慢",其实是指数增长,一点都不慢。
- 拥塞避免:窗口涨到慢启动阈值(ssthresh)后,改为每 RTT 线性增加 1 个 MSS,变成加法增长。
- 快速恢复:检测到丢包时,把 ssthresh 减半,窗口回撤,然后逐步恢复,而不是直接退到 1 重来。
这三步的经典图形是"锯齿状"曲线。理解它的一个生活类比是开车:不知道前面路况,先试探着加速(慢启动),快到感觉拥堵的速度就稳住慢慢加(拥塞避免),一发现堵了就减速(快恢复),然后再试探。不同算法(CUBIC、BBR)只是"试探策略"不同。
4.3 零窗口与窗口缩放的实际影响
前面提过窗口缩放,这里展开说为什么它是长肥管道(带宽大、时延高)场景的关键。举个例子:一条 RTT 100ms、带宽 100Mbps 的链路,带宽时延积 = 12.5MB/s × 0.1s = 1.25MB。如果窗口只有 64KB,那么每个 RTT 最多只能传 64KB,实际吞吐上限约 640KB/s,不到带宽的 1%。这不是带宽不够,是窗口填不满管道。
窗口缩放因子在握手时协商,一旦确定,整条连接生命周期内不变。所以如果两端操作系统或者中间设备对这个选项处理有问题,整条连接的上限就被钉死了。某些老设备或者代理层会剥离 TCP 选项,这时候即使两端都是新系统,也会退化成没缩放的状态,表现为"链路很快但吞吐上不去"。
4.4 拥塞控制算法的选择
Linux 默认算法这些年换过几次,目前主流发行版默认是CUBIC,部分场景会选BBR。两者思路差异很大:
| 维度 | CUBIC(基于丢包) | BBR(基于带宽和时延) |
|---|---|---|
| 拥塞信号 | 丢包 | 带宽估计 + RTT 最小值 |
| 高丢包链路表现 | 窗口激进收缩,吞吐差 | 不把丢包当拥塞信号,表现更好 |
| 浅缓冲区链路 | 容易造成排队延迟 | 主动控制排队,延迟较低 |
| 公平性 | 与同类算法共存良好 | 与 CUBIC 混跑时可能抢占 |
| 适用场景 | 通用 | 长距离、有丢包的国际链路 |
切换算法很简单,sysctl -w net.ipv4.tcp_congestion_control=bbr即可,但前提是内核模块已加载(modprobe tcp_bbr),且内核版本要够。这里有个实际经验:不要所有机器一把梭切 BBR。我的做法是先在一小部分节点上灰度,观察丢包率、平均 RTT、以及同链路上其他业务的指标——BBR 在混合部署环境里确实会有抢占行为,挤占同链路上的 CUBIC 流量,如果这条链路是共享的,切换前要跟链路的其他使用方打个招呼。
5. 四次挥手与 TIME_WAIT:最常被误杀的正常状态
5.1 挥手为什么是四次
握手三次,挥手四次,这个不对称是有原因的。握手时服务端把 SYN 和 ACK 合在一个包里发(SYN-ACK),但挥手时不行:主动关闭方发 FIN 表示"我没数据要发了",被动方回 ACK 表示"收到了",此时被动方可能还有数据要发,所以要等自己的数据发完再单独发一个 FIN。ACK 和 FIN 不能合并,于是成了四次。
这里有个容易混淆的点:"我不发了"不代表"我不能收"。TCP 是半关闭的,A 发 FIN 之后仍然可以接收 B 的数据,这就是半关闭状态。这个特性在某些协议里很有用,比如客户端发完请求就 shutdown 写端,等服务器把响应全部发完再关闭,能实现更高效的流式传输。但很多代码直接close(),等于读写都关了,白白浪费了这个能力。
5.2 TIME_WAIT 的两个不可替代的作用
主动关闭连接的一方,在发完最后一个 ACK 后会进入TIME_WAIT状态,持续 2 个 MSL(Linux 上固定 60 秒)。这个状态经常被当成"病"来治,但它其实承担了两个非常实际的功能:
第一,确保最后一个 ACK 能到达对端。如果这个 ACK 丢了,对端会重发 FIN,处于 TIME_WAIT 的一方还能再回一次 ACK。如果直接关闭,对端重发的 FIN 会收到 RST,对端就会认为连接异常终止,可能丢掉还没处理完的数据。
第二,让旧连接的残留报文在网络中消散。同一四元组的新连接建立后,如果还有旧连接的延迟报文在路上,可能被新连接误收。TIME_WAIT 等 2MSL,理论上足够让一个来回的报文全部过期。
理解了这两点,就知道无条件缩短 TIME_WAIT 是有风险的。那些"调小tcp_fin_timeout就能减少 TIME_WAIT"的说法,其实是个误解——tcp_fin_timeout控制的是 FIN_WAIT_2 状态的超时,跟 TIME_WAIT 的持续时间没关系。TIME_WAIT 的时长在 Linux 里是编译期常量,改不了。
5.3 TIME_WAIT 堆积的成因分类与处置
ss -tan state time-wait | wc -l一跑发现几万个 TIME_WAIT,先别慌,要分成几种情况看:
情况一:主动关闭方是客户端,且用了短连接。这是最常见的。每次请求新建连接、用完就关,客户端自然承担 TIME_WAIT。处置方式优先是改用连接池 / 长连接,这是从根上解决。如果改不了,可以考虑net.ipv4.tcp_tw_reuse=1,它允许在安全条件下复用 TIME_WAIT 状态的连接去发起新连接(需要开启时间戳),注意它只对主动发起连接的客户端方向有效,对服务端被动连接无效。
情况二:服务端因为业务逻辑主动关闭连接。比如 HTTP 服务端在响应超时后主动断开,或者某些网关在读完就 close。这种情况应该优先调整协议行为,比如支持客户端先关、或者用SO_LINGER配合明确语义。实在不行,可以适度扩大本地端口范围:net.ipv4.ip_local_port_range,默认通常 32768-60999,扩到 1024-65535 能多出不少可用端口。但注意端口范围扩太大会和固定端口冲突,要检查现有服务占用的端口段。
情况三:连接被异常终止,进入 TIME_WAIT 的其实是残留状态。这种要看是否是 RST 风暴引起,得先解决 RST 的来源。
至于tcp_tw_recycle,千万不要碰。这个参数在 NAT 环境下会导致连接异常断开(因为它基于时间戳判断,NAT 后面多台机器时间戳不一致),而且从 4.12 内核起已经被彻底移除。任何还在推荐这个参数的文章,都可以视为过时了。
5.4 CLOSE_WAIT 才是要警惕的信号
和 TIME_WAIT 相比,CLOSE_WAIT 堆积几乎一定是代码问题。CLOSE_WAIT 表示被动关闭方收到了对方的 FIN、内核回了 ACK,但应用层还没有调用close()。也就是说,连接已经半个身子入土了,应用还攥着不放。
这通常意味着:代码里有分支没有关闭连接,比如异常路径直接 return 了、连接对象被放进池子但没被回收、或者回调里忘了关。CLOSE_WAIT 会一直堆积直到进程重启,文件描述符耗尽后新连接全部失败,报 "Too many open files"。
# 按进程统计 CLOSE_WAIT 数量,快速定位是哪个服务在漏 ss -tan state close-wait | awk 'NR>1 {print $NF}' | sort | uniq -c | sort -rn排查这类问题的思路很直接:找到堆得最多的进程,看它的连接是从哪个下游来的,然后回到代码里搜这条链路的连接创建点,检查所有异常分支。用 try-with-resources 或者 defer close 这类语言特性可以大幅降低复发概率。
6. 抓包现场:那些和教科书对不上的现象
6.1 粘包:TCP 从来没承诺过消息边界
这是被误解最深的一个问题。严格说,"粘包"这个说法本身就不准确——TCP 是字节流协议,它从来就没打算保留消息边界。你觉得"粘"了,是因为你在用消息协议的思维去用流协议。
具体表现是:你连续send()两次各 100 字节,对端recv()一次读到了 200 字节;或者你send()了 1000 字节,对端recv()只读到 600 字节,剩下 400 字节要再读一次。这两种现象都是正常的、符合规范的。原因有两层:发送端可能因为 Nagle 算法把多个小包合并;接收端可能因为数据已经都在缓冲区里,一次就全交给你。
解决办法只有一条:在应用层自己划边界。常见三种:
- 定长消息:每条消息固定 N 字节,简单但浪费,不灵活。
- 分隔符:用
\r\n或特殊字符分隔,HTTP 头、Redis 协议都是这个思路,但要求消息内容里不能出现分隔符,通常需要转义。 - 长度前缀:先发 4 字节长度,再发消息体。这是最通用、最高效的方案,Protobuf、大多数 RPC 框架都用这个。
# 错误示范:把一次 recv 当成一条完整消息 data = sock.recv(1024) handle(data) # data 可能是半条、也可能是一条半 # 正确做法:长度前缀 + 循环读满 def recv_exact(sock, n): buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("peer closed") buf += chunk return buf header = recv_exact(sock, 4) body_len = int.from_bytes(header, "big") body = recv_exact(sock, body_len)这里有个容易忽略的细节:recv()返回空 bytes 表示对端已关闭,不是"暂时没数据"。很多代码把空返回当成继续循环的条件,结果死循环空转。非阻塞 socket 的"暂时没数据"是通过返回EAGAIN异常表达的,两者必须区分清楚。
6.2 一次超时排查的完整链路
回到开头那个 P99 飙到 1.2s 的问题,把排查过程完整走一遍,这套思路可以复用。
第一步,确认问题边界。是全局还是单节点?是单接口还是所有接口?是持续还是间歇?当时的数据是:只有部分节点有问题,且集中在同一个可用区,其他区正常。这就把范围缩到了网络路径或者该区的宿主机。
第二步,看协议层指标。nstat -az里看TcpRetransSegs、TcpExtTCPTimeouts、TcpExtListenOverflows。发现TcpRetransSegs在问题时段每分钟涨几百,其他时段接近 0。重传有,但不至于把延迟拉到 1.2s,因为重传一次也就 200ms 起步。
第三步,抓包定位重传模式。用tcpdump -i eth0 -w cap.pcap 'tcp port 8080 and host x.x.x.x',抓了 30 秒。过滤tcp.analysis.retransmission之后发现两个特征:一是重传集中在几个特定对端;二是伴随出现ZeroWindow和Window Full。
第四步,确认窗口问题。展开握手包的窗口缩放因子,发现协商结果是 scale=7,窗口上限 8MB,看起来不小。但抓包里接收方多次把窗口降到 0,说明接收缓冲区被填满了。回到应用侧看:这个服务的处理逻辑里有一步是同步写日志到某个外部组件,那个组件当时响应变慢,导致业务线程卡在写日志上,recv()之后迟迟不进入下一轮读取,缓冲区就满了。
第五步,验证修复。把日志写入改成异步 + 有界队列,同时适度调大接收缓冲区作为缓冲余量。改完之后重传率降到接近 0,P99 回落。
这个案例的关键在于:协议层的现象(重传、零窗口)是结果,根因常常在应用层。窗口降零说明接收方来不及消费,重传说明链路或缓冲区有问题。顺着现象往上一层找,比在内核参数上试错效率高得多。
6.3 40ms 的幽灵延迟
还有一个非常经典的坑:某些接口的延迟稳定在 40ms 左右,不多不少,而且只在小请求上出现。这种"精确的固定延迟"往往不是网络问题,而是Nagle 算法和延迟 ACK 撞在了一起。
机制是这样的:发送方开了 Nagle(默认就是开的),上一个包的 ACK 没回来之前,小包先攒着不发。接收方的延迟 ACK 机制是收到数据后不立刻回 ACK,等 40ms 或者攒够两个包再回。于是发送方等 ACK 才能发,接收方等更多数据才回 ACK,两边互相等,最后靠 40ms 的定时器打破僵局。
解决办法是设置TCP_NODELAY,关闭 Nagle:
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)但要提醒一句:不是所有场景都该关 Nagle。Nagle 的存在是为了减少小包数量,在大量小消息写入的场景下,它是有效的合并手段。真正该关的是交互式、延迟敏感、每次请求响应都很小的场景(比如 RPC、数据库协议、游戏)。判断标准很简单:如果你的协议是"一问一答"且消息很小,关掉;如果是"批量流式写入",可以保留。
关了 Nagle 之后通常要配一个TCP_CORK或者应用层的批量写,否则可能产生大量小包,把 PPS 打上去反而伤带宽。这是另一面,很多人只记得关 Nagle,不记得关了之后的代价。
7. 参数调优:改之前先想清楚在改什么
7.1 常用参数与副作用对照表
调参这件事,我最怕看到的是从某篇文章里抄一整套sysctl直接刷上去。每个参数都有它的适用场景和代价,不看场景地调,往往是把一个指标修好了、另一个指标崩了。
| 参数 | 作用 | 主要副作用 | 建议 |
|---|---|---|---|
tcp_syncookies | SYN 队列满时用 cookie 建连 | 丢失部分 TCP 选项(如窗口缩放) | 保持开启,作为兜底 |
somaxconn | accept 队列上限 | 无副作用,只是内存 | 按业务 QPS 调大 |
tcp_max_syn_backlog | SYN 队列上限 | 内存占用 | 大流量服务适当调大 |
tcp_rmem / tcp_wmem | 收发缓冲区范围 | 缓冲区变大推高延迟 | 高带宽高时延场景才调大 |
tcp_tw_reuse | 复用 TIME_WAIT 连接(客户端侧) | 需时间戳,语义有限制 | 短连接客户端可开 |
tcp_fin_timeout | FIN_WAIT_2 超时 | 与 TIME_WAIT 无关,别指望它 | 一般保持默认 |
tcp_slow_start_after_idle | 空闲后是否重置窗口 | 长连接空闲后起步变慢 | 长连接场景可关闭 |
tcp_notsent_lowat | 未发送数据阈值 | 能降低排队延迟 | 延迟敏感场景可设 |
其中tcp_slow_start_after_idle值得单独说。默认开启时,一条空闲超过一个 RTO 的连接,拥塞窗口会被重置回初始值。对长连接池来说这是灾难性的——池里的连接大部分时间是闲的,每次拿来用都要重新爬坡。把这个关掉,长连接的尾延迟能明显改善。但要注意,在某些链路上这会让突发流量更猛,需要结合业务流量形态判断。
7.2 我踩过的两个参数坑
第一个坑是盲目调大缓冲区。早期做文件传输服务时,听人说调大tcp_rmem能提升吞吐,直接把最大值从 6MB 调到 64MB。结果是吞吐没怎么涨,反而有几台机器的内存被吃满了,因为缓冲区是按连接分配的,连接数一多,内存就线性膨胀。更麻烦的是延迟变高了——数据在缓冲区里排队,应用读到的数据更"陈旧"。后来改成了按业务分级配置:文件传输类连接单独用SO_RCVBUF设置大缓冲,普通业务保持默认,问题才解决。
第二个坑是tcp_tw_recycle。这个参数早年在"优化高并发"的文章里被广泛推荐,我也在一台测试机上开过。结果出现了诡异的间歇性连接失败——同一台客户端有时能连有时不能,换台机器就正常。查了很久才定位到:客户端在 NAT 后面,多台机器共享出口 IP,而tcp_tw_recycle依赖对端时间戳单调递增来判断连接新旧,NAT 后面不同机器的时间戳顺序不一致,就会被内核判定为异常而丢包。这个参数现在已经被移除,但凡看到有人推荐,基本说明资料是十年以上的老古董。
调参的通用原则我一直这么用:一次只改一个参数,改完至少观察一个完整业务周期(比如包含高峰和低谷),改之前先记下基线指标,改之后对比。批量改参数看着效率高,出问题时根本不知道是哪一个导致的,回滚也做不到精准。
7.3 用工具把"看不见的"变"看得见"
最后分享几个我常用的观察手段,比翻文档管用。
ss -ti能直接看到单条连接的详细状态,包括 cwnd、rtt、重传计数、拥塞算法,比抓包快得多:
# 看某条连接的 rtt、cwnd、重传次数、算法 ss -tin dst 10.0.0.5:8080输出里rtt:0.5/0.2表示当前 RTT 和平均 RTT,cwnd:10是拥塞窗口(单位是 MSS),retrans:0/3表示当前重传 0 次、历史累计 3 次,bbr/cubic标明算法。这个命令在排查"这条连接为什么慢"时比抓包高效得多。
nstat -az适合看全局计数器,尤其是错误类指标。TcpExtTCPAbortOnMemory、TcpExtTCPBacklogDrop、TcpExtPAWSAbort这些名字看着生僻,但每一个对应一种具体的异常,比笼统地看"网络有问题"精准得多。
抓包工具上,我更倾向在服务端抓而不是客户端,因为服务端能看到真实的接收窗口和 ACK 行为,客户端抓到的往往是已经经过本地协议栈"美化"过的视图。另外抓包一定要限制时长和大小,tcpdump -c 10000或者-G 60 -W 5,避免把磁盘写满反而引发新故障。
8. 几句实在话
TCP 这套机制从 1981 年的 RFC 793 到现在,核心设计几乎没变过,变的是跑它的环境和用它的方式。当年设计者假设的是"链路会丢包、带宽有限、端到端对称",而现在的很多场景是"内网几乎不丢包、带宽过剩、中间隔着一堆代理和负载均衡"。这中间的错位,就是各种"教科书说 A,实际表现是 B"的来源。
我个人最深的体会是:遇到网络问题,先分清是协议层现象还是应用层根因。窗口降零、重传、TIME_WAIT 堆积、CLOSE_WAIT 泄漏,这些在抓包和ss里都是一眼能看见的现象,但它们几乎从不是"病因",只是"症状"。真正花时间的部分是顺着症状往回找——是消费慢了、是连接没关、是某个分支漏了收尾。工具能帮你三分钟定位到现象,剩下三小时靠的是读代码和对业务链路的理解。
如果只让我留一条建议:把ss -ti和nstat加到你的日常排查手册里,遇到不确定的问题先跑一遍,比凭感觉猜要靠谱得多。