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

2018-08-10 14:27:32 +08:00
 javaCoder

原文地址: 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 粘包问题复现

理论推敲

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

第一种情况: 如上图中的第一根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();
            }
        }
    }

}


小结

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

参考文章

21047 次点击
所在节点    程序员
137 条回复
zjp
2018-08-10 16:51:05 +08:00
有一点一直没明白,
哪怕 TCP 没有缓冲机制,客户端发送多少,服务器就立马收到多少,也不能假设客户端将数据一次性全部发送。还是得处理数据被分段的问题。所以他们到底在介意 TCP 什么?
Applenice
2018-08-10 16:51:57 +08:00
真让人孩怕,TCP 什么时候有包的概念了。。。
mhycy
2018-08-10 16:52:51 +08:00
@wysnylc
应用层无法简单获知 TCP 底层的包状态
事实上在系统内核做拥塞控制的时候就已经解包到字节流缓冲区了
既然给应用层的是字节流,那么自然不存在包的概念,更不可能粘起来。

(注意:极端情况下一次 socket read 调用会只获得一个字节的数据返回)
mhycy
2018-08-10 17:03:14 +08:00
@zjp
面向 Socket API 学习 TCP 协议了解下...
说白了还是基础不扎实...

看了下那本《 Netty 权威指南》粘包相关部分的网络知识同样如此
另: 这篇文章其实就是相关章节的重写
petelin
2018-08-10 17:05:01 +08:00
整个程序设计都有流的概念, 通过流, 我们有 map, reduce 这种处理大数据的基石, 我们有统一的访问网络, 本地文件, 甚至一切数据的范式, 你说的这种东西根本不是流的缺点, 这么简单的代码都写不好. 别当程序员了. 什么狗屁粘包, 你读取命令行数据也有命令行数据解析粘包问题?
momocraft
2018-08-10 17:05:27 +08:00
- TCP 叫什么
传输控制协议

- TCP 保证什么
一端写入的字节在被对方读到时是同样顺序

- TCP 不保证什么
一端写入的字节一定到达另一端
到达另一端的字节一定有人读
另一端读了这一端一定知道

- TCP 需要理解上层协议吗
不需要

- 一个应用层协议的消息可能对应几个 TCP 包?
不定

- 一次 recv()读到的字节对应几个 TCP 包?
不定

- 如果以上几点我们可以达成共识,那对 TCP 及其 API 做不必要的假设,并发明奇特概念的人应该叫什么
_____
glacer
2018-08-10 17:05:42 +08:00
@wysnylc TCP 本身就是要被切割成段才能封装成 ip 包发送,这里的粘包应该值得是业务表示的数据包,而 TCP 层并不关心业务数据如何组织,它的任务只是完整地交付数据罢了。所以这里对粘包拆包的处理应该属于应用层协议。
Applenice
2018-08-10 17:07:38 +08:00
@mhycy 去看了下参考文章。。都是直接进了博客。。。
zjp
2018-08-10 17:11:27 +08:00
@mhycy 我的理解和你上面的说法一样…不觉得我的说法有错误,可以细说吗?
mhycy
2018-08-10 17:12:29 +08:00
@zjp
显然我的想法和你是一样的...
mhycy
2018-08-10 17:13:25 +08:00
@zjp
只是我把你不愿意说的话说出来了而已...
zjp
2018-08-10 17:14:44 +08:00
@mhycy 补充前面的表达"还是得处理数据被分段的问题。" > "还是得在应用层处理数据被分段的问题。"
zjp
2018-08-10 17:17:43 +08:00
@mhycy emmm …好吧。我并没有想这么说😂
qk3z
2018-08-10 17:18:40 +08:00
@mhycy #24 我觉得作者还是比较谨慎的,所以书里说的是 “所以在业务上认为,一个完整的包可能会被 TCP 拆分成多个包进行发送”。然而楼上人一看 tcp 粘包就高潮了...
xiao17174
2018-08-10 17:22:59 +08:00
楼主不要被楼上阵容整齐的的嘲讽给吓到了.其实很多人都吃过这个亏的.
楼主能够一本正经的整理出这么一大堆资料,也是一个认真的人.
相信你能在别的事情上成功的.
解释一下粘包,TCP 确实是没有粘包这个说法.之所以会有这个错觉产生,其实是由于自我脑补.
很多人第一次入门网络编程,往往是发一个"helloworld"到另一端,对端大多数时候一次回调或者读取就收到了整个的"helloworld".自然地就以为发了一次,收了一次.就是一个数据包的传输完成了.
这中间发生了什么呢?TCP 底层收到命令到要发送一块内存数据("helloworld"),那么发送端就一个一个字节(只是比喻,实际是 IP 数据帧)地向接收端发送.接收端也开始一个字节一个字节的接受.在发送前,两端没有任何的通信来约定这一次传输一共有多少长的数据.也就是说接收端在收到第一个字节的时候,并不知道接下来它还会收到剩余多少数据.
那么问题就来了,接收端通知你有数据可读时,是以什么为依据呢?
它的逻辑概括一下是(好久好久前的东西了,并不准确):1.收到了一些数据,并且在随后若干毫秒内就没有新数据了;2.数据缓存区满了;3.收到了其它的指令(重置,断开等).以上任意条件触发,那么就通知上层(你的程序)有 TCP 数据到来了.但是它本身并没有你所以为的另外一层含义----这是一个完整的数据包的到来.
只要清楚 TCP 的这一本质,那么你在写代码时就要知道,当你被通知有数据可以接收时,此刻能读取的这些数据本身并没有任何意义,它可能是某个完整数据包的一部分,也可能就是一个完整的数据包.只有你自己亲自去拆开(解析)它后才能判断.
mhycy
2018-08-10 17:26:39 +08:00
@qk3z
前提:业务无法获知 TCP 的底层分包情况

既然无法获知,那么这句“业务上认为”是不存在的,因为业务不能对流式协议的底层做任何假设

而且书上认为 TCP 最终发包的时候会被如何分片的估计情况并不完整
毕竟 TCP 作为一个流式传输协议,在网络传递过程中被中间设备如何重组数据包都是不确定的
vy0b0x
2018-08-10 17:27:33 +08:00
安利一个 c#的 System.IO.Pipelines 在这方面贼好用
lolizeppelin
2018-08-10 17:29:55 +08:00
中文造了个容易理解的名词而已
能解决问题就好
非要上纲上线干啥
xiao17174
2018-08-10 17:29:57 +08:00
另外补充一下,楼主图中的三种粘包情况的示意图,显然是一个半吊子人画的,1 和 2 还能理解是站在应用层看待 tcp 传输的,而情况 3 是不可能在应用层发生的.
情况 3 的确是 tcp 传输过程中会遇到的,但如果要讨论,这情况就多了去了:丢包,重包,乱序包.tcp 本身都已经把这些都处理掉了,所以在应用层,要么能接收到正确的数据,要么就是读不到数据.不会有情况 3 出现的.
zhujinliang
2018-08-10 17:57:39 +08:00
@wysnylc 水滴对应字节,所谓的包,可以理解为每连续 n 滴水滴。
或者说,先滴 1 滴染成红色的水,后滴 1 滴染成蓝色的水,你在水管另一端能恰好地分隔开红色液体和蓝色液体吗

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/478610

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX