1. TCP到底是什么?网络协议里的C位选手
1.1 先看全局:TCP在TCP/IP协议族中的位置
很多人把“TCP/IP协议”当成一个单独协议,其实它是一整条协议族,从网卡驱动、链路层、网络层、传输层一直到应用层,层层分工。TCP协议处在传输层,往上是HTTP、FTP、MQTT这些应用协议,往下是IP协议。你可以把IP想象成一个快递干线网络,它只负责把一个个数据包从A城市送到B城市,但不保证顺序、不保证不丢;而TCP就是在快递之上加了一个“签收回执系统”——每一件货到了都要回执,没到就重发,顺序乱了就重排。
这也是为什么HTTP、WebSocket、数据库连接、文件传输这些场景几乎全部跑在TCP上。它们都需要完整、按序、不丢的数据流,而UDP只提供“尽力而为”的投递,适合音视频、游戏同步这类丢几帧也问题不大的场景。
1.2 TCP和UDP的区别,以及为什么80%的场合选TCP
拿日常场景打个比方:TCP像打电话,先拨号、接通、确认对方在听,你说一句他“嗯”一声,说完挂断;UDP像对讲机,按下就说,松手就完,对方有没有收到、听没听清,你根本不知道。
从技术参数上讲,差别主要体现在四个方面:
| 维度 | TCP | UDP |
|---|---|---|
| 连接状态 | 面向连接,需建立和释放连接 | 无连接,直接发包 |
| 可靠性 | 确认、重传、排序、去重 | 不可靠,丢包不负责 |
| 传输方式 | 字节流,无边界 | 数据报,有边界 |
| 首部开销 | 20~60字节 | 8字节 |
选型时我的经验是:只要业务对数据完整性敏感,就无脑选TCP。比如设备上报温湿度、PLC读写寄存器、Web接口调用,任何一帧丢了你都没法接受。只有当数据量大、实时性要求极高且能容忍偶尔丢失时,才去考虑UDP或者在此基础上自己实现确认和重传。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 连接是怎么建立的:三次握手与四次挥手
2.1 三次握手,重点在“双方收发能力都确认”
TCP建立连接时,通信双方各发一个SYN包,最后再确认一次。之所以需要三次而不是两次,关键是要确认双方的收发能力都正常。简单说:
- 客户端发SYN,告诉服务端“我要建立连接了”。这一步,服务端能确认客户端的发送能力没问题。
- 服务端回SYN+ACK,告诉客户端“我收到你的请求了,我也准备好了”。这一步,客户端能确认服务端的收发能力都没问题。
- 客户端回ACK,告诉服务端“我也收到你的确认了”。这一步,服务端才能确认客户端的接收能力没问题。
如果只有两次握手,服务端无法确认客户端能不能正常接收。极端情况:客户端发了一个SYN因为网络拥堵延迟了,后来重发一个新的SYN并建立了连接,而那个旧SYN又到达服务端,服务端会认为这是一次新连接并分配资源,此时客户端根本不打算建立这条连接,服务端的资源就被白白占用了。三次握手让服务端在收到第三次ACK后才分配正式的连接资源,能有效避免这种历史重复连接造成的资源浪费。
2.2 四次挥手,重点在“半关闭状态”
断开连接要四次,原因是TCP连接是全双工的,两个方向要分别关闭。四个步骤:
- 主动方发FIN,不再发数据了,但还能收数据。
- 被动方回ACK,表示知道你不再发了,但自己可能还有数据要发。
- 被动方发完剩余数据后,也发FIN,表示“我也没数据了”。
- 主动方回ACK,然后等待一段时间进入CLOSED。
有人问为什么第三步不能和第二步合并?因为被动方在收到FIN后,可能还有业务数据没处理完、没发送完,不能立刻关闭发送方向。所以被动方先回ACK确认,等自己的发送队列清空后再发FIN。这就是所谓的“半关闭”状态,实际应用里,比如客户端要结束上传但还想接收服务端的最终结果,用的就是这种机制。我在做文件传输工具时会主动调用shutdown(SHUT_WR)来关闭发送方向而保留接收方向,这样服务端读完数据后能正常返回结果。
2.3 连接状态机:看到这里你就能看懂netstat了
排查网络问题时,netstat或ss命令会显示各种状态,很多时候报错就藏在状态机里。常用状态大致有11个,我整理了一张对照表:
| 状态 | 含义 | 常见出现位置 |
|---|---|---|
| LISTEN | 服务端正在监听端口 | 服务器 |
| SYN_SENT | 主动方发出SYN,等待回复 | 客户端 |
| SYN_RCVD | 服务端收到SYN并回了SYN+ACK | 服务端 |
| ESTABLISHED | 连接建立成功,正常传输数据 | 双方 |
| FIN_WAIT_1 | 主动方发了FIN,等待ACK | 主动关闭方 |
| FIN_WAIT_2 | 主动方已收到ACK,等待对方FIN | 主动关闭方 |
| CLOSE_WAIT | 被动方收到FIN,等待自己应用关闭连接 | 被动关闭方 |
| LAST_ACK | 被动方发了FIN,等待最后ACK | 被动关闭方 |
| TIME_WAIT | 主动方发了最后ACK,等待2MSL后关闭 | 主动关闭方 |
| CLOSED | 连接完全关闭 | — |
看到大量CLOSE_WAIT,通常说明程序里收到了关闭通知但没有调用close(),多半是代码忘了释放连接。看到大量TIME_WAIT,一般是高并发短连接场景,这个后面会专门讲。
3. 可靠传输的底层功夫:ACK、超时重传与滑动窗口
3.1 确认与重传:TCP怎么保证数据不丢
TCP把应用层的数据切成一连串字节,每个字节都有序号。接收方收到数据后会回一个ACK,带上“下一个期望收到的字节序号”。发送方如果在一定时间内没收到ACK,就认为数据丢了,重新发送。
这里有个细节:重传超时时间(RTO)不是固定的。TCP会动态估算网络的往返时间(RTT),再结合抖动情况算出一个合适的超时值。网络快时超时短,网络慢时超时长。如果网络状况波动大,估算不准,就可能出现两种情况:超时太短导致重复发送,超时太长导致卡顿。实际调优时,Linux下可以查看/proc/sys/net/ipv4/tcp_rto_min等参数,但绝大多数场景默认值已经够用了。
另一种更高效的重传叫“快速重传”。当接收方收到乱序数据时,会立刻重复发送对缺失序号的ACK。发送方连续收到3个相同的ACK,就判断丢包了,不等超时直接重传。这比干等超时快得多。
3.2 滑动窗口与流量控制:拥堵的一条路怎么塞车
TCP用接收窗口(rwnd)做流量控制。接收方在ACK里带上自己还能接收多少数据——也就是接收缓冲区剩余大小,发送方就按这个大小控制未确认的数据量,不能一股脑全发出去。
这里的核心概念叫滑动窗口,窗口大小决定了一次能发多少数据。窗口越大,传输效率越高,但如果超过网络承载能力就会造成拥塞;窗口太小,等待ACK的时间就会变长,吞吐量上不去。实际项目中,接收方的缓冲区设置直接影响性能。我在写文件传输服务时,如果把接收缓冲区从64KB调到1MB,在局域网内吞吐量能提升好几倍,效果非常明显。
还有一个细节是“零窗口探测”。当接收方缓冲区满了,rwnd变成0,此时发送方不能再发数据,但会定期发一个1字节的探测包,看看接收方缓冲区是不是腾出来了。接收方即使缓冲区是满的也必须回ACK,这就是TCP窗口探测包(ZWP)。如果这个机制工作不正常,通讯就会陷入假死:看不出连接断开,但数据完全不动。
3.3 拥塞控制:慢启动、拥塞避免、快重传、快恢复
除了接收窗口,TCP还有另一套窗口:拥塞窗口(cwnd)。发送方实际能发多少,取决于这两个窗口的较小值。接收窗口是保护接收方,拥塞窗口是保护整个网络。
拥塞控制主要有四个阶段:
- 慢启动:从头开始,拥塞窗口指数增长。每收到一个ACK,窗口增加一个MSS。首次传输从1个MSS开始,很快就能涨上去。这个“慢”是指起点低,不是说速度慢。
- 拥塞避免:当窗口达到慢启动门限(ssthresh)后,指数增长改为线性增长,避免瞬间把网络打爆。
- 快重传:收到3个重复ACK,立即重传丢失的数据,不等超时。
- 快恢复:触发快重传后,把ssthresh降低一半,窗口减半并进入线性增长,而不是回到慢启动从1开始。
TCP有很多拥塞算法变体:Linux默认的CUBIC适合高带宽长链路,BBR是谷歌推出的基于瓶颈带宽和往返时间估算的算法,在高延迟、高丢包链路上表现更稳。如果你在弱网环境做传输优化,这几套算法的区别值得专门去查一下。
4. 网络协议分析实战:用Wireshark看TCP的状态流转
4.1 抓包抓到什么才算“看懂了”
光看不练,理解永远停在表面。我建议你装一个Wireshark,随便访问一个HTTP网站,抓个几百个包,然后过滤出TCP流来对照状态机看一遍。
抓包时关注几个关键字段:
- Sequence Number:当前报文第一个字节的序号。
- Acknowledgment Number:期望收到的下一个字节序号。
- Flags:SYN、ACK、FIN、RST、PSH等标志位。
- Window:接收窗口大小。
用Wireshark的流量分析功能(Statistics -> Flow Graph)可以直观看到三次握手和四次挥手的时序图。我曾经靠这个功能排查过一次MySQL连接频繁超时的问题:抓包后一眼看到服务器端发了大量RST包,说明后端进程因为某种原因直接重置了连接,而不是正常走完挥手流程。顺着RST往回查,发现是负载均衡健康检查的TCP探测把空闲连接给掐了——问题从“数据库超时”直接定位到“中间层误杀连接”,整个过程不到10分钟。
4.2 用过滤器快速定位重传和乱序
Wireshark的显示过滤器是排查弱网问题的一把利器,常用的几个我列一下:
code复制# 查看所有SYN包
tcp.flags.syn == 1
# 查看重传包
tcp.analysis.retransmission
# 查看重复ACK
tcp.analysis.duplicate_ack
# 查看乱序包
tcp.analysis.out-of-order
# 按TCP流过滤
tcp.stream eq 0
实际使用中,我的习惯是先按tcp.analysis.retransmission过滤一遍。如果重传比例超过1%,说明链路质量有问题,结合IO Graph看是周期性抖还是持续丢包。如果是跨机房传输,优先怀疑线路拥塞或运营商丢包;如果只出现在特定时间段,则可能是业务高峰期带宽被占满。
4.3 抓包时要避开的几个坑
第一,抓包点要选对。如果怀疑服务端问题,就在服务端所在机器上抓;如果怀疑中间链路问题,最好在两个端都抓,然后对比哪个方向丢的包。只在客户端抓,看到重传,但不确定是服务端丢包还是回程链路丢包。
第二,注意抓包本身的开销。在千兆高流量服务器上跑tcpdump,如果dump文件落盘太慢,可能造成内核缓冲区溢出,反而把网络搞卡。用tcpdump时加 -n 减少DNS解析、-s 96 只抓包头,必要时用 -C 和 -W 做文件轮转,避免单个文件过大。
第三,混杂模式抓包时数据量可能巨大,建议先用BPF过滤器限定IP和端口。比如:
bash复制tcpdump -i eth0 -nn 'tcp port 8080' -w /tmp/8080.pcap
这样只抓经过8080端口的TCP报文,文件体积能控制得很好。
5. 实际开发中绕不开的坑:粘包、Keepalive与心跳
5.1 粘包与拆包:为什么你读到的数据总是“粘”在一起
TCP是流式协议,应用层写的“消息”和网络传输的“报文”没有一一对应关系。发送方连续写了两次消息,接收方可能一次性读到这两个消息拼接在一起,这就是粘包;反之,发送方写了一长段消息,接收方可能分几次才读完,这就是拆包。本质是:应用层没有消息边界,而TCP只保证字节顺序和完整性,不保证消息边界。
这个坑几乎所有写网络程序的人都踩过。我最早写一个GPS设备接入服务时,客户端发的报文一会儿粘包一会儿半包,导致解析出的经纬度全是乱码,当时排查了很久才发现是这个问题。
5.2 5种解决粘包/拆包的常用方案
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 定长报文 | 每条消息固定N字节 | 实现最简单 | 浪费带宽,灵活性差 |
| 分隔符 | 消息末尾加\r\n或0x00 | 简单,直观 | 消息内容不能包含分隔符 |
| 长度前缀 | 头部4字节存长度,后面跟数据 | 最常用,效率高 | 需要处理字节序 |
| TLV结构 | 类型+长度+值 | 可扩展性最强 | 实现较复杂 |
| 应用层流协议 | 类似HTTP的分帧 | 兼容性好 | 需要完整协议设计 |
实际项目中,长度前缀方式最常被推荐。具体做法:发送方先把消息长度转成4字节整数(建议用网络字节序,大端),再拼接消息内容;接收方先读4字节得到长度,再按长度读取完整消息。这里有一个特别容易忽视的坑:一次read可能读不满4字节,也可能4字节之后连后面消息的内容都一起读进来了,所以接收端必须有一个累积缓冲区和状态机来处理“半包”。
给一个Python示例,演示最基础的长度前缀分帧:
python复制import socket
import struct
def send_msg(sock, data: bytes):
# 4字节大端长度 + 数据
header = struct.pack(">I", len(data))
sock.sendall(header + data)
def recv_exact(sock, size: int) -> bytes:
buf = b""
while len(buf) < size:
chunk = sock.recv(size - len(buf))
if not chunk:
raise ConnectionError("连接断开")
buf += chunk
return buf
def recv_msg(sock) -> bytes:
header = recv_exact(sock, 4)
length = struct.unpack(">I", header)[0]
return recv_exact(sock, length)
5.3 TCP Keepalive与业务心跳的区别
TCP内置的Keepalive机制,默认开启后每7200秒发一个探测包,连续9次无响应才断开连接。内核参数可以用sysctl调整:
bash复制sysctl net.ipv4.tcp_keepalive_time
sysctl net.ipv4.tcp_keepalive_intvl
sysctl net.ipv4.tcp_keepalive_probes
问题是这个默认参数几乎没法直接用在业务上,两个小时才探测一次,探测失败后还要再等十几分钟。所以生产环境里大家都倾向于自己做应用层“心跳”:业务层定时发一个心跳包,一段时间没收到对方数据就主动断开重连。
我的经验是:TCP Keepalive适合清理僵尸连接,防止系统资源被半开连接占满;而业务心跳适合快速感知对方是否存活。两者不冲突,线上可以都开着。只不过心跳包本身也会触发网络传输,频率不宜太高,一般3~10秒一次比较合适,频率过高反而浪费带宽和资源。
6. 工控场景实例:昆仑通态HMI走TCP自由协议
6.1 什么是“自由协议”
工控领域经常听到“TCP自由协议”这个词,尤其在做组态屏和PLC通信时。所谓自由协议,并不是一种标准协议名称,而是指用户自己定义报文格式,用最基础的TCP Socket进行数据收发。比如昆仑通态(MCGS)触摸屏,除了支持Modbus TCP、Siemens S7等标准驱动外,还允许用户通过脚本或扩展组件,按自定义格式收发数据——这就是走TCP自由协议。
为什么要用自由协议?因为不是所有下位机都支持标准Modbus,很多单片机、私有协议设备、老旧PLC只提供串口或裸TCP接口。HMI想直接对话,要么写厂家驱动,要么自己组帧。自由协议这时候就成了通用方案:不管对端是什么设备,只要能收发TCP字节流,就能通信。
6.2 HMI与PLC通信的报文设计
在设计自由协议时,核心就是定义好帧格式。结合前面讲的粘包问题,我建议直接采用定长头部+长度字段的方案。比如一个常见的读写请求帧:
| 字节偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 2字节 | 帧起始标志,固定0xAA 0x55 |
| 2 | 2字节 | 命令字,0x0001读,0x0002写 |
| 4 | 2字节 | 数据长度 |
| 6 | N字节 | 数据区 |
| 6+N | 2字节 | CRC16校验 |
接收方先收6字节定长头部,解析出数据长度N,再收取N字节的数据区,最后做CRC校验。校验通过才认为是一帧完整有效的数据,这样既解决了粘包拆包,又保证了数据完整性。
这里特别提醒一个坑:很多组态软件自带的TCP驱动,对数据格式有隐含约束,比如寄存器地址按字还是按字节偏移、高位在前还是低位在前。参数配置错了,通信表面正常,读出来的值却全是错的。我在现场调试水处理设备时遇到过一次,HMI显示液位偶尔跳变,排查到最后是字节序配置反了,把高低字节调换后数据立刻稳定。
6.3 实测踩坑:一次握手正常但读不到数据的排查
今年帮一个现场调设备时遇到一个很有意思的问题:昆仑通态屏作为TCP客户端连PLC服务器,看HMI上的连接状态始终为“正在连接”,但用PC抓包发现三次握手整个过程都正常,数据包都发出去了,就是等不到PLC回应。
后来逐层排查:先确认PLC侧监听端口没错,又确认驱动选型用的是“TCP自由协议”而不是“Modbus TCP”。最后发现问题出在帧格式不匹配——PLC固件要求帧头是0x55 0xAA,而HMI配置里填的是0xAA 0x55。对端收到后直接丢弃报文,连接保持但不回数据。这种“连接正常但业务不响应”的问题,比连接失败更难查,因为从TCP层面看你完全看不出毛病。
这个案例给我的教训是:设备通信联调时,最好先在PC上用网络调试助手一边抓包一边模拟收发,先把帧格式和TCP交互走通,再与组态屏对接。直接在屏上调试,变量多、反馈慢,浪费时间。
7. 常见问题与排查技巧速查
7.1 大量TIME_WAIT怎么处理
高并发短连接服务,经常在netstat里看到几千上万条TIME_WAIT。TIME_WAIT本身是TCP设计的一部分,它保证最后一个ACK如果丢了可以被重发,同时避免旧连接的延迟报文污染新连接。但数量太多会导致本地端口紧缺。
处理方式分几层:如果业务允许,优先考虑长连接,减少连接建立和释放的频率;其次调整内核参数:
bash复制# 开启TIME_WAIT复用
sysctl net.ipv4.tcp_tw_reuse=1
# 修改端口范围
sysctl net.ipv4.ip_local_port_range="1024 65535"
注意,Linux系统默认不建议开启tcp_tw_recycle,新版内核已经直接移除,这个参数在NAT环境下会造成严重问题。tcp_tw_reuse只对主动发起的新连接有效,要理解这一点再动手改。
7.2 连接超时与SYN重传
连接一直建立不上,抓包看到客户端反复发SYN但服务端不回,优先排查:服务端端口是否在监听、防火墙/安全组是否拦截了入方向TCP包。我曾遇到过一次,服务端进程还活着,但netstat显示监听端口从LISTEN变成大量SYN_RCVD,一查是应用线程池满了,accept队列溢出,内核无法及时接受新连接。这时候调大backlog队列参数,并优化应用处理速度,问题就解决了。
SYN_RCVD堆积还有一个常见的推手是SYN Flood攻击,表现为半连接大量堆积、正常用户连接超时。处理方案是开启内核的syncookies能力:
bash复制sysctl net.ipv4.tcp_syncookies=1
这种场景我一般建议服务器全部默认开启syncookies,开销极小,防御价值高。
7.3 抓包经验与工具推荐
Wireshark和tcpdump是网络排查的基础工具。tcpdump适合在服务器上快速抓包,Wireshark适合本地做深度分析。我个人的工作流是:在服务器上用tcpdump抓包保存成pcap文件,然后拉回本地用Wireshark打开,配合Expert Info功能看TCP异常事件,效率非常高。
另外推荐两个命令行工具:ss查看当前连接状态比netstat更快更准;nethogs可以按进程实时查看网络流量占用,排查哪个进程在疯狂发包时非常好用。
还有一些常见的TCP问题,我梳理成了一张速查表:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 连接创建慢 | DNS解析超时、防火墙拦截SYN | 抓包看SYN/RTT时间 |
| 传输速度上不去 | 窗口太小、丢包重传多 | 看Window字段、retransmission比例 |
| 连接频繁断开 | 心跳超时、RST包 | 抓包看谁发送了RST |
| 端口发不出去 | TIME_WAIT过多、端口耗尽 | ss -s 看socket统计 |
| 对端收不到数据 | 防火墙、半开连接、Nagle算法 | 先排查拥塞,再关Nagle测试 |
Nagle算法值得单独提一句:它会把多个小包合并成一个大包发送,减少网络报文数量,但也可能带来延迟。尤其对交互类应用(远程桌面、Telnet、游戏),开头的“小包立即发,大包攒一攒”策略会明显增加延迟。实时性要求高的TCP程序可以考虑关闭Nagle(TCP_NODELAY),但代价是可能产生更多小包,需要在吞吐量和延迟之间做权衡。
最后再分享一个我在实际项目中屡试不爽的经验:遇到任何复杂的TCP问题,先别急着看代码。先抓包,把三次握手、数据传输、连接关闭这三个阶段的报文拉出来,看是在哪个环节出的错。90%的问题在包面前都能现出原形,剩下的10%,大部分是对协议理解不够导致的自我怀疑——平时多抓点包、多看几次状态流转,比背多少协议文档都管用。TCP这东西,逼着你用数据说话。
