MQ - 02 基础篇_通讯协议
  jt6CnuRn5coj 2023年11月02日 102 0

@[toc]

MQ - 02 基础篇_通讯协议_消息队列



导图

MQ - 02 基础篇_通讯协议_通信协议_02

MQ - 02 基础篇_通讯协议


概述

从功能上来看,一个最基础的消息队列应该具备生产、存储、消费的能力

也就是能完成“生产者把数据发送到 Broker,Broker 收到数据后,持久化存储数据,最后消费者从 Broker 消费数据”的整个流程。


我们从这个流程来拆解技术架构,如下图所示,最基础的消息队列应该具备五个模块。

  • 通信协议:用来完成客户端(生产者和消费者)和 Broker 之间的通信,比如生产或消费。
  • 网络模块:客户端用来发送数据,服务端用来接收数据。
  • 存储模块:服务端用来完成持久化数据存储。
  • 生产者:完成生产相关的功能。
  • 消费者:完成消费相关的功能。

MQ - 02 基础篇_通讯协议_消息队列_03

消息队列本质上讲是个 CS 模型,通过客户端和服务端之间的交互完成生产、消费等行为。

那问题来了:客户端和服务端之间的通信流程是怎么实现的呢?

为了完成交互,我们第一步就需要确定服务端和客户端是如何通信的。而通信的第一步就是确定使用哪种通信协议进行通信。

说到协议,我们开发者最熟悉的可能就是 HTTP 协议了,HTTP 作为一个标准协议,有很多优点。那能否用 HTTP 协议作为消息队列的通信协议呢?


通信协议基础

所有协议的选择和设计都是根据需求来的,我们知道消息队列的核心特性是高吞吐、低延时、高可靠,所以在协议上至少需要满足:

  • 协议可靠性要高,不能丢数据。
  • 协议的性能要高,通信的延时要低。
  • 协议的内容要精简,带宽的利用率要高。
  • 协议需要具备可扩展能力,方便功能的增减

那有没有现成的满足这四个要求的协议呢?

目前业界的通信协议可以分为公有协议和私有协议两种。公有协议指公开的受到认可的具有规范的协议,比如 JMS、HTTP、STOMP 等。私有协议是指根据自身的功能和需求设计的协议,一般不具备通用性,比如 Kafka、RocketMQ、Puslar 的协议都是私有协议。

其实消息队列领域是存在公有的、可直接使用的标准协议的,比如 AMQP、MQTT、OpenMessaging,它们设计的初衷就是为了解决因各个消息队列的协议不一样导致的组件互通、用户使用成本高、重复设计、重复开发成本等问题。但是,公有的标准协议讨论制定需要较长时间,往往无法及时赶上需求的变化,灵活性不足。 因此大多数消息队列为了自身的功能支持、迭代速度、灵活性考虑,在核心通信协议的选择上不会选择公有协议,都会选择自定义私有协议

MQTT 是为了满足物联网领域的通信而设计的,背景是网络环境不稳定、网络带宽小,从而需要极精简的协议结构,并允许可能的数据丢失。

AMQP 是主要面向业务消息的协议,因为要承载复杂的业务逻辑,所以协议设计上要尽可能丰富,包含多种场景,并且在传输过程中不允许出现数据丢失。因为 AMQP 协议本身的设计具有很多局限,比如功能太简单,所以不太符合移动互联网、云原生架构下的消息需求。

OpenMessaging 的设计初衷是设计一个符合更多场景的消息队列协议。

那私有协议要怎么设计实现呢?

从技术上来看,私有协议设计一般需要包含三个步骤。

  • 网络通信协议选型,指计算机七层网络模型中的协议选择。比如传输层的 TCP/UDP、应用层的 HTTP/WebSocket 等。
  • 应用通信协议设计,指如何约定客户端和服务端之间的通信规则。比如如何识别请求内容、如何确定请求字段信息等。
  • 编解码(序列化 / 反序列化)实现,用于将二进制的消息内容解析为程序可识别的数据格式

网络通信协议选型 ---> TCP协议

每一步具体如何选择和实现呢?我们先看网络通信协议。

从功能需求出发,为了保证性能和可靠性, 几乎所有主流消息队列在核心生产、消费链路的协议选择上,都是基于可靠性高、长连接的 TCP 协议

MQ - 02 基础篇_通讯协议_消息队列_04

