TCP:互联网的 “通信契约”—— 从连接到数据的隐形规则
最近在项目性能压测中,面对 1300 + 并发的长连接与 SSE 场景,频繁遇到连接排队、线程阻塞、TIME_WAIT 堆积等问题。为解决这些瓶颈,我们从 Netty 事件循环线程调优、SO_BACKLOG 扩容,到连接池上限提升、TIME_WAIT 快速回收等多维度入手,过程中发现很多优化本质都绕不开 TCP 的底层机制。比如调整 SO_LINGER 参数减少 TIME_WAIT,其原理正是四次
文章目录
前言
最近在项目性能压测中,面对 1300 + 并发的长连接与 SSE 场景,频繁遇到连接排队、线程阻塞、TIME_WAIT 堆积等问题。为解决这些瓶颈,我们从 Netty 事件循环线程调优、SO_BACKLOG 扩容,到连接池上限提升、TIME_WAIT 快速回收等多维度入手,过程中发现很多优化本质都绕不开 TCP 的底层机制。比如调整 SO_LINGER 参数减少 TIME_WAIT,其原理正是四次挥手的状态设计;提升 backlog 则直接关联全连接队列的承载能力。这篇文章便结合这次优化实践,拆解 TCP 从连接建立到资源管理的核心逻辑,让这些藏在代码背后的 “隐形规则” 浮出水面。
一、TCP面向连接:不是“插网线”,而是“内核建资源”
提到TCP,第一个标签就是“面向连接、可靠传输”,但很多人只知道“三次握手”,却不懂连接的本质——TCP连接不是物理链路,而是双方内核开辟的“资源集合”,三次握手只是建立资源的“仪式”。
1.1 三次握手:不只是“发三个包”,更是“资源初始化+状态转换”
1.1.1 三次握手图解

1.1.2 三次握手抓包

1.1.3 三次握手描述
通过抓包可以观察到三次握手的过程,但关键不在“发什么包”,而在“包发完后做什么”以及“双方状态如何变化”:
- 第一次握手(客户端→服务端):客户端从
CLOSED状态进入SYN_SENT状态,发送SYN报文(同步序列号),告诉服务端“我要连接你,我的初始序列号是X”; - 第二次握手(服务端→客户端):服务端收到
SYN后从LISTEN状态进入SYN_RCVD状态,回发SYN+ACK报文,告诉客户端“我收到了,我的初始序列号是Y,确认你的序列号X+1”; - 第三次握手(客户端→服务端):客户端收到
SYN+ACK后从SYN_SENT状态进入ESTABLISHED状态,发送ACK报文,告诉服务端“我收到你的确认,确认你的序列号Y+1”;服务端收到ACK后从SYN_RCVD状态进入ESTABLISHED状态。
核心细节:三次握手完成后,客户端和服务端的内核都会开辟资源,包括:
- TCB(传输控制块):存储连接的核心信息(四元组、序列号、窗口大小、缓冲区指针等);
- 发送缓冲区/接收缓冲区:用于暂存待发送或待读取的数据(比如客户端发数据先存到发送缓冲区,服务端收数据先存到接收缓冲区)。
这就是“面向连接”的本质:不是拉一根网线,而是双方内核通过三次握手“约定好资源”,后续通信基于这些资源进行。
1.2 连接断开:四次挥手与CLOSE_WAIT/TIME_WAIT状态解析
TCP连接的断开需要“四次挥手”(比三次握手多一次,因为数据传输是双向的,双方需分别确认关闭),CLOSE_WAIT和TIME_WAIT是挥手过程中最易出现且需重点关注的状态,也是面试高频考点。
1.2.1 四次挥手图解
1.2.2 四次挥手抓包

