《深入拆解消息队列47讲》基础篇 ——小记随笔

通信协议:如何设计一个好的通信协议?

从功能上来看,一个最基础的消息队列应该具备生产、存储、消费的能力,也就是能完成“生产者把数据发送到 Broker,Broker 收到数据后,持久化存储数据,最后消费者从 Broker 消费数据”的整个流程。我们从这个流程来拆解技术架构,如下图所示,最基础的消息队列应该具备五个模块。

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

img

为了完成交互,我们第一步就需要确定服务端和客户端是如何通信的。

通信协议基础

消息队列的核心特性是高吞吐、低延时、高可靠,所以在协议上至少需要满足:

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

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

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

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

网络通信协议选型

img

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

七层的 HTTP 协议每次通信都需要经历三次握手、四次关闭等步骤,并且协议结构也不够精简。从而在性能(比如耗时)上的表现较差,不适合高吞吐、大流量、低延时的场景。所以主流协议在核心链路上很少使用 HTTP。但是,很少并不代表没有,HTTP 协议的优点是客户端库非常丰富,协议成熟,非常容易和第三方集成,用户使用起来成本非常低。所以一些主打轻量、简单的消息队列,比如 AWS SQS、Tencent CMQ,它们主链路的协议就是用的 HTTP 协议。核心考虑是满足多场景的需求,即支持多种接入方式并降低接入门槛。

应用通信协议设计

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

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

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

img

协议头的设计

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

而返回头,一般只需要携带本次请求的 ID、本次请求的处理结果(成功或失败)等几个信息。

kafka 为例:

img
img

协议体的设计

协议体的设计就和业务功能密切相关了。因为协议体是携带本次请求 / 返回的具体内容的,不同接口是不一样的,比如生产、消费、确认,每个接口的功能不一样,结构基本千差万别。不过设计上还是有共性的,注意三个点:极简、向后兼容、协议版本管理。

kafka 为例

img
img

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

编解码实现

img

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

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

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

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

从 RocketMQ 看编解码的实现

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

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

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

如下图所示,自定义的 Remoting 协议的整体结构,包括协议头(消息头)和协议体(消息体)两部分。消息头包含请求操作码、版本、标记、扩展信息等通用信息。消息体包含的就是各个请求的具体内容,比如生产请求就是包含生产请求的请求数据,是一个复合的结构。

img

生产请求接口的消息体中的具体内容,包含生产组、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 的生产消息的请求结构,内容简单,只需要定义生产请求需要携带的相关信息,比如 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) {}
}

使用现成的编解码框架比自定义编解码更加方便和高效。

网络:如何设计高性能的网络模块?

消息队列是需要满足高吞吐、高可靠、低延时,并支持多语言访问的基础软件,网络模块最需要解决的是性能、稳定性、开发成本三个问题。

网络模块的性能瓶颈分析

img

对于单个请求来说,请求流程是:客户端(生产者 / 消费者)构建请求后,向服务端发送请求包 -> 服务端接收包后,将包交给业务线程处理 -> 业务线程处理完成后,将结果返回给客户端。其中可能消耗性能的有三个点。

  • 编解码的速度。
  • 网络延迟。也就是客户端到服务端的网络延迟,这一点在软件层面几乎无法优化,取决于网络链路的性能,跟网络模块无关。
  • 服务端 / 客户端网络模块的处理速度。发送 / 接收请求包后,包是否能及时被处理,比如当逻辑线程处理完成后,网络模块是否及时回包。这一点属于性能优化,是网络模块设计的核心工作

对于并发请求来说,在单个请求维度的问题的基础上,还需要处理高并发、高 QPS、高流量等场景带来的性能问题。主要包含三个方面。

  • 高效的连接管理:当客户端和服务端之间的 TCP 连接数很多,如何高效处理、管理连接。
  • 快速处理高并发请求:当客户端和服务端之间的 QPS 很高,如何快速处理(接收、返回)请求。
  • 大流量场景:当客户端和服务端之间的流量很高,如何快速吞吐(读、写)数据。

大流量场景,某种意义上是高并发处理的一种子场景。因为大流量分为单个请求包大并发小、单个请求包小并发大两种场景。第一种的瓶颈主要在于数据拷贝、垃圾回收、CPU 占用等方面,主要依赖语言层面的编码技巧来解决,一般问题不大。第二种场景是我们需要主要解决的。

高性能网络模块的设计实现

高性能网络模块的设计可以分为

  • 如何高效管理大量的 TCP 连接、
  • 如何快速处理高并发的请求、
  • 如何提高稳定性和降低开发成本等三个方面。

基于多路复用技术管理 TCP 连接

从技术原理来看,高效处理大量 TCP 连接,在消息队列中主要有单条 TCP 连接的复用和多路复用两种技术思路。

单条 TCP 连接的复用

RabbitMQ 用的就是这种方案。

这是在一条真实的 TCP 连接中,创建信道(channel,可以理解为虚拟连接)的概念。通过编程手段,我们把信道当做一条 TCP 连接使用,做到 TCP 连接的复用,避免创建大量 TCP 连接导致系统资源消耗过多。缺点是在协议设计和编码实现的时候有额外开发工作量,而且近年随着异步 IO、IO 多路复用技术的发展,这种方案有点多余。

img

IO 多路复用技术

主流的消息队列 Kakfa、RocketMQ、Pulsar 的网络模块都是基于 IO 多路复用的思路开发的。

IO 多路复用技术,是指通过把多个 IO 的阻塞复用到同一个 selector 的阻塞上,让系统在单线程的情况下可以同时处理多个客户端请求。最大的优势是系统开销小,系统不需要创建额外的进程或者线程,降低了维护的工作量,也节省了资源。

img

不过,即使用了这两种技术,单机能处理的连接数还是有上限的。

  • 操作系统的 FD 上限,如果连接数超过了 FD 的数量,连接会创建失败
  • 第二个限制是系统资源的限制,主要是 CPU 和内存。频繁创建、删除或者创建过多连接会消耗大量的物理资源,导致系统负载过高。

基于 Reactor 模型处理高并发请求

Reactor 模型是一种处理并发服务请求的事件设计模式,当主流程收到请求后,通过多路分离处理的方式,把请求分发给相应的请求处理器处理。如下图所示,Reactor 模式包含 Reactor、Acceptor、Handler 三个角色。

img

  • Reactor:负责监听和分配事件。收到事件后分派给对应的 Handler 处理,事件包括连接建立就绪、读就绪、写就绪等。
  • Acceptor:负责处理客户端新连接。Reactor 接收到客户端的连接事件后,会转发给 Acceptor,Acceptor 接收客户端的连接,然后创建对应的 Handler,并向 Reactor 注册此 Handler。
  • Handler:请求处理器,负责业务逻辑的处理,即业务处理线程。

从技术上看,Reactor 模型一般有三种实现模式。

  • 单 Reactor 单线程模型(单 Reactor 单线程)
  • 单 Reactor 多线程模型 (单 Reactor 多线程)
  • 主从 Reactor 多线程模型 (多 Reactor 多线程)

单 Reactor 单线程模型,

img

单 Reactor 单线程模型,特点是 Reactor 和 Handler 都是单线程的串行处理。

  • 优点是所有处理逻辑放在单线程中实现,没有上下文切换、线程竞争、进程通信等问题。
  • 缺点是在性能与可靠性方面存在比较严重的问题。
    • 性能上,因为是单线程处理,无法充分利用 CPU 资源,并且业务逻辑 Handler 的处理是同步的,容易造成阻塞,出现性能瓶颈。
    • 可靠性主要是因为单 Reactor 是单线程的,如果出现异常不能处理请求,会导致整个系统通信模块不可用。

所以单 Reactor 单进程模型不适用于计算密集型的场景,只适用于业务处理非常快速的场景。

单 Reactor 多线程模型

img

单 Reactor 多线程模型,业务逻辑处理 Handler 变成了多线程,也就是说,获取到 IO 读写事件之后,业务逻辑是一批线程在处理。

  • 优点是 Handler 收到响应后通过 send 把响应结果返回给客户端,降低 Reactor 的性能开销,提升整个应用的吞吐。而且 Handler 使用多线程模式,可以充分利用 CPU 的性能,提高了业务逻辑的处理速度。
  • 缺点是 Handler 使用多线程模式,带来了多线程竞争资源的开销,同时涉及共享数据的互斥和保护机制,实现比较复杂。另外,单个 Reactor 承担所有事件的监听、分发和响应,对于高并发场景,容易造成性能瓶颈。

主从 Reactor 多线程模型

在此基础上,主从 Reactor 多线程模型,是让 Reactor 也变为了多线程。

img

当前业界消息队列的网络模型,比如 Pulsar、Kafka、RocketMQ,为了保证性能,都是基于主从 Reactor 多线程模型开发的。

优点是

  • Reactor 的主线程和子线程分工明确。主线程只负责接收新连接,子线程负责完成后续的业务处理。
  • 同时主线程和子线程的交互也很简单,子线程接收主线程的连接后,只管业务处理即可,无须关注主线程,可以直接在子线程把处理结果返回给客户端。

缺点是

  • 如果基于 NIO 从零开始开发,开发的复杂度和成本较高。
  • 另外,Acceptor 是一个单线程,如果挂了,如何处理客户端新连接是一个风险点。

基于成熟网络框架提高稳定性并降低开发成本

如果我们要基于 Java NIO 库开发一个 Server,需要处理网络的闪断、客户端的重复接入、连接管理、安全认证、编解码、心跳保持、半包读写、异常处理等等细节,工作量非常大。所以在消息队列的网络编程模型中,为了提高稳定性或者降低成本,选择现成的、成熟的 NIO 框架是一个更好的方案。

img

而 Netty 就是这样一个基于 Java NIO 封装的成熟框架。所以我们一提到 Java 的网络编程,最先想到的就是 Netty。当前业界主流消息队列 RocketMQ、Pulsar 也都是基于 Netty 开发的网络模块,Kafka 因为历史原因是基于 Java NIO 实现的。

主流消息队列的网络模型实现

Kafka 网络模型

看整个网络层的结构图。

  • Processor 线程和 Handler 线程之间通过 RequestChannel 传递数据,
  • RequestChannel 中包含一个 RequestQueue 队列和多个 ResponseQueues 队列。
  • 每个 Processor 线程对应一个 ResponseQueue。

img

具体流程上:

  • 一个 Acceptor 接收客户端建立连接的请求,创建 Socket 连接并分配给 Processor 处理。
  • Processor 线程把读取到的请求存入 RequestQueue 中,Handler 线程从 RequestQueue 队列中取出请求进行处理。
  • Handler 线程处理请求产生的响应,会存放到 Processor 对应的 ResponseQueue 中,Processor 线程从其对应的 ResponseQueue 中取出响应信息,并返回给客户端。

RocketMQ 网络模型

RocketMQ 采用 Netty 组件作为底层通信库,遵循 Reactor 多线程模型,同时又在 Reactor 模型上做了一些扩展和优化。所以它的网络模型是 Netty 的网络模型,Netty 底层采用的是主从 Reactor 多线程模型,模型的原理逻辑跟前面讲到的主从 Reactor 多线程模型是一样的。

img

  • 一个 Reactor 主线程负责监听 TCP 网络连接请求,建立好连接,创建 SocketChannel,并注册到 Selector 上。RocketMQ 的源码中会自动根据 OS 的类型选择 NIO 和 Epoll,也可以通过参数配置,监听真正的网络数据。
  • 接收到网络数据后,会把数据传递给 Reactor 线程池处理。
  • 真正执行业务逻辑之前,会进行 SSL 验证、编解码、空闲检查、网络连接管理,这些工作在 Worker 线程池处理(defaultEventExecutorGroup)。
  • 处理业务操作,放在业务 Processor 线程池中执行。