四层的 UDP 虽然也是长连接,性能更高,但是因为其不可靠传输的特性,业界几乎没有消息队列用它通信。

七层的 HTTP 协议每次通信都需要经历三次握手、四次关闭等步骤,并且协议结构也不够精简。从而在性能(比如耗时)上的表现较差,不适合高吞吐、大流量、低延时的场景。所以主流协议在核心链路上很少使用 HTTP。 但是,很少并不代表没有,HTTP 协议的优点是客户端库非常丰富,协议成熟,非常容易和第三方集成,用户使用起来成本非常低。

所以一些主打轻量、简单的消息队列,比如 AWS SQS、Tencent CMQ,它们主链路的协议就是用的 HTTP 协议。核心考虑是满足多场景的需求,即支持多种接入方式并降低接入门槛。七层协议虽然在性能上有一些降低,但是在一些特殊场景或者某些对耗时不敏感的业务中,降低接入成本是收益很高的事情。


应用通信协议设计 --->协议头+协议体

接下来看应用通信协议的设计,如何构成?设计的时候我们应该关注什么呢?

从应用通信协议构成的角度,协议一般会包含协议头和协议体两部分。

  • 协议头包含一些通用信息和数据源信息,比如协议版本、请求标识、请求的 ID、客户端 ID 等等。
  • 协议体主要包含本次通信的业务数据,比如一串字符串、一段 JSON 格式的数据或者原始二进制数据等等。

从编解码协议的设计角度来看,需要分别针对“请求”和“返回”设计协议,请求协议结构和返回协议结构一般长这样。

MQ - 02 基础篇_通讯协议_消息队列_05

设计的原则是:请求维度的通用信息放在协议头,消息维度的信息就放在协议体。

那具体怎么设计呢?我们结合 Kafka 协议来分析。


协议头的设计 ---> 携带哪些通用的信息

协议头的设计,首先要确认协议中需要携带哪些通用的信息。一般情况下,请求头要携带本次请求以及源端的一些信息,返回头要携带请求唯一标识来表示对应哪个请求,这样就可以了。

所以,请求头一般需要携带协议版本、请求标识、请求的 ID、客户端 ID 等信息。而返回头,一般只需要携带本次请求的 ID、本次请求的处理结果(成功或失败)等几个信息。

接下来,我们分析一下 Kafka 协议的请求头和返回头的内容,对协议头的设计有个更直观认识。如下图所示,Kafka V2 协议的请求头中携带了四个信息。

MQ - 02 基础篇_通讯协议_通信协议_06

  • 用来标识请求类型的 api_key,如生产、消费、获取元数据。
  • 用来标识请求协议版本的 api_version,如 V0、V1、V2。
  • 用来唯一标识该请求 correlation_id,可以理解为请求 ID。
  • 用来标识客户端的 client_id。

Kafka V0 协议的返回头中只携带了一个信息,即该请求的 correlation_id,用来标识这个返回是哪个请求的。

MQ - 02 基础篇_通讯协议_编解码_07

请求协议头是 V2 版本,返回协议头是 V0 版本,会不会有点问题呢?

其实是没有的。因为从协议的角度,一般业务需求的变化(增加或删除)都会涉及请求内容的修改,所以请求的协议变化是比较频繁的,而返回头只要能标识本次对应的请求即可,所以协议的变化比较少。

所以,请求头和返回头的协议版本制定,是建议分开定义的,这样在后期的维护升级中会更加灵活。


协议体的设计 ---> 携带本次请求 / 返回的具体内容

协议体的设计就和业务功能密切相关了。因为协议体是携带本次请求 / 返回的具体内容的,不同接口是不一样的,比如生产、消费、确认,每个接口的功能不一样,结构基本千差万别。

不过设计上还是有共性的,注意三个点:极简、向后兼容、协议版本管理。

  • 协议在实现上首先需要具备向后兼容的能力,后续的变更(如增加或删除)不会影响新老客户端的使用;
  • 然后协议内容上要尽量精简(比如字段和数据类型),这样可以降低编解码和传输过程中的带宽的开销,以及其他物理资源的开销;
  • 最后需要协议版本管理,方便后续的变更。

同样为了直观感受协议体的设计,我们看 Kafka 生产请求和返回的协议内容

Kafka 生产请求协议体如下:

