你有没有遇到过这种情况:接口偶发超时,查了半天日志,最后发现是 TCP 重传在搞鬼?或者线上服务器 CLOSE_WAIT 堆积到几万,服务假死却找不到原因?
TCP 是每个程序员的 "老熟人",但大多数人对它的理解停留在 "三次握手、四次挥手" 的八股层面。真正到了线上排障、性能调优的时候,才发现自己对这个 "最熟悉的陌生人" 知之甚少。
这篇文章,我想带着你跟着一个 TCP 连接从生到死走一遍,把那些零散的知识点串成一条完整的故事线。文末附 Wireshark 抓包实战,看完你不仅能应付面试,更能解决实际问题。
一、全局认知:TCP 到底在哪一层?
TCP 工作在 OSI 模型的第 4 层 —— 传输层,在 TCP/IP 四层模型中同样位于传输层(第三层)。它的上面是应用层(HTTP、Redis、MySQL 等),下面是网络层(IP 协议)。
图1 TCP的在协议栈的位置
网络层负责把数据包从 A 机器送到 B 机器,但它是不可靠的 —— 包可能丢、可能乱序、可能重复。TCP 就是在 IP 之上,为应用层提供了一层 "可靠交付" 的抽象。
一句话定义 TCP:面向连接的、可靠的、基于字节流的、全双工的传输层协议。
这四个关键词,每一个都对应着一套复杂的机制,我们后面会逐一拆解。
面试视角:面试官问 "TCP 和 UDP 的区别",不要只背 "TCP 可靠、UDP 不可靠"。本质区别是:TCP 在不可靠的 IP 层之上,通过序列号、确认、重传、窗口等机制,模拟出了一条 "可靠的管道";而 UDP 只是对 IP 包做了薄薄的一层封装(加了端口和校验和),几乎不提供任何额外保证。
二、TCP 的核心功能:它到底解决了什么问题?
很多人学 TCP,是从 "三次握手" 开始背的,但这是本末倒置。握手只是手段,TCP 的核心目标是以下四件事:
最后两个的区别:流量控制是端到端的(怕接收方扛不住),拥塞控制是全局的(怕网络扛不住)。
三、连接建立:三次握手
1、为什么需要 "握手"?
TCP 是面向连接的协议。"面向连接" 不是说物理上有一条专线,而是说通信双方在传输数据之前,需要协商一系列参数:初始序列号(ISN)、窗口大小、MSS(最大段长度)、是否支持 SACK 等。
三次握手,本质上就是双方交换这些初始信息的过程。
2、三次握手的完整过程
图2 TCP建连-三次握手
第一次:客户端发送 SYN=1, seq=x,进入 SYN_SENT 状态
第二次:服务器回复 SYN=1, ACK=1, seq=y, ack=x+1,进入 SYN_RCVD 状态
第三次:客户端发送 ACK=1, seq=x+1, ack=y+1,双方进入 ESTABLISHED 状态
这里有个细节:SYN 包和 FIN 包都要消耗一个序列号,所以确认号是 +1。普通数据包的确认号则是 "期望收到的下一个字节的序号"。
3、为什么是三次,不是两次?
核心原因有两个:
1)防止已失效的连接请求到达服务器。如果客户端发了一个 SYN,因为网络延迟迟迟没到,客户端超时重发了一个新的 SYN 并建立了连接。之后那个 "老 SYN" 终于到了服务器,如果是两次握手,服务器就会建立一条多余的连接。三次握手中,客户端收到服务器对老 SYN 的确认后,发现这个 seq 不对,会回复一个 RST 拒绝。
2)双方都需要确认对方的发送和接收能力正常。三次握手是 "双方各发一个 SYN、各收一个 ACK" 的最小次数:
客户端发 SYN → 服务器知道客户端能发
服务器回 SYN+ACK → 客户端知道服务器能发能收
客户端回 ACK → 服务器知道客户端能收
工程视角:三次握手的第三步(客户端的 ACK)是可以携带数据的。但在实际实现中,大多数客户端不会这么做,因为此时还不知道服务器的接收窗口等参数。另外,HTTP/2 的连接前言(preface)就是在第三次 ACK 之后立刻发送的。
4、SYN 洪水攻击与半连接队列
服务器收到 SYN 后,会把这条连接放入半连接队列(SYN Queue),并分配资源。如果攻击者大量发送 SYN 但不回复第三次 ACK,半连接队列就会被占满,正常请求无法建立连接。
防御手段:
SYN Cookies:服务器不分配资源,而是把连接信息编码到 seq 中发回,客户端回复 ACK 时再验证重建
调大 tcp_max_syn_backlog
开启 tcp_syncookies(Linux 默认在压力大时自动启用)
四、数据传输:可靠交付与速率控制
连接建立之后,就进入了 TCP 最核心的部分:如何在不可靠的网络上实现可靠传输。
可靠性线:序列号、确认、重传 —— 怎么保证数据不丢、不乱
速率控制线:流量控制、拥塞控制 —— 怎么决定一次能发多快
1、序列号与确认号:可靠的基石
TCP 把数据看成一串无结构的字节流,每个字节都有一个编号。初始序列号(ISN)在握手时随机生成(不是从 0 开始,这是为了防止序列号冲突和安全问题)。
序列号(seq):本报文段所发送数据的第一个字节的编号
确认号(ack):期望收到对方下一个报文段的第一个字节的编号,意味着 "这个编号之前的所有字节我都收到了"
这就是累计确认(Cumulative Acknowledgment)机制。
图3 累计确认
举个例子:客户端发送了 seq=100,携带 20 字节数据。服务器回复 ack=120,意思是 "100-119 我都收到了,下次从 120 开始发"。
2、超时重传:丢包了怎么办?
如果发送方发了数据,但迟迟没收到 ACK,就需要重传。但等多久才算 "超时"?
这就是 RTO(Retransmission TimeOut,重传超时时间) 的计算问题。
RTO 不是固定值,而是根据网络的 RTT(Round-Trip Time,往返时间) 动态计算的。经典的 Jacobson 算法:
SRTT ← (1 - α) × SRTT + α × RTT_sample (α 通常取 1/8)
RTTVAR ← (1 - β) × RTTVAR + β × |SRTT - RTT_sample| (β 通常取 1/4)
RTO = SRTT + 4 × RTTVAR
简单说:平滑的平均往返时间 + 4 倍的波动值。网络越不稳定(RTT 波动大),RTO 就越大,避免不必要的重传。
一旦超时重传,TCP 会执行一个激进的操作:把慢启动阈值 ssthresh 设为当前窗口的一半,然后 cwnd 降为 1,重新开始慢启动。这就是 "超时重传惩罚很重" 的原因。
工程视角:你可以用 ss -ti 命令查看每条 TCP 连接的 RTT、RTO、cwnd 等参数,这在排查 "为什么我的连接这么慢" 时非常有用。 ss -ti dst 10.0.0.1
3、快速重传:不用等超时
超时重传有个问题:RTO 通常至少几百毫秒,等超时再重传,延迟太高了。
所以 TCP 还有快速重传(Fast Retransmit)机制:如果发送方收到 3 个重复的 ACK(即总共收到 4 个相同的 ack),就认为这个报文段丢了,不等超时直接重传。
为什么是 3 个?因为 1-2 个重复 ACK 可能只是报文段乱序,3 个以上基本可以确定是丢包了。
快速重传之后,不会像超时重传那样把 cwnd 降到 1,而是进入快恢复(Fast Recovery):cwnd 设为 ssthresh + 3(每个重复 ACK 代表一个包离开了网络),然后线性增长。具体我们在拥塞控制部分讲。
4、滑动窗口:从 "停等" 到流水线
前面讲的确认和重传,解决的是 "丢了怎么办"。如果每发一个包就等一个 ACK,那效率太低了(这叫 "停等协议")。TCP 用滑动窗口实现流水线传输:发送方可以连续发送多个报文段而不必逐个等待确认,只要这些报文段都在窗口范围内。
图4 滑动窗口
收到新的 ACK → 窗口左边界右移(滑动)
接收方处理了数据 → 窗口右边界右移(窗口扩大)
窗口越大,同一时间在途的数据越多,吞吐量越高
这里要明确一点:滑动窗口机制本身是可靠传输的效率优化,它让 "可靠交付" 不再以 "极低效率" 为代价。但窗口到底能开多大?这不是滑动窗口自己决定的,而是由下面两个机制共同约束。
5、流量控制:rwnd 防止淹没接收方
接收方在每个 ACK 中通过 Window 字段告诉发送方 "我的接收缓冲区还剩多少空间",这就是 rwnd(接收窗口)。发送方的发送窗口不能超过 rwnd。
流量控制:端到端的速率匹配,防止发送方发太快导致接收方缓冲区溢出、数据被丢弃。
当接收方处理不过来时,会把 rwnd 设为 0,发送方就停止发送。这就是零窗口探测(Zero Window Probe):发送方会定期发一个字节的探测包,看接收方窗口是否恢复。
经典坑:糊涂窗口综合征(Silly Window Syndrome) 接收方每次只释放一点点缓冲区(比如 1 字节),发送方就发 1 字节的数据,导致网络上充斥着大量小包,效率极低。
图5 糊涂窗口综合征
解决方法:接收方不要急于通知小窗口(等缓冲区够大了再通知),发送方用 Nagle 算法合并小包。
6、拥塞控制:cwnd 防止淹没网络
流量控制是怕接收方扛不住,拥塞控制是怕网络扛不住。这是 TCP 最精妙、也最复杂的部分。拥塞控制通过 cwnd(拥塞窗口) 来控制发送速率,核心是四个算法:
1)慢启动(Slow Start)
连接刚建立时,cwnd 从 1 个 MSS 开始,每收到一个 ACK,cwnd 加 1。结果就是每经过一个 RTT,cwnd 翻倍(指数增长)。
"慢启动" 其实一点也不慢,它是指数增长的。名字的由来是相对于之前没有拥塞控制时一上来就发满窗口而言的。
当 cwnd 增长到 ssthresh(慢启动阈值) 时,切换到拥塞避免。
2)拥塞避免(Congestion Avoidance)
进入拥塞避免后,每经过一个 RTT,cwnd 只加 1(线性增长),而不是翻倍。
这就是 AIMD(Additive Increase, Multiplicative Decrease) 算法:加性增、乘性减。出问题时大幅降窗口,没问题时慢慢加,最终让所有连接公平地共享带宽。
3)快重传(Fast Retransmit)
前面讲过,收到 3 个重复 ACK 就重传,不等超时。
4)快恢复(Fast Recovery)
快重传之后,不把 cwnd 降到 1 重新慢启动,而是:
ssthresh = cwnd / 2
cwnd = ssthresh + 3
每收到一个重复 ACK,cwnd 加 1
收到新 ACK 后,cwnd = ssthresh,进入拥塞避免
整个拥塞控制的 cwnd 变化曲线大概长这样:
图6 拥塞控制的 cwnd 变化曲线
工程视角:Linux 内核默认使用 CUBIC 拥塞控制算法(RFC 8312),而不是经典的 Reno。CUBIC 的窗口增长函数是三次曲线,在高带宽、长延迟的网络中表现更好。你可以用 sysctl net.ipv4.tcp_congestion_control 查看当前算法。
7、三者协作:一个公式收束
最后把可靠性线和速率控制线收束到一起:
实际发送窗口 = min(rwnd, cwnd)
滑动窗口机制:提供了 "可以连续发多少" 的框架,是可靠传输的效率保障
rwnd(流量控制):接收方说 "我缓冲区还能收多少"—— 端到端速率匹配
cwnd(拥塞控制):网络状况说 "你现在能发多少"—— 全局速率匹配
取两者较小值,既不淹没接收方,也不淹没网络
所以准确地说:可靠传输(序列号 / 确认 / 重传)、流量控制、拥塞控制是 TCP 的三个并列核心功能,它们通过滑动窗口机制协同工作,共同实现了 "既可靠又高效" 的数据传输。
五、连接关闭:四次挥手与 TIME_WAIT 之谜
1、为什么是四次?
数据传完了,要断开连接。因为 TCP 是全双工的,两个方向需要分别关闭,所以需要四次挥手:
图7 TCP断连-四次挥手
客户端发 FIN → 进入 FIN_WAIT_1
服务器回 ACK → 客户端进入 FIN_WAIT_2,服务器进入 CLOSE_WAIT
服务器发 FIN → 服务器进入 LAST_ACK
客户端回 ACK → 客户端进入 TIME_WAIT,服务器收到后进入 CLOSED
客户端等待 2MSL 后进入 CLOSED
为什么中间的 ACK 和 FIN 不能合并成一次(像三次握手那样)?因为服务器收到客户端的 FIN 时,可能还有数据没发完,所以只能先回 ACK,等数据发完了再发 FIN。这就是四次的由来。
2、TIME_WAIT:为什么要等 2MSL?
这是 TCP 中最著名的 "为什么"。客户端发完最后一个 ACK 后,不是直接关闭,而是要等待 2MSL(Maximum Segment Lifetime,报文最大生存时间)。
两个原因:
1)保证最后一个 ACK 能到达服务器。如果这个 ACK 丢了,服务器会超时重发 FIN,客户端还有机会重发 ACK。如果客户端直接关闭了,服务器收不到 ACK 就会一直重传 FIN。
2)让旧连接的报文在网络中消失。防止上一次连接的残留报文被新连接(四元组相同)误收。
MSL 在 RFC 中建议是 2 分钟,Linux 实际实现中通常是 30 秒(TCP_TIMEWAIT_LEN),所以 TIME_WAIT 大约持续 1 分钟。
3、线上排障:CLOSE_WAIT 堆积
如果你的服务器上 CLOSE_WAIT 状态的连接大量堆积,说明服务器没有调用 close () 关闭连接。
常见原因:
应用层代码 bug,异常分支没关连接
连接池泄漏
对方关闭了连接,但你的程序还在等数据
排查命令:
# 查看各状态连接数 ss -ant | awk '{print $1}' | sort | uniq -C # 查看 CLOSE_WAIT 连接的进程 ss -antp state close-wait
工程视角:TIME_WAIT 过多通常不是问题(Linux 有 tcp_tw_reuse 等参数可以优化),但 CLOSE_WAIT 过多一定是应用层 bug。这两个不要搞混。
六、TCP 有限状态机:11 种状态全景
前面我们分别讲了三次握手和四次挥手,但如果你把它们拼在一起,会发现 TCP 连接的整个生命周期可以用一个有限状态机(Finite State Machine, FSM)来精确描述。
1、一张图记住状态机
图8 状态机
核心就三个阶段:建立 → 传输 → 关闭,每个阶段有几个中间状态。理解了 "为什么需要这个中间状态"(比如 TIME_WAIT 是为了等最后一个 ACK 和旧报文消失),就不需要死记硬背了。
2、工程视角:用状态机排查线上问题
理解状态机最大的价值是排障。状态机能让你从 "记步骤" 升级到 "看全局"—— 线上排障时,netstat/ss 看到一个异常状态,你立刻就能知道连接卡在了哪个环节、可能是什么原因。下面是几个经典场景:
场景一:CLOSE_WAIT 大量堆积
ss -ant | awk '{print $1}' | sort | uniq -C
如果 CLOSE_WAIT 有几千甚至几万个,100% 是应用层 bug。
状态机告诉我们:CLOSE_WAIT 的下一步是 LAST_ACK,触发条件是 "应用层调用 close ()"。连接卡在 CLOSE_WAIT,说明对方已经发了 FIN(对方关闭了连接),但你的程序收到 FIN 后没有调用 close()。
常见原因:异常分支没关连接、连接池泄漏、线程阻塞在 read 上没机会执行 close。
场景二:SYN_RECV 大量堆积
SYN_RCVD 状态的连接在半连接队列中。如果这个状态暴增,说明客户端只发了 SYN 但不回第三次 ACK,这就是 SYN 洪水攻击的特征。
排查:
# 查看半连接队列当前长度 ss -lnt | grep :80 # Send-Q 列就是当前 SYN_RECV 数量,Recv-Q 是全连接队列 backlog
防御:开启 tcp_syncookies、调大 tcp_max_syn_backlog。
场景三:FIN_WAIT_2 大量堆积
FIN_WAIT_2 是主动关闭方等对方 FIN 的状态。如果大量连接卡在这里,说明你发了 FIN,对方收到了并回了 ACK,但对方迟迟不发 FIN。
这通常意味着对方的应用层还在 CLOSE_WAIT(对方也有 bug),或者对方直接宕机了没来得及发 FIN。
Linux 有 tcp_fin_timeout 参数控制 FIN_WAIT_2 的超时,默认 60 秒,超时后内核会自动回收。
场景四:TIME_WAIT 过多
TIME_WAIT 是正常状态,高并发短连接服务器上出现几万个 TIME_WAIT 是常见的,不一定是问题。
但如果 TIME_WAIT 多到耗尽端口(客户端连接新服务器时没有可用端口),可以优化:
net.ipv4.tcp_tw_reuse = 1:允许将 TIME_WAIT 的端口用于新的出站连接
net.ipv4.ip_local_port_range:调大可用端口范围
应用层用长连接替代短连接(根本解法)
注意:tcp_tw_recycle 已经在 Linux 4.12 中被移除了,不要再用这个参数。
七、包解析(Wireshark 实战)
说了这么多理论,不如抓一次包来得直观。下面我们用 tcpdump 抓包,再用 Wireshark 分析一次完整的 HTTP 请求。
1、抓包命令
# 抓取 80 端口的 TCP 包,保存到文件
sudo tcpdump -i any -s 0 -w http_capture.pcap 'tcp port 80'
# 或者实时查看简要信息
sudo tcpdump -i any -nn 'tcp port 80'
2、TCP 首部字段解读
一个标准的 TCP 首部(20 字节,不含选项):
图9 TCP 字段解读
6 个标志位是抓包分析的重点:
SYN:同步序列号,建立连接时置 1
ACK:确认号有效,连接建立后通常都置 1
FIN:发送方数据发完,请求关闭连接
RST:复位连接,异常情况使用
PSH:推送,接收方应立即交给应用层
URG:紧急指针有效
3、完整抓包序列分析
一次 curl HTTP://example.com 的抓包大概长这样:
图10 抓包解析
观察序列号的变化:
第 1 包 SYN,seq=0(Wireshark 显示的是相对序列号,实际 ISN 是随机值)
第 2 包 SYN+ACK,ack=1(SYN 消耗一个序列号)
第 4 包发了 77 字节数据,seq=1
第 5 包 ack=78(1+77,确认了所有数据)
第 9 包 FIN,seq=78
第 10 包 ack=79(FIN 也消耗一个序列号)
4、实用 Wireshark 过滤技巧
# 只看握手和挥手包
tcp.flags.syn == 1 or tcp.flags.fin == 1
# 找重传包(排查网络质量)
tcp.analysis.retransmission
# 找零窗口(排查接收方处理能力)
tcp.analysis.zero_window
# 跟踪一条 TCP 流(看完整的请求响应)
右键 → Follow → TCP Stream
教一个实用技巧:用 tcp.flags.syn==1 过滤握手包,用 tcp.analysis.retransmission 找重传
八、复盘总结
TCP 是一个设计极其精妙的协议。从 1981 年 RFC 793 诞生至今,四十多年过去了,它依然是互联网的基石。看似简单的 "序列号 + 确认 + 重传 + 窗口",背后是对 "如何在不可靠网络上构建可靠服务" 这个问题的深刻回答。
>>>>参考资料
RFC 793(TCP 基础规范)
RFC 5681(拥塞控制)
RFC 8312(CUBIC)
《TCP/IP 详解 卷 1》
作者丨知乎用户琳
来源丨网址:https://zhuanlan.zhihu.com/p/2075189264237838600?share_code=1m79xxjYIs8Gi&utm_psn=2083502236529865818
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn返回搜狐,查看更多