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();
            }
        }
    }

}


小结

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

参考文章

21049 次点击
所在节点    程序员
137 条回复
bombless
2018-08-11 02:24:57 +08:00
@zjp 一楼已经说了。其实就是把字节流拆成一段段的,可以理解成是自己在设计应用层协议(或者更一般的说,设计一个在字节流服务上跑的协议)
binarylu
2018-08-11 08:57:23 +08:00
看来大家讨论那么多,感觉问题主要是:
1. 楼主问题描述有问题,TCP 包确实是错误概念,楼主想表达的应该是 走 TCP 协议的应用层包
2. 大家纠结在 TCP 包这个错误概念上,没有理解楼主的真实想法。
个人观点:
这个问题还是要处理的,TCP 并不知道应用层包的边界,recv 到的数据还是有可能包含一部分上一个(应用层)包尾和一部分下一个(应用层)包头。

附上一些国外网友的讨论:

"If you want to send and receive "packets" with TCP, you have to implement packet start markers, length markers and so on. How about using a message oriented protocol like UDP instead? UDP guarantees one send() call translates to one sent datagram and to one recv() call!"
ref: https://gamedev.stackexchange.com/questions/96945/what-is-better-lots-of-small-tcp-packets-or-one-long-one

"If you stick with SOCK_STREAM, then your receiving code needs to be prepared for the fact that a single call to recv may not retrieve a whole message as sent by the sender. recv will return the number of bytes actually received, or -1 if there is an error. If the messages are fixed length then you can repeatedly call it until you have the whole message."
ref: https://stackoverflow.com/questions/24051965/maximum-limit-on-size-of-data-in-ipc-using-sockets-in-unix
binarylu
2018-08-11 08:59:58 +08:00
urmyfaith
2018-08-11 10:45:55 +08:00
有啥可喷的?

编程的遇到的问题,命名了一个新词而已。

就比如外国人第一次用筷子吃饺子,他发明一个新词,叫 “筷饺现象”,然后在他们国家内讨论的火热。

这个时候,你看到中国会讨论这个现象吗 ?

只是遇到问题,总结一下前人的经验。上纲上线,好好讨论问题不行,有人基础差,或者说你水平高,你耐心评论一下,多指导下不就得了,何必冷嘲热讽?
bombless
2018-08-11 11:10:30 +08:00
@urmyfaith 主要是看到不懂的人出来传教确实挺尴尬的。可以看出来 po 主没理解 tcp/ip 的多层设计到底是怎么回事。

说起来最近小火了的一个 golang 程序员对归档文件和 jar 的理解也是错的,然后还
bombless
2018-08-11 11:10:49 +08:00
跑出来教人
pangliang
2018-08-11 11:32:30 +08:00
要说本质问题, 本质问题是 tcp 一个流协议却给了一个"包"式的接口:
实际长度=read(缓冲区, 最大长度)
newtype0092
2018-08-11 11:33:42 +08:00
@eastlhu 上大学学的计算机网络,TCP 接数据就这么写的,毕业后接触到粘包的概念,总以为自己没搞懂 TCP,感叹自己没好好学。。。
zn
2018-08-11 11:48:08 +08:00
先搞清楚什么叫 TCP 吧,还神他妈沾包。

其实就是自己基于 TCP 传输的自定义协议的解析问题。

*是*你*自*己*协*议*的*解*析*问*题*,懂了吗?

跟 TCP 一根毛的关系都没有。
pangliang
2018-08-11 11:51:34 +08:00
实际长度=read(缓冲区, 最大长度) 底层就这个接口,
在这段代码层面, 每次 read 完了, "缓冲区" 就是这一段代码这个层面的"包"
这个层面的"包" 跟应用层面的 "包" 不一一对应, 所以出现 两个层面的"包" 的拆和 粘的问题

就算 tcp 是个流, 你有一堆高级特性的 api, 你就骄傲了?
永远还是会遇到 2 个层面的包无法一一对应的问题
硬件缓冲区是不是"包" ? 现在硬件缓冲区能当"流"对待了? 总线不也还是一次按总线位数取"一块"?

拿着一堆高级特性 api 在嘲笑底层特性的 api 用法, 你们到底是在骄傲个啥?
crayygy
2018-08-11 11:51:37 +08:00
@newtype0092 多看英文书...所谓的博客有很多都是臆测,甚至抄袭,所以有些错误的表述就这样被广泛传播
yingtl
2018-08-11 12:11:32 +08:00
@binarylu 所以说应用层使用 TCP 判应用层数据是否完整只有以下几个办法
1. HTTP 1.1 类似的, 用 \r\n\r\n 特定标示表示结尾, 但处理起来略麻烦, HTTP 2.0 就改进了
2. 加包头
2.1 接收时先收个固定长度包头,包头指明的包体长度再接收包体, 这样会多一次调用, 高性能场合会顾及这个性能损失
2.2 直接接收一大块数据, 然后再根据里面的包头信息进行解析, 处理起来有点麻烦
zhicheng
2018-08-11 12:31:54 +08:00
本来不想回复的,但看到有人误人子弟误得更深了,回复一下:

如果你觉得 read(缓冲区,长度)这个读的是一个 “包” , 那么请问我要传送一个 1TB 的数据包,应该怎么做?发送端和接收端各初始化一个 1TB 大小的缓冲区吗?另外接收端怎么知道对方要发一个 1TB 的数据包?还有我的机器是 32 位的怎么办?

经常写代码的工程师,即使没接触过流式协议也会很快想到这个问题,而这个问题的答案,随手就能找到。你们讨论的 “新手” 和 “老手” 其实是 “完全不懂网络编程的” 和 “懂网络编程的” 或者 “完全不懂编程硬要装着自己懂的” 和 “这个问题我不懂要看下书” 的两种人。
timothyqiu
2018-08-11 12:43:26 +08:00
我一般把一本正经讨论「粘包问题」的情况理解为不肯好好看文档、面向直觉编程。
pangliang
2018-08-11 12:43:36 +08:00
@zhicheng 你到底拿上层高特性的 api 在较什么劲?
底层能跑的开这种方式收数据吗? 动不动流式协议? 你硬件缓冲区是流式的吗? 你硬件缓冲区有 1TB 吗?

拿着一堆流式协议高级的 api, 然后说"不存在拆和粘的问题, 因为我是流式的" ?
zhicheng
2018-08-11 12:46:21 +08:00
@pangliang 你回错贴子了,这个贴子讨论的是 TCP 协议,TCP 协议就是流式协议。
pangliang
2018-08-11 12:49:32 +08:00
@zhicheng 楼主的题目是: (用)tcp(做业务会有)粘包问题

然后一堆人在教楼主 tcp 是流式协议...
zhicheng
2018-08-11 12:52:09 +08:00
@pangliang 因为 TCP 是流式协议,所以没有包的概念,更没有粘包的概念。你到底要表达的是什么?
pangliang
2018-08-11 12:57:03 +08:00
@zhicheng
楼主说的是: (用)tcp(这种流协议)(实现业务层的包协议) 会有粘(业务层的)包问题.

所以你们一再强调 tcp 没有包是为啥? 楼主什么时候说 tcp 有包了?

楼主帖子里, "为什么会发生 TCP 粘包、拆包" 说的清清楚楚, 哪一句说 tcp 包了?
zhicheng
2018-08-11 13:07:59 +08:00
@pangliang 既然没有包,那你又拆什么和粘什么?加深别人的错误印象?

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

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

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

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

© 2021 V2EX