实战说明TCP协议栈的数据包拆包粘包问题

Posted 代码狂魔

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了实战说明TCP协议栈的数据包拆包粘包问题相关的知识,希望对你有一定的参考价值。

所谓问题

严格来说不能叫问题,维基百科TCP协议这么定义的:

传输控制协议(英语:Transmission Control Protocol,缩写:TCP)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC 793定义

也就是说TCP只能保证数据(字节流)按照顺序发送并且到达,发几次,怎么发,每次发的长度多少,是TCP说了算的,并不是应用层,所以就会出现下面的问题:

  1. 客户端发了两次,怎么服务端一次读取就读完了?

  2. 客户端发了很长的一段数据,怎么服务端两次读取才读取完?

要解决这个问题,就要定一个应用层协议,比如,HTTP协议,定一个head,一个body,head里面有个内容长度Content-Length,比如说服务端发了一段消息,长度为100,客户端你没收到100你继续收,收到100为止,而不管你收了几次,因为这是TCP来确定的(根据缓冲区大小,是否到达最大长度等)

这要求定义一个应用层协议,但是很多人将未定义协议的锅甩给TCP,称为拆包(案列2)粘包(案例1)问题。

这个问题,姑且称作未定协议问题

未定义协议问题

即未定义协议产生的问题

"粘包"问题演示

服务端

服务端

package ProtocolDesignProblem.problem;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class Server {

   public static void main(String[] args) throws IOException {
       // 监听指定的端口
       int port = 8081;
       ServerSocket server = new ServerSocket(port);

       // server将一直等待连接的到来
       System.out.println("server将一直等待连接的到来");
       Socket socket = server.accept();
       // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
       InputStream inputStream = socket.getInputStream();
       byte[] bytes = new byte[1024 * 1024];
       int len;
       int cnt = 1;
       while ((len = inputStream.read(bytes)) != -1) {
           //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
           String content = new String(bytes, 0, len,"UTF-8");
           System.out.println("cnt = " + (cnt ++ ) + ", len = " + len + ", content: " + content);
      }
       inputStream.close();
       socket.close();
       server.close();
  }
}

客户端

客户端1

package ProtocolDesignProblem.problem;

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;

public class Client1 {

   public static void main(String[] args) throws IOException, InterruptedException {
       // 要连接的服务端IP地址和端口
       String host = "127.0.0.1";
       int port = 8081;
       // 与服务端建立连接
       Socket socket = new Socket(host, port);
       // 建立连接后获得输出流
       OutputStream outputStream = socket.getOutputStream();
       String message = "【这是一段消息】";
       for (int i = 0; i < 100; i++) {
           //Thread.sleep(1);
           outputStream.write(message.getBytes("UTF-8"));
      }
       outputStream.close();
       socket.close();
       System.out.println("数据发送完毕!");
  }
}

先跑服务端、再跑客户端,服务端输出如下

cnt = 1, len = 408, content: 【这是一段消息】【这是一段消息】...
...
cnt = 15, len = 120, content: 【这是一段消息】

按照正常来讲,发一百次你得收一百次,这才能对上,现在只收到了15次,很多消息还连在一起了,明显不符合预期,这就是所谓的"粘包"问题

"拆包"问题演示

服务端

服务端,服务端还是和上面一样的

package ProtocolDesignProblem.problem;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class Server {

   public static void main(String[] args) throws IOException {
       // 监听指定的端口
       int port = 8081;
       ServerSocket server = new ServerSocket(port);

       // server将一直等待连接的到来
       System.out.println("server将一直等待连接的到来");
       Socket socket = server.accept();
       // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
       InputStream inputStream = socket.getInputStream();
       byte[] bytes = new byte[1024 * 1024];
       int len;
       int cnt = 1;
       while ((len = inputStream.read(bytes)) != -1) {
           //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
           String content = new String(bytes, 0, len,"UTF-8");
           System.out.println("cnt = " + (cnt ++ ) + ", len = " + len + ", content: " + content);
      }
       inputStream.close();
       socket.close();
       server.close();
  }
}

客户端

客户端2

package ProtocolDesignProblem.problem;

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;

public class Client2 {

