Skip to content

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 是传输层的两个核心协议,它们的设计哲学截然不同:

特性TCPUDP
连接方式面向连接(三次握手)无连接
可靠性可靠(确认应答、重传)不可靠(尽最大努力交付)
数据顺序保证有序不保证
流量控制有(滑动窗口)无
拥塞控制有(慢启动、拥塞避免)无
首部开销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)。

假设只有两次握手:

txt
客户端 ── 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 四次挥手:关闭连接 ​

建立连接需要三次握手,关闭连接却需要四次挥手:

txt
    客户端 (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 秒,但实际值取决于平台和网络参数:

  1. 为最后一个 ACK 的丢失留出重传窗口。 如果确认丢失,对端可能重发 FIN;主动关闭方仍能再次确认。
  2. 降低旧报文影响后续连接的概率。 等待期间,使用相同四元组的新连接不会立即建立。

生产中的 TIME_WAIT 问题:

在高并发短连接场景(如 HTTP 短连接),大量 TIME_WAIT 状态的连接会占用端口资源:

bash
# 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_timeout

2.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 在做什么的第一个抓手。

txt
发送方                                    接收方
│                                         │
│  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,就重传该报文段。

txt
发送方                                    接收方
│                                         │
│  seq=1000, len=100 ──── ✖ (丢失) ──▶     │
│                                         │
│  ... 等待 RTO ...                        │
│                                         │
│  seq=1000, len=100 ──── (重传) ────▶     │
│                                         │
│  ◀──── ACK=1100 ────────────────────    │

RTO 的计算: 不是固定值,而是根据网络往返时间(RTT)动态调整的:

txt
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 字节,不足的补零。

java
// 发送方
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)作为消息边界。

java
// 发送方
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(长度字段) ​

在消息头部用固定字节数表示消息体长度。这是最通用的方案。

txt
┌──────────────┬─────────────────────┐
│ Length (4B)  │     Payload         │
│  0x00000005  │  "Hello" (5 bytes)  │
└──────────────┴─────────────────────┘
java
// 发送方
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 提供了开箱即用的解决方案:

java
// 固定长度
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 生产环境的经验法则 ​

  1. 按消息特征选择方案——消息长度变化大且允许设计消息头时,长度字段通用且易扩展;固定大小控制消息适合固定长度;有明确终止符的文本协议适合分隔符
  2. 读取时一定要循环读取——一次 read() 不保证返回请求的全部字节数
  3. 消息边界问题只在 TCP 中存在——如果你用 UDP,不需要处理粘包,但仍要考虑丢包、乱序与长度限制
  4. 使用成熟的框架(Netty)——自己处理粘包容易出错

理解 TCP 的连接、可靠交付和消息边界后,还需要区分协议语义与可调参数。下一页集中说明 Nagle、KeepAlive、缓冲区等性能选项,并用 Java 代码和抓包验证行为。

下一页: TCP 性能参数与 Java 实践

Released under the MIT License.