NIO 编程属于 TCP 层网络编程,我们还需要进行协议设计、编解码、链路的建立 / 关闭等工作,才算完成一个完整的网络模块的开发。有没有更好的方案可以解决这些问题,减少我们的工作量呢?
要想不关心底层的调用细节(如底层的网络协议和传输协议等),我们可以调用远端机器上的函数或方法来实现,也就是 RPC(Remote Procedure Call)远程过程调用。

img

因为 RPC 调用的是一个远端对象,调用者和被调用者处于不同的节点上,想完成调用,必须实现 4 个能力。

  • 网络传输协议:远端调用底层需要经过网络传输,所以需要选择网络通信协议,比如 TCP。
  • 应用通信协议:网络传输需要设计好应用层的通信协议,比如 HTTP2 或自定义协议。
  • 服务发现:调用的是远端对象,需要可以定位到调用的服务器地址以及调用的具体方法。
  • 序列化和反序列化:网络传输的是二进制数据,因此 RPC 框架需要自带序列化和反序列化的能力。

在当前的微服务架构中,RPC 已经是我们很熟悉、很常用且很成熟的技术了。

我们以 gRPC 框架举例分析。gRPC 是 Google 推出的一个 RPC 框架,可以说是 RPC 框架中的典型代表。主要有以下三个优点:

  • gRPC 内核已经很好地实现了服务发现、连接管理、编解码器等公共部分,我们可以把开发精力集中在消息队列本身,不需要在网络模块消耗太多精力。
  • gRPC 几乎支持所有主流编程语言,开发各个消息队列的 SDK 可以节省很多开发成本。
  • 很多云原生系统,比如 Service Mesh 都集成了 gRPC 协议,基于 HTTP2 的 gRPC 的消息队列很容易被云原生系统中的其他组件所访问,组件间的集成成本很低。

但是当前主流的消息队列都不支持 gRPC 框架,这是因为如果支持就要做很大的架构改动。而且,gRPC 底层默认是七层的 HTTP2 协议,在性能上,可能比直接基于 TCP 协议实现的方式差一些。但是 HTTP2 本身在性能上做了一些优化,从实际表现来看,性能损耗在大部分场景下是可以接受的。

存储:消息数据和元数据的存储是如何设计的?

消息队列中的数据一般分为元数据和消息数据。元数据是指 Topic、Group、User、ACL、Config 等集群维度的资源数据信息,消息数据指客户端写入的用户的业务数据.

元数据信息的存储

元数据信息的特点是数据量比较小,不会经常读写,但是需要保证数据的强一致和高可靠,不允许出现数据的丢失。同时,元数据信息一般需要通知到所有的 Broker 节点,Broker 会根据元数据信息执行具体的逻辑。
所以元数据信息的存储,一般有两个思路。

  • 基于第三方组件来实现元数据的存储。
  • 在集群内部实现元数据的存储。

基于第三方组件来实现元数据的存储是目前业界的主流选择

img

优点:

  • 集成方便,开发成本低,能满足消息队列功能层面的基本要求,因为我们可以直接复用第三方组件已经实现的一致性存储、高性能的读写和存储、Hook 机制等能力,
  • 而且在后续集群构建中也可以复用这个组件,能极大降低开发难度和工作成本。

缺点:

  • 引入第三方组件会增加系统部署和运维的复杂度,而且第三方组件自身的稳定性问题会增加系统风险
  • 第三方组件和多台 Broker 之间可能会出现数据信息不一致的情况,导致读写异常。

另一种思路,集群内部实现元数据的存储是指在集群内部完成元数据的存储和分发。也就是在集群内部实现类似第三方组件一样的元数据服务,比如基于 Raft 协议实现内部的元数据存储模块或依赖一些内置的数据库。

img

优点是部署和运维成本低,不会因为依赖第三方服务导致稳定性问题,也不会有数据不一致的问题。但缺点是开发成本高,前期要投入大量的开发人力。

消息数据的存储

消息队列的存储主要是指消息数据的存储,分为存储结构、数据分段、数据存储格式、数据清理四个部分。

数据存储结构设计

跟存储有关的主要是 Topic 和分区两个维度。用户可以将数据写入 Topic 或直接写入到分区。
不过如果写入 Topic,数据也是分发到多个分区去存储的。所以从实际数据存储的角度来看,Topic 和 Group 不承担数据存储功能,承担的是逻辑组织的功能,实际的数据存储是在在分区维度完成的。

img

从技术架构的角度,数据的落盘存储也有两个思路。

  • 每个分区单独一个存储“文件”。
  • 每个节点上所有分区的数据都存储在同一个“文件”。

特别说明下,这里的“文件”是一个虚指,即表示所有分区的数据是存储在一起,还是每个分区的数据分开存储的意思。在实际的存储中,这个“文件”通常以目录的形式存在,目录中会有多个分段文件。接下来讲到的文件都是表示这个意思。

每个分区单独一个存储”文件“

优点:

  • 每个分区上的数据顺序写到同一个磁盘文件中,数据的存储是连续的。因为消息队列在大部分情况下的读写是有序的,所以这种机制在读写性能上的表现是最高的。

缺点:

  • 但如果分区太多,会占用太多的系统 FD 资源,极端情况下有可能把节点的 FD 资源耗完,并且硬盘层面会出现大量的随机写情况,导致写入的性能下降很多,
  • 另外管理起来也相对复杂。

Kafka 在存储数据的组织上用的就是这个思路。

img

具体的磁盘的组织结构一般有“目录 + 分区二级结构”和“目录 + 分区一级结构”两种形式。不过从技术上来看,没有太大的优劣区别。

目录+分区二级结构:
├── topic1
│   ├── partrt0
│   ├── 1
│   └── 2
└── topic2
    ├── 0
    ├── 1

目录+分区一级结构:
├── topic1-0
├── topic1-1
├── topic1-2
├── topic2-0
├── topic2-1
└── topic2-2

每个节点上所有分区的数据都存储在同一个“文件”。

这种方案需要为每个分区维护一个对应的索引文件,索引文件里会记录每条消息在 File 里面的位置信息,以便快速定位到具体的消息内容。

img

优点:

  • 因为所有文件都在一份文件上,管理简单,也不会占用过多的系统 FD 资源,单机上的数据写入都是顺序的,写入的性能会很高。

缺点:

  • 是同一个分区的数据一般会在文件中的不同位置,或者不同的文件段中,无法利用到顺序读的优势,读取的性能会受到影响,但是随着 SSD 技术的发展,随机读写的性能也越来越高。如果使用 SSD 或高性能 SSD,一定程度上可以缓解随机读写的性能损耗,但 SSD 的成本比机械硬盘高很多。

目前 RocketMQ、RabbitMQ 和 Pulsar 的底层存储 BookKeeper 用的就是这个方案。
这种方案的数据组织形式一般是这样的。假设这个统一的文件叫 commitlog,则 commitlog 就是用来存储数据的文件,.index 是每个分区的索引信息。

.
├── commitlog
├── topic-0.index
├── topic-1.index
└── topic-2.index

方案对比

  • 第一种方案,单个文件读和写都是顺序的,性能最高。但是当文件很多且都有读写的场景下,硬盘层面就会退化为随机读写,性能会严重下降。
  • 第二种方案,因为只有一个文件,不存在文件过多的情况,写入层面一直都会是顺序的,性能一直很高。但是在消费的时候,因为多个分区数据存储在同一个文件中,同一个分区的数据在底层存储上是不连续的,硬盘层面会出现随机读的情况,导致读取的性能降低。

消息数据的分段实现

数据分段的规则一般是根据大小来进行的,比如默认 1G 一个文件,同时会支持配置项调整分段数据的大小。看数据目录中的文件分段示意图。

img

如果进行了分段,消息数据可能分布在不同的文件中。所以我们在读取数据的时候,就需要先定位消息数据在哪个文件中。为了满足这个需求,技术上一般有根据偏移量定位或根据索引定位两种思路。

根据偏移量定位

根据偏移量(Offset)来定位消息在哪个分段文件中,是指通过记录每个数据段文件的起始偏移量、中止偏移量、消息的偏移量信息,来快速定位消息在哪个文件。
当消息数据存储时,通常会用一个自增的数值型数据(比如 Long)来表示这条数据在分区或 commitlog 中的位置,这个值就是消息的偏移量。

img

在实际的编码过程中,记录文件的起始偏移量一般有两种思路:

  • 单独记录每个数据段的起始和结束偏移量,
  • 在文件名称中携带起始偏移量信息。
    因为数据是顺序存储的,每个文件记录了本文件的起始偏移量,那么下一个文件的起始偏移量就是上一个文件的结束偏移量。

img

根据索引定位

具体是通过维护一个单独的索引文件,记录消息在哪个文件和文件的哪个位置。读取消息的时候,先根据消息 ID 找到存储的信息,然后找到对应的文件和位置,读取数据。RabbitMQ 和 RocketMQ 用的就是这个思路。

img

方案对比

  • 根据偏移量定位数据,通常用在每个分区各自存储一份文件的场景;根据索引定位数据,通常用在所有分区的数据存储在同一份文件的场景。因为在前一种场景,每一份数据都属于同一个分区,那么通过位点来二分查找数据的效率是最高的。
  • 第二种场景,这一份数据属于多个不同分区,则通过二分查找来查找数据效率很低,用哈希查找效率是最高的。

消息数据存储格式

  • 写入文件的格式
  • 消息内容的格式

写入文件的格式

消息写入文件的格式指消息是以什么格式写入到文件中的,比如 JSON 字符串或二进制。从性能和空间冗余的角度来看,消息队列中的数据基本都是以二进制的格式写入到文件的。这部分二进制数据,我们不能直接用 vim/cat 等命令查看,需要用专门的工具读取,并解析对应的格式。

比如,我们想查看 Kafka 消息数据存储文件中的数据,如果用 cat 命令查看是乱码,用日志解析工具 kafka.tools.DumpLogSegments 查看,才是格式化的数据。