   public static void main(String[] args) throws IOException, InterruptedException {
       // 要连接的服务端IP地址和端口
       String host = "127.0.0.1";
       int port = 8081;
       // 与服务端建立连接
       Socket socket = new Socket(host, port);
       // 建立连接后获得输出流
       OutputStream outputStream = socket.getOutputStream();
       String message = genLongChinese(Integer.valueOf(args[0]));//utf-8编码,一个汉字三字节
       for (int i = 0; i < 1; i++) {
           //Thread.sleep(1);
           outputStream.write(message.getBytes("UTF-8"));
      }
       outputStream.close();
       socket.close();
       System.out.println("数据发送完毕!");
  }


   public static String genLongChinese(int len){
       StringBuffer sb = new StringBuffer();
       for (int i = 0; i < len; i++) {
           sb.append("中");//一个中字在UTF-8编码中占3个字节
      }
       return sb.toString();
  }
}

第二个就要麻烦些了,需要在Linux环境下演示,本例在CentOS7中演示,本地回环网卡lo,查看loMTU(Maximum Transmission Unit,即为最大传输单元),超过此值将会被截断发送,如果太大就设置小点,比如设置mtu为10240字节(临时设置)

ifconfig lo mtu 10240 up

顺带提一下MTU的概念

MTU泛指通讯协议中的最大传输单元。一般用来说明TCP/IP四层协议中数据链路层的最大传输单元,不同类型的网络MTU也会不同,我们普遍使用的以太网的MTU是1500,即最大只能传输1500字节的数据帧。可以通过ifconfig命令查看电脑各个网卡的MTU。

同时需要tcpdump观察数据的发送,如果没有tcpdump,则执行yum install -y tcpdump安装,执行下面命令观察8081端口

tcpdump -i lo 'port 8081'

跑服务端java Server,跑客户端,先发送1000个汉字试试java Client2 1000,观察tcpdump输出

# 三次握手
09:46:39.083150 IP localhost.42110 > localhost.tproxy: Flags [S], seq 214022135, win 20400, options [mss 10200,sackOK,TS val 3970228 ecr 0,nop,wscale 7], length 0
09:46:39.083166 IP localhost.tproxy > localhost.42110: Flags [S.], seq 879271140, ack 214022136, win 20376, options [mss 10200,sackOK,TS val 3970228 ecr 3970228,nop,wscale 7], length 0
09:46:39.083174 IP localhost.42110 > localhost.tproxy: Flags [.], ack 1, win 160, options [nop,nop,TS val 3970228 ecr 3970228], length 0
# length 3000 数据发送,刚好3000字节
09:46:39.084746 IP localhost.42110 > localhost.tproxy: Flags [P.], seq 1:3001, ack 1, win 160, options [nop,nop,TS val 3970229 ecr 3970228], length 3000
# 四次挥手
09:46:39.084783 IP localhost.42110 > localhost.tproxy: Flags [F.], seq 3001, ack 1, win 160, options [nop,nop,TS val 3970229 ecr 3970228], length 0
09:46:39.086550 IP localhost.tproxy > localhost.42110: Flags [.], ack 3001, win 319, options [nop,nop,TS val 3970232 ecr 3970229], length 0
09:46:39.087278 IP localhost.tproxy > localhost.42110: Flags [F.], seq 1, ack 3002, win 319, options [nop,nop,TS val 3970232 ecr 3970229], length 0
09:46:39.087284 IP localhost.42110 > localhost.tproxy: Flags [.], ack 2, win 160, options [nop,nop,TS val 3970232 ecr 3970232], length 0

TCP三次握手、四次挥手看的清清楚楚,数据发送一次发完3000字节,没毛病

下面来试验下4000个汉字,也就是12000字节,超过了我们设置的mtu 10240字节,跑服务端java Server,跑客户端,这次发送4000个汉字试试java Client2 4000,观察tcpdump输出