1.2.2 四次挥手描述
假设客户端主动发起断开请求,完整流程及双方状态变化如下:
- 第一次挥手(客户端→服务端):客户端完成数据发送后,调用
close(),从ESTABLISHED状态进入FIN_WAIT_1状态,发送FIN报文(终止报文),告诉服务端“我这边数据发完了,要关闭连接了”; - 第二次挥手(服务端→客户端):服务端收到
FIN后,从ESTABLISHED状态进入CLOSE_WAIT状态,回复ACK报文,告诉客户端“我收到你的关闭请求了,正在处理剩余数据”; 客户端收到ACK后进入FIN_WAIT_2状态
✅ 关键:此时服务端仍可向客户端发送未传完的数据,客户端需继续接收; - 第三次挥手(服务端→客户端):服务端处理完剩余数据后,调用
close(),从CLOSE_WAIT状态进入LAST_ACK状态,发送FIN报文,告诉客户端“我这边数据也发完了,现在可以关闭连接了”; - 第四次挥手(客户端→服务端):客户端收到
FIN后,从FIN_WAIT_2状态进入TIME_WAIT状态,回复ACK报文,告诉服务端“我收到你的关闭确认了”;
✅ 关键:客户端不会立即释放连接,而是在TIME_WAIT状态停留一段时间(默认2MSL,约1-4分钟),之后才进入CLOSED状态;服务端收到ACK后直接进入CLOSED状态。
1.2.3 核心状态详解:CLOSE_WAIT与TIME_WAIT
(1)CLOSE_WAIT状态:服务端的“未关连接”
- 产生场景:服务端收到客户端的
FIN报文(第一次挥手),回复ACK后进入该状态,本质是“服务端已确认客户端关闭,但自身未调用close()关闭连接”。 - 状态含义:“我(服务端)知道你(客户端)要关了,但我还有事没处理完,暂时不关”。
- 常见问题:
CLOSE_WAIT状态堆积(netstat看到大量该状态Socket),通常是服务端程序bug——比如处理完数据后忘记调用close()释放连接,导致连接长期占用资源(文件描述符、内存),最终引发“too many open files”错误。 - 排查思路:检查服务端代码中
read()返回0(表示客户端已关闭发送通道)后,是否及时调用close();或通过lsof -p 进程ID查看未释放的FD。
(2)TIME_WAIT状态:客户端的“收尾等待”
- 产生场景:客户端发送第四次挥手的
ACK后进入该状态,是TCP的“主动关闭方”(通常是客户端,也可能是服务端主动关闭)的必经状态。 - 核心作用:防止延迟报文干扰新连接——TCP报文可能因网络延迟到达,若客户端立即释放连接,新连接可能复用相同四元组,导致延迟报文被误判为新连接的数据;2MSL(报文最大生存时间的2倍)的等待时间,能确保网络中残留的延迟报文已消失。
- 常见问题:高并发场景下,大量
TIME_WAIT状态Socket可能占用端口(客户端端口范围1024-65535),导致新连接无法分配端口(“address already in use”)。 - 优化方案:通过内核参数调优,如
net.ipv4.tcp_tw_reuse(允许复用TIME_WAIT状态的端口)、net.ipv4.tcp_tw_recycle(快速回收TIME_WAIT连接,需谨慎使用)、net.ipv4.tcp_fin_timeout(缩短TIME_WAIT时长,默认60秒,可调整为30秒)。
1.2.4 netstat查看特征
CLOSE_WAIT:状态列显示CLOSE_WAIT,PID/Program name指向服务端进程(因服务端未关闭连接,进程仍持有FD);TIME_WAIT:状态列显示TIME_WAIT,PID/Program name通常为-(因客户端已主动关闭,进程已释放FD,仅内核维护状态);- 两者均会保留完整四元组信息,直到状态结束(
CLOSE_WAIT需服务端close()后消失,TIME_WAIT等待2MSL后消失)。
1.2.5 数据传输抓包

