实战说明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: 【这是一段消息】
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

代码狂魔v

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值