MQ - 02 基础篇_通讯协议_编解码_08

Kafka 生产返回协议体如下:、

MQ - 02 基础篇_通讯协议_消息队列_09

Kafka 生产请求的协议体包含了事务 ID、acks 信息、请求超时时间、Topic 相关的数据,都是和生产操作相关的。

生产返回的协议体包含了限流信息、分区维度的范围信息等。

这些字段中的每个字段都是经过多轮迭代、重复设计定下来的,每个字段都有用处。

所以在协议体的设计上,最核心的就是要遵循“极简”原则,在满足业务要求的基础上,尽量压缩协议的大小。


讨论一下数据类型,在协议设计里,我们很容易忽略的一个事就是数据类型,比如上面 throttle_time_ms 是 INT32,error_code 是 INT16。

数据类型很简单,用来标识每个字段的类型,不过为什么会有这个东西呢,不能直接用 int、string、char 等基础类型吗?这里有两个原因。

  • 消息队列是多语言通信的。不同语言对于同一类型的定义和实现是不一样的,如果使用同一种基础类型在不同的语言进行解析,可能会出现解析错乱等错误。
  • 需要尽量精简消息的长度。比如只需要 1 个 byte 就可以表示的内容,如果用 4 个 byte 来表示,就会导致消息的内容更长,消耗更多的物理带宽

所以一般在协议设计的时候,我们也需要设计相关的基础数据类型(如何设计可以参考 Kafka 的协议数据类型 or Protobuf 的数据类型 )


编解码实现 ---> 编/解码(序列化/反序列)

编解码也称为序列化和反序列,就是数据发送的时候编码,收到数据的时候解码。

为什么要编解码呢? 如下图所示,

  • 因为数据在网络中传输时是二进制的形式,所以在客户端发送数据的时候就要将原始的格式数据编码为二进制数据,以便在 TCP 协议中传输,这一步就是序列化
  • 然后在服务端将收到的二进制数据根据约定好的规范解析成为原始的格式数据,这就是反序列化

MQ - 02 基础篇_通讯协议_消息队列_10

在序列化和反序列化中,最重要的就是 TCP 的粘包和拆包

我们知道 TCP 是一个“流”协议,是一串数据,没有明显的界限,TCP 层面不知道这段流数据的意义,只负责传输。所以应用层就要根据某个规则从流数据中拆出完整的包,解析出有意义的数据,这就是粘包和拆包的作用。

粘包 / 拆包的几个思路就是:

  • 消息定长。
  • 在包尾增加回车换行符进行分割,例如 FTP 协议。
  • 将消息分为消息头和消息体,消息头中包含消息总长度,然后根据长度从流中解析出数据。
  • 更加复杂的应用层协议,比如 HTTP、WebSocket 等。

早期,消息队列的协议设计几乎都是自定义实现编解码,如 RabbitMQ、RocektMQ 4.0、Kafka 等。

但从 0 实现编解码器比较复杂,随着业界主流编解码框架和编解码协议的成熟,一些消息队列(如 Pulsar 和 RocketMQ 5.0)开始使用业界成熟的编解码框架,如 Google 的 Protobuf

Protobuf 是一个灵活、高效、结构化的编解码框架,业界非常流行,很多商业产品都会用,它支持多语言,编解码性能较高,可扩展性强,产品成熟度高。这些优点,都是我们在设计协议的时候需要重点考虑和实现的,并且我们自定义实现编解码的效果不一定有 Protobuf 好。所以新的消息队列产品或者新架构可以考虑选择 Protobuf 作为编解码框架。


为了加深对“自定义实现编解码”和“使用现成的编解码框架”两个路径的选择判断,我们来结合 RocketMQ 的通信协议分析一下。

从 RocketMQ 看编解码的实现

RocketMQ 是业界唯一一个既支持自定义编解码,又支持成熟编解码框架的消息队列产品。RocketMQ 5.0 之前支持的 Remoting 协议是自定义编解码,5.0 之后支持的 gRPC 协议是基于 Protobuf 编解码框架。

用 Protobuf 的主要原因是它选择 gRPC 框架作为通信框架。而 gRPC 框架中默认编解码器为 Protobuf,编解码操作已经在 gRPC 的库中正确地定义和实现了,不需要单独开发。所以 RocketMQ 可以把重点放在 Rocket 消息队列本身的逻辑上,不需要在协议方面上花费太多精力。

