naturalCloud naturalCloud

记录精彩的程序人生

目录
所谓tcp"粘包"问题
/      

所谓tcp"粘包"问题

Golang 处理 TCP“粘包”问题

原文链接

什么是粘包?

“粘包”这个说法已经被诟病很久了,既然坊间流传这个说法咱们就沿用吧,关于这个问题比较准确的解释可以参考下面几点:

TCP 是流传输协议,是一种面向连接的、可靠的、基于字节流的传输层通信协议

TCP 没有包的概念,它只负责传输字节序列,UDP 是面向数据报的协议,所以不存在拆包粘包问题

应该由应用层来维护消息和消息的边界,即需要一个应用层协议,比如 HTTP

所以,本质上这是一个没有正确使用 TCP 协议的而产生的问题,有网友说了一句非常形象的话:“打开家里的水龙头, 看着自来水往下流, 然后你告诉我, 看, 自来水粘在一起了, 不是有病?”

如何解决粘包?

通常来说,一般有下面几种方式:

  1. 消息长度固定,提前确定包长度,读取的时候也安固定长度读取,适合定长消息包。

  2. 使用特殊的字符或字符串作为消息的边界,例如 HTTP 协议的 headers"\r\n" 为字段的分隔符

  3. 自定义协议,将消息分为消息头和消息体,消息头中包含表示消息总长度

来看一个存在粘包问题的例子:

Server 端:

 1package main
 2
 3import (
 4	"log"
 5
 6	"net"
 7
 8	"strings"
 9)
10
11func main() {
12
13	listen, err := net.Listen("tcp", "127.0.0.1:8888")
14
15	if err != nil {
16
17		panic(err)
18
19	}
20
21	defer listen.Close()
22
23	for {
24
25		conn, err := listen.Accept()
26
27		if err != nil {
28
29			panic(err)
30
31		}
32
33		for {
34
35			data := make([]byte, 10)
36
37			_, err := conn.Read(data)
38
39			if err != nil {
40
41				log.Printf("%s\n", err.Error())
42
43				break
44
45			}
46
47			receive := string(data)
48
49			log.Printf("receive msg: %s\n", receive)
50
51			send := []byte(strings.ToUpper(receive))
52
53			_, err = conn.Write(send)
54
55			if err != nil {
56
57				log.Printf("send msg failed, error: %s\n", err.Error())
58
59			}
60
61			log.Printf("send msg: %s\n", receive)
62
63		}
64
65	}
66
67}

简单说一下这段代码,有点 socket 编程的基础的话应该很容易理解,基本上都是 Listen -> Accept -> Read 这个套路。

有些人一下子就看出来这个服务有点“问题”,它是同步阻塞的,也就意味着这个服务同一时间只能处理一个连接请求,其实解决这个问题也很简单,得益于 Go 协程的强大,我们只需要开启一个协程单独处理每一个连接就行了。不过这不是今天的主题,有兴趣的童鞋可以自行研究。

Client 端:

这个服务的功能特别简单,客户端输入什么我就返回什么,客户端的话,这里我使用 telnet 来演示:

 1
 2
 3script
 4
 5
 6$ telnet 127.0.0.1 8888
 7
 8
 9Trying 127.0.0.1...
10
11
12Connected to 127.0.0.1.
13
14
15Escape character is '^]'.
16
17
18111111
19
20
21111111
22
23
24123456
25
26
27123456
28
29

当你按回车键的时候 telnet 会在消息后面自动追加 ** "\r\n"** 换行符并发送消息!

从代码里面可以看到,在接受消息的时候我们每次读取 10 个字节的内容输出并返回,如果输入的消息小于等于 8(减去换行符)个字符的时候没有问题,但是当我们在 telnet 里面输入大于 10 个字符的内容的时候,这些数据的时候会被强行拆开处理。

当然这里有人说了,可不可以一次读多点,然而读多少都会存在这个问题,而且 TCP 会有缓存区,不一定能够及时把消息发出去,像 Nagle 优化算法会将多次间隔较小、数据量小的数据,合并成一个大的数据块,然后进行封包,还是会存在问题。

