基于tcp的应用层消息边界如何定义
聊聊基于tcp的应用层消息边界如何定义

背景
2018年笔者有幸接触一个项目要用到长连接实现云端到设备端消息推送,所以借机了解过相关的内容,最终是通过rabbitmq+mqtt实现了相关功能,同时在心里也打了一个问号“如果自己实现长连接框架,该怎么定义消息的边界呢?”,之后断断续续整理了一些,一直不成体系,最近放假了整理出来跟大家交流一番。
为什么需要消息边界
消息边界并非长连接场景才需要,即使是短连接也可能需要,拿我们比较常用的http1.0协议(http1.1稍微复杂一些,后面会单独说)来说,它基于tcp这个传输协议来传递消息,而tcp协议又是一个面向流的协议,怎么能识别出已经到了流的末尾呢?我们需要一种规则来定义消息的边界,告诉对方读取已经到了末尾,可以结束了。
举一个生活中的例子来帮助理解,2020年由于疫情的原因,平日里都是在线下会议室开会,特殊时期演变成了线上会议。不知道大家有没有遇到过这种情况,线下开会时通过观察别人的动作、神情很容易知道他说完了,这时候下一个人就可以接着发言了,但是线上开会时这样就行不通了,你如果想发言是不是得先确认下别人有没有说完,如果直接发言可能会打断别人,这样很不礼貌,为什么会出现这种情况呢?因为你不知道他到底有没有结束发言,更专业一点说你不知道是否到达了消息的边界。那怎么改进呢,如果每个人发言完毕都显示的告诉别人“我说完了”,是不是会好一些呢,“我说完了”这四个字就是一种消息的边界,给接收方传达一种消息结束的讯息。
TCP层面的分析
(本节来源于https://netty.io/wiki/user-guide-for-4.x.html#wiki-h3-10)
在基于流的传输(例如TCP / IP)中,将接收到的数据存储到套接字接收缓冲区中。不幸的是,基于流的传输的缓冲区不是数据包队列而是字节队列。这意味着,即使您将两个消息作为两个独立的数据包发送,操作系统也不会将它们视为两个消息,而只是一堆字节。因此,不能保证读取的内容与远端写的完全一样。例如,假设操作系统的TCP / IP栈已收到三个数据包:

由于是基于流的协议,因此很有可能在应用程序中读到以下四个分段:

因此,无论是服务器端还是客户端,接收方都应将接收到的数据整理到一个或多个有意义的帧中,以使应用程序逻辑易于理解。在上面的示例中,正确的数据应采用以下格式:

消息边界的种类
前面介绍了消息边界的定义以及作用,这一节我们来看看大概会有哪几种消息边界。
1.特殊字符:比如上面提到的“我说完了”这就是一种特殊字符作为消息边界的例子,以特殊字符为边界的典型产品有我们熟知的redis,客户端和服务器发送的命令或数据一律以 \r\n (CRLF)结尾,还有Netty中的DelimiterBasedFrameDecoder。
2.基于消息长度:比如约定了消息长度为4k字节,接收方每次读取4k字节以后就认为已到达消息边界,结束本次读取。当然现实中消息长度一般是变长的,这样就需要设计一个约定好的消息头部,将消息长度作为头部的一部分传输过去,以长度为边界的例子有Dubbo、http
、websocket,Netty中的FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder等。
附上一张dubbo协议头,供大家体会

redis如何解析完整消息
上面说过,redis是通过\r\n来作为消息边界的,下面我将从源码角度分析下redis具体是如何处理的。
1.这里通过telnet来发送内联格式命令请求redis,之所以没有选用redis-cli是想模拟一条指令redis-server分多次收到的情况,在telnet模式下,每输入一个字符,就会发送给redis-server端,而redis-cli不是,它是按下回车时才会发送整体输入的命令,redis-server端是分多次还是一次收到完整的命令,这个取决于底层,如果想模拟分多次收到,这个过程较为复杂。
2.redis-server端每次有输入时会触发readQueryFromClient(networking.c)函数,对redis执行流程感兴趣的可以参考我之前的文章“redis源码学习之工作流程初探”。
3.redis-server将收到的内容暂存到redisClient的querybuf中,如果没有收到\r\n就等待,直到收到\r\n才将querybuf中的内容解析成指令执行。
测试步骤如下:
- telnet 中输入g

- debug查看redisClient中querybuf的值,目前只有g

- telnet中输完get a按回车以后,redisClient中querybuf保存了所有的输入get a \r\n

源码分析如下:
readQueryFromClient
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
redisClient *c = (redisClient*) privdata;
int nread, readlen;
size_t qblen;
REDIS_NOTUSED(el);
REDIS_NOTUSED(mask);
server.current_client = c;
readlen = REDIS_IOBUF_LEN;
/* If this is a multi bulk request, and we are processing a bulk reply
* that is large enough, try to maximize the probability that the query
* buffer contains exactly the SDS string representing the object, even
* at the risk of requiring more read(2) calls. This way the function
* processMultiBulkBuffer() can avoid copying buffers to create the
* Redis Object representing the argument. */
if (c->reqtype == REDIS_REQ_MULTIBULK && c->multibulklen && c->bulklen != -1
&& c->bulklen >= REDIS_MBULK_BIG_ARG)
{
int remaining = (int)((unsigned)(c->bulklen+2)-sdslen(c->querybuf));
if (remaining < readlen) readlen = remaining;
}
qblen = sdslen(c->querybuf);
if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);
//从fd中读取内容,读取的内容存到redisClient的querybuf中
nread = read(fd, c->querybuf+qblen, readlen);
if (nread == -1) {
if (errno == EAGAIN) {
nread = 0;
} else {
#ifdef _WIN32
redisLog(REDIS_VERBOSE, "Reading from client: %s",wsa_strerror(errno));
#else
redisLog(REDIS_VERBOSE, "Reading from client: %s",strerror(errno));
#endif
freeClient(c);
return;
}
} else if (nread == 0) {
redisLog(REDIS_VERBOSE, "Client closed connection");
freeClient(c);
return;
}
#ifdef WIN32_IOCP
aeWinReceiveDone(fd);
#endif
if (nread) {
sdsIncrLen(c->querybuf,nread);
c->lastinteraction = server.unixtime;
if (c->flags & REDIS_MASTER) c->reploff += nread;
} else {
server.current_client = NULL;
return;
}
if (sdslen(c->querybuf) > server.client_max_querybuf_len) {
sds ci = getClientInfoString(c), bytes = sdsempty();
bytes = sdscatrepr(bytes,c->querybuf,64);
redisLog(REDIS_WARNING,"Closing client that reached max query buffer length: %s (qbuf initial bytes: %s)", ci, bytes);
sdsfree(ci);
sdsfree(bytes);
freeClient(c);
return;
}
//正常的读取,继续执行processInputBuffer
processInputBuffer(c);
server.current_client = NULL;
}
processInputBuffer
void processInputBuffer(redisClient *c) {
/* Keep processing while there is something in the input buffer */
while(sdslen(c->querybuf)) {
/* Immediately abort if the client is in the middle of something. */
if (c->flags & REDIS_BLOCKED) return;
/* REDIS_CLOSE_AFTER_REPLY closes the connection once the reply is
* written to the client. Make sure to not let the reply grow after
* this flag has been set (i.e. don't process more commands). */
if (c->flags & REDIS_CLOSE_AFTER_REPLY) return;
/* Determine request type when unknown. */
//判断协议类型,如果是*开头的就是redis的统一请求协议,否则就是内联协议
if (!c->reqtype) {
if (c->querybuf[0] == '*') {
c->reqtype = REDIS_REQ_MULTIBULK;
} else {
c->reqtype = REDIS_REQ_INLINE;
}
}
//走内联协议的处理函数processInlineBuffer
if (c->reqtype == REDIS_REQ_INLINE) {
//如果命令不完整或者解析失败,不会执行命令
if (processInlineBuffer(c) != REDIS_OK) break;
} else if (c->reqtype == REDIS_REQ_MULTIBULK) {
if (processMultibulkBuffer(c) != REDIS_OK) break;
} else {
redisPanic("Unknown request type");
}
/* Multibulk processing could see a <= 0 length. */
if (c->argc == 0) {
resetClient(c);
} else {
/* Only reset the client when the command was executed. */
//命令解析完成,执行具体的命令对应的函数
if (processCommand(c) == REDIS_OK)
resetClient(c);
}
}
}
processInlineBuffer
int processInlineBuffer(redisClient *c) {
char *newline;
int argc, j;
sds *argv, aux;
size_t querylen;
/* Search for end of line */
newline = strchr(c->querybuf,'\n');
/* Nothing to do without a \r\n */
//最后一个字符不是\n,返回REDIS_ERR,说明命令不完整,继续等待
if (newline == NULL) {
if (sdslen(c->querybuf) > REDIS_INLINE_MAX_SIZE) {
addReplyError(c,"Protocol error: too big inline request");
setProtocolError(c,0);
}
return REDIS_ERR;
}
/* Handle the \r\n case. */
//继续判断是否是以\r\n结尾的,如果是就截取\r\n前面的内容为参数
if (newline && newline != c->querybuf && *(newline-1) == '\r')
newline--;
/* Split the input buffer up to the \r\n */
querylen = newline-(c->querybuf);
aux = sdsnewlen(c->querybuf,querylen);
argv = sdssplitargs(aux,&argc);
sdsfree(aux);
if (argv == NULL) {
addReplyError(c,"Protocol error: unbalanced quotes in request");
setProtocolError(c,0);
return REDIS_ERR;
}
/* Newline from slaves can be used to refresh the last ACK time.
* This is useful for a slave to ping back while loading a big
* RDB file. */
if (querylen == 0 && c->flags & REDIS_SLAVE)
c->repl_ack_time = server.unixtime;
/* Leave data after the first line of the query in the buffer */
sdsrange(c->querybuf,querylen+2,-1);
/* Setup argv array on client structure */
if (c->argv) zfree(c->argv);
c->argv = zmalloc(sizeof(robj*)*argc);
/* Create redis objects for all arguments. */
for (c->argc = 0, j = 0; j < argc; j++) {
if (sdslen(argv[j])) {
c->argv[c->argc] = createObject(REDIS_STRING,argv[j]);
c->argc++;
} else {
sdsfree(argv[j]);
}
}
zfree(argv);
return REDIS_OK;
}
Netty FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder如何解析完整消息
有兴趣的小伙伴可以看看FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder源码的java doc说明,里面讲的比较详细,在此不再重复。
总结
网络上其他作者将这类问题称之为TCP“粘包”和“拆包”,与本文提到的消息边界本质上没有太多区别,之所以没有继续叫“拆包”是不想把概念复杂化,回到本质其实就是需要一种机制来定义消息的边界,帮助应用层来正确的解析消息。
通过redis源码的简单分析,大体可以得到解决这类问题的关键点有以下两步:
1.需要一种边界的定义,基于特殊字符、基于长度等;
2.消息接收端需要暂存收到的内容,不到边界时等待,直到符合边界条件(收到了特殊字符或者收到的字节数达到约定的长度)。
虽说不是一个高大上的知识点,但是通过查资料和阅读源码也解决了心中的困惑,过程中通过发散式的学习也了解到Netty框架针对这类问题的解决方案,算是对Netty的认识又深入了一点。