接下来,我们看一下 RocketMQ 的“生产请求”在 Remoting 协议和 gRPC 协议中的协议结构。

自定义的 Remoting 协议

如下图所示,自定义的 Remoting 协议的整体结构,包括协议头(消息头)和协议体(消息体)两部分。

  • 消息头包含请求操作码、版本、标记、扩展信息等通用信息。
  • 消息体包含的就是各个请求的具体内容,比如生产请求就是包含生产请求的请求数据,是一个复合的结构。

MQ - 02 基础篇_通讯协议_通信协议_11

生产请求接口的消息体中的具体内容,包含生产组、Topic、标记等生产请求需要携带的信息,服务端需要根据这些信息完成对应操作。

public class SendMessageRequestHeader......{
    private String producerGroup;
    private String topic;
    private String defaultTopic;
    private Integer defaultTopicQueueNums;
    private Integer queueId;
    private Integer sysFlag;
    private Long bornTimestamp;
    private Integer flag;
    private String properties;
    private Integer reconsumeTimes;
    private boolean unitMode = false;
    private boolean batch = false;
    private Integer maxReconsumeTimes;
    ......
    private transient byte[] body;
}

gRPC 协议

gRPC 的生产消息的请求结构,内容简单,只需要定义生产请求需要携带的相关信息,比如 Topic、用户属性、系统属性等。

message Message {
  Resource topic = 1;
  map<string, string> user_properties = 2;
  SystemProperties system_properties = 3;
  bytes body = 4;
}
message SendMessageRequest {
  repeated Message messages = 1;
}
service MessagingService {
  rpc SendMessage(SendMessageRequest) returns (SendMessageResponse) {}
}
协议对比

对比上面两段代码,我们可以很直观地看到区别,gRPC 的协议结构比 Remoting 的协议结构简单很多,比如不需要定义消息头,定义方式也更加简单。

有这样的区别主要有以下两点原因:

  • Remoting 需要先设计协议的整体结构,协议头和协议体,而 gRPC 只需要关注协议体。因为 gRPC 已经完成了整体结构和协议体的设计。
  • Remoting 需要在各个语言自定义实现相关代码,而 gRPC 基于 Protobuf 编解码框架,只要根据 Protobuf 语法定义好协议体的内容,就可以使用工具生成各语言的代码。

这也佐证了我们的看法,使用现成的编解码框架比自定义编解码更加方便和高


总结

从功能支持、迭代速度、灵活性上考虑,大多数消息队列的核心通信协议都会优先考虑自定义的私有协议。

私有协议的设计主要考虑网络通信协议选择、应用通信协议设计、编解码实现三个方面。

  • 网络通信协议选型,基于可靠、低延时的需求,大部分情况下应该选择 TCP。
  • 应用通信协议设计,分为请求协议和返回协议两方面。协议应该包含协议头和协议体两部分。协议头主要包含一些通用的信息,协议体包含请求维度的信息。
  • 编解码,也叫序列化和反序列化。在实现上分为自定义实现和使用现成的编解码框架两个路径。

其中最重要的是应用通信协议部分的设计选型,这部分需要设计协议头和协议体。重要的是要思考协议头和协议体里面分别要放什么,放多了浪费带宽影响传输性能,放少了无法满足业务需求,需要频繁修改协议内容。另外,每个字段的类型也有讲究,需要尽量降低每次通信的数据大小。

所以应用通信协议的内容设计是非常考验技术功底或者经验的。有一个技巧是,如果需要实现自定义的协议,可以去参考一下业界主流的协议实现,看看都包含哪些元素,各自踩过什么坑。总结分析后,这样一般能设计出一个相对较好的消息队列。

另外,我们不可能一下子设计出完美的协议,所以核心是保证协议的向前兼容和向后兼容的能力,以便后续的升级和改造。

因为历史发展原因,业界大部分的消息队列的编解码都是自己实现的,只有近年兴起的 Pulsar 和 RocketMQ 的新版本选择了 Protobuf 作为编解码框架。从编解码框架的选择来看,如果是一个全新的项目或架构,使用现成的编解码框架比如 Protobuf,是比较好的选择。

MQ - 02 基础篇_通讯协议_消息队列_12

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年11月08日 0

暂无评论

jt6CnuRn5coj