#三次握手
09:47:22.003451 IP localhost.42114 > localhost.tproxy: Flags [S], seq 182084378, win 20400, options [mss 10200,sackOK,TS val 4013148 ecr 0,nop,wscale 7], length 0
09:47:22.003465 IP localhost.tproxy > localhost.42114: Flags [S.], seq 1098406208, ack 182084379, win 20376, options [mss 10200,sackOK,TS val 4013148 ecr 4013148,nop,wscale 7], length 0
09:47:22.003474 IP localhost.42114 > localhost.tproxy: Flags [.], ack 1, win 160, options [nop,nop,TS val 4013148 ecr 4013148], length 0
# 数据发送,发送了两次 10188 + 1812 刚好为 12000
09:47:22.007582 IP localhost.42114 > localhost.tproxy: Flags [.], seq 1:10189, ack 1, win 160, options [nop,nop,TS val 4013152 ecr 4013148], length 10188
09:47:22.007593 IP localhost.42114 > localhost.tproxy: Flags [P.], seq 10189:12001, ack 1, win 160, options [nop,nop,TS val 4013152 ecr 4013148], length 1812
# 四次挥手,还设有一次不知道是干啥的,可能是上面数据拆分后的确认信息?
09:47:22.007631 IP localhost.42114 > localhost.tproxy: Flags [F.], seq 12001, ack 1, win 160, options [nop,nop,TS val 4013152 ecr 4013148], length 0
09:47:22.008012 IP localhost.tproxy > localhost.42114: Flags [.], ack 10189, win 319, options [nop,nop,TS val 4013152 ecr 4013152], length 0
09:47:22.008020 IP localhost.tproxy > localhost.42114: Flags [.], ack 12001, win 478, options [nop,nop,TS val 4013152 ecr 4013152], length 0
09:47:22.011443 IP localhost.tproxy > localhost.42114: Flags [F.], seq 1, ack 12002, win 478, options [nop,nop,TS val 4013156 ecr 4013152], length 0
09:47:22.011450 IP localhost.42114 > localhost.tproxy: Flags [.], ack 2, win 160, options [nop,nop,TS val 4013156 ecr 4013156], length 0

直接看上面注释就知道,这4000个汉字其实是被分两次发送了,这就是所谓的"拆包"问题

定义应用层协议

那怎么解决上面的问题?定义个协议就好,比如每次发送的时候前面四个字节用来指定后面内容的长度,如果发送没够长度就继续发送,直到长度够为止,接收长度不够就继续接收,直到收够数据为止,定义协议如下,int32位整数占用前面四个字节用来表示后面数据的长度

长度 真正数据
----==========
  1. 粘包:A区域数据拿出来得到数据长度为length,根据length则知道B为真实数据,拿出来处理,最后把A和B扔掉,留下CDE做下一次解析,长度够就继续解析,不够就读取下数据再做解析,最后留下E拼接在下次数据的前面

  A      B      C        D      E
----==========----=============---
  1. 拆包:A区域数据拿出来得到数据长度为length,则知道了真实数据B的长度,发现B长度不够,则再去读,读到C和D,根据length则知道B+C为真实数据,拿出来处理,最后把ABC扔掉,D留下拼接在下次数据的前面

  A   B                   C          D
----===== ===================---

参考代码:先跑服务端、再跑客户端,现在发1000次消息顺序都不会乱

客户端

客户端相对简单,要将发送的数据编码成长度和消息本体

package ProtocolDesignProblem.designProtocol;

import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.Socket;

import static ProtocolDesignProblem.designProtocol.Util.*;

public class Client {

public static void main(String[] args) throws IOException {
// 要连接的服务端IP地址和端口
String host = "127.0.0.1";
int port = 8081;
// 与服务端建立连接
Socket socket = new Socket(host, port);
// 建立连接后获得输出流
OutputStream outputStream = socket.getOutputStream();
String message = "【这是一段消息】";
for (int i = 0; i < 1000; i++) {
outputStream.write(encodeMessage(message));
}
outputStream.close();
socket.close();
System.out.println("数据发送完毕!");
}


/**
*
* @param message 消息
* @return 消息长度+消息本体
* @throws UnsupportedEncodingException
*/

public static byte[] encodeMessage(String message) throws UnsupportedEncodingException {
//消息
byte[] content = message.getBytes(CHAR_SET);

//消息长度编码成字节
byte[] contentLengthBytes = int2Bytes(content.length);

//消息长度 + 消息本体
byte[] encode = new byte[content.length + INTEGER_LENGTH];
System.arraycopy(contentLengthBytes,0,encode,0,INTEGER_LENGTH);
System.arraycopy(content,0,encode,INTEGER_LENGTH,content.length);
return encode;
}


}

服务端

服务端就相对复杂了

package ProtocolDesignProblem.designProtocol;


import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

import static ProtocolDesignProblem.designProtocol.Util.*;

