TCP/IP:可靠通信的基础
TCP 是互联网上使用最广泛的传输层协议。它承诺"可靠、有序、不重复"的字节流传输——但这个承诺是怎么兑现的?三次握手为什么是三次而不是两次?四次挥手中 TIME-WAIT 等待 2MSL 有什么用?粘包问题的根因是什么?本章深入 TCP 的核心机制,帮你建立扎实的传输层认知。
先看一个构造的线上场景。
一个运行中的 HTTP 服务突然无法接受新请求,但监听端口仍然存在。日志中出现
nf_conntrack: table full, dropping packet,连接跟踪表达到上限。用
netstat -ant | grep 8080可以看到大量连接处于TIME_WAIT。短连接客户端每次请求都建立并关闭连接,主动关闭方通常进入TIME_WAIT,持续积累后可能占用端口或连接跟踪资源。
TIME_WAIT的持续时间取决于 MSL、内核配置和系统实现,Linux 常见示例约为 60 秒,但不能作为所有平台的固定值。应结合ss/netstat、dmesg和内核参数确认限制来自端口、连接跟踪还是其他资源。排查时应先确认主动关闭方、连接速率、TIME_WAIT 数量和资源上限,再决定复用连接、调整系统参数或扩容。TIME_WAIT 数量多不等于连接跟踪表已经耗尽:出现
nf_conntrack: table full时,应检查连接跟踪表的当前占用量与容量;若怀疑临时端口耗尽,则另行检查端口范围与可用端口。只有证据指向相应资源时,才能认定它是根因。重启只能暂时清空状态,不能替代根因分析。本章通过构造场景说明这些资源限制与 TCP 状态的关系。
1. TCP 为什么存在
1.1 传输层要解决的问题
应用层产生数据,网际层(IP)负责路由寻址——但 IP 提供的是尽力而为的数据报交付,不保证:
- 数据包是否到达(可能丢包)
- 到达顺序是否正确(可能乱序)
- 是否有重复数据包(可能重复)
- 是否有数据损坏(IPv4 首部校验和只覆盖 IP 首部,IPv6 不含首部校验和)
传输层的任务就是在不可靠的 IP 层之上,构建可靠(或不可靠)的通信通道。
1.2 UDP vs TCP
TCP 和 UDP 是传输层的两个核心协议,它们的设计哲学截然不同:
| 特性 | TCP | UDP |
|---|---|---|
| 连接方式 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠(确认应答、重传) | 不可靠(尽最大努力交付) |
| 数据顺序 | 保证有序 | 不保证 |
| 流量控制 | 有(滑动窗口) | 无 |
| 拥塞控制 | 有(慢启动、拥塞避免) | 无 |
| 首部开销 | 20 字节起 | 8 字节 |
| 传输模式 | 字节流 | 数据报 |
| 典型场景 | Web、文件传输、邮件 | DNS、视频直播、游戏 |
选择哪个,取决于你的业务需要哪些传输语义,以及这些语义由哪一层负责实现。TCP 在传输层提供可靠、有序的字节流,并负责重传、流量控制和拥塞控制,应用通常不必自行实现,代价是连接管理与可靠交付会引入额外状态与时延。UDP 提供面向数据报的传输,不负责重传、按序交付或拥塞控制,适合希望自行决定丢包处理策略的应用,但并不天然保证更低时延。
1.3 为什么 HTTP 选择 TCP
HTTP 定义请求与响应消息及消息体的完整性语义,但 HTTP/1.1 和 HTTP/2 默认运行在 TCP 上,借助 TCP 的校验、确认、重传和有序交付处理传输层丢包与乱序。HTTP/3 则定义在 QUIC 上,由 QUIC 自己提供可靠交付和流控。
但 HTTP/3(基于 QUIC 协议)改用 UDP 作为承载,在 QUIC 自身实现可靠传输、拥塞控制和流量控制。HTTP/2 虽然可以在一个 TCP 连接中复用多个流,但 TCP 仍按字节顺序交付:某段数据丢失时,后续字节即使已到达,也不能越过缺口交付给 HTTP/2。QUIC 为不同流维护独立的交付进度,一个流丢包时,其他流已完整接收的数据通常不必等待该流恢复,从而缓解跨流队头阻塞。这个故事我们留到第 7 章再讲。
2. TCP 三次握手与四次挥手
2.1 三次握手:建立连接
TCP 连接的建立需要三次数据交互,这就是著名的"三次握手":
这跟你的代码有什么关系? 每次建一个新的 TCP 连接,在你的第一个字节数据发出去之前,客户端和服务器已经来回飞了三趟(SYN→SYN-ACK→ACK)。如果 RTT 是 40ms,这 40ms 是纯等待——你的代码还没开始跑。这就是连接池(HTTP Keep-Alive、数据库连接池、Dubbo 长连接)存在的根本原因:复用已有连接,省掉这个 1-RTT 的握手代价。TLS 握手还要再花 1-RTT——所以首次访问 HTTPS 总比 HTTP 慢一拍,不是网络问题,是协议设计决定的。
为什么是三次而不是两次?
核心原因是:TCP 需要双方都确认对方的初始序列号(ISN)。
假设只有两次握手:
客户端 ── SYN, seq=x ──▶ 服务端
客户端 ◀── SYN+ACK, seq=y, ack=x+1 ── 服务端
// 连接建立?不行!问题在于:客户端知道服务端收到了自己的 SYN(因为 ack=x+1),但服务端不知道客户端是否收到了自己的 SYN+ACK。如果 SYN+ACK 在网络中丢失,服务端会停留在 SYN-RECEIVED 状态等待客户端的 ACK,客户端则停留在 SYN-SENT 状态,双方状态不一致。第三次握手让服务端拿到客户端确实收到 SYN+ACK 的证据,从而完成被动打开、进入 ESTABLISHED 状态。
更深层的原因是:两次握手无法防止历史重复连接的初始化。如果一个延迟到达的旧 SYN 报文触发了服务端分配连接状态,服务端会白白分配资源。第三次握手让客户端有机会说"这是过期的连接,我不认"。
序列号(Sequence Number)的作用:
序列号不仅仅是为了确认"收到了",更重要的是:
- 保证数据的有序性(接收端按序列号重组)
- 实现可靠重传(通过 ACK 确认哪些数据已送达)
- 防止历史报文干扰(通过序列号识别过期数据)
2.2 四次挥手:关闭连接
建立连接需要三次握手,关闭连接却需要四次挥手:
客户端 (Client) 服务端 (Server)
│ │
│ ──── FIN, seq=u ──────────────────▶ │ ① 客户端发送 FIN
│ │ (我没有数据要发了)
│ │
│ ◀── ACK, ack=u+1 ───────────────── │ ② 服务端确认 FIN
│ │ (知道了,但我可能还有数据要发)
│ │
│ ... 服务端可能继续发送数据 ... │
│ │
│ ◀── FIN, seq=w ─────────────────── │ ③ 服务端发送 FIN
│ │ (我也没有数据要发了)
│ │
│ ──── ACK, ack=w+1 ────────────────▶ │ ④ 客户端确认 FIN
│ │
│ 等待 2MSL (TIME_WAIT) │ 连接 CLOSED
│ 然后 CLOSED │为什么是四次而不是三次?
因为 TCP 是全双工的——两个方向的数据流是独立的。当客户端说"我没有数据要发了"(发送 FIN),服务端可能还有数据在发送中。所以服务端通常先回 ACK,等自己把数据发完再发 FIN,两者发生在不同时间。但这并不意味着协议禁止 ACK 和 FIN 同时置位:如果服务端在确认客户端 FIN 时已经准备好关闭自己的发送方向,就可以在同一个报文段里携带 ACK 和 FIN。
为什么需要 2MSL(Maximum Segment Lifetime)?
客户端在确认对方最后一个 FIN 后进入 TIME_WAIT,等待 2MSL。Linux 中常见配置得到约 60 秒,但实际值取决于平台和网络参数:
- 为最后一个 ACK 的丢失留出重传窗口。 如果确认丢失,对端可能重发 FIN;主动关闭方仍能再次确认。
- 降低旧报文影响后续连接的概率。 等待期间,使用相同四元组的新连接不会立即建立。
生产中的 TIME_WAIT 问题:
在高并发短连接场景(如 HTTP 短连接),大量 TIME_WAIT 状态的连接会占用端口资源:
# Linux;同时观察本地与远端关闭方对应的 TIME-WAIT
ss -ant state time-wait | wc -l
# tcp_tw_reuse 控制外向连接的时间等待复用;tcp_fin_timeout 控制 FIN-WAIT-2 等待,
# 两者作用不同。修改前应核对内核文档、连接速率和业务复用方式。
sysctl net.ipv4.tcp_tw_reuse net.ipv4.tcp_fin_timeout2.3 TCP 状态机
TCP 连接的生命周期可以用一个状态机来描述:
对 Java 开发者的意义: 当你在代码中调用 socket.close() 时,操作系统并不一定立即关闭连接——它可能进入 FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT 等状态。理解这些状态,能帮你解释为什么 close() 后端口还被占用、为什么连接池中的连接"看起来还活着"。
3. TCP 数据传输机制
3.1 序列号与确认应答
TCP 为每个字节都分配了一个序列号。发送方和接收方各自维护一个序列号计数器:
序列号不只是一个教材概念。 当你在 Wireshark 里看到同一个 seq 号出现了两次——那是重传,TCP 没能按时收到对方的 ACK。当你在 APM 里看到某个 RPC 调用的延迟突然翻倍——那可能就是这个连接在等 RTO 超时、或者丢包触发了快速重传。序列号是你看懂 TCP 在做什么的第一个抓手。
发送方 接收方
│ │
│ seq=1000, len=100 ─────────────────▶ │ 发送 100 字节,序列号从 1000 开始
│ │
│ ◀──── ACK=1100 ──────────────────── │ 确认:已收到序列号 1100 之前的所有数据
│ │
│ seq=1100, len=200 ─────────────────▶ │ 发送 200 字节
│ │
│ ◀──── ACK=1300 ──────────────────── │ 确认:已收到 1300 之前的所有数据累积确认(Cumulative ACK): ACK=N 表示"序列号 N 之前的所有数据我都收到了"。这是一种简洁但有缺陷的设计——如果中间某个包丢失了,接收方通常仍确认缺口开始的位置,而不会把缺口之后的数据算作连续交付。TCP 的选择性确认(SACK)扩展可以额外报告已收到的非连续数据区间,帮助发送方更有针对性地重传缺失数据。
3.2 超时重传
TCP 为每个已发送但未确认的报文段设置一个重传定时器(RTO, Retransmission Timeout)。如果在 RTO 时间内没有收到 ACK,就重传该报文段。
发送方 接收方
│ │
│ seq=1000, len=100 ──── ✖ (丢失) ──▶ │
│ │
│ ... 等待 RTO ... │
│ │
│ seq=1000, len=100 ──── (重传) ────▶ │
│ │
│ ◀──── ACK=1100 ──────────────────── │RTO 的计算: 不是固定值,而是根据网络往返时间(RTT)动态调整的:
RTTVAR = (1 - β) × RTTVAR + β × |SRTT_old - RTT_sample| (β = 0.25)
SRTT = (1 - α) × SRTT_old + α × RTT_sample (α = 0.125)
RTO = SRTT + max(G, 4 × RTTVAR)其中 SRTT_old 是本次更新前的 SRTT,G 是时钟粒度。上面的公式是简化版,完整的 RTO 算法(RFC 6298)还包括首次 RTT 样本的初始化、RTO 下限,以及超时后的指数退避。
这个公式的核心思想是: 这个公式的核心思想是:RTO 要比平均 RTT 大一些,但不能太大——太大了重传慢,太小了会产生不必要的重传。
在继续往下读之前,建议你亲眼看一下 TCP 的实际行为。
在你电脑上启动本页第 4.2 节的 Java 示例,用 Wireshark 抓包,再用防火墙规则暂时丢弃该连接的数据包(只针对测试连接,避免误操作影响本机网络)。
你会看到:TCP 发送方在 RTO 时间内没有收到 ACK → 重传 → 再等(RTO 退避)→ 再重传。实验结果会受操作系统、内核参数和网络条件影响,连接最终何时失败或是否继续工作,取决于具体环境。抓包能帮你把 RTO、重传报文与 ACK 变化对应起来,这是理解超时重传机制最直接的方式。
3.3 滑动窗口:发送方不能发太快
如果每发一个包就等一个 ACK,效率太低(尤其是高延迟网络)。滑动窗口允许发送方在等待 ACK 的同时继续发送后续数据:
窗口大小由接收方通告(Window Advertisement): 接收方在 ACK 中告诉发送方"我的接收缓冲区还有多少空间",发送方据此控制发送速率。这就是流量控制(Flow Control)。
为什么高 RTT 连接可能跑不满带宽?
假设连接 RTT 约 40ms,发送方可用的接收窗口为 128KiB。在忽略丢包、协议开销和其他限制的情况下,窗口对应的吞吐上限约为:
128KiB / 0.04s ≈ 3.28MB/s ≈ 26Mbps
这说明高 RTT 连接需要足够大的窗口才能充分利用带宽,但 128KiB 只是本例的假设值,不代表所有 Linux 发行版或 JDK 应用的固定默认值。增大接收缓冲区只能解除可能存在的窗口限制,实际吞吐还取决于拥塞窗口、丢包、发送方供数能力和接收应用的处理速度,不能只根据缓冲区大小推断最终带宽。接收方通告窗口 rwnd 属于流量控制,拥塞窗口 cwnd 属于拥塞控制,简化理解下,发送方可用的在途数据量上限约等于 min(rwnd, cwnd),两者共同限制在途数据量,解决的是不同问题。
3.4 拥塞控制:发多快才不堵路
流量控制解决的是"接收方处理不过来"的问题,但网络中还有另一个问题:中间链路(路由器)可能拥塞。如果所有发送方都不控制速率,网络会越来越堵,最终大量丢包。
TCP 的拥塞控制包含四个阶段:
| 阶段 | 主要作用 | 说明 |
|---|---|---|
| 慢启动(Slow Start) | 探测可用网络容量 | 拥塞窗口随确认逐步增长,经典算法近似按 RTT 指数增加 |
| 拥塞避免(Congestion Avoidance) | 控制窗口增长速度 | 窗口超过 ssthresh 后,经典算法每个 RTT 近似线性增长 |
| 快速重传(Fast Retransmit) | 尽早修复疑似丢失 | 收到 3 个重复 ACK 后不等超时立即重传 |
| 快速恢复(Fast Recovery) | 避免不必要地回到慢启动 | 具体窗口更新因算法而异,经典 Reno 规则不能推广到所有实现 |
现代 Linux 常见的拥塞控制算法还包括 CUBIC 等,并不严格使用经典 Reno 的同一套公式。你不能直接控制内核的拥塞控制算法,但理解它能帮你解释"为什么网络变慢了":如果吞吐下降同时伴随重传或拥塞窗口收缩,应结合 RTT、接收窗口和应用处理速度继续定位瓶颈,而不是直接排除代码问题。
4. TCP 粘包与拆包
4.1 根因:TCP 是字节流,没有消息边界
TCP 向应用交付的是连续字节流,不保留发送方每次 write() 的消息边界。发送端的一次写入可能被接收端分多次读取,多次写入也可能在接收端的一次读取中一起出现。
为什么 UDP 没有这个问题? 因为 UDP 是数据报协议,接收端可以区分各个数据报的边界,不会像 TCP 那样把多个数据报拼接成连续字节流。但 UDP 不保证每个数据报都到达:数据报可能丢失、重复或乱序,接收缓冲区不足时也可能丢弃已到达的数据报。所以 UDP 不需要处理粘包,却仍要考虑丢包、乱序、重复和消息长度限制。
4.2 三种解决方案
4.2.1 方案一:固定长度
每条消息固定为 N 字节,不足的补零。
// 发送方
byte[] message = new byte[64]; // 固定 64 字节
System.arraycopy("Hello".getBytes(StandardCharsets.UTF_8), 0, message, 0, 5);
out.write(message);
// 接收方:一次 read 不保证读满 64 字节,需循环读取
byte[] buffer = new byte[64];
int offset = 0;
while (offset < buffer.length) {
int n = in.read(buffer, offset, buffer.length - offset);
if (n == -1) {
throw new EOFException("消息未接收完整");
}
offset += n;
}固定长度方案还需约定填充与还原规则:短消息补零后,接收方要能区分有效内容与填充字节。若是二进制结构,按字段解析,不把整块缓冲区直接当字符串;若是文本字段,解码前按约定移除填充字节。
| 优点 | 缺点 |
|---|---|
| 实现最简单 | 浪费带宽(短消息要补零) |
| 无状态 | 不适合变长消息 |
4.2.2 方案二:分隔符
用特殊字符(如 \n、\r\n)作为消息边界。
// 发送方
out.write("Hello\n".getBytes(StandardCharsets.UTF_8));
out.write("World\n".getBytes(StandardCharsets.UTF_8));
// 接收方 - 逐行读取
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
String line = reader.readLine(); // "Hello"
line = reader.readLine(); // "World"| 优点 | 缺点 |
|---|---|
| 简单直观 | 消息内容不能包含分隔符(或需要转义) |
| 适合文本协议(HTTP、SMTP) | 二进制数据不方便 |
4.2.3 方案三:Length Field(长度字段)
在消息头部用固定字节数表示消息体长度。这是最通用的方案。
┌──────────────┬─────────────────────┐
│ Length (4B) │ Payload │
│ 0x00000005 │ "Hello" (5 bytes) │
└──────────────┴─────────────────────┘// 发送方
byte[] data = "Hello".getBytes(StandardCharsets.UTF_8);
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
out.writeInt(data.length); // 写入 4 字节长度
out.write(data); // 写入数据体
// 接收方:长度字段是不可信输入,先校验再分配内存
DataInputStream in = new DataInputStream(socket.getInputStream());
int length = in.readInt(); // 读取 4 字节长度
if (length < 0 || length > 1024 * 1024) {
throw new IOException("非法消息长度: " + length);
}
byte[] data = new byte[length];
in.readFully(data); // 确保读取完整| 优点 | 缺点 |
|---|---|
| 通用,适合任何数据类型 | 需要预先知道最大长度 |
| 无转义问题 | 头部有固定开销 |
| 大多数二进制协议的选择 | 实现稍复杂 |
Netty 中的内置解码器:
Netty 提供了开箱即用的解决方案:
// 固定长度
pipeline.addLast(new FixedLengthFrameDecoder(64));
// 分隔符
pipeline.addLast(new DelimiterBasedFrameDecoder(1024,
Delimiters.lineDelimiter()));
// 长度字段(最常用)
// 本配置假设长度字段只表示消息体长度,不包含 4 字节长度字段本身;
// 若协议的长度字段表示整个帧长度或含其他头部,需相应调整 lengthAdjustment 与 initialBytesToStrip
pipeline.addLast(new LengthFieldBasedFrameDecoder(
65535, // maxFrameLength:帧总长度上限,需与协议最大帧长度一致
0, // lengthFieldOffset:长度字段从帧头偏移 0 开始
4, // lengthFieldLength:长度字段占 4 字节
0, // lengthAdjustment:长度字段不含额外头部长度
4 // initialBytesToStrip:交给后续处理器前剥离 4 字节长度字段
));4.3 生产环境的经验法则
- 按消息特征选择方案——消息长度变化大且允许设计消息头时,长度字段通用且易扩展;固定大小控制消息适合固定长度;有明确终止符的文本协议适合分隔符
- 读取时一定要循环读取——一次
read()不保证返回请求的全部字节数 - 消息边界问题只在 TCP 中存在——如果你用 UDP,不需要处理粘包,但仍要考虑丢包、乱序与长度限制
- 使用成熟的框架(Netty)——自己处理粘包容易出错
理解 TCP 的连接、可靠交付和消息边界后,还需要区分协议语义与可调参数。下一页集中说明 Nagle、KeepAlive、缓冲区等性能选项,并用 Java 代码和抓包验证行为。
下一页: TCP 性能参数与 Java 实践