如果我们把这个内容看作是一个业务消息,这个业务消息就被拆分放到下个消息里面处理,必然会产生问题,这就是“粘包”问题的由来。说到底,还是用的人的问题,没有确定好数据边界,如果简单粗暴的读取固定长度的内容,必然会出现问题。

边界符解决粘包问题

前面说过这个问题,我们可以通过定义一个边界符号解决粘包问题,比如说在上面的例子里面 telnet 会自动在每一条消息后面追加“\r\n”符号,我们恰好可以利用这点来区分消息。

定义一个 buffer 来临时存放消息

conn 里面读取固定字节大小内容,判断当前内容里面有没有分隔符

如果没有找到分隔符,把当前内容追加到 buffer 里面,然后重复第 2 步

如果找到分隔符,把当前内容里面分隔符之前的内容追加到 buffer 后输出

然后重置 buffer,把分隔符之后的内容追加到 buff,重复第 2 步

不过 Go 里面提供了一个非常好用的 buffer 库,为我们节省了很多操作

我们可以使用 bufio 库里面的 NewReaderconn 包装一下,然后使用 ReadSlice 方法读取内容,该方法会一直读直到遇到分隔符,非常简单实用。

Server 端:

 1package main
 2
 3import (
 4	"bufio"
 5
 6	"fmt"
 7
 8	"net"
 9)
10
11func main() {
12
13	listen, err := net.Listen("tcp", "127.0.0.1:8888")
14
15	if err != nil {
16
17		panic(err)
18
19	}
20
21	defer listen.Close()
22
23	for {
24
25		conn, err := listen.Accept()
26
27		if err != nil {
28
29			panic(err)
30
31		}
32
33		reader := bufio.NewReader(conn)
34
35		for {
36
37			slice, err := reader.ReadSlice('\n')
38
39			if err != nil {
40
41				continue
42
43			}
44
45			fmt.Printf("%s", slice)
46
47		}
48
49	}
50
51}

Client 端:

Client 这里可以直接使用 telnet,也可以自己写一个,代码如下:

 1package main
 2
 3import (
 4	"log"
 5
 6	"net"
 7
 8	"strconv"
 9
10	"testing"
11
12	"time"
13)
14
15func Test(t *testing.T) {
16
17	conn, err := net.Dial("tcp", "127.0.0.1:8888")
18
19	if err != nil {
20
21		log.Println("dial error:", err)
22
23		return
24
25	}
26
27	defer conn.Close()
28
29	i := 0
30
31	for {
32
33		var err error
34
35		_, err = conn.Write([]byte(strconv.Itoa(i) + " => 77777\n"))
36
37		_, err = conn.Write([]byte(strconv.Itoa(i) + " => 88888\n"))
38
39		_, err = conn.Write([]byte(strconv.Itoa(i) + " => 555555555555555555555555555555555555555555\n"))
40
41		if err != nil {
42
43			panic(err)
44
45		}
46
47		time.Sleep(time.Second * 1)
48
49		_, err = conn.Write([]byte(strconv.Itoa(i) + " => 123456\n"))
50
51		_, err = conn.Write([]byte(strconv.Itoa(i) + " => 123456\n"))
52
53		if err != nil {
54
55			panic(err)
56
57		}
58
59		time.Sleep(time.Second * 1)
60
61		_, err = conn.Write([]byte(strconv.Itoa(i) + " => 9999999\n"))
62
63		_, err = conn.Write([]byte(strconv.Itoa(i) + " => 0000000000000000000000000000000000000000000\n"))
64
65		if err != nil {
66
67			panic(err)
68
69		}
70
71		i++
72
73	}
74
75}

如果要说缺点,这种方式主要存在 2 点,第一点是分隔符的选择问题,如果需要传输的消息包含分隔符,那就需要提前做转义处理。第二点就是性能问题,如果消息体特别大,每次查找分隔符的位置的话肯定会有一点消耗。