1.3 可靠传输:靠“确认+序列号”,不是“百分百不丢包”
TCP的“可靠”不是不会丢包,而是丢包后能补救,核心依赖两个机制:
- 序列号(Sequence Number):每个TCP报文都带序列号,比如客户端发3个报文,序列号是100、200、300,服务端收到后能按序列号排序,避免乱序;
- 确认号(Acknowledgment Number):服务端收到报文后,会回复
ACK,确认号=“期望收到的下一个序列号”。比如收到序列号100的报文(数据长度100字节),确认号就是200,告诉客户端“我已经收到100-199的字节,下次发200开始的”。
如果客户端没收到ACK(比如报文丢了),会触发“超时重传”——等待一段时间后重新发送该报文,直到收到确认,这就是“可靠”的保障。
二、Socket与四元组:TCP连接的“身份证”,唯一标识的关键
通常我们说“Socket是四元组”,这是理解“服务端不用多端口”的核心。很多人误以为“服务端要为每个客户端分配新端口”,其实完全不需要——四元组已经能唯一标识每个连接,而Socket正是对这一标识的抽象。
2.1 什么是Socket?四元组如何锁定唯一连接?
Socket是应用程序与TCP协议栈交互的接口,本质上是对TCP连接的抽象描述,其核心组成是“四元组”,由4个元素组成,缺一不可:
- 客户端IP地址(比如192.168.1.100);
- 客户端端口号(比如12345,客户端随机分配);
- 服务端IP地址(比如203.0.113.10);
- 服务端端口号(比如80,服务端固定监听)。
为什么能唯一? 举个例子:
- 客户端A(IP:192.168.1.100,端口:12345)连接服务端(IP:203.0.113.10,端口:80),四元组是(192.168.1.100:12345 ↔ 203.0.113.10:80);
- 同一客户端开新标签(IP不变,端口:12346)连接同一服务端,四元组是(192.168.1.100:12346 ↔ 203.0.113.10:80);
- 另一客户端B(IP:192.168.1.101,端口:12345)连接服务端,四元组是(192.168.1.101:12345 ↔ 203.0.113.10:80)。
这三个连接的四元组完全不同,服务端内核能通过四元组精准区分,把数据放到对应的接收缓冲区,根本不需要为每个客户端分配新端口。
2.2 监听Socket与连接Socket:连接建立的“前后台”分工
理解四元组的同时,必须明确两个关键概念:监听Socket(Listening Socket) 和连接Socket(Connected Socket),它们是服务端处理连接的“前后台”角色:
-
监听Socket:
服务端启动时通过bind()绑定端口、listen()进入监听状态的Socket,唯一作用是接收客户端的连接请求。它的状态是LISTEN,绑定固定的服务端IP和端口(如80),但不参与实际数据传输,也没有对应的客户端信息(四元组不完整,缺少客户端IP和端口)。
例如:netstat查看时,0.0.0.0:80且状态为LISTEN的条目,就是监听Socket。 -
连接Socket:
当客户端的三次握手完成后,内核会自动创建一个新的Socket,即连接Socket。它的状态是ESTABLISHED,包含完整的四元组(客户端IP:端口 ↔ 服务端IP:端口),是实际用于客户端与服务端数据传输的载体。
服务端程序通过accept()获取连接Socket的文件描述符(FD),后续的read()/write()都基于这个FD操作,而监听Socket始终保持LISTEN状态,继续接收新的连接请求。
形象类比:监听Socket像“前台接待”,负责登记新访客(接收连接请求);连接Socket像“专属客服”,每个访客对应一个客服,负责具体沟通(数据传输)。前台只有一个,客服却可以有多个,这就是服务端用一个端口处理多连接的底层逻辑。
2.3 面试高频题:服务端需要为客户端分配新端口吗?
答案:不需要。
原因很简单:服务端的监听端口(如80)只用于“接收连接请求”,一旦三次握手完成,连接的唯一标识是四元组(即Socket的核心),服务端通过四元组找到对应的TCB和缓冲区,程序通过“文件描述符(FD)”操作这些资源——FD是程序内的抽象标识,比如进程A的FD=3对应连接1,进程B的FD=3对应连接2(进程隔离,FD可重复,但四元组唯一)。
延伸场景:一台服务端能支持多少连接?
理论上没有上限,只要内存足够——每个连接占用的TCB和缓冲区很小(几KB到几十KB),1GB内存能支持几十万连接。实际中受内核参数(如ulimit、tcp_max_syn_backlog)限制,调优后支持百万连接也很常见。
三、backlog参数:监听队列的“红绿灯”,控制连接堆积
实际实验中,把backlog设为2,超过2个未accept的连接后,新连接会卡在SYN_RCVD状态。但很多人误以为backlog是“最大连接数”,其实它控制的是“全连接队列的大小”——TCP连接建立过程中,有两个关键队列:
3.1 两个队列:半连接队列 vs 全连接队列
TCP连接建立不是“三次握手完就给程序”,而是要经过两个队列:
| 队列类型 | 状态 | 作用 | 控制参数 |
|---|---|---|---|
| 半连接队列 | SYN_RCVD |
存储已收SYN但未完成三次握手的连接 |
内核参数tcp_max_syn_backlog |
| 全连接队列 | ESTABLISHED |
存储已完成三次握手但未被程序accept的连接 |
listen时的backlog参数 |
这一现象的本质:
当backlog=2时,全连接队列最多存2个连接。第3个连接完成三次握手后,全连接队列满,服务端会忽略新的ACK报文,客户端会重发ACK,最终连接卡在SYN_RCVD状态(半连接队列),这就是“超过2个连接后连不上”的原因。
3.2 实际配置建议:backlog怎么设?
backlog不是越大越好,要结合“程序处理连接的速度”:
- 如果程序
accept速度快(比如用Netty的NIO模型),backlog设50-100即可; - 如果程序
accept速度慢(比如传统BIO模型),backlog可设200-500,避免全连接队列满导致新连接失败; - 注意:backlog的最大值受内核参数
somaxconn限制(默认128或1024),设太大也无效,需先调优somaxconn。
四、滑动窗口:TCP的“快递批量发货”,提升传输效率
用“扔馒头”来类比的话:如果发一个馒头等一个确认,效率太低;如果一次扔多个馒头,等批量确认,效率会大幅提升——这就是滑动窗口的核心逻辑,TCP通过窗口机制把“串行传输”变成“并行传输”。
4.1 窗口大小:双方协商的“最大并发量”
三次握手时,客户端和服务端会交换“窗口大小”(Window Size),这个值代表“当前可用的缓冲区大小”,比如:
- 客户端告诉服务端:“我的接收缓冲区还能存14600字节(10个MSS),你最多一次发10个报文”;
- 服务端告诉客户端:“我的接收缓冲区还能存7300字节(5个MSS),你最多一次发5个报文”;
- 后续通信中,双方会实时更新窗口大小(比如服务端读取缓冲区数据后,窗口变大,会在
ACK报文中告诉客户端)。
4.2 窗口&MTU&MSS
4.2.1 MTU:链路层的“最大包装尺寸”(含包装)
- 定义:MTU(Maximum Transmission Unit,最大传输单元)是链路层(如以太网)规定的“帧”的最大总大小(包含帧的头部、数据部分、尾部)。
- 类比:相当于快递公司规定的“单个包裹的最大总重量/体积”(比如1500克,含包装盒本身)。
- 默认值:以太网中MTU默认是1500字节(这是链路层的硬件/协议限制,超过这个大小的帧会被分片发送,效率降低)。
4.2.2 MSS:传输控制层的“单个包裹的最大货物量”(不含包装)
- 定义:MSS(Maximum Segment Size,最大分段大小)是传输控制层规定的“单个TCP报文段中数据部分的最大大小”(不含TCP头、IP头这些“包装”)。
- 计算方式:MSS = MTU - IP头大小(通常20字节) - TCP头大小(通常20字节)。
以默认MTU=1500字节为例,MSS = 1500 - 20 - 20 = 1460字节。 - 类比:相当于“单个包裹中实际能装的货物最大重量”(比如1500克总容量,减去包装盒20克、填充物20克,实际货物最多1460克)。
- 注意:MSS是“单个TCP报文的数据部分最大限制”,实际发送的单个报文数据可能小于等于MSS(比如最后一个报文可能不满),但不会超过。
4.2.3 win(窗口) - 传输控制层的 “一次能发多少个包裹”(批量上限)
- 定义:窗口大小(Window Size)是接收方告知发送方的“当前接收缓冲区可用空间”,代表“发送方一次最多能发送的未确认数据总字节数”(无需等待中间确认)。
- 类比:相当于收件人告诉寄件人“我家快递柜现在还能放10个包裹”,寄件人就可以一次发10个,不用发一个等一个。
- 与MSS的关系:窗口大小是“总字节数”,通常是MSS的整数倍。例如窗口大小=14600字节,就相当于一次能发10个MSS=1460字节的报文(1460×10=14600)。
- 动态性:窗口大小会随接收方处理速度变化(比如收件人取走5个包裹,快递柜空间变大,会告诉寄件人“现在能再放5个”)。
这意味着:每个TCP报文最多带1460字节数据,窗口大小14600字节对应10个报文,服务端一次能发10个报文,不用等每个报文的ACK,只需等最后一个报文的ACK(确认号=14601),效率提升10倍。
4.3 拥塞控制:窗口的“智能调节”
滑动窗口不是“窗口越大越快”——如果网络拥塞,大窗口会导致丢包。TCP会通过“拥塞控制”动态调整窗口大小:
- 慢启动:初始窗口很小(比如2个MSS),每次收到
ACK窗口翻倍,直到达到“慢启动阈值”; - 拥塞避免:超过阈值后,窗口每次加1个MSS,缓慢增长;
- 拥塞发生:丢包后,窗口重置为1个MSS,重新慢启动(或用“快速恢复”优化)。
这就是为什么“发太多数据会丢包”——窗口满了还发,缓冲区溢出,内核会丢弃报文,触发拥塞控制。
五、内核与程序的交互:FD是“桥梁”,缓冲区是“中转站”
需要注意的是“程序不accept,数据也能到内核缓冲区”,这背后是“内核负责通信,程序负责处理”的分工:
5.1 数据流转路径:客户端→内核→程序
- 客户端发数据:数据先到服务端网卡,网卡交给内核TCP协议栈;
- 内核处理:TCP协议栈验证四元组(即Socket标识),把数据放到对应连接的接收缓冲区;
- 程序读取:程序调用
read,通过FD找到接收缓冲区,把数据读到用户空间; - 程序处理:数据处理完后,调用
write,通过FD把数据写到发送缓冲区,内核再把数据发回客户端。
关键结论:数据不经过程序,内核也能接收和暂存——实际场景中“程序不accept,客户端发的数据能到内核缓冲区”,就是这个道理,直到缓冲区满,新数据会被丢弃。
5.2 超时设置:避免“僵尸连接”
如果程序一直不read数据,缓冲区会满,新数据丢包。为避免“连接占着资源不干活”,TCP有两个超时机制:
- 连接超时:
listen时可设SO_RCVTIMEO,accept阻塞超过时间会抛异常,程序可重试; - 数据超时:
setsockopt设SO_KEEPALIVE,内核会定期发“心跳包”,检测连接是否存活,死连接会被回收。
总结
TCP看似复杂,其实所有机制都是为了“两个目标”:可靠传输和高效传输,逻辑链如下:
- 可靠传输:通过“三次握手建资源+状态转换”→“序列号+确认号保顺序”→“超时重传补丢包”;
- 高效传输:通过“Socket四元组唯一标识多连接”→“滑动窗口批量发数据”→“拥塞控制避丢包”;
- 连接管理:通过“backlog控制队列”→“超时机制清死连接”→“FD桥接内核与程序”。
更多推荐

所有评论(0)