# cat 00000000000000000000.log 
>f0z�sl{]�sl{���������������xlobo

# kafka-run-class.sh  kafka.tools.DumpLogSegments --files 00000000000000000000.log --print-data-log

baseOffset: 0 lastOffset: 0 count: 1 baseSequence: -1 lastSequence: -1 producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0 isTransactional: false isControl: false position: 0 LogAppendTime: 1681268702091 size: 74 magic: 2 compresscodec: NONE crc: 1714453217 isvalid: true
| offset: 0 LogAppendTime: 1681268702091 keysize: 2 valuesize: 4 sequence: -1 headerKeys: [] key: xu payload: lobo

消息内容的格式

消息内容的格式是指写入到文件中的数据都包含哪些信息。对于一个成熟的消息队列来说,消息内容格式不仅关系功能维度的扩展,还牵涉性能维度的优化。
如果消息格式设计得不够精简,功能和性能都会大打折扣。比如冗余字段会增加分区的磁盘占用空间,使存储和网络开销变大,性能也会下降。如果缺少字段,则可能无法满足一些功能上的需要,导致无法实现某些功能,又或者是实现某些功能的成本较高。
所以,在数据的存储格式设计方面,内容的格式需要尽量完整且不要有太多冗余。

消息数据清理机制

消息队列的数据过期机制一般有手动删除和自动删除两种形式,从实现上看主要有三种思路。

  • 消费完成执行 ACK 删除数据
  • 根据时间和保留大小删除
  • ACK 机制和过期机制相结合

消费完成执行 ACK 删除数据

消费完成执行 ACK 删除数据,技术上的实现思路一般是:当客户端成功消费数据后,回调服务端的 ACK 接口,告诉服务端数据已经消费成功,服务端就会标记删除该行数据,以确保消息不会被重复消费。

img

ACK 的请求一般会有单条消息 ACK 和批量消息 ACK 两种形式。因为消息队列的 ACK 一般是顺序的,如果前一条消息无法被正确处理并 ACK,就无法消费下一条数据,导致消费卡住。此时就需要死信队列的功能,把这条数据先写入到死信队列,等待后续的处理。然后 ACK 这条消息,确保消费正确进行。

这个方案,优点:

  • 是不会出现重复消费,一条消息只会被消费一次。
    缺点是:
  • ACK 成功后消息被删除,无法满足需要消息重放的场景。

根据时间和保留大小删除

根据时间和保留大小删除指消息在被消费后不会被删除,只会通过提交消费位点的形式标记消费进度。

实现思路一般是服务端提供偏移量提交的接口,当客户端消费成功数据后,客户端会回调偏移量提交接口,告诉服务端这个偏移量的数据已经消费成功了,让服务端把偏移量记录起来。然后服务端会根据消息保留的策略,比如保留时间或保留大小来清理数据。一般通过一个常驻的异步线程来清理数据。

img

这个方案,优点:

  • 一条消息可以重复消费多次。不管有没有被成功消费,消息都会根据配置的时间规则或大小规则进行删除。
  • 消息可以多次重放,适用于需要多次进行重放的场景。

缺点是:

  • 在某些情况下(比如客户端使用不当)会出现大量的重复消费。

ACK 机制和过期机制相结合

实现核心逻辑跟方案二很像,但保留了 ACK 的概念,不过 ACK 是相对于 Group 概念的。
当消息完成后,在 Group 维度 ACK 消息,此时消息不会被删除,只是这个 Group 也不会再重复消费到这个消息,而新的 Group 可以重新消费订阅这些数据。所以在 Group 维度避免了重复消费的情况,也可以允许重复订阅。

img

前面我们虽然反复提到“删除”,但数据实际怎么删除也有讲究。

img

当前主流的思路都是延时删除,以段数据为单位清理,降低频繁修改文件内容和频繁随机读写文件的操作。
只有该段里面的数据都允许删除后,才会把数据删除。而删除该段数据中的某条数据时,会先对数据进行标记删除,比如在内存或 Backlog 文件中记录待删除数据,然后在消费的时候感知这个标记,这样就不会重复消费这些数据。

存储:如何提升存储模块的性能和可靠性?

核心要解决的其实就是两个问题:“写得快”和“读得快”。这两个问题如何解决呢?我们从四点和存储性能优化有关的基础理论讲起。

  • 内存读写的效率高于硬盘读写
  • 批量读写的效率高于单条读写
  • 顺序读写的效率高于随机读写
  • 数据复制次数越多,效率越低

提升写入操作的性能

消息队列的数据最终是存储在文件中的,数据写入需要经过内存,最终才到硬盘,所以写入优化就得围绕内存和硬盘展开。写入性能的提高主要有缓存写、批量写、顺序写三个思路,这里对比来讲。

缓存写和批量写

在计算机理论基础中,计算机多级存储模型的层级越高,代表速度越快(同时容量也越小,价格也越贵),也就是说写入速度从快到慢分别是:寄存器 > 缓存 > 主存 > 本地存储 > 远程存储。

img

所以基于理论 1 和 2:内存读写的效率高于硬盘读写,批量读写的效率高于单条读写
写入优化的主要思路之一是:将数据写入到速度更快的内存中,等积攒了一批数据,再批量刷到硬盘中。

平时我们在一些技术文章看到的“数据先写入 PageCache,再批量刷到硬盘”,说的就是这个思路。PageCache 指操作系统的页缓存,简单理解就是内存,通过缓存读写数据可以避免直接对硬盘进行操作,从而提高性能。

img

具体来说就是把硬盘中的数据缓存到内存中,这样对硬盘的访问就变成了对内存的访问。然后再通过一定的策略,把缓存中的数据刷回到硬盘。一般情况下,内存数据是自动批量刷到硬盘的,这个逻辑对应用是透明的。把缓存数据刷回到硬盘,一般有“按照空间占用比例”、“时间周期扫描”和“手动强制刷新”三种策略。操作系统内核提供了前两种处理策略,不需要应用程序感知。我们具体了解一下。

按空间占用比例刷新是指当系统内存中的“脏”数据大于某个阈值时会将数据刷新到硬盘。操作系统提供了两个配置项。

  • “脏”数据在内存中的占比(dirty_background_ratio)
  • “脏”数据的绝对的字节数(dirty_background_bytes)
    当这两个配置超过阈值,就会触发刷新操作。如果两者同时设置,则以绝对字节数为更高优先级。

按时间周期刷新是指根据配置好的时间,周期性刷新数据到硬盘。主要通过脏页存活时间(dirty_expire_seconds) 和刷新周期(dirty_writeback_centisecs)两个参数来配置。两个配置默认都是 1/100,也就说时间间隔为每秒 100 次,根据刷新周期的配置周期性执行刷新,刷新会检查脏页的存活时间是否超过配置的最大存活时间,如果是则刷入硬盘。

同时,操作系统也提供了第三种方法程序手动强制刷新,你可以通过系统提供的 sync()/msync()/fsync() 调用来强制刷新缓存。

消息队列一般会同时提供:是否同步刷盘、刷盘的时间周期、刷盘的空间比例三个配置项,让业务根据需要调整自己的刷新策略。从性能的角度看,异步刷新肯定是性能最高的,同步刷新是可靠性最高的。

随机写和顺序写

多文件顺序写入硬盘,系统中有很多文件同时写入,这个时候从硬盘的视角看,你会发现操作系统同时对多个不同的存储区域进行操作,硬盘控制器需要同时控制多个数据的写入,所以从硬盘的角度是随机写的。

img

所以,在消息队列中,实现随机写和顺序写的核心就是数据存储结构的设计。上节课我们讲过数据存储结构设计有两个思路:每个 Partition/Queue 单独一个存储文件,每台节点上所有 Partition/Queue 的数据都存储在同一个文件。
第一种方案,对单个文件来说读和写都是顺序的,性能最高,但当文件很多且都有读写,在硬盘层面就会退化为随机读写,性能会下降很多。第二种方案,因为只有一个文件,不存在文件过多的情况,写入层面一直都会是顺序的,性能一直很高。所以为了提高写的性能,我们最好使用第二种方案。

提升写入操作的可靠性

为了提高数据可靠性,在消息队列的存储模块中,一般会通过三种处理手段:同步刷盘、WAL 预写日志、多副本备份,进一步提升数据的可靠性。

同步刷盘

同步刷盘指每条数据都同步刷盘,等于回到了直接写硬盘的逻辑,一般通过写入数据后调用 force() 操作来完成数据刷盘。这种方案无法利用内存写入速度的优势,效率会降低很多。

img

一般消息队列都会开放这个配置项,默认批量刷盘,但有丢失数据的风险。如果业务需要修改为直接刷盘的策略来提高数据的可靠性,则会有一定的性能降低。

WAL

WAL(预写日志)指在写数据之前先写日志,当出现数据丢失时通过日志来恢复数据,避免数据丢失。
WAL 日志需要写入持久存储,业务数据也要写入缓存,多了一步,性能会不会降低呢?

img

没错,从理论来看,WAL 机制肯定会比直接写入缓存中的性能低。但我们实际落地的时候往往可以通过一些手段来优化,降低影响,达到性能要求。
因为在消息队列中,消息数据的数据量是非常大的,我们不可能直接使用非常高性能的持久存储设备,成本太高。虽然 WAL 日志需要极高的写入性能,但是数据量一般很小,而且是可顺序存储的、可预测的(根据配置的缓存大小和更新策略可明确计算)。

所以在实际落地中,我们可以采取 WAL 日志盘和实际数据盘分离的策略,提升 WAL 日志的写入速度。具体就是让 WAL 数据盘是高性能、低容量的数据盘,数据盘是性能较低、容量较大的数据盘,如果出现数据异常,就通过 WAL 日志进行数据恢复。

多副本的备份

多副本的备份就是将数据拷贝到多台节点,每台节点都写入到内存中,从而完成数据的可靠性存储。因为单机层面也是把数据写入到内存中就记录写入成功,单机层面也可能出现数据丢失,所以核心思路是同时在多台节点中缓存数据,只要不是多台节点同时重启,数据就可以恢复。

img

有节点在同一时刻重启,数据还是有可能丢失的,无法保证百分百的数据高可靠。

提升读取操作的性能

提高读取的性能主要有读热数据、顺序读、批量读、零拷贝四个思路。

冷读和热读

热读是指消息数据本身还在缓存中,读取数据是从内存中获取,此时性能最高,不需要经过硬盘。冷读是指消息数据刷到硬盘中了,并且数据已经被换页换出缓存了,此时读取数据需要从硬盘读取。

img

理想情况,肯定全部是热读最好,因为性能最高。但是在代码层面,我们是无法控制冷读或热读的,只能通过配置更大的内存,尽量保证缓存中保留更多的数据,从而提高热读的概率。

顺序读、随机读、批量读

为了能尽快返回数据给客户端,服务端都会实现数据的预读机制。在读取数据的时候,也读取客户下一步可能会用的数据,预先加载到内存中,以便更快返回数据。数据的预读分为两种:硬盘层面预读、应用程序的预读。

硬盘层面的预读

硬盘层面的预读,是在连续的地址空间中读取数据。但具体实现,我们在程序中无法控制,这和数据目录存储结构设计有关。

img

之前讲了两种数据存储目录结构设计。

  • 方案一在读取的过程中,因为数据是连续存储的,数据预读非常方便,只要在硬盘上读取连续的数据块即可,不需要在程序上做逻辑处理,性能最高。
  • 方案二需要根据分区上的数据索引,在具体存储文件的不同位置读取数据。数据可能是连续的,也可能是不连续的。这种情况下硬盘的预读就很有随机性,大部分情况下在硬盘看来就是随机读。性能比第一种方案低。

应用程序的预读

应用程序的预读就比较简单,一般通过程序中的逻辑关系,提前通过调度去硬盘读取数据(可能是连续的也可能是不连续的)。因为消息队列的数据是分区有序的,当读取到某条数据时,手动读取后面的一个批次的数据就可以了。这种方案需要程序去控制,比如 read(0) 时,要同时读 read(1,10) 的数据,相对繁琐,并且性能较低。

零拷贝原理和使用方式

img

如上图所示,在正常读取数据的过程中,数据要经过五步,硬盘 -> ReadBuffer -> 应用程序 -> SocketBuffer -> 网卡设备,四次复制。因为数据在复制过程耗费资源和时间,会降低性能,所以优化流程最重要的是减少数据复制的次数和资源损耗。

零拷贝指的是数据在内核空间和用户空间之间的拷贝次数,即图中的第 2 步和第 3 步。如果只有 1 和 4 两步,没有执行 2 和 3 的话,那么内核空间和用户空间之间的拷贝次数就是零,“零拷贝”的零指的是这个次数“零”,因此是零拷贝。

为了解决复制次数带来的性能损耗,“零拷贝”这个概念就被提出来了。主要思路是通过减少数据复制次数、减少上下文(内核态和用户态)切换次数、通过 DMA(直接内存)代替 CPU 完成数据读写,来解决复制和资源损耗的问题。

img

  • 将数据复制链路缩短成了:硬盘 -> ReadBuffer -> 网卡设备,复制次数从四次减为两次。
  • 用户空间和内核空间之间的数据复制需要进行上下文切换,优化完复制链路后,数据只在内核空间复制传输,就可以减少两次上下文切换。
  • 通过 DMA 来搬运数据,使数据复制不需要通过 CPU,释放 CPU。

零拷贝主要用于在消费的时候提升性能,具体有两种实现方式:mmap+write 和 sendfile。

mmap + write

mmap 是一种内存映射文件的方法,把文件或者其他对象映射到进程的地址空间,修改内存文件也会同步修改,这样就减少了一次数据拷贝。所以,我们不需要把数据拷贝到用户空间,修改后再回写到内核空间。

img

正常的“读取数据并发送”流程是通过 read + write 完成的,比如:

  read(file, tmp_buf, len);
  write(socket, tmp_buf, len);

而操作系统层面的 read(),系统在调用的过程中,会把内核缓冲区的数据拷贝到用户的缓冲区里,为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。比如:

buf = mmap(file, len);
write(sockfd, buf, len);

sendfile

另外一种实现思路 sendfile,是指 Linux 内核提供的一个系统调用 sendfile() ,它可以将数据从一个文件描述符传输到另一个文件描述符。之前图中的红色线路就是通过 senfile 系统调用和 DMA 技术,将四次的数据复制次数变为了两次,提高了性能。

在 Java 中也可以使用零拷贝技术,主要是在 NIO FileChannel 类中。

  • transferTo() 方法:可以将数据从 FileChannel 直接传输到另外一个 Channel。
  • transferFrom() 方法:可以将数据从 Channel 传输到 FileChannel。
    几乎所有的消息队列在消费时都使用了 sendfile 的调用,因为它配合 DMA 技术至少可以提升一倍的消费速度。

通过硬件和系统优化提升性能

提升硬件配置

消息队列是一款非常重视 IO 的组件,使用更快的硬盘 IO 设备,提高单机的吞吐能力,也能快速提升性能

配置多盘读写

系统层面,我们可以通过在机器上挂多块硬盘提升单机的硬盘吞吐能力。这种方案要内核支持这个机制,在部署的时候进行相关配置才能生效。

img

一般实现思路是在消息队列的内核支持多目录读写的能力,将不同的文件或者不同的数据段调度存放在不同硬盘设备对应的挂载目录中。此时在数据的写入和读取的过程中,就可以同时利用到多块盘的吞吐和存储。

配置 RAID 和 LVM 硬盘阵列

多目录读写的问题是多块盘之间无法共享 IO 能力和存储空间,当遇到数据倾斜时,在单机层面会出现性能和容量瓶颈。Linux 提供了 RAID 硬盘阵列和 LVM 逻辑卷管理两种方式,通过串联多块盘的读写能力和容量,提升硬盘的性能和吞吐能力

img

生产端:生产者客户端的SDK有哪些设计要点?

消息队列的客户端主要包含生产、消费、集群管控三类功能。这节课我们聚焦在生产和集群管控。从客户端 SDK 实现的角度来看,生产模块包含客户端基础功能和生产相关功能两部分,其中基础功能是客户端中所有功能共用的。

我们看一张生产模块的功能结构图。

img

基础功能是蓝色部分,包括请求连接管理、心跳检测、内容构建、序列化、重试、容错处理等等。生产功能是黄色部分,包括客户端寻址、分区选择、批量发送,生产错误处理、SSL、压缩、事务、幂等等等。

客户端基础功能

连接管理

在大部分实现中,为了避免连接数膨胀,每个客户端实例和每台 Broker 只会维护一条 TCP 连接。

建立一条 TCP 连接很简单,更关键的是,什么情况下建立连接?一般有初始化创建连接和使用时创建链接两种方式。

  • 初始化创建连接,指在实例初始化时就创建到各个 Broker 的 TCP 连接,等待数据发送。好处是提前创建好可以避免发送的时候冷启动。缺点是需要提前创建好所有的连接,可能导致连接空跑,会消耗一定的资源。
  • 使用时创建链接,指在实例初始化时不建立连接,当需要发送数据时再建立。好处是发送时才建立,连接的使用率会较高。缺点是可能出现连接冷启动,会增加一点本次请求的耗时。

因为连接并不是任何时候都有数据,可能出现长时间连接空闲。所以连接都会搭配连接回收机制,连接建立后如果连接出现长时间空闲,就回收连接。连接回收的策略一般是判断这段时间内是否有发送数据的行为,如果没有就判断是空闲,然后执行回收。

因为单个 TCP 连接发送性能存在上限,我们就需要在客户端启动多个生产者,提高并发读写的能力。一般情况下,每个生产者会有一个唯一的 ID 或唯一标识来标识客户端,比如 ProduceID 或客户端的 IP+Port。
单个 TCP 的瓶颈和很多因素有关,比如网路带宽、网络延迟、客户端请求端的 socketbuff 的配置、TCP 窗口大小、发送速率导致本地数据反压堆积、服务端请求队列的堆积情况、收包和回包的速度等等。

心跳检测

所以客户端和服务端之间的心跳检测机制的实现,一般有基于 TCP 的 KeepAlive 保活机制和应用层主动探测两种形式。

  • 基于 TCP 的 KeepAlive 保活机制是 TCP/IP 协议层内置的功能,需要手动打开 TCP 的 KeepAlive 功能。通过这种方案实现心跳探测,优点是简单,缺点是 KeepAlive 实现是在服务器侧,需要 Server 主动发出检测包,此时如果客户端异常,可能出现很多不可用的 TCP 连接。这种连接会占用服务器内存资源,导致服务端的性能下降。
  • 应用层主动探测一般是 Client 向 Server 发起的,主要解决灵活性和 TCP KeepAlive 的缺陷。探测流程一般是客户端定时发送保活心跳,当服务端连续几次没收到请求,就断开连接。这样做的好处是,可以将压力分担到各个客户端,避免服务端的过载。

错误处理

在客户端的处理中也会将错误分为可重试错误和不可重试错误两类。客户端收到可重试错误后,会通过一定的策略进行重试,尽量确保生产流程的顺利进行。

重试机制

重试策略一般会支持重试次数和退避时间的概念。当消息失败,超过设置的退避时间后,会继续重试,当超过重试次数后,就会抛弃消息或者将消息投递到配置好的重试队列中。

生产相关功能

客户端寻址机制

从客户端的角度看,往服务端写入数据的时候,服务端有那么多台节点,请求要发给哪台节点呢?怎么知道这个分区在这台 Broker 上的对应关系存在哪里呢?为了解决这个问题,业界提出了 Metadata(元数据)寻址机制和服务端内部转发两个思路。

img

Metadata(元数据)寻址机制

服务端会提供一个获取全量的 Metadata 的接口,客户端在启动时,首先通过接口拿到集群所有的元数据信息,本地缓存这部分数据信息。然后,客户端发送数据的时候,会根据元数据信息的内容,得到服务端的地址是什么,要发送的分区在哪台节点上。最后根据这两部分信息,将数据发送到服务端。

img

客户端一般通过定期全量更新 Metadata 信息和请求报错时更新元数据信息两种方式

服务端内部转发机制

具体思路是服务端的每一台 Broker 会缓存所有节点的元数据信息,生产者将数据发送给 Broker 后,Broker 如果判断分区不在当前节点上,会找到这个分区在哪个节点上,然后把数据转发到目标节点。

img

这个方案的好处是分区寻址在服务端完成,客户端的实现成本比较低。但是生产流程多了一跳,耗时增加了。另外服务端因为转发多了一跳,会导致服务端的资源损耗多一倍,比如 CPU、内存、网卡,在大流量的场景下,这种损耗会导致集群负载变高,从而导致集群性能降低。

生产分区分配策略

数据可以直接写入分区或者写入 Topic。写入 Topic 时,最终数据还是要写入到某个分区。这个数据选择写入到哪个分区的过程,就是生产数据的分区分配过程。过程中的分配策略就是生产分区分配策略。

一般情况下,消息队列默认支持轮询、按 Key Hash、手动指定、自定义分区分配策略四种分区分配策略。

  • 轮询是所有消息队列的默认选项。消息通过轮询的方式依次写入到各个分区中,这样可以保证每个分区的数据量是一样的,不会出现分区数据倾斜。
  • 按 Key Hash 是指根据消息的 Key 算出一个 Hash 值,然后跟 Topic 的分区数取余数,算出一个分区号,将数据写入到这个分区中。这种方案的好处是可以根据 Key 来保证数据的分区有序。比如某个用户的访问轨迹,以客户的 AppID 为 Key,按 Key Hash 存储,就可以确保客户维度的数据分区有序。缺点是分区数量不能变化,变化后 Hash 值就会变,导致消息乱序。并且因为每个 Key 的数据量不一样,容易导致数据倾斜。
  • 手动指定很简单,就是在生产数据的时候,手动指定数据写入哪个分区。这种方案的好处就是灵活,用户可以在代码逻辑中根据自己的需要,选择合适的分区,缺点就是业务需要感知分区的数量和变化,代码实现相对复杂。
  • 消息队列也支持自定义分区分配策略,让用户灵活使用。内核提供 Interface(接口)机制,用户如果需要指定自定义的分区分配策略,可以实现对应的接口,然后配置分区分配策略。

批量语义

客户端支持批量写入数据的前提是,需要在协议层支持批量的语义。否则就只能在业务中自定义将多条消息组成一条消息。批量发送的实现思路一般是在客户端内存中维护一个队列,数据写入的时候,先将其写到这个内存队列,然后通过某个策略从内存队列读取数据,发送到服务端。

img

Kafka 是按照时间的策略批量发送的,提供了 linger.ms、max.request.size、batch.size 三个参数,来控制数据的批量发送。

  • linger.ms:设置消息延迟发送的时间,这样可以等待更多的消息组成 Batch 发送。默认为 0 表示立即发送。
  • max.request.size:生产者能够发送的请求包大小上限,默认为 1MB。
  • batch.size:生产者会尝试将业务发送到相同的 Partition 的消息合包后再进行发送,它设置了合包的大小上限。

数据发送方式

消息队列一般也会提供同步发送、异步发送、发送即忘三种形式。
发送即忘指消息发送后不关心请求返回的结果,立即发送下一条。这种方式因为不用关心发送结果,发送性能会提升很多。缺点是当数据发送失败时无法感知,可能有数据丢失的情况,所以适合用在发送不重要的日志等场景。Kafka 提供了 ack=0、RocketMQ 提供了 sendOneway 来支持这种模式。

集群管控操作

集群管控操作一般是用来完成资源的创建、查询、修改、删除等集群管理动作。资源包括主题、分区、配置、消费分组等等。

从功能上来看,消息队列一般会提供多种集群管理方式,比如命令行、客户端、HTTP 接口等等。命令行工具是最基本的支持方式。如下图所示,它的底层主要通过包装客户端 SDK 和服务端的相关功能接口进行交互。程序编码上一般由命令行、参数包装、底层 SDK 调用三部分组成。主要流程是接收参数、处理参数、调用 SDK 等相关操作。有的消息队列也会支持 HTTP 接口形式的管控操作。

消费端:消费者客户端的SDK有哪些设计要点?

消费相关功能包括消费模型、分区消费模式、消费分组(订阅)、消费确认、消费失败处理五个部分。

消费模型的选择

从实现机制上来看,主流消息队列一般支持 Pull、Push、Pop 三种消费模型。

Pull 模型

Pull(拉)模型是指客户端通过不断轮询的方式向服务端拉取数据。它是消息队列中使用最广泛和最基本的模型,主流的消息队列都支持这个模型。

img

  • 它的好处是客户端根据自身的处理速度去拉取数据,不会对客户端和服务端造成额外的风险和负载压力。
  • 缺点是可能会出现大量无效返回的 Pull 调用,另外消费及时性不够,无法满足一些需要全链路低耗时的场景。
  • 为了提高消费性能,Pull 模型都会支持批量读,即在客户端指定需要拉取多少条数据或者拉取多大的数据,然后传递给服务端。

所以为了解决空请求带来的问题,一般服务端会协助处理,有如下两个思路。

  • 服务端 hold 住请求
    当客户端根据策略拉取数据时,如果没有足够的数据,就先在服务端等一段时间,等有数据后一起返回给客户端。这种方案的好处是,可以尽量提高吞吐能力,不会有太多的空交互请求。缺点是如果长时间不给客户端回包,会导致客户端请求超时,另外当数据不够时,hold 住请求的时间太长就会提高消费延时。

  • 服务端有数据的时候通知客户端
    当服务端不 hold 住请求,立刻返回空数据,客户端收到空数据时则不再发起请求,会等待服务端的通知。当服务端有数据的时候,再主动通知客户端来拉取。这种方案的好处是可以及时通知客户端来拉取数据,从而降低消费延时。缺点是因为客户端和服务端一般是半双工的通信,此时服务端是不能主动向客户端发送消息的。

所以在 Pull 模型中,比较合适的方案是客户端告诉服务端:最多需要多少数据、最少需要多少数据、未达到最小数据时可以等多久三个信息。然后服务端首先判断是否有足够的数据,有的话就立即返回,否则就根据客户端设置的等待时长 hold 住请求,如果超时,无论是否有数据,都会直接给客户端返回当前的结果。

Push 模型

Push(推)模型是为了解决消费及时性而提出来的。这个模型的本意是指当服务端有数据时会主动推给客户端,让数据的消费更加及时。理想中的思路如下图所示,即当服务端有数据以后,会主动推动给各个消费者

img

  • 第一种,Broker 内置 Push 功能是指在 Broker 中内置标准的 Push 的能力,由服务端向客户端主动推送数据。

Broker 内部可以感知到数据堆积情况,可以保证消息被及时消费。缺点是当消费者很多时,内核需要主动维护很多与第三方的长连接,并且需要处理各种客户端异常,比如客户端卡住、接收慢、处理慢等情况。这些推送数据、异常处理、连接维护等工作需要消耗很多的系统资源,在性能上容易对 Broker 形成反压,导致 Broker 本身的性能和稳定性出现问题。

  • 第二种,Broker 外独立实现 Push 功能的组件是指独立于 Broker 提供一个专门实现推模型的组件

通过先 Pull 数据,再将数据 Push 给客户端,从而简化客户端的使用,提高数据消费的及时性。这种方案的好处是将 Push 组件独立部署,解决了 Broker 的性能和稳定性问题,也能实现 Push 的效果。缺点是虽然实现了 Push 的模型,但其本质还是先 Pull 再 Push,从全链路来看,还是会存在延时较高的问题,并且需要单独开发独立的 Push 组件,开发和运维成本较高。

  • 第三种,在客户端实现伪 Push 功能是指在客户端内部维护内存队列,SDK 底层通过 Pull 模型从服务端拉取数据存储到客户端的内存队列中。然后通过回调的方式,触发用户设置的回调函数,将数据推送给应用程序,在使用体验上看就是 Push 的效果。

img

这种方案的好处在于通过客户端底层的封装,从用户体验看是 Push 模型的效果,解决用户代码层面的不断轮询问题,降低了用户的使用复杂度。缺点是底层依旧是 Pull 模型,还是得通过不断轮询的方式去服务端拉取数据,就会遇到 Pull 模型遇到的问题。在客户端实现伪 Push,是目前消息队列在实现 Push 模型上常用的实现方案,因为它解决了客户体验上的主动回调触发消费问题。虽然底层会有不断轮询和消费延时的缺点,但是可以通过编码技巧来降低这两个问题的影响。

Pop 模型

因为 Push 模型需要先分配分区和消费者的关系,客户端就需要感知分区分配、分区均衡等操作,从而在客户端就需要实现比较重的逻辑。并且当客户端和订阅的分区数较多时,容易出现需要很长的重平衡时间的情况。此时为了解决这个问题,业界提出了 Pop 模型。

它的思路是客户端不需要感知到分区,直接通过 Pop 模型提供的 get 接口去获取到数据,消费成功后 ACK 数据。就跟我们发起 HTTP 请求去服务端拉取数据一样,不感知服务端的数据分布情况,只需要拉到数据。这种方案的好处是简化了消费模型,同时服务端可以感知到消费的堆积情况,可以根据堆积情况返回那些分区的数据给客户端,这样也简化了消息数据的分配策略。

img

分区消费模式的设计

在数据的消费模式上主要有独占消费、共享消费、广播消费、灾备消费四个思路。

独占消费

  • 独占消费是指一个分区在同一个时间只能被一个消费者消费。在消费者启动时,会分配消费者和分区之间的消费关系。当消费者数量和分区数量都没有变化的情况下,两者之间的分配关系不会变动。
  • 当分配关系变动时,一个分组也只能被一个消费者消费,这个消费者可能是当前的,也可能是新的。
  • 如果消费者数量大于分区数量,则会有消费者被空置;反之,如果分区数量大于消费者数量,一个消费者则可以同时消费多个分区。

img

独占消费的好处是可以保证分区维度的消费是有序的。缺点是当数据出现倾斜、单个消费者出现性能问题或 hang 住时,会导致有些分区堆积严重。现在大部分消息队列默认支持的就是独占消费的类型,比如 Kafka、RocketMQ、Pulsar 等。

共享消费

共享消费是指单个分区的数据可以同时被多个消费者消费。即分区的数据会依次投递给不同的消费者,一条数据只会投递给一个消费者。

img

这种方式的好处是,可以避免单个消费者的性能和稳定性问题导致分区的数据堆积。缺点是无法保证数据的顺序消费。这种模式一般用在对数据的有序性无要求的场景,比如日志。

广播消费

广播消费是指一条数据要能够被多个消费者消费到。即分区中的一条数据可以投递给所有的消费者,这种方式是需要广播消费的场景。
实现广播消费一般有内核实现广播消费的模型、使用不同的消费分组消费和指定分区消费三种技术思路。

  • 内核实现广播消费的模型,指在 Broker 内核中的消息投递流程实现广播消费模式,即 Broker 投递消息时,可以将一条消息吐给不同的消费者,从而实现广播消费。
  • 使用不同的消费分组对数据进行消费,指通过创建不同的消费者组消费同一个 Topic 或分区,不同的消费分组管理自己的消费进度,消费到同一条消息,从而实现广播消费的效果。
  • 指定分区消费,是指每个消费者指定分区进行消费,在本地记录消费位点,从而实现不同消费者消费同一条数据,达到广播消费的效果。

img

灾备消费

灾备消费是独占消费的升级版,在保持独占消费可以支持顺序消费的基础上,同时加入灾备的消费者。当消费者出现问题的时候,灾备消费者加入工作,继续保持独占顺序消费。

好处是既能保持独占顺序消费,又能保证容灾能力。缺点是无法解决消费倾斜的性能问题,另外还需要准备一个消费者来做灾备,使用成本较高。

img

消费分组

消费分组是用来组织消费者、分区、消费进度关系的逻辑概念。
在没有消费分组直接消费 Topic 的场景下,如果希望不重复消费 Topic 中的数据,那么就需要有一个标识来标识当前的消费情况,比如记录进度。这个唯一标识就是消费分组。

img

因为 Topic 不存储真实数据,分区才存储消息数据,所以就需要解决消费者和分区的分配关系,即哪个分区被哪个消费者消费,这个分配的过程就叫做消费重平衡(Rebalance)。

img

如果消费者和分区出现变动,比如消费者挂掉、新增消费者、订阅的 Topic 的分区数发生变化等等,就会重新开始分配消费关系

协调者

从实现上来看,如果要对消费者和分区进行分配,肯定需要有一个模块拥有消费分组、所有的消费者、分区信息三部分信息,这个模块我们一般命名为协调者。协调者主要的工作就是执行消费重平衡,并记录消费分组的消费进度。

  • 在协调者完成,即协调者首先获取消费者和分区的信息,然后在协调者内部完成分区分配,最后再把分配关系同步给所有消费者。
  • 在消费者完成,即负责分配的消费者获取所有消费者和分区的信息,然后该消费者完成分区分配操作,最后再把分配关系同步给其他消费者。

从技术上来看,这两种形式的优劣区别并不大,取决于代码的实现。一般在创建消费分组和消费者 / Topic 分区发生变化的时候,会触发协调者执行消费重平衡。

img

从实现的角度来看,协调者一般是 Broker 内核的一个模块,就是一段代码或者一个类,专门用来完成上述的工作。当有多台 Broker 时,协调者的实现有多种方式,比如 Kafka 集群每台 Broker 都有协调者存在。通过消费分组的名称计算出来一个 hash 值和 __consumer_offset 的分区数,取余计算得出一个分区号。最后这个分区号对应的 Leader 所在的 Broker 节点就是协调者所在的节点。客户端就和计算出来的这台 Broker 节点进行交互,来执行消费重平衡的相关操作。

消费分区分配策略

在具体实现上,一般内核会默认提供几种分配策略,也可以通过定义接口来支持用户自定义实现分区分配策略

分区分配策略的制定一般遵循以下三个原则:

  • 各个分区的数据能均匀地分配给每个消费者,保证所有消费者的负载最大概率是均衡的,该原则最为常用。
  • 在每次重新分配的时候,尽量减少分区和消费者之间的关系变动,这样有助于加快重新分配的速度,并且保持数据处理的连续性,降低处理切换成本。
  • 可以允许灵活地根据业务特性制定分配关系,比如根据机房就近访问最近的分区、某个 Topic 的奇数分区分配给第一个消费者等等。

消费确认

那么当数据被消费成功后,就必须进行消费确认操作了,告诉服务端已经成功消费了这个数据。消费确认就是我们在消息队列中常说的 ACK。

  • 确认后删除数据是指集群的每条消息只能被消费一次,只要数据被消费成功,就会回调服务端的 ACK 接口,服务端就会执行数据删除操作。在实际开发的过程中,一般都会支持单条 ACK 和批量 ACK 两种操作。这种方式不利于回溯消费,所以用得比较少。

img

  • 消费成功保存消费进度是指当消费数据成功后,调用服务端的消费进度接口来保存消费进度。这种方式一般都是配合消费分组一起用的,服务端从消费分组维度来保存进度数据。

img

为了保证消息的回溯消费和多次消费,消息队列大多数用的是第二种方案。数据的删除交由数据过期策略去执行。保存消费进度一般分为服务端保存和客户端自定义保存两种实现机制。

  1. 服务端保存是指当消费端消费完成后,客户端需要调用一个接口提交信息,这个接口是由服务端提供的“提交消费进度”接口,然后服务端会持久保存进度。

在提交位点信息的时候,底层一般支持自动提交和手动提交两种实现。

  • 自动提交一般是根据时间批次或数据消费到客户端后就自动提交,提交过程客户无感知。
  • 手动提交是指业务根据自己的处理情况,手动提交进度信息,以避免业务处理异常导致的数据丢失。

img

  1. 客户端自定义保存是指当消费完成后,客户端自己管理保存消费进度。

自定义保存进度信息即可,比如保存在客户端的缓存、文件、自定义的服务中,当需要修改和回滚的时候就比较方便。这种方案的优点就是灵活,缺点就是会带来额外的工作量。

消费失败处理

一个完整的消费流程包括消费数据、本地业务处理、消费进度提交三部分。那么从消费失败的角度来看,就应该分为从服务端拉取数据失败、本地业务数据处理失败、提交位点信息失败三种情况。

  • 从服务端拉取数据失败,和客户端的错误逻辑处理是一致的,根据可重试错误和不可重试错误的分类,进行重复消费或者向上抛错。

  • 本地业务数据处理失败,处理起来就比较复杂了。如果是偶尔失败,那么在业务层做好重试处理逻辑,配合手动提交消费进度的操作即可解决。如果是一直失败,即使重试多次也无法被解决,比如这条数据内容有异常,导致无法被处理。此时如果一直重试,就会出现消费卡住的情况,这就需要配合死信队列的功能,将无法被处理的数据投递到死信队列中,从而保存异常数据并保证消费进度不阻塞。

  • 提交位点信息失败,其处理方法通常是一直重试,重复提交,如果持续失败就向上抛错。因为如果提交进度失败,即使再从服务端拉取数据,还是会拉到同一批数据,出现重复消费的问题。

从基础功能拆解RabbitMQ的架构设计与实现

RabbitMQ 系统架构

img

RabbitMQ 由 Producer、Broker、Consumer 三个大模块组成。生产者将数据发送到 Broker,Broker 接收到数据后,将数据存储到对应的 Queue 里面,消费者从不同的 Queue 消费数据。

Exchange 称为交换器,它是一个逻辑上的概念,用来做分发,本身不存储数据。流程上生产者先将消息发送到 Exchange,而不是发送到数据的实际存储单元 Queue 里面。然后 Exchange 会根据一定的规则将数据分发到实际的 Queue 里面存储。这个分发过程就是 Route(路由),设置路由规则的过程就是 Bind(绑定)。即 Exchange 会接收客户端发送过来的 route_key,然后根据不同的路由规则,将数据发送到不同的 Queue 里面。

那为什么 RabbitMQ 会有 Exchange、Bind、Route 这些独有的概念呢?主要和当时业界的架构设计思想以及主导设计 AMQP 协议的公司背景有关。当时的设计思路是:希望发消息跟写信的流程一样,可以有一个集中的分发点(邮局),通过填写好地址信息,最终将信投递到目的地。这个集中分发点(邮局)就是 Exchange,地址信息就是 Route,填写地址信息的操作就是 Bind,目的地是 Queue。

协议和网络模块

在网络通信协议层面,RabbitMQ 数据流是基于四层 TCP 协议通信的,跑在 TCP 上的应用层协议是 AMQP。如果开启 Management 插件,也可以支持 HTTP 协议的生产和消费。TCP + AMQP 是数据流的默认访问方式,也是官方推荐的使用方式,因为它性能会比 HTTP 高很多。

AMQP 是一个应用层的通信协议,可以看作一系列结构化命令的集合,用来填充 TCP 层协议的 body 部分。通过协议命令进行交互,可以完成各种消息队列的基本操作,如 Connection.Start(建立连接)、Basic.Publish(发送消息)等等

img

先来看下面这张图,在 RabbitMQ 的网络层有 Connectoion 和 Channel 两个概念需要关注。

img

Connection 是指 TCP 连接,Channel 是 Connection 中的虚拟连接。两者的关系是:

  • 一个客户端和一个 Broker 之间只会建立一条 TCP 连接,就是指 Connection。
  • Channel(虚拟连接)的概念在这个连接中定义,一个 Connection 中可以创建多个 Channel。客户端和服务端的实际通信都是在 Channel 维度通信的。

主要包含 tcp_listener、tcp_acceptor、rabbit_reader 三个进程。如下图所示,RabbitMQ 服务端通过 tcp_listener 监听端口,tcp_acceptor 接收请求,rabbit_reader 处理和返回请求。本质上来看是也是一个多线程的网络模型。

img

数据存储

img

元数据存储

RabbitMQ 的元数据都是存在于 Erlang 自带的分布式数据库 Mnesia 中的。即每台 Broker 都会起一个 Mnesia 进程,用来保存一份完整的元数据信息。因为 Mnesia 本身是一个分布式的数据库,自带了多节点的 Mnesia 数据库之间的同步机制。所以在元数据的存储模块,RabbitMQ 的 Broker 只需要调用本地的 Mnesia 接口保存、变更数据即可。不同节点的元数据同步 Mnesia 会自动完成。

消息数据存储

RabbitMQ 消息数据的最小存储单元是 Queue,即消息数据是按顺序写入存储到 Queue 里面的。在底层的数据存储方面,所有的 Queue 数据是存储在同一个“文件”里面的。这个“文件”是一个虚拟的概念,表示所有的 Queue 数据是存储在一起的意思。

img

这个“文件”由队列索引(rabbit_queue_index)和消息存储(rabbitmq_msg_store)两部分组成。

img

  • 即在节点维度,所有 Queue 数据都是存储在 rabbit_msg_store 里面的,每个节点上只有一个 rabbit_msg_store,数据会依次顺序写入到 rabbit_msg_store 中。
  • rabbit_msg_store 是一个逻辑概念,底层的实际存储单元分为两个,msg_store_persistent 和 msg_store_transient,分别负责持久化消息和非持久化消息的存储。
  • msg_store_persistent 和 msg_store_transient 在操作系统上是以文件夹的形式表示的,具体的数据存储是以不同的文件段的形式存储在目录中,所有消息都会以追加的形式写入到文件中。当一个文件的大小超过了配置的单个文件的最大值,就会关闭这个文件,然后再创建一个文件来存储数据。
  • 队列索引负责存储、维护队列中落盘消息的信息,包括消息的存储位置、是否交付、是否 ACK 等等信息。队列索引是 Queue 维度的,每个 Queue 都有一个对应的队列索引。

RabbitMQ 也提供了过期时间(TTL)机制,用来删除集群中没用的消息。它支持单条消息和队列两个维度来设置数据过期时间。如果在队列上设置 TTL,那么队列中的所有消息都有相同的过期时间。我们也可以对单条消息单独设置 TTL,每条消息的 TTL 可以不同。如果两种方案一起使用,那么消息的 TTL 就会以两个值中最小的那个为准。如果不设置 TTL,则表示此消息不会过期。

删除消息时,不会立即删除数据,只是从 Erlang 中的 ETS 表删除指定消息的相关信息,同时更新消息对应的存储文件的相关信息。此时文件中的消息不会立即被删除,会被标记为已删除数据,直到一个文件中都是可以删除的数据时,再将这个文件删除,这个动作就是常说的延时删除。另外内核有检测机制,会检查前后两个文件中的数据是否可以合并,当符合合并规则时,会进行段文件的合并。

生产者和消费者

img

  • RabbitMQ 集群部署后,为了提高容灾能力,就需要在集群前面挂一层负载均衡来进行灾备。
  • 在每个 Broker 上会设置有转发的功能。

img

客户端和服务端传输协议的内容遵循 AMQP 协议,底层以二进制流的形式序列化数据。即根据 AMQP 协议的格式构建内容后,然后序列化为二进制的格式,传递给 Broker 进行处理。

  • 生产端发送数据不是直接发送到 Queue,而是直接发送到 Exchange。即发送时需要指定 Exchange 和 route_key,服务端会根据这两个信息,将消息数据分发到具体的 Queue。
  • Exchagne 和 Route 的功能就是生产分区分配的过程,只是将这个逻辑从客户端移动到了服务端而已。

在消费端,RabbitMQ 支持 Push(推)和 Pull(拉)两种模式,

  • 如果使用了 Push 模式,Broker 会不断地推送消息给消费者。不需要客户端主动来拉,只要服务端有消息就会将数据推给客户端。当然推送消息的个数会受到 channel.basicQos 的限制,不能无限推送,在消费端会设置一个缓冲区来缓冲这些消息。
  • 拉模式是指客户端不断地去服务端拉取消息,RabbitMQ 的拉模式只支持拉取单条消息。

在 AMQP 协议中,是没有定义 Topic 和消费分组的概念的,所以在消费端没有消费分区分配、消费分组 Rebalance 等操作,消费者是直接消费 Queue 数据的。
为了保证消费流程的可靠性,RabbitMQ 也提供了消息确认机制。消费者在消费到数据的时候,会调用 ACK 接口来确认数据是否被成功消费。底层提供了自动 ACK 和手动 ACK 两种机制。

  • 自动 ACK 表示当客户端消费到数据后,消费者会自动发送 ACK,默认是自动 ACK。
  • 手动 ACK 表示客户端消费到数据后,需要手动调用。ACK 的时候,支持单条 ACK 和批量 ACK 两种动作,批量 ACK 可以用来提升 ACK 效率。
  • 另外,为了提升 ACK 动作的性能,有些客户端也支持异步的 ACK。

HTTP 协议支持和管控操作

RabbitMQ 内核本身不支持 HTTP 协议的生产、消费和集群管控等操作。如果需要支持,则需要先手动开启 Management 插件,通过插件的形式让内核支持这个功能。
从实现上来看 Management 插件对 HTTP 协议的支持,就是在开启插件的时候,会启动一个新的 HTTP Server 来监听一个新的端口。客户端只需要访问这个端口提供的 HTTP 接口,就可以完成 HTTP 读写数据和一些集群管控的操作

img

从基础功能拆解RocketMQ的架构设计与实现

RocketMQ 系统架构

img

RocketMQ 由 Producer、NameServer、Broker、Consumer 四大模块组成。其中,NameServer 是 RocketMQ 的元数据存储组件。另外,在 RocketMQ 5.0 后,还增加了 Proxy 模块,用来支持 gRPC 协议,并为后续的计算存储分离架构做准备。

RocketMQ 有 Topic、MessageQueue、Group 的概念,一个 Topic 可以包含一个或多个 MessageQueue,一个 Group 可以订阅一个或多个 Topic。MessageQueue 是具体消息数据的存储单元,订阅的时候通过 Group 来管理消费订阅关系。

从流程上看,Broker 在启动的时候会先连接 NameServer,将各自的元数据信息上报给 NameServer,NameServer 会在内存中存储元数据信息。客户端在连接集群的时候,会配置对应的 NameServer 地址,通过连接 NameServer 来实现客户端寻址,从而连接上对应的 Broker。

客户端在发送数据的时候,会指定 Topic 或 MessageQueue。Broker 收到数据后,将数据存储到对应的 Topic 中,消息存储在 Topic 的不同 Queue 中。在底层的文件存储中,所有 Queue 的数据是存储在同一个 CommitLog 文件中的。在订阅的时候会先创建对应的 Group,消费消息后,再确认数据。

从客户端来看,在 RocketMQ 5.0 以后,我们也可以通过直连 Proxy,将数据通过 gRPC 协议发送给 Proxy。Proxy 在当前阶段本质上只是一个代理(gRPC 协议的代理),不负责真正的数据存储,当收到数据后,还是将数据转发到 Broker 进行保存。

协议和网络模块

在协议方面,如下图所示,RocketMQ 5.0 之前支持自定义的 Remoting 协议,在 5.0 之后,增加了 gRPC 协议的支持。
这是在 Proxy 组件上完成了对 gRPC 协议的支持,即 Broker 依旧只支持 Remoting 协议,如果需要支持 gRPC 协议,那么就需要单独部署 Proxy 组件。

img

在传输层协议方面,Remoting 和 gRPC 都是基于 TCP 协议传输的。Remoting 直接基于四层的 TCP 协议通信,gRPC 是基于七层的 HTTP2 协议通信,不过 HTTP2 底层也是基于 TCP,它们本质上都是应用层的协议。

数据存储

元数据存储

img

RocketMQ 的元数据信息实际是存储在 Broker 上的,Broker 启动时将数据上报到 NameServer 模块中汇总缓存。NameServer 是一个简单的 TCP Server,专门用来接收、存储、分发 Broker 上报的元数据信息。这些元数据信息是存储在 NameServer 内存中的,NameServer 不会持久化去存储这些数据。

Broker 启动或删除时,会调用 NameServer 的注册和退出接口,每个 Broker 都会存储自己节点所属的元数据信息(比如有哪些 Topic、哪些 Queue 在本节点上),在 Broker 启动时,会把全量的数据上报到 NameServer 中。

从部署形态上看,NameServer 是多节点部署的,是一个集群。 但是不同节点之间是没有相互通信的,所以本质上多个 NameServer 节点间数据没有一致性的概念,是各自维护自己的数据,由每台 Broker 上报元数据来维护每台 NameServer 节点上数据的准确性。

消息数据

RocketMQ 消息数据的最小存储单元是 MessageQueue,也就是我们常说的 Queue 或 Partition。Topic 可以包含一个或多个 MessageQueue,数据写入到 Topic 后,最终消息会分发到对应的 MessageQueue 中存储。

img

在底层的文件存储方面,并不是一个 MessageQueue 对应一个文件存储的,而是一个节点对应一个总的存储文件,单个 Broker 节点下所有的队列共用一个日志数据文件(CommitLog)来存储,和 RabbitMQ 采用的是同一种存储结构。存储结构如下图所示:

img

图中主要包含 CommitLog、ConsumeQueue、IndexFile 三个跟消息存储相关的文件,下面我们来简单了解一下。

  • CommitLog 是消息主体以及元数据存储主体,每个节点只有一个,客户端写入到所有 MessageQueue 的数据,最终都会存储到这一个文件中。
  • ConsumeQueue 是逻辑消费队列,是消息消费的索引,不存储具体的消息数据。引入的目的主要是提高消息消费的性能。由于 RocketMQ 是基于主题 Topic 的订阅模式,消息消费是针对主题进行的,如果要遍历 Commitlog 文件,基于 Topic 检索消息是非常低效的。Consumer 可根据 ConsumeQueue 来查找待消费的消息,ConsumeQueue 文件可以看成是基于 Topic 的 CommitLog 索引文件。
  • IndexFile 是索引文件,它在文件系统中是以 HashMap 结构存储的。在 RocketMQ 中,通过 Key 或时间区间来查询消息的功能就是由它实现的。

CommitLog 进行分段存储。CommitLog 底层默认单个文件大小为 1G,消息是顺序写入到文件中,当文件满了,就会写入下一个文件。
支持按照时间清理数据。这个时间是按照消息的生产时间计算的,和消息是否被消费无关,只要时间到了,那么数据就会被删除。
不过跟 RabbitMQ 不同的是,RocketMQ 不是按照主题或队列维度来清理数据的,而是按照节点的维度来清理的。原因和 RocketMQ 的存储模型有关,上面说到 RocketMQ 所有 Queue 的日志都存储在一个文件中,如果要支持主题和队列单独管理,需要进行数据的合并、索引的重建,实现难度相对复杂,所以 RocketMQ 并没有选择主题和队列这两个维度的清理逻辑。

生产者和消费者

RocketMQ 的客户端连接服务端是需要经过客户端寻址的。如下图所示,首先和 NameServer 完成寻址,拿到 Topic/MessageQueue 和 Broker 的对应关系后,接下来才会和 Broker 进行交互。

img

生产端

从生产端来看,生产者是将数据发送到 Topic 或者 Queue 里面的。如果是发送到 Topic,则数据要经历生产数据分区分配的过程。即决定消息要发送到哪个目标分区。

由于 RocketMQ 在协议层不支持批量发送消息的协议,所以在 SDK 底层是没有等待、聚合发送逻辑的。所以如果需要批量发送数据,就需要在生产的时候进行聚合,然后发送。

为了满足不同的发送场景,RocketMQ 支持单向发送、同步发送、异步发送三种发送形式。单向发送(Oneway)指发送消息后立即返回,不处理响应,不关心是否发送成功。同步发送(Sync)指发送消息后等待响应。异步发送(Async)指发送消息后立即返回,在提供的回调方法中处理响应。

消费端

在 RocketMQ 消费端,为了满足不同场景的消费需要,RocketMQ 同时支持 Pull、Push、Pop 三种消费模型。

  • 默认的消费模型是 Pull,Pull 的底层是以客户端会不断地去服务端拉取数据的形式实现的。
  • Push 模型底层是以伪 Push 的方式实现的,即在客户端底层用一个 Pull 线程不断地去服务端拉取数据,拉到数据后,触发客户端设置的回调函数。让客户端从感受上看,是服务端直接将数据 Push 过来的。
  • RocketMQ 推出了 Pop 模式,将消费分区、分区分配关系、重平衡都移到了服务端,减少了重平衡机制给客户端带来的复杂性。

消息按照哪种逻辑分配给哪个消费者,就是由消费者负载均衡策略所决定的。

  • 消息粒度负载均衡是指同一消费者分组内的多个消费者,将按照消息粒度平均分摊主题中的所有消息。即同一个队列中的消息,会被平均分配给多个消费者共同消费。

img

  • 队列粒度负载均衡是指同一消费者分组内的多个消费者,将按照队列粒度消费消息,即每个队列仅被一个消费者消费。

img

在服务端,RocketMQ 会为每个消费分组维护一份消费位点信息,信息中会保存消费的最大位点、最小位点、当前消费位点等内容。

img

  • 从实现来看,客户端消费完数据后,就会调用 Broker 的消费位点更新接口,提交当前消费的位点信息。
  • 在服务端,消息被某个消费者消费完成后,不会立即在队列中被删除,以便当消费者客户端停止又再次重新上线时,会严格按照服务端保存的消费进度继续处理消息。
  • 如果服务端保存的历史位点信息已过期被删除,此时消费位点向前移动至服务端存储的最小位点。

HTTP 协议支持和管控操作

RocketMQ 原生不支持 HTTP 协议的生产消费,但是在一些云厂商的商业化版本是支持的。从技术上来看,HTTP 协议的支持和 gRPC 的支持可以是一个技术思路,即通过使用 Proxy 模式来实现。

RocketMQ 的管控操作都是通过 Remoting 协议支持的,在 gRPC 协议中也不支持管控操作。即在 Broker 中,通过 Remoting 协议暴露不同的接口或者在 NameServer 中暴露 TCP 的接口,来实现一些对应的管控操作。

从基础功能拆解Kafka的架构设计与实现

Kafka 系统架构

img

Kafka 由 Producer、Broker、ZooKeeper、Consumer 四个模块组成。其中,ZooKeeper 用来存储元数据信息,集群中所有元数据都持久化存储在 ZooKeeper 当中。

从消息的生命周期来看,

  • 生产者也需要通过客户端寻址拿到元数据信息。客户端通过生产分区分配机制,选择消息发送到哪个分区,然后根据元数据信息拿到分区 Leader 所在的节点,最后将数据发送到 Broker。Broker 收到消息并持久化存储。
  • 消费端使用消费分组或直连分区的机制去消费数据,如果使用消费分组,就会经过消费者和分区的分配流程,消费到消息后,最后向服务端提交 Offset 记录消费进度,用来避免重复消费。

协议和网络模块

Kafka 是自定义的私有协议,经过多年发展目前有 V0、V1、V2 三个版本,稳定在 V2 版本。
Kafka 协议从结构上来看包含协议头和协议体两部分,协议头包含基础通用的信息,协议体由于每个接口的功能参数不一样,内容结构上差异很大。
Kafka 服务端的网络层是基于 Java NIO 和 Reactor 来开发的,通过多级的线程调度来提高性能。

数据存储

元数据存储

Kafka 的元数据是存储在 ZooKeeper 里面的。元数据信息包括 Topic、分区、Broker 节点、配置等信息。ZooKeeper 会持久化存储全量元数据信息,Broker 本身不存储任何集群相关的元数据信息。在 Broker 启动的时候,需要连接 ZooKeeper 读取全量元数据信息

Kakfa 集群中的一些如消费进度信息、事务信息,分层存储元数据,以及 3.0 后的 Raft 架构相关的元数据信息,都是基于内置 Topic 来完成存储的。把数据存储在内置 Topic 中,算是一个比较巧妙的思路了,也是一个值得借鉴的技巧。

img

消息数据

Kafka 的数据是以分区为维度单独存储的。即写入数据到 Topic 后,根据生产分区分配关系,会将数据分发到 Topic 中不同的分区。此时底层不同分区的数据是存储在不同的“文件”中的,即一个分区一个数据存储“文件”。这里提到的“文件”也是一个虚指,在系统底层的表现是一个目录,里面的文件会分段存储。

当 Broker 收到数据后,是直接将数据写入到不同的分区文件中的。所以在消费的时候,消费者也是直接从每个分区读取数据。

img

在底层数据存储中,Kafka 的存储结构是以 Topic 和分区维度来组织的。一个分区一个目录,目录名称是 TopicName + 分区号,

/data/kafka/data#ll
drwxr-xr-x 2 root root 4096 2月  15 2020 __consumer_offsets-0
drwxr-xr-x 2 root root 4096 2月  15 2020 __consumer_offsets-1
drwxr-xr-x 2 root root 4096 2月  15 2020 __consumer_offsets-2
drwxr-xr-x 2 root root 4096 2月  15 2020 __transaction_state-0
drwxr-xr-x 2 root root 4096 2月  15 2020 __transaction_state-1
drwxr-xr-x 2 root root 4096 2月  15 2020 __transaction_state-2

每个分区的目录下,都会有 .index、.log、.timeindex 三类文件。其中,.log 是消息数据的存储文件,.index 是偏移量(offset)索引文件,.timeindex 是时间戳索引文件。两个索引文件分别根据 Offset 和时间来检索数据。

/data/data/data#ll __consumer_offsets-0
总用量 0
-rw-r--r-- 1 root root 10485760 11月 19 2020 00000000000000000000.index
-rw-r--r-- 1 root root        0 2月  15 2020 00000000000000000000.log
-rw-r--r-- 1 root root 10485756 11月 19 2020 00000000000000000000.timeindex
-rw-r--r-- 1 root root        0 2月  15 2020 leader-epoch-checkpoint

在节点维度,也会持久存储当前节点的数据信息(如 BrokerID)和一些异常恢复用的 Checkpoint 等数据。

Kafka 提供了根据过期时间和数据大小清理的机制,清理机制是在 Topic 维度生效的。当数据超过配置的过期时间或者超过大小的限制之后,就会进行清理。清理的机制也是延时清理的机制,它是根据每个段文件进行清理的,即整个文件的数据都过期后,才会清理数据。
特别说明的是,根据大小清理的机制是在分区维度生效的,不是 Topic。即当分区的数据大小超过设置的大小,就会触发清理逻辑。

在存储性能上,Kafka 的写入大量依赖顺序写、写缓存、批量写来提高性能。消费方面依赖批量读、顺序读、读缓存的热数据、零拷贝来提高性能。在这些技巧中,每个分区的顺序读写是高性能的核心。

生产者和消费者

所以在新版本的 Kafka 中,客户端是通过直连 Broker 完成寻址操作的,不会跟 ZooKeeper 交互。即 Broker 跟 ZooKeeper 交互,在本地缓存全量的元数据信息,然后客户端通过连接 Broker 拿到元数据信息,从而避免对 ZooKeeper 造成太大负载。

生产者

发送到 Topic 时会经过生产分区分配的流程,即根据一定的策略将数据发送到不同的分区。Kafka 提供了轮询和 KeyHash 两种策略。生产消息分配的过程是在客户端完成的。
Kafka 协议提供了批量(Batch)发送的语义。所以生产端会在本地先缓存数据,根据不同的分区聚合数据后,再根据一定的策略批量将数据写入到 Broker。因为这个 Batch 机制的存在,客户端和服务端的吞吐性能会提高很多。

img

这里多讲一点,客户端批量往服务端写有两种形式:一种是协议和内核就提供了 Batch 语义,一种是在业务层将一批数据聚合成一次数据发送。这两种虽然都是批量发送,但是它们的区别在于:

  • 第一种批量消息中的每条消息都会有一个 Offset,每条消息在 Broker 看来就是一条消息。第二种批量消息是这批消息就是一条消息,只有一个 Offset。
  • 在消费端看来,第一种对客户端是无感的,一条消息就是一条消息。第二种需要消费者感知生产的批量消息,然后解析批量,逐条处理。

消费者

Kafka 的消费端只提供了 Pull(拉)模式的消费。即客户端是主动不断地去服务端轮询数据、获取数据,消费则是直接从分区拉取数据的。
Kafka 提供了消费分组消费和直连分区消费两种模式,这两者的区别在于,是否需要进行消费者和分区的分配,以及消费进度谁来保存。
大部分情况下,都是基于消费分组消费。消费分组创建、消费者或分区变动的时候会进行重平衡,重新分配消费关系。Kafka 默认提供了 RangeAssignor(范围)、RoundRobinAssignor(轮询)、 StickyAssignor(粘性)三种策略,也可以自定义策略。消费分组模式下,一个分区只能给一个消费者消费,消费是顺序的。
当客户端成功消费数据后,会往服务端提交消费进度信息,此时服务端也不会删除具体的消息数据,只会保存消费位点信息。位点数据保存在内部的一个 Topic(__consumer_offset)中。消费端同样提供了自动提交和手动提交两种模式。当消费者重新启动时,会根据上一次保存的位点去消费数据,用来避免重复消费。

HTTP 协议支持和管控操作

Kafka 内核是不支持 HTTP 协议的,如果需要支持,则需要在 Broker 前面挂一层代理。如 Confluent 开源的 Kafka Rest。
管控的大部分操作是通过 Kafka Protocol 暴露的,基于四层的 TCP 进行通信。还有部分可以通过直连 ZooKeeper 完成管控操作。
在早期很多管控操作都是通过操作 ZooKeeper 完成的。后来为了避免对 ZooKeeper 造成压力,所有的管控操作都会通过 Broker 再封装一次,即客户端 SDK 通过 Kafka Protocol 调用 Broker,Broker 再去和 ZooKeeper 交互。

从基础功能拆解Pulsar的架构设计与实现

Pulsar 系统架构

img

Pulsar 的架构就复杂很多了,它和其他消息队列最大的区别在于 Pulsar 是基于计算存储分离的思想设计的架构,所以 Pulsar 整体架构要分为计算层和存储层两层。我们通常说的 Pulsar 是指计算层的 Broker 集群和存储层的 BookKeeper 集群两部分。
存储层是独立的一个组件 BookKeeper,是一个专门用来存储日志数据的开源项目,它由 Bookies(Node)和 ZooKeeper 组成。
BookKeeper 本质上就是一个远程存储。比如 Kafka 的计算层是 Broker,存储层是本地的硬盘空间,Broker 收到数据后,通过本地文件的写入调用,如 FileChannel,将数据写入到本地文件。Pulsar 的计算层是 Broker,存储层是远程的 BookKeeper 集群,Broker 收到数据后,通过 BookKeeper 的客户端 SDK 将数据写入到 BookKeeper 集群中。
从部署上来看,计算层和存储层其实是独立的。先部署一套存储的 BookKeeper 集群,然后一套或多套 Pulsar 集群可以将数据写入到同一套 BookKeeper 集群中。

在 Pulsar 中,你还可以看到一套或者多套 ZooKeeper。因为 Pulsar 和 BookKeeper 都是使用 ZooKeeper 来存储元数据的。在实际部署当中,为了节省资源的开销,通常 Pulsar Broker 集群和 BookKeeper 会共用一套 ZooKeeper 集群。

协议和网络层

和 Kafka 一样,Pulsar Broker 的协议也是自定义的私有协议。协议的格式是以行格式解析,即自定义的编解码格式。如下图所示,Pulsar 的整体协议是行格式的自定义编解码,但是协议中的命令(Command)和部分元数据是用 Protobuf 组织来表示的,比如下图黄色的部分。

img

从协议的内容上看,Pulsar 协议分为了 SimpleCommands 和 MessageCommands 两种格式。

Simple Commands 指不需要携带消息内容的简单命令,即指在交互过程中不需要携带消息内容的交互请求,如建立连接,心跳检测等等。消息结构如下所示,可以看到简单协议只携带了 TotalSize、CommandSize、Command 三个字段,不携带消息体。

img

Message Commands 指需要携带消息内容的复杂命令,比如生产消息操作就需要携带消息内容、生产者信息、批量消息等等相关数据。此时就需要使用 Message Commands 来进行交互,它的结构如下:

img

Pulsar Broker 的网络层是基于 Netty 框架开发实现的,属于业界比较常用的实现方案。

数据存储

元数据存储

当前 Pulsar 元数据存储的核心是 ZooKeeper。最新版本的内核支持可插拔的元数据存储框架,即支持将元数据存储到多种第三方存储引擎,比如 etcd、本地内存、RocksDB 等

img

因为架构要求,Pulsar 需要存储更多元数据,所以 Pulsar 对 ZooKeeper 造成的压力会更大。也正因为如此,Pulsar 才会支持可插拔的元数据存储框架,希望通过其他的存储引擎来缓解 ZooKeeper 的瓶颈。

消息数据

在计算层,Pulsar 不负责消息存储。流程上就是调用 BookKeeper 的 SDK,往 BookKeeper 写入数据。在 BookKeeper 看来,Pulsar Broker 就是 BookKeeper 集群的一个普通的客户端。

在 Pulsar Broker,消息数据的存储是以分区维度组织的,即一个分区一份文件。在实际的存储中,分区的数据是以一段一段 Ledger 的形式组织的,不同的 Ledger 会存储到不同的 Bookie 上。每段 Ledger 包含一批 Entry,一个 Entry 可以理解为一条消息。存储结构如下图所示:

img

通过图示,我们可以看到,

  • 在 Broker 中,消息会先以 Entry 的形式追加写入到 Ledger 中,一个分区同一时刻只有一个 Ledger 处于可写状态。
  • 当写入一条新数据时,会先找到当前可用的 Ledger,然后写入消息。
  • 当 Ledger 的长度或 Entry 个数超过阈值时,新消息会存储到新的 Ledger 中。
  • 一个 Ledger 会根据 Broker 指定的 QW 数量,存储到多个不同的 Bookie 中。
  • 一个 Bookie 可以存放多个不连续的 Ledger。

这种分段存储结构的好处就是,当一台 Bookie(节点)的负载高了或者容量满了后,就可以通过禁用该台节点的写入,将负载快速转移到其他节点上,从而实现存储的弹性。

在写入性能方面,Broker 不太关注实际的写入性能的提升,性能主要依赖 BookKeeper 的性能优化。BookKeeper 在底层会通过 WAL 机制、批量写、写缓存的形式来提高写入的性能

同时,Pulsar 提供了 TTL 和 Retention 机制来支持消息删除。

  • TTL 策略指消息在指定时间内没有被用户 ACK 掉时,会被 Broker 主动 ACK 掉。此处需注意,这个 ACK 操作不涉及数据删除,因为 TTL 不涉及与删除相关的操作。
  • Retention 策略指消息被 ACK 之后(消费者 ACK 或者 TTL ACK)继续在 Bookie 侧保留的时间,消息被 ACK 之后就归属于 Retention 策略,即在 BookKeeper 保留一定时间。Retention 以 Ledger 为最小操作单元,删除即是删除整个 Ledger。

生产者和消费者

生产端

Pulsar 生产端支持访问模式的概念。访问模式指的是一个分区在同一时间支持怎样的生产者以何种方式写入。
Pulsar 提供了 Shared(共享)、Exclusive(独占)、WaitForExclusive(灾备)三种访问模式。

  • Shared 指允许多个生产者将消息写入到同一个 Topic。
  • Exclusive 指只有一个生产者可以将消息写入到 Topic,当其他生产者尝试写入消息到这个 Topic 时,会发生错误。
  • WaitForExclusive 指只有一个生产者可以将消息发送到 Topic,其他生产者连接会被挂起而不会产生错误,类似 ZooKeeper 的观察者模式。

因为分区模型的存在,Pulsar 在生产端提供了 RoundRobinPartition(轮询)、SinglePartition(随机选择分区)、CustomPartition(自定义)三种路由模式,用来决定数据会发送到哪个分区里面。

  • RoundRobinPartition,指当消息没有指定 Key 时,生产者以轮询方式将消息写入到所有分区。
  • SinglePartition,指当消息没有指定 Key,生产者会随机选择一个分区,并将所有消息写到这个分区。针对上述这两种策略,如果消息指定了 Key,分区生产者会优先根据 Key 的 Hash 值将该消息分配到对应的分区。
  • CustomPartition,用户可以创建自定义路由模式,通过实现 MessageRouter 接口来自定义路由规则。

因为 Pulsar 的协议支持 Batch 语义,所以在生产端是支持批量发送的。启用批量处理后,生产者会在客户端累积并发送一批消息。批量处理时的消息数量,取决于最大可发送消息数和最大发布延迟。

在客户端写入模式上,Pulsar 生产端也支持同步写入、异步写入两种方式。

消费端

在消费端,Pulsar 主要支持 Pull 消费模型,即由客户端主动从服务端 Pull 数据来支持消费。同样的,Pulsar 在消费端也支持订阅的概念,订阅对 Pulsar 的作用相当于 Kafka 的消费分组。

Pulsar 的订阅支持消息和分区两个维度,即可以将整个分区绑定给某个消费者,也可以将分区中的消息投递给不同的消费者。
所以在实现上,Pulsar 支持独占、灾备、共享、Key_Shared 四种订阅类型。

  • 独占,指一个订阅只可以与一个消费者关联,只有这个消费者能接收到 Topic 的全部消息,如果这个消费者故障了就会停止消费。
  • 灾备,指一个订阅可以与多个消费者关联,但只有一个消费者会消费到数据,当该消费者故障时,由另一个消费者来继续消费。
  • 共享,指一个订阅可以与多个消费者关联,消息会通过轮询机制发送给不同的消费者。
  • Key 共享,指一个订阅可以与多个消费者关联,消息根据给定的映射规则,相同 Key 的消息由同一个消费者消费。

Pulsar 支持持久化和非持久化两种订阅模式。这两种模式的核心区别在于,游标是否是持久化存储。如果是持久化的存储,当 Broker 重启后还可以保留游标进度,否则游标就会丢失。RocketMQ 和 Kafka 的消费分组都是持久化的订阅。

当客户端消费成功后,为了确认消费完成,也需要进行消费确认。消费确认就是提交当前消费的进度,Pulsar 指的是提交游标的进度。它提供了累积确认和单条确认两种模式,

  • 累积确认指消费者只需要确认收到的最后一条消息,在此之前的消息,都不会被再次发送给消费者;
  • 单条确认指消费者需要确认每条消息并发送 ACK 给 Broker。

同时 Pulsar 也提供了取消确认的功能。即当某些消息已经被确认,已经消费不到数据了,此时如果还想消费到数据,就要通过客户端发送取消确认的命令,使其可以再消费到这条数据。取消确认操作支持单条和批量,不过这两种操作方式在不同订阅类型中的支持情况是不一样的,这一点需要注意一下。

HTTP 协议支持和管控操作

在访问层面,Pulsar 的管控操作和生产消费数据流操作是分开支持的。即数据流走的是自定义协议通信,管控走的是 HTTP 协议形式的访问。从访问上就隔离了管控和数据流操作,在后续的权限管理、客户端访问等方面提供了很多便利。

img

命令行 CLI 的底层也是通过 HTTP Client 发起访问的,用 HTTP 的好处就是,不需要单独在二进制协议、服务端接口、客户端 SDK 方面单独进行管控的支持。

总结

img

posted @ 2023-09-04 11:28  Blue Mountain  阅读(611)  评论(0)    收藏  举报