在头部放入信息长度

目前应用最广泛的是在消息的头部添加数据包长度,接收方根据消息长度进行接收;在一条 TCP 连接上,数据的流式传输在接收缓冲区里是有序的,其主要的问题就是第一个包的包尾与第二个包的包头共存接收缓冲区,所以根据长度读取是十分合适的。

Server 端:

 1package main
 2
 3import (
 4	"bufio"
 5
 6	"bytes"
 7
 8	"encoding/binary"
 9
10	"fmt"
11
12	"net"
13)
14
15func main() {
16
17	listen, err := net.Listen("tcp", "127.0.0.1:8888")
18
19	if err != nil {
20
21		panic(err)
22
23	}
24
25	defer listen.Close()
26
27	for {
28
29		conn, err := listen.Accept()
30
31		if err != nil {
32
33			panic(err)
34
35		}
36
37		reader := bufio.NewReader(conn)
38
39		for {
40
41			//前 4 个字节表示数据长度
42
43			peek, err := reader.Peek(4)
44
45			if err != nil {
46
47				continue
48
49			}
50
51			buffer := bytes.NewBuffer(peek)
52
53			//读取数据长度
54
55			var length int32
56
57			err = binary.Read(buffer, binary.BigEndian, &length)
58
59			if err != nil {
60
61				continue
62
63			}
64
65			//Buffered 返回缓存中未读取的数据的长度,如果缓存区的数据小于总长度,则意味着数据不完整
66
67			if int32(reader.Buffered()) < length+4 {
68
69				continue
70
71			}
72
73			//从缓存区读取大小为数据长度的数据
74
75			data := make([]byte, length+4)
76
77			_, err = reader.Read(data)
78
79			if err != nil {
80
81				continue
82
83			}
84
85			fmt.Printf("receive data: %s\n", data[4:])
86
87		}
88
89	}
90
91}

Client 端:

需要注意的是发送数据的编码,这里使用了 Gobinary 库,先写入 4 个字节的头,再写入消息主体,最后一起发送过去。

 1package main
 2
 3import (
 4	"bytes"
 5
 6	"encoding/binary"
 7
 8	"fmt"
 9
10	"log"
11
12	"net"
13
14	"testing"
15
16	"time"
17)
18
19func Test(t *testing.T) {
20
21	conn, err := net.Dial("tcp", "127.0.0.1:8888")
22
23	if err != nil {
24
25		log.Println("dial error:", err)
26
27		return
28
29	}
30
31	defer conn.Close()
32
33	for {
34
35		data, _ := Encode("123456789")
36
37		_, err := conn.Write(data)
38
39		data, _ = Encode("888888888")
40
41		_, err = conn.Write(data)
42
43		time.Sleep(time.Second * 1)
44
45		data, _ = Encode("777777777")
46
47		_, err = conn.Write(data)
48
49		data, _ = Encode("123456789")
50
51		_, err = conn.Write(data)
52
53		time.Sleep(time.Second * 1)
54
55		fmt.Println(err)
56
57	}
58
59}
60
61func Encode(message string) ([]byte, error) {
62
63	// 读取消息的长度
64
65	var length = int32(len(message))
66
67	var pkg = new(bytes.Buffer)
68
69	// 写入消息头
70
71	err := binary.Write(pkg, binary.BigEndian, length)
72
73	if err != nil {
74
75		return nil, err
76
77	}
78
79	// 写入消息实体
80
81	err = binary.Write(pkg, binary.BigEndian, []byte(message))
82
83	if err != nil {
84
85		return nil, err
86
87	}
88
89	return pkg.Bytes(), nil
90
91}

总结

世界上本没有“粘包”,只不过是少数人没有正确处理 TCP 数据边界问题,成熟的应用层协议(httpSSH)都不会存在这个问题。但是如果你使用纯 TCP 自定义协议,那就需要自己处理好了。


标题:所谓tcp"粘包"问题
作者:naturalCloud
地址:https://yunqiblog.cn/articles/2020/03/23/1584965831008.html