基于tcp的应用层消息边界如何定义的更多相关文章
- 构建基于TCP的应用层通信模型
各层的关系如下图,表述的是两个应用或CS间通信的过程: 通常使用TCP构建应用时,需要考虑传输层的通信协议,以便应用层能够正确识别消息请求.比如,一个请求的内容很长(如传文件),那肯定要分多次发送 ...
- TCP和UDP的"保护消息边界" (经典)
在socket网络程序中,TCP和UDP分别是面向连接和非面向连接的.因此TCP的socket编程,收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有 ...
- 基于TCP/IP协议的C++网络编程(API函数版)
源代码:http://download.csdn.net/detail/nuptboyzhb/4169959 基于TCP/IP协议的网络编程 定义变量——获得WINSOCK版本——加载WINSOCK库 ...
- 介绍开源的.net通信框架NetworkComms框架之四 消息边界
原文网址: http://www.cnblogs.com/csdev Networkcomms 是一款C# 语言编写的TCP/UDP通信框架 作者是英国人 以前是收费的 目前作者已经开源 许可是 ...
- UDP TCP 消息边界
先明确一个问题,如果定义了一个数据结构,大小是,比方说 32 个字节,然后 UDP 客户端连续向服务端发了两个包.现在假设这两个包都已经到达了服务器,那么服务端调用 recvfrom 来接收数据,并且 ...
- Mina、Netty、Twisted一起学(二):TCP消息边界问题及按行分割消息
在TCP连接开始到结束连接,之间可能会多次传输数据,也就是服务器和客户端之间可能会在连接过程中互相传输多条消息.理想状况是一方每发送一条消息,另一方就立即接收到一条,也就是一次write对应一次rea ...
- TCP和UDP的保护消息边界机制
在socket网络程序中,TCP和UDP分别是面向连接和非面向连接的.TCP的socket编程,收发两端都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化 ...
- TCP和UDP的"保护消息边界”
转自:http://blog.csdn.net/zhangxinrun/article/details/6721427 在socket网络程序中,TCP和UDP分别是面向连接和非面向连接的.因此TCP ...
- Fixed-Length Frames 谈谈网络编程中应用层(基于TCP/UDP)的协议设计
http://blog.sina.com.cn/s/blog_48d4cf2d0101859x.html 谈谈网络编程中应用层(基于TCP/UDP)的协议设计 (2013-04-27 19:11:00 ...
随机推荐
- Django(中间件)
中间件的概念 中间件顾名思义,是介于request与response处理之间的一道处理过程,相对比较轻量级,并且在全局上改变django的输入与输出.因为改变的是全局,所以需要谨慎实用,用不好会影响到 ...
- Hadoop优势,组成的相关架构,大数据生态体系下的模式
Hadoop优势,组成的相关架构,大数据生态体系下的模式 一.Hadoop的优势 二.Hadoop的组成 2.1 HDFS架构 2.2 Yarn架构 2.3 MapReduce架构 三.大数据生态体系 ...
- (13)Linux文件系统的优缺点
通过文件系统的方式来组织磁盘存储和数据管理.有以下几个方面的好处. 数据的读取.管理操作变得简单 文件系统给用户提供了一个简单的操作界面,用户可以通过对文件系统的简单操作,实现对磁盘的管理.虽然 Li ...
- 如何用RabbitMQ实现延迟队列
前言 在 jdk 的 juc 工具包中,提供了一种延迟队列 DelayQueue.延迟队列用处非常广泛,比如我们最常见的场景就是在网购或者外卖平台中发起一个订单,如果不付款,一般 15 分钟后就会被关 ...
- Codeforces Round #627 (Div. 3) D - Pair of Topics(双指针)
题意: 有长为n的a,b两序列,问满足ai+aj>bi+bj(i<j)的i,j对数. 思路: 移项得:(ai-bi)+(aj-bj)>0,i<j即i!=j,用c序列保存所有ai ...
- P2617 Dynamic Rankings (动态开点权值线段树 + 树状数组)
题意:带修求区间k小 题解:回忆在使用主席树求区间k小时 利用前缀和的思想 既然是前缀和 那么我们可以使用更擅长维护前缀和的树状数组 但是这里每一颗权值线段树就不是带版本的 而是维护数组里i号点的权值 ...
- Python进阶丨如何创建你的第一个Python元类?
摘要:通过本文,将深入讨论Python元类,其属性,如何以及何时在Python中使用元类. Python元类设置类的行为和规则.元类有助于修改类的实例,并且相当复杂,是Python编程的高级功能之一. ...
- 【转】Dockerfile
1. 关于docker build docker build可以基于Dockerfile和context打包出一个镜像,其中context是一系列在PATH或URL中指定的位置中的文件(contex ...
- Shell 信号处理 & Expect 免交互
监控脚本项目 信号处理 1 什么是信号 由键盘组合键或者 kill 命令发出操作称之为信号 信号是发送给进程的,进程在收到信号后会作出默认的响应 2 为何要在进程内处理信号 进程在收到信号后会有默认的 ...
- Nginx 四层负载均衡
目录 四层负载均衡概述 配置七层负载均衡 配置四层负载均衡 四层负载均衡概述 四层负载均衡是基于IP+端口的负载均衡,七层负载均衡是基于URL或主机名等应用层信息的负载均衡. 其他层负载均衡(转载): ...