TCP 粘包和拆包
基本概念
TCP 是一种面向连接的、可靠的、基于字节流的传输层通信协议,正因为它是基于字节流的传输协议,它的协议字段中是不带有长度的,数据接收方是不知道需要接收多少的数据才算接收完成。因此对于一端发送的消息,另一端读取的时候是不知道是否读到了完整的消息。
在 TCP 协议中,进行数据包的发送时,会根据 TCP 缓冲区的实际情况对包进行划分,可能会将一个包拆分开来进行发送,也可能将多个包合并再一起发送,这就是所谓的 TCP 粘包和拆包问题。
如下图所示,可以对这个问题进行说明。客户端向服务端发送两条消息 Msg1 和 Msg2,由于 TCP 传输协议的特点,服务端一次读取到的字节数是不确定的,可以分为以下四种情况:
- 服务端分两次接收到了两个独立的数据包,分别是 Msg1 和 Msg2 ,没有发生粘包和拆包。
- 服务端一次接收到一个数据包,其中包括了 Msg1 和 Msg2,发生了粘包。
- 服务端分两次接收到了两个数据包,第一个是完整的 Msg1 和 Msg2 的一部分,第二个是 Msg2 剩下的部分,发生了拆包。
- 服务段接收到多余两个数据包,两条消息被分成了多个数据包发送。
产生原因
发生 TCP 粘包、拆包主要是以下原因 :
- 应用程序写入的数据大于套套接字的缓冲区大小,会发生拆包;小于套接字缓冲区大小会发生粘包。
- 进行MSS大小的TCP分段。
- 应用程序不计数读取套接字缓冲区内的数据,会发生粘包。
- …
模拟粘包拆包的发生
服务端代码
/**
* @author XinHui Chen
* @date 2020/2/6 22:11
*/
public class TCPServer {
private boolean stop = false;
private Selector selector = null;
TCPServer() {
try {
selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress("localhost", 8888));
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("server started at localhost:8888.");
} catch (IOException e) {
System.out.println("fail to open TCP server " + e.getMessage());
}
}
public void start() {
while (!stop) {
try {
selector.select(1000);
} catch (IOException e) {
System.out.println("fail to select " + e.getMessage());
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
handleAccept(key);
}
if (key.isReadable()) {
handleRead(key);
}
if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(32);
int len = 0;
try {
len = sc.read(byteBuffer);
} catch (IOException e) {
key.cancel();
System.out.println("fail to read " + e.getMessage());
}
if (len > 0) {
byteBuffer.flip();
System.out.print("[server] receive: ");
System.out.println(new String(byteBuffer.array()));
} else if (len == -1) {
try {
sc.close();
System.out.println("close connection...");
} catch (IOException e) {
System.out.println("fail to close channel " + e.getMessage());
}
key.cancel();
}
}
}
}
}
public void handleAccept(SelectionKey key) {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel sc;
try {
sc = ssc.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
System.out.println("client " + sc.getRemoteAddress().toString() + " connected");
} catch (IOException e) {
key.cancel();
System.out.println("fail to accept " + e.getMessage());
}
}
public void handleRead(SelectionKey key) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(32);
int len = 0;
try {
len = sc.read(byteBuffer);
} catch (IOException e) {
key.cancel();
System.out.println("fail to read " + e.getMessage());
}
if (len > 0) {
byteBuffer.flip();
System.out.print("[server] receive: ");
System.out.println(new String(byteBuffer.array()));
} else if (len == -1) {
try {
sc.close();
System.out.println("close connection...");
} catch (IOException e) {
System.out.println("fail to close channel " + e.getMessage());
}
key.cancel();
}
}
public static void main(String[] args) {
new TCPServer().start();
}
}
客户端代码
/**
* @author XinHui Chen
* @date 2020/2/6 22:11
*/
public class TcpClient {
private boolean stop = false;
private Selector selector = null;
TcpClient() {
try {
selector = Selector.open();
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("localhost", 8888));
socketChannel.register(selector, SelectionKey.OP_CONNECT);
System.out.println("connect to localhost:8888");
} catch (IOException e) {
System.out.println("fail to connect " + e.getMessage());
}
}
public void start() {
while (!stop) {
try {
selector.select(1000);
} catch (IOException e) {
System.out.println("fail to select " + e.getMessage());
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isConnectable()) {
handleConnect(key);
}
if (key.isWritable()) {
handleWrite(key);
}
}
}
}
public void handleConnect(SelectionKey key) {
SocketChannel sc = (SocketChannel) key.channel();
try {
if (sc.finishConnect()) {
sc.register(selector, SelectionKey.OP_WRITE);
}
} catch (IOException e) {
System.out.println("fail to connect " + e.getMessage());
}
}
public void handleWrite(SelectionKey key) {
SocketChannel sc = (SocketChannel) key.channel();
for (int i = 0; i < 100; i++) {
String message = i + " message from client!";
int len = message.length();
ByteBuffer byteBuffer = ByteBuffer.allocate(4 + len);
byteBuffer.putInt(len);
byteBuffer.put(message.getBytes());
System.out.println(String.format("[client] send: length[%d], %s", len, message));
byteBuffer.flip();
while (byteBuffer.hasRemaining()) {
try {
sc.write(byteBuffer);
} catch (IOException e) {
System.out.println("fail to write " + e.getMessage());
}
}
}
try {
sc.close();
selector.close();
} catch (IOException e) {
System.out.println("fail to close " + e.getMessage());
}
stop = true;
}
public static void main(String[] args) {
new TcpClient().start();
}
}
客户端向服务端连续写100条消息,客户端控制台输出为:
connect to localhost:8888
[client] send: 0 message from client!
[client] send: 1 message from client!
...
[client] send: 64 message from client!
[client] send: 65 message from client!
[client] send: 66 message from client!
[client] send: 67 message from client!
[client] send: 68 message from client!
[client] send: 69 message from client!
[client] send: 70 message from client!
[client] send: 71 message from client!
[client] send: 72 message from client!
[client] send: 73 message from client!
[client] send: 74 message from client!
[client] send: 75 message from client!
[client] send: 76 message from client!
[client] send: 77 message from client!
[client] send: 78 message from client!
[client] send: 79 message from client!
[client] send: 80 message from client!
[client] send: 81 message from client!
[client] send: 82 message from client!
[client] send: 83 message from client!
[client] send: 84 message from client!
...
[client] send: 99 message from client!
服务端接收到的结果为:
server started at localhost:8888.
client /127.0.0.1:56644 connected
[server] receive: 0 message from client!
[server] receive: 1 message from client!
...
[server] receive: 64 message from client!
[server] receive: 65 message from client!66 messag
[server] receive: e from client!67 message from cl
[server] receive: ient!68 message from client!69 m
[server] receive: essage from client!70 message fr
[server] receive: om client!71 message from client
[server] receive: !72 message from client!
[server] receive: 73 message from client!
[server] receive: 74 message from client!75 messag
[server] receive: e from client!76 message from cl
[server] receive: ient!77 message from client!
[server] receive: 78 message from client!79 messag
[server] receive: e from client!80 message from cl
[server] receive: ient!81 message from client!82 m
[server] receive: essage from client!
[server] receive: 83 message from client!
[server] receive: 84 message from client!
...
[server] receive: 99 message from client!
close connection...
可以看到在第64条消息到84条消息之间发生了不同程度的拆包(程序的接收缓冲区只有32B,如果大小合适,可以看到粘包现象)。
解决方案
为了解决这一问题,根本目的就是要划分出每一条消息的界限,可以从数据传输的格式出发:
- 每次传输一定长度的字节,比如 200 个字节。这样客户端和服务端都约定好了长度,就算遇到粘包和拆包现象也能分清楚消息的界限。
- 使用特定的分隔符来表示消息的结束。
- 采用消息头和消息体的形式,消息头中可以包含消息长度等信息,消息头的长度固定。
使用 Java NIO 实现第三种方式来解决拆包粘包问题,其中消息头为 4 字节,代表消息的长度,剩余部分为消息本身,修改服务端代码如下所示:
/**
* @author XinHui Chen
* @date 2020/2/6 22:11
*/
public class TcpServer {
private boolean stop = false;
private int length = -1;
private Selector selector = null;
private ByteBuffer byteBuffer = null;
TcpServer() {
try {
byteBuffer = ByteBuffer.allocate(16);
selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress("localhost", 8888));
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("server started at localhost:8888.");
} catch (IOException e) {
System.out.println("fail to open TCP server " + e.getMessage());
}
}
public void start() {
while (!stop) {
try {
selector.select(1000);
} catch (IOException e) {
System.out.println("fail to select " + e.getMessage());
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
handleAccept(key);
}
if (key.isReadable()) {
handleRead(key);
}
}
}
}
public void handleAccept(SelectionKey key) {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel sc;
try {
sc = ssc.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
System.out.println("client " + sc.getRemoteAddress().toString() + " connected");
} catch (IOException e) {
key.cancel();
System.out.println("fail to accept " + e.getMessage());
}
}
public void handleRead(SelectionKey key) {
SocketChannel sc = (SocketChannel) key.channel();
int len = 0;
try {
len = sc.read(byteBuffer);
} catch (IOException e) {
key.cancel();
System.out.println("fail to read " + e.getMessage());
}
if (len != -1) {
while (true) {
byteBuffer.flip();
if (!byteBuffer.hasRemaining()) {
byteBuffer.compact();
break;
}
if (length == -1) {
if (byteBuffer.remaining() >= 4) {
length = byteBuffer.getInt();
} else {
byteBuffer.compact();
break;
}
System.out.print(String.format("[server] receive: length[%d], ", length));
}
if (byteBuffer.remaining() < length) {
length -= byteBuffer.remaining();
byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
System.out.print(new String(bytes));
} else {
byte[] bytes = new byte[length];
byteBuffer.get(bytes);
System.out.println(new String(bytes));
length = -1;
}
byteBuffer.compact();
}
} else {
try {
sc.close();
System.out.println("close connection...");
} catch (IOException e) {
System.out.println("fail to close channel " + e.getMessage());
}
key.cancel();
}
}
public static void main(String[] args) {
new TcpServer().start();
}
}
测试后发现,无论程序的缓冲区设置成多大,都能正确的接收到客户端发送的 100 条消息,成功的解决了拆包和粘包的问题。
如果使用之前的服务端,当服务端的缓冲区设置的很小的时候(小于一条消息的长度),那么服务端无法正常输出消息。但是用了修改后的代码,程序可以正常允许。读者可以自己尝试一下。