MQ003——通信协议

如何为消息引擎系统设计一个好的通信协议?

前言

过上面两篇博客的梳理,已经了解了 MQ 的基本概念。从功能上看,一个最基础的消息引擎系统应该具备生产、存储和消费的能力。也就是能够完成“生产者把数据发送到 Broker,Broker 收到数据后,持久化存储数据,最后消费者从 Broker 消费数据”的整个流程。

从整个流程来拆解技术架构,最基础的消息引擎系统应该具备五个模块:

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

mq-framework

其实消息引擎系统,本质上讲就是个 CS 模型,即通过客户端和服务端之间的交互完成生产、消费等行为。那么客户端和服务端之间的通信流程是如何实现的呢??

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

通信协议基础

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

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

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

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

其实 MQ 领域是存在公有的、可直接使用的标准协议的,比如 AMQP、MQTT、OpenMessaging,它们设计的初衷就是为了解决因各个消息队列的协议不一样导致的组件互通、用户使用成本高、重复设计、重复开发成本等问题。但是,公有的标准协议讨论制定需要较长时间,往往无法及时赶上需求的变化,灵活性不足。

因此大多数消息队列为了自身的功能支持、迭代速度、灵活性考虑,在核心通信协议的选择上不会选择公有协议,都会选择自定义私有协议。那私有协议要怎么设计实现呢?从技术上来看,私有协议设计一般需要包含三个步骤。

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

网络通信协议选型

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

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

七层的 HTTP 协议每次通信都需要经历三次握手、四次关闭等步骤,并且协议结构也不够精简。因此在性能(比如耗时)上的表现比较差,不适合高吞吐、大流量、低延时的场景。所以主流协议在核心链路上很少使用 HTTP。

应用通信协议设计

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

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

从编解码协议的设计角度来看,需要分别针对“请求”和“返回”设计协议,请求协议结构和返回协议结构一般如下图:

protocol

设计的原则是:请求维度的通用信息放在协议头,消息维度的信息就放在协议体。下面结合 Kafka 协议来详细分析一下:

协议头的设计

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

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

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

requestheader

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

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

requestheader

这里有个细节你可能注意到了,请求协议头是 V2 版本,返回协议头是 V0 版本,会不会有点问题呢?

其实是没有的。因为从协议的角度,一般业务需求的变化(增加或删除)都会涉及请求内容的修改,所以请求的协议变化是比较频繁的,而返回头只要能标识本次对应的请求即可,所以协议的变化比较少。所以,请求头和返回头的协议版本制定,是建议分开定义的,这样在后期的维护升级中会更加灵活。

协议体的设计

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

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

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

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

Kafka 生产请求协议如下:

request-protocol

Kafka 生产返回协议如下:

response-protocol

Kafka 生产请求的协议体包含了事务 ID、acks 信息、请求超时时间、Topic 相关的数据,都是和生产操作相关的。生产返回的协议体包含了限流信息、分区维度的范围信息等。这些字段中的每个字段都是经过多轮迭代、重复设计定下来的,每个字段都用用处。

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

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

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

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

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

编解码实现

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

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

encode

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

沾包 / 拆包的基本思路:

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

早期,消息队列的协议设计几乎都是自定义实现编解码,如 RabbitMQ、RocektMQ 4.0、Kafka 等。但从 0 实现编解码器比较复杂,随着业界主流编解码框架和编解码协议的成熟,一些消息队列(如 Pulsar 和 RocketMQ 5.0)开始使用业界成熟的编解码框架,如 Google 的 Protobuf。Protobuf 是一个灵活、高效、结构化的编解码框架,业界非常流行,很多商业产品都会用,它支持多语言,编解码性能较高,可扩展性强,产品成熟度高。这些优点,都是我们在设计协议的时候需要重点考虑和实现的,并且我们自定义实现编解码的效果不一定有 Protobuf 好。所以新的消息队列产品或者新架构可以考虑选择 Protobuf 作为编解码框架。

如果想关注如何在 MQ 中实现自定义编码,可以去深入了解 RocketMQ,它是目前业界唯一一个既支持自定义编解码,又支持成熟编解码框架的消息引擎系统。RocketMQ 5.0 之前支持的 Remoting 协议是自定义编解码,5.0 之后支持的 gRPC 协议是基于 Protobuf 编解码框架。用 Protobuf 的主要原因是它选择 gRPC 框架作为通信框架。而 gRPC 框架中默认编解码器为 Protobuf,编解码操作已经在 gRPC 的库中正确地定义和实现了,不需要单独开发。所以 RocketMQ 可以把重点放在 Rocket 消息队列本身的逻辑上,不需要在协议方面上花费太多精力。

总结

无论是做业务开发,还是数据开发,都或多或少和 MQ 打过交道,很多程序员可能只停留在如何使用上,其实慢慢尝试往下走一步会有不一样的收获~~

这篇博客也只是浅谈 MQ 底层关于通信协议设计的讨论。从功能支持、迭代速度、灵活性上考虑,大多数消息队列的核心通信协议都会优先考虑自定义的私有协议。私有协议的设计主要考虑网络通信协议选择、应用通信协议设计、编解码实现三个方面。

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

其中最重要的是应用通信协议部分的设计选型,这部分需要设计协议头和协议体。重要的是要思考协议头和协议体里面分别要放什么,放多了浪费带宽影响传输性能,放少了无法满足业务需求,需要频繁修改协议内容。

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

思考??

为什么业界的消息引擎系统有多种标准的协议呢??

业界的消息队列有多种标准的协议,如 MQTT、AMQP、OpenMessaging。主要是因为业务场景不一样,一套协议标准无法满足多种场景需要。

  • MQTT 是为了满足物联网领域的通信而设计的,背景是网络环境不稳定、网络带宽小,从而需要极精简的协议结构,并允许可能的数据丢失。
  • AMQP 是主要面向业务消息的协议,因为要承载复杂的业务逻辑,所以协议设计上要尽可能丰富,包含多种场景,并且在传输过程中不允许出现数据丢失。因为 AMQP 协议本身的设计具有很多局限,比如功能太简单,所以不太符合移动互联网、云原生架构下的消息需求。
  • OpenMessaging 的设计初衷是设计一个符合更多场景的消息队列协议。