V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
javaCoder
V2EX  ›  程序员

TCP 粘包问题浅析及其解决方案

  •  
  •   javaCoder · 2018-08-10 14:27:32 +08:00 · 20496 次点击
    这是一个创建于 2083 天前的主题,其中的信息可能已经有所发展或是发生改变。

    原文地址: haifeiWu 的博客
    博客地址:www.hchstudio.cn
    欢迎转载,转载请注明作者及出处,谢谢!

    最近一直在做中间件相关的东西,所以接触到的各种协议比较多,总的来说有 TCP,UDP,HTTP 等各种网络传输协议,因此楼主想先从协议最基本的 TCP 粘包问题搞起,把计算机网络这部分基础夯实一下。

    TCP 协议的简单介绍

    TCP 是面向连接的运输层协议

    简单来说,在使用 TCP 协议之前,必须先建立 TCP 连接,就是我们常说的三次握手。在数据传输完毕之后,必须是释放已经建立的 TCP 连接,否则会发生不可预知的问题,造成服务的不可用状态。

    每一条 TCP 连接都是可靠连接,且只有两个端点

    TCP 连接是从 Server 端到 Client 端的点对点的,通过 TCP 传输数据,无差错,不重复不丢失。

    TCP 协议的通信是全双工的

    TCP 协议允许通信双方的应用程序在任何时候都能发送数据。TCP 连接的两端都设有发送缓冲区和接收缓冲区,用来临时存放双向通信的数据。发送数据时,应用程序把数据传送给 TCP 的缓冲后,就可以做自己的事情,而 TCP 在合适的时候将数据发送出去。在接收的时候,TCP 把收到的数据放入接收缓冲区,上层应用在合适的时候读取数据。

    TCP 协议是面向字节流的

    TCP 中的流是指流入进程或者从进程中流出的字节序列。所以向 Java,golang 等高级语言在进行 TCP 通信是都需要将相应的实体序列化才能进行传输。还有就是在我们使用 Redis 做缓存的时候,都需要将放入 Redis 的数据序列化才可以,原因就是 Redis 底层就是实现的 TCP 协议。

    **TCP 并不知道所传输的字节流的含义,TCP 并不能保证接收方应用程序和发送方应用程序所发出的数据块具有对应大小的关系(这就是 TCP 传输过程中产生的粘包问题)。**但是应用程序接收方最终受到的字节流与发送方发送的字节流是一定相同的。因此,我们在使用 TCP 协议的时候应该制定合理的粘包拆包策略。

    下图是 TCP 的协议传输的整个过程:

    TCP 面向字节流

    下面这个图是从老钱的博客里面取到的,非常生动 TCP 传输动图

    TCP 粘包问题复现

    理论推敲

    如下图所示,出现的粘包问题一共有三种情况

    TCP 粘包问题

    第一种情况: 如上图中的第一根bar所示,服务端一共读到两个数据包,每个数据包都是完成的,并没有发生粘包的问题,这种情况比较好处理,服务器只需要简单的从网络缓冲区去读就好了,每次服务端读取到的消息都是完成的,并不会出现数据不正确的情况。

    第二种情况: 服务端仅收到一个数据包,这个数据包包含客户端发出的两条消息的完整信息,这个时候基于第一种情况的逻辑实现的服务端就蒙了,因为服务端并不能很好的处理这个数据包,甚至不能处理,这种情况其实就是 TCP 的粘包问题。

    第三种情况: 服务端收到了两个数据包,第一个数据包只包含了第一条消息的一部分,第一条消息的后半部分和第二条消息都在第二个数据包中,或者是第一个数据包包含了第一条消息的完整信息和第二条消息的一部分信息,第二个数据包包含了第二条消息的剩下部分,这种情况其实是发送了 TCP 拆包问题,因为发生了一条消息被拆分在两个包里面发送了,同样上面的服务器逻辑对于这种情况是不好处理的。

    为什么会发生 TCP 粘包、拆包

    1. 应用程序写入的数据大于套接字缓冲区大小,这将会发生拆包。

    2. 应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘包。

    3. 进行 MSS (最大报文长度)大小的 TCP 分段,当 TCP 报文长度-TCP 头部长度>MSS 的时候将发生拆包。

    4. 接收方法不及时读取套接字缓冲区数据,这将发生粘包。

    如何处理粘包、拆包

    通常会有以下一些常用的方法:

    1. 使用带消息头的协议、消息头存储消息开始标识及消息长度信息,服务端获取消息头的时候解析出消息长度,然后向后读取该长度的内容。

    2. 设置定长消息,服务端每次读取既定长度的内容作为一条完整消息,当消息不够长时,空位补上固定字符。

    3. 设置消息边界,服务端从网络流中按消息编辑分离出消息内容,一般使用‘\n ’。

    4. 更为复杂的协议,例如楼主最近接触比较多的车联网协议 808,809 协议。

    TCP 粘包拆包的代码实践

    下面代码楼主主要演示了使用规定消息头,消息体的方式来解决 TCP 的粘包,拆包问题。

    server 端代码: server 端代码的主要逻辑是接收客户端发送过来的消息,重新组装出消息,并打印出来。

    
    import java.io.*;
    import java.net.InetSocketAddress;
    import java.net.ServerSocket;
    import java.net.Socket;
    
    /**
     * @author wuhf
     * @Date 2018/7/16 15:50
     **/
    public class TestSocketServer {
        public static void main(String args[]) {
            ServerSocket serverSocket;
            try {
                serverSocket = new ServerSocket();
                serverSocket.bind(new InetSocketAddress(8089));
                while (true) {
                    Socket socket = serverSocket.accept();
                    new ReceiveThread(socket).start();
    
                }
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
    
        static class ReceiveThread extends Thread {
            public static final int PACKET_HEAD_LENGTH = 2;//包头长度
            private Socket socket;
            private volatile byte[] bytes = new byte[0];
    
            public ReceiveThread(Socket socket) {
                this.socket = socket;
            }
    
            public byte[] mergebyte(byte[] a, byte[] b, int begin, int end) {
                byte[] add = new byte[a.length + end - begin];
                int i = 0;
                for (i = 0; i < a.length; i++) {
                    add[i] = a[i];
                }
                for (int k = begin; k < end; k++, i++) {
                    add[i] = b[k];
                }
                return add;
            }
    
            @Override
            public void run() {
                int count = 0;
                while (true) {
                    try {
                        InputStream reader = socket.getInputStream();
                        if (bytes.length < PACKET_HEAD_LENGTH) {
                            byte[] head = new byte[PACKET_HEAD_LENGTH - bytes.length];
                            int couter = reader.read(head);
                            if (couter < 0) {
                                continue;
                            }
                            bytes = mergebyte(bytes, head, 0, couter);
                            if (couter < PACKET_HEAD_LENGTH) {
                                continue;
                            }
                        }
                        // 下面这个值请注意,一定要取 2 长度的字节子数组作为报文长度,你懂得
                        byte[] temp = new byte[0];
                        temp = mergebyte(temp, bytes, 0, PACKET_HEAD_LENGTH);
                        String templength = new String(temp);
                        int bodylength = Integer.parseInt(templength);//包体长度
                        if (bytes.length - PACKET_HEAD_LENGTH < bodylength) {//不够一个包
                            byte[] body = new byte[bodylength + PACKET_HEAD_LENGTH - bytes.length];//剩下应该读的字节(凑一个包)
                            int couter = reader.read(body);
                            if (couter < 0) {
                                continue;
                            }
                            bytes = mergebyte(bytes, body, 0, couter);
                            if (couter < body.length) {
                                continue;
                            }
                        }
                        byte[] body = new byte[0];
                        body = mergebyte(body, bytes, PACKET_HEAD_LENGTH, bytes.length);
                        count++;
                        System.out.println("server receive body:  " + count + new String(body));
                        bytes = new byte[0];
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
    }
    
    

    **client 端代码:**客户端代码主要逻辑是组装要发送的消息,确定消息头,消息体,然后发送到服务端。

    
    import java.io.*;
    import java.net.InetSocketAddress;
    import java.net.Socket;
    
    /**
     * @author wuhf
     * @Date 2018/7/16 15:45
     **/
    public class TestSocketClient {
        public static void main(String args[]) throws IOException {
            Socket clientSocket = new Socket();
            clientSocket.connect(new InetSocketAddress(8089));
            new SendThread(clientSocket).start();
    
        }
    
        static class SendThread extends Thread {
            Socket socket;
            PrintWriter printWriter = null;
    
            public SendThread(Socket socket) {
                this.socket = socket;
                try {
                    printWriter = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()));
                } catch (IOException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
    
            @Override
            public void run() {
                String reqMessage = "HelloWorld ! from clientsocket this is test half packages!";
                for (int i = 0; i < 100; i++) {
                    sendPacket(reqMessage);
                }
                if (socket != null) {
                    try {
                        socket.close();
                    } catch (IOException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }
                }
    
            }
    
            public void sendPacket(String message) {
                try {
                    OutputStream writer = socket.getOutputStream();
                    writer.write(message.getBytes());
                    writer.flush();
                } catch (IOException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
    
    }
    
    
    

    小结

    最近一直在写一些框架性的博客,专门针对某些问题进行原理性的技术探讨的博客还比较少,所以楼主想着怎样能在自己学到东西的同时也可以给一同在技术这条野路子上奋斗的小伙伴们一些启发,是楼主一直努力的方向。

    参考文章

    137 条回复    2023-10-10 20:05:37 +08:00
    1  2  
    bombless
        101
    bombless  
       2018-08-11 14:16:26 +08:00 via Android
    @pangliang 好歹你知道这几张图都是对问题的错误理解了吧
    iwtbauh
        102
    iwtbauh  
       2018-08-11 14:16:31 +08:00 via Android
    @pangliang #92

    这是两个问题,不要转移话题

    原因是 I/O 请求的不确定性,可能应用程序想一次性读取 512 个字节但是剩下的数据只有 100 字节了。另外,这个返回值同样起到 0 表示 EOF (文件结束,也就是说流结束)的作用和<0 表示出错的情况
    pangliang
        103
    pangliang  
       2018-08-11 14:18:23 +08:00
    @iwtbauh 根本没必要故意隐藏“文件描述符”, 一个 read 接口肯定有这个, 隐含掉就看不懂?

    读取到的长度=read(文件描述符, 缓冲区, 缓冲区长度)
    这个 api 就是 "块" "包" 式的接口
    每次操作返回 读取到的长度 这一块, 1B 也是一块
    pangliang
        104
    pangliang  
       2018-08-11 14:19:06 +08:00
    @iwtbauh 可能应用程序想一次性读取 512 个字节但是剩下的数据只有 100 字节了

    那这个不就是 我的 512 被拆成 100 和 xxx 了吗?
    bombless
        105
    bombless  
       2018-08-11 14:21:42 +08:00 via Android
    @pangliang 其实是你本来就应该一次读一个字节,然后设计套接字接口的时候为了性能就一次返回了缓冲区中多个字节。读文件的时候同理
    dawniii
        106
    dawniii  
       2018-08-11 14:22:33 +08:00
    @pangliang tcp 的角度来看最小传输单位应该是字节吧,而不是“包”。tcp 两端,一端发的是字节流,另一端接的也是字节流。你说的这个 api,应该是我现在收到多少字节流 我就读多少字节没毛病啊。应该不是“块化”了。
    iwtbauh
        107
    iwtbauh  
       2018-08-11 14:23:18 +08:00 via Android
    @pangliang

    文件描述符的说法来自 Unix 系统,Unix 系统的设计就是“一切皆文件”,这个我上面已经说了。不过你知不知道 Unix 的文件和一些早期其他操作系统的“文件”不同,Unix 的文件没有记录结构之类的概念,因此 Unix 文件被称为“字节大袋子”,Unix 的一切皆文件就是在暗示 Unix 一切皆字节流。因此这个文件描述符隐含的信息量太大了。

    至于说为什么这个接口是流,除了这个原因,参考本帖第一页我回复的你的楼层,我不想再重复了。
    sylxjtu
        108
    sylxjtu  
       2018-08-11 14:26:21 +08:00 via Android
    感觉没有多少人 diss 到点上,这篇文章的主要问题是把分包传输当成了一个普遍的原则,然后又列了一些所谓的例外,问题是所谓的分包传输才是例外
    iwtbauh
        109
    iwtbauh  
       2018-08-11 14:27:19 +08:00 via Android
    @pangliang #104

    哪里拆了???是应用程序之前从这个流里面读了一些字节,然后再读一部分字节,这里面并没有包的概念。

    一个包(如 IP 包),是有完整的数据结构,并不是想从当前读随便多少字节
    bombless
        110
    bombless  
       2018-08-11 14:28:01 +08:00 via Android
    @sylxjtu 跟 tcp 没关系,其实 po 主讨论的问题是如何处理字节流,然后随手放了点对问题的错误理解。
    pangliang
        111
    pangliang  
       2018-08-11 14:28:14 +08:00
    @bombless 早就说了...根本原因是 缓冲区
    bombless
        112
    bombless  
       2018-08-11 14:29:30 +08:00 via Android
    @pangliang 那你知道 po 主的图错在哪里了吧
    bombless
        113
    bombless  
       2018-08-11 14:31:21 +08:00 via Android
    @pangliang 主要是这个文章超过一半的内容都是错的,然后你还觉得没问题
    pangliang
        114
    pangliang  
       2018-08-11 15:27:48 +08:00   ❤️ 2
    @bombless 看你怎么理解这个图吧
    在 tcp 报文 这个角度, 确实是把 Data 业务层的"包" 拆掉了
    一个"报文" 包含的 业务包 的个数和完整性不确定 就是 拆和粘
    在这个"报文"层面这个图没问题

    只不过 tcp 协议的报文对业务层透明, 对业务层只表现出 "流"特性
    所以根本也没有所谓报文拆了业务包的事情
    在这个角度, 这图就是错的

    这图把要发的那么多字节在 tcp 层实际怎么传输的画出来了而已
    所以很多人看图不看字, 就自然的以为是 tcp 报文导致楼主说的问题

    但是其实楼主的文字里本来就说了, 缓冲区, 缓冲区, 缓冲区

    ===
    为什么会发生 TCP 粘包、拆包
    应用程序写入的数据大于套接字缓冲区大小,这将会发生拆包。
    应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘包。
    进行 MSS (最大报文长度)大小的 TCP 分段,当 TCP 报文长度-TCP 头部长度>MSS 的时候将发生拆包。
    接收方法不及时读取套接字缓冲区数据,这将发生粘包。
    ===
    bombless
        115
    bombless  
       2018-08-11 15:36:37 +08:00 via Android
    @pangliang 他发这些图跟缓冲区没关系,他还以为他在处理 ip 数据包在网络上怎么分组……
    bombless
        116
    bombless  
       2018-08-11 15:36:57 +08:00 via Android
    数据报*
    pangliang
        117
    pangliang  
       2018-08-11 15:49:45 +08:00
    @bombless
    一张图把它要表达的表达就行了, 你说它没有把其他细节表达出来就是错的, 我是不赞同的
    就比如第二张图, 它就是想表达, 读写都需要先 经过 甚至重点强调了 分别经过读写 buffer
    这张图就可以了
    你不是在看论文, 不需要那么讲究的好么
    那它没有表达其他东西, 不能说它错吧?
    crab
        118
    crab  
       2018-08-11 15:51:52 +08:00   ❤️ 1
    能解决问题就行。非要计算机笔试上的答案?
    zyp0921
        119
    zyp0921  
       2018-08-11 15:56:20 +08:00
    楼主也在搞车联网吗 jt808 1078 感觉都是坑啊
    bombless
        120
    bombless  
       2018-08-11 15:58:20 +08:00 via Android
    @pangliang 也不是讲究,他讲这些也可以用在处理文件上,而他以为他在处理一个 tcp 专有的问题。有点可惜
    USNaWen
        121
    USNaWen  
       2018-08-11 16:04:04 +08:00
    那啥,这不就是 tcp 没收完继续收的事么。。。怎么冒出包来的?
    data>buffer 不都这样?那换地方存下来继续收直到 end 然后拼起来啊。
    qnnnnez
        122
    qnnnnez  
       2018-08-11 16:33:23 +08:00
    @pangliang 但是 TCP 并不是按报文段序号发 ACK 的。极端一点的情况,你收到一个报文段,但是只从里面读半个段的数据,然后发出确认包,对端系统还得把后半个包给你发过来。

    而且建 TCP socket 的时候,明明白白的 SOCK_STREAM。
    pangliang
        123
    pangliang  
       2018-08-11 16:45:07 +08:00   ❤️ 2
    @qnnnnez

    我同意你说的, 只是我看评论区喷楼主给我的感觉,
    就像是一堆研究生拿着科研的方式来讨论初中课本上的一条理论是否正确

    某个层次的知识肯定需要一定程度的概括, 便于理解
    概括肯定有一定的信息丢失(比如极端情况)

    然后一帮人在喷楼主丢失了信息而忽略的要表达的东西本身
    mhycy
        124
    mhycy  
       2018-08-12 01:10:12 +08:00
    @pangliang
    希望能有技术人员应有的严谨态度
    socradi
        125
    socradi  
       2018-08-12 15:15:49 +08:00
    讲的易懂,支持
    tsui
        126
    tsui  
       2018-08-12 15:21:10 +08:00
    @pangliang 初中课本不会发明不存在的东西
    cchange
        127
    cchange  
       2018-08-12 18:05:54 +08:00
    学习了 解释了 tcp ip 为什么不是实时的
    对于工业上的实时以太网会有更多的要求 多谢楼主
    zbcwilliam
        128
    zbcwilliam  
       2018-12-26 10:16:43 +08:00
    @eastlhu #18 不错不错,根据链接的伪代码,用 c++在应用层成功拆分消息 https://github.com/zbcwilliam/tcp-split-message-protobuf
    wlgq2
        129
    wlgq2  
       2019-11-19 17:59:03 +08:00
    @tsui 初中课本会告诉你“温度”这个不存在的东西。初中课本还告诉你“温度越高,分子运动越快"这个错误概念。实际并没有“温度”,只有分子的平均运动速率。说“温度”都是没学好物理的二把刀。

    我觉得把 ”流传输协议上构造具有可靠分段特性的(包 /报文 /数据段)传输协议”现象称之为“粘包",简单易交流,没什么不好
    tsui
        130
    tsui  
       2019-11-20 02:52:00 +08:00
    @wlgq2 这坟挖的。。。温度其他语言有对应的单词,请问“粘包”的英文是什么?

    简单易交流无非是民科自欺欺人罢了
    wlgq2
        131
    wlgq2  
       2019-11-20 09:45:02 +08:00 via Android
    @tsui 笑了,能就事论事是一个正常的能力,只是有些人的脑容量不能支持,你倒是正面回答我温度是不是一个不存在人类抽象出来的概念?
    什么时候民科的定义变成了"给一个存在的现象起个名字",
    你告诉我"江湖"对应的英文是什么,"江湖"是否存在,不用这个词你告诉我用什么简洁的词能替代。
    甲乙丙丁对应的英文是什么?用甲醛,乙醇命名物质是民科吧?
    tsui
        132
    tsui  
       2019-11-21 05:31:20 +08:00
    @wlgq2 你要是没见过词典,至少可以用 Google Translate. 自造概念就是民科的典型代表,你自己娱乐吧
    wlgq2
        133
    wlgq2  
       2019-11-21 20:53:43 +08:00
    @tsui 笑,,,你倒是正面回复啊,难道 TCP 不存在从字节流提取协议包这个操作?给某个现象起个简单名子,是挖了你家祖坟了?您老是脑容量不够还是现实中太不如意才需要颅内民科来 YY 找点存在感……
    wmlhust
        134
    wmlhust  
       2019-11-21 20:55:49 +08:00
    楼上这群人好过分呀,都在在嘲讽啥,这么牛逼的吗???
    TCP 是流协议,但是应用层不一定是流协议,应用层将一段一段的数据,写入 TCP,应用层希望再一段一段的形式来读,有啥问题?对应用层来说,就是出现了粘包和拆包的现象。

    另外,TCP 依赖于 IP 层 /链路层,即使 TCP 属于流协议,在网络上传输时,也确实是一个一个包的形式,如果不使用缓冲区,要一个包一个包去读 TCP 的 payload,完全有可能。
    tsui
        135
    tsui  
       2019-11-22 03:20:57 +08:00
    @wlgq2 你这么激动跳墙是为了啥?
    @Livid
    nl101531
        136
    nl101531  
       2020-12-13 13:04:19 +08:00
    挖个坟,TCP 会根据 MSS 进行数据分段传输,对于应用层来说消息就被拆分了,讨论粘包与半包更多的是讨论这种解决方式,还是很有意义的。
    fgasdzxc
        137
    fgasdzxc  
       196 天前
    开发了一个 Golang 包 tcpack 来解决 Goalng 开发中的这个问题,大家有什么建议吗?
    Github:[https://github.com/lim-yoona/tcpack]( https://github.com/lim-yoona/tcpack)
    1  2  
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   3283 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 30ms · UTC 13:37 · PVG 21:37 · LAX 06:37 · JFK 09:37
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.