public class Server {

public static void main(String[] args) throws IOException {
// 监听指定的端口
int port = 8081;
ServerSocket server = new ServerSocket(port);

// server将一直等待连接的到来
System.out.println("server将一直等待连接的到来");
Socket socket = server.accept();
// 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取
InputStream inputStream = socket.getInputStream();


byte[] bytes = new byte[1024 * 1024];
int len = 0,cnt = 0;

/*1 定义出上次未处理完的字节(超出的字节)*/
int lastBytesLength = 0;
byte[] lastBytes = new byte[lastBytesLength];

while ((len = inputStream.read(bytes)) != -1) {
/*2 将本次读取到的字节和上次没处理完的字节拼接在一起
形成【上次未处理完字节 + 本次新读取到的字节】*/

byte[] tempBytes = new byte[lastBytesLength + len];
System.arraycopy(lastBytes,0,tempBytes,0,lastBytesLength);
System.arraycopy(bytes,0,tempBytes,0,len);
lastBytesLength += len;
lastBytes = tempBytes;

/*3 到目前为止如果字节大于了内容长度的字节数,说明可以取内容了,
否则继续读取,读到多余4个字节为止*/

while (lastBytesLength > INTEGER_LENGTH){
/*4 取出前面4个字节解析成内容长度*/
byte[] contentLengthBytes = new byte[INTEGER_LENGTH];
System.arraycopy(lastBytes,0,contentLengthBytes,0,INTEGER_LENGTH);
int contentLength = bytes2Int(contentLengthBytes);//内容长度

/*5 如果目前读取到的字节还没撑够内容的长度,
那继续读,读到撑够为止,即可能发生了拆包*/

if (lastBytesLength - INTEGER_LENGTH < contentLength){
break;
}

/*6 内容够了 取出内容做处理*/
String message = new String(lastBytes,INTEGER_LENGTH,contentLength,CHAR_SET);
System.out.println("cnt = " + (++cnt) + ", content: " + message);

/*7 重点,将本次未处理完的内容保留下来,
拼接在下次读取到数据的前面,即可能发生了粘包*/

lastBytesLength -= (contentLength + INTEGER_LENGTH);
byte[] leftBytes = new byte[lastBytesLength];
System.arraycopy(lastBytes,contentLength+INTEGER_LENGTH,leftBytes,0,lastBytesLength);
lastBytes = leftBytes;
}
}

inputStream.close();
socket.close();
server.close();
}
}

Util.java

package ProtocolDesignProblem.designProtocol;

public class Util {

public static void main(String[] args) {
System.out.println(bytes2Int(int2Bytes(12)));
}

public static final int INTEGER_LENGTH = 4;//内容长度数据本身的长度,即为int32位整数4字节长

public static final String CHAR_SET = "UTF-8";//客户端与服务端编码一致

//int数值转为字节数组
public static byte[] int2Bytes(int i) {
byte[] result = new byte[4];
result[0] = (byte) (i >> 24 & 0xFF);//i向右移动24位,然后和0xFF也就是(11111111)进行与运算
result[1] = (byte) (i >> 16 & 0xFF);
result[2] = (byte) (i >> 8 & 0xFF);
result[3] = (byte) (i & 0xFF);
return result;
}
//字节数组转为int数值
public static int bytes2Int(byte[] bytes){
int num = bytes[3] & 0xFF;
num |= ((bytes[2] << 8) & 0xFF00);
num |= ((bytes[1] << 16) & 0xFF0000);
num |= ((bytes[0] << 24) & 0xFF000000);
return num;
}
}

参考代码:先跑服务端、再跑客户端,现在发1000次消息顺序都不会乱!

运行结果

server将一直等待连接的到来
cnt = 1, content: 【这是一段消息】
cnt = 2, content: 【这是一段消息】
....省略
cnt = 512, content: 【这是一段消息】
....省略
cnt = 998, content: 【这是一段消息】
cnt = 999, content: 【这是一段消息】
cnt = 1000, content: 【这是一段消息】


以上是关于实战说明TCP协议栈的数据包拆包粘包问题的主要内容,如果未能解决你的问题,请参考以下文章

Netty——解决TCP粘包、拆包

TCP-缓冲区和粘包、拆包有啥关系?

Netty框架之编解码机制二(自定义协议)

Netty框架之编解码机制二(自定义协议)

Netty框架之编解码机制二(自定义协议)

netty之粘包拆包ByteToMessageDecoder