在网络编程的世界里,很多初学者都会掉进一个“直觉陷阱”:认为我发了一个 hello,对方就理应完整地收到一个 hello。
TCP 并不理解业务上的“话语”,它只是一条永不停歇的传送带,机械地搬运着每一个字节。这种“无边界”的特性,直接导致了我们在开发高性能通信框架(如 Netty)时必须面对的头号难题:粘包与半包。
有一个服务端和客户端的代码如下
java
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss=new ServerSocket(9000);
while (true) {
Socket s = ss.accept();
InputStream in = s.getInputStream();
// 这里这么写,有没有问题
byte[] arr = new byte[4];
while(true) {
int read = in.read(arr);
// 这里这么写,有没有问题
if(read == -1) {
break;
}
System.out.println(new String(arr, 0, read));
}
}
}
}java
public class Client {
public static void main(String[] args) throws IOException {
Socket max = new Socket("localhost", 9000);
OutputStream out = max.getOutputStream();
out.write("hello".getBytes());
out.write("world".getBytes());
out.write("你好".getBytes());
max.close();
}
}输出的内容是:
hell
owor
ld�
�好缓冲区大小与中文字节:缓冲区 byte[] arr = new byte[4] 只有 4 个字节,而我们的数据流是连续的。
为什么会出现这种输出?
我们逐行分析服务端的读取过程:
- 第一轮读取:从 Socket 缓冲区读取了 4 个字节,内容是
hell。此时hello还没读完。 - 第二轮读取:读取接下来的 4 个字节。这包括了
hello剩下的o以及world的前三个字母wor。这就是所谓的粘包(Sticky Package)——两条逻辑上独立的消息在物理读取时被“粘”在了一个缓冲区里。 - 第三轮读取:读取
world剩下的ld,以及“你”字的前两个字节(UTF-8 中一个汉字占 3 字节)。此时,缓冲区内容为[l, d, 你1, 你2]。由于“你”的字节被截断了,new String无法正确解码,于是出现了乱码 ``。这就是半包(Half Package)——一条完整的逻辑消息被拆分成了多次读取。 - 第四轮读取:读取“你”字的最后一个字节和“好”字的三个字节。
核心结论:TCP 是“流”
这种现象的根本原因在于:TCP 是一个面向字节流的协议,它没有消息边界。
- 半包(拆包):是因为接收方的缓冲区大小限制、或者 MTU 限制,导致一条完整的消息被拆分。
- 粘包:是因为发送方为了提高效率(Nagle 算法),或者接收方处理速度跟不上,导致多条消息在缓冲区中堆积,被一次性读取。
对于 TCP 来说,它只负责把 A 发送的字节序列原封不动地传给 B,它并不关心这些字节哪些属于“消息 1”,哪些属于“消息 2”。
解决消息边界的三种主流方案
在实际开发中(如使用 Netty),我们通常通过以下方式来“识破”消息边界:
1. 固定长度(Fixed Length)
发送方约定每条消息必须是固定的 10 个字节,不够的用空格补齐。
- 优点:解析简单。
- 缺点:浪费带宽。
2. 分隔符(Delimiter Based)
每条有效消息的结尾加一个特定字符(如 \n)。
- 优点:灵活。
- 缺点:消息内容中不能包含分隔符,否则需要转义,比较麻烦。
3. 长度域(Length Field Based)—— 最推荐
在消息头部用 4 个字节存储这段消息的总长度,后面紧跟消息正文。
- 工作原理:
- 先读 4 个字节,算出长度 (N)。
- 只有当缓冲区积累够 (N) 个字节时,才认为是一条完整的消息。
- 优点:精准、高效,Netty 中的
LengthFieldBasedFrameDecoder就是基于此原理实现的。
