通信设计:请不要让消息通信拖垮了系统的整体性能

首先,为何基于消息队列的通信设计这般重要?简而言之,其在软件系统中的地位与作用,恰似接力赛里的交棒环节,只要有选手在交接棒时出现失误,整个团队的成绩就会受到拖累。所以,要是基于消息队列的通信设计没做好,系统的整体性能必然难以理想。

此前,我曾参与一个项目,在该业务系统中大量采用 Redis 作为消息队列进行传输。但是,业务开发人员频繁因接口使用有误,导致消息丢失。此外,由于未对队列容量加以把控,还出现了消息队列的数据堆积情况,使得整个 Redis 的速度变得迟缓,进而致使业务的整体性能极其不稳定。

还有一个关键原因在于,基于消息队列的通信设计,和软件系统的众多设计存在关联,像并发设计、IO 设计等,它对整个软件架构的影响较大。所以在软件的设计阶段,我们就应当予以关注。

那么,具体是怎样的思路呢?在对一个高性能分布式系统进行通信队列设计时,我通常会依照以下几个步骤开展设计工作,分别是:消息队列选型设计、消息体设计、消息通信过程设计。因为消息队列选型是最为基础的,它会直接影响后续的通信过程设计。

消息队列选型设计

首先,在软件设计阶段,消息队列选型设计的核心目标在于依据软件架构选定适宜的消息队列底层实现、相应的框架或者服务等,以此把底层的传输成本降至最低。并且当下处于一个技术丰富的时代,面对问题时我们能够选择的框架和服务众多。而如我前面所讲,具体的消息队列的实现原理及性能,会对软件的架构设计形成制约。


所以,倘若消息队列选型失误,那么后续即便投入再大的成本,或许都难以弥补在性能方面的缺陷。我曾经有过这样一个项目经历,此项目对时延的要求极高,然而由于选用了 RabbitMQ 这款消息队列系统,其在传输消息时,都必须多经过一个代理服务器,相比在实例间直接进行点对点通信要慢许多,最终无法满足系统对延迟的极致需求。

由此可见,在开展消息队列选型设计之前,务必要明确需要进行通信的软件实例的运行位置,防止由于不了解该选用何种底层消息传输机制,从而对最终的软件性能造成影响。

那接下来您或许会问:究竟应当如何依据不同软件实例的位置分布,来选取不同的底层消息传输机制呢?此刻我们来看一张图片,这是一张有关不同层级的消息队列底层原理的图片,图中依照底层实现机制,把消息队列划分为了四个层级,分别是线程内队列、线程间队列、进程间队列、服务器间队列。这四个层级自上至下,其传输时延会逐步增大。

这也就是说,如果我们在线程内通信时选择了线程间队列,虽说它能够满足功能需求,然而由于需要处理同步互斥的问题,其性能通常并非最佳。所以,在选型设计阶段,我们应当优先选取既能满足通信要求,底层实现又最为迅速的消息队列类型。


不过,在实际的系统设计和实现进程中,许多人实际上都忽略了不同底层实现的消息队列之间存在的性能差异。所以接下来,我就为您逐一介绍前面这四种消息队列类型的实现原理,以及在进行选型设计时的核心关注点。

不同类型的消息队列都有啥特点?

首先要说的是线程内队列。如今我们已然知晓,线程内队列的传输速度最为迅速、时延最低,它能够作为同一线程的各个模块单元之间的通信方式,由于无需考虑并发问题,因而性能相对较高。


其次是线程间队列。在一个进程当中,我们能够基于内存创建队列来实现通信,但是鉴于跨线程内存访问数据一致性的问题,极易引发执行结果的不确定性。也就是说,在运用线程间队列的过程中,或许会引入加锁或者内存屏障机制,进而导致性能有所下降。


第三种是进程间队列。我们能够借助 IPC(Inter-Process Communication,进程间通信)队列,或者利用进程间共享的一块内存来建立通信队列,因为这种通信队列能够完全在内存中得以实现,所以几乎能够达到与线程间队列较为接近的性能。例如,Java 语言的 traffic-shm(Anna) 库所提供的消息队列,由于无需跨服务器的消息队列,从而能够避免引入额外的网络传输开销。


最后是服务器间队列。它的实现原理是基于物理网络设备,构建传输网络来进行通信,所以这样或许会受到传输带宽、网络拥塞等各类问题的影响,传输时延相对较长,并且有可能不太稳定。

实际上,线程内、线程间、进程间通信队列设计在嵌入式分布式系统中运用得颇为频繁,而对于网络服务化的分布式系统来说,这些通信队列机制通常已经内置到了特定的语言或者框架库当中,例如 Go 语言的 Channel 队列、Java 的 CurrentQueue 等。


  不过,对于互联网的分布式系统而言,消息队列选型设计的核心关注点,主要在于服务器间的消息队列选型。因为大多数的软件/服务实例都部署在不同的机器之上,所以我们只能运用服务器间消息队列。


那么针对服务器间的消息队列,我们能够选择采用的消息队列实现众多,比如有 RabbitMQ、ActiveMQ、Kafka、RocketMQ、ZeroMQ 等等。当然,还有一些基于特定数据库封装实现的消息队列,如基于 Redis 实现的消息队列等。既然如此,在这些消息队列之间,我们又应当如何进行选择呢,它们对性能又有着怎样的影响呢?接下来,我就为您分享一下选择满足业务性能需求的消息队列的办法。

如何选择满足业务性能需求的消息队列?

首先您需要明白,尽管前面我所列举的这些消息队列均能够达成不同服务器间的消息通信,然而由于它们在底层实现方面存在差别,会直接对软件的性能表现产生影响,我给您列举几个例子。

譬如,就 ZeroMQ 而言,它是基于 C 语言开发的,不存在中间代理服务器用于缓存消息,会直接通过服务器中间的网络链路进行通信,所以它的时延速度是最快的。而 Kafka 是一款具备多分区、多副本,并且基于 ZooKeeper 协调的分布式消息系统,从理论上讲能够支撑规模极大的集群,所以它所能支撑的业务吞吐量会非常高。我之前在开发大数据平台时,借助 Kafka,让消息吞吐量能够支撑到几百 MB/s 的速度。

那么,相较于 Kafka 来说,RabbitMQ 的吞吐量会稍逊一筹,不过它在时延性能和功能方面并不差,能够满足大多数业务场景的性能需求。再举个例子,我发现有众多的软件系统,实际上都是利用数据库来构建消息队列的,例如基于 Redis 开发的消息队列等。从理论上讲,您确实能够基于各类分布式数据库来开发消息队列。但这样的操作,一方面会受到不同数据库实现架构的影响,另一方面它也不具备诸多针对消息通信的优化设计与开发配置策略,所以通常在性能上并非最优。

所以综合上述不同消息队列的特性和使用场景,我想要告知您的是,当您在面临一些消息通信极为关键的性能场景时,对于消息队列的选型,需要从时延、吞吐量等多个维度展开性能评估与分析,而在进行评估分析之前,基于底层实现机制进行选型是您需要完成的第一步。

好了,在确定了消息队列选型之后,为了支撑分布式系统通信的高性能,您还需要做好消息体设计,下面我们就具体来瞧瞧它的核心关注点。

消息体设计

实际上,消息体设计的核心包含两点,即消息内容设计与消息编码设计。首先,什么叫做消息内容设计呢?情况是这样的,同样的一个信息,往往存在诸多的表示方法。例如“北京”和“中国的首都城市”,它们都指代相同的地方,然而二者所需的数据量(字数)却是不同的。也就是说,不同的消息内容呈现形式会直接左右传输的消息体大小,这对于通信性能极为关键,但是这部分却常常被我们所忽视。


除了消息内容设计以外,不同的消息编码格式设计也会对传输的消息体大小产生影响,进而直接作用于消息传输的时延和吞吐量。当下较为常见的消息编码格式主要有若干种:TLV 格式(Type/Tag、Length、Value)、ProtoBuffer 格式、JSON 格式、XML 格式。其中,TLV 格式和 ProtoBuffer 格式并非自描述的,所以就需要生产者与消费者之间预先约定好消息格式,或者依据特定的格式描述文件来进行解释;而采用 JSON 和 XML 这类消息格式,通常消息内容是自描述的,因而消息体中会携带额外的描述消息,从而导致消息体较大,所以我不太建议在一些高性能的通信业务场景中运用。


所以说,如果消息通信性能对于您的软件系统性能至关重要,您就应当优先考虑前两种消息编码格式。但采用这两种方式,您还需要在软件实现中处理接口的兼容性,因而会耗费更多的精力。


事实上,针对关系型数据库,表信息就是对字段内容格式的描述,所以在某些特殊场景下,我们基于数据库也能够构建出一些高性能的通信队列。例如,在 Mongo 的主节点和其他 Replica 节点之间,传输信息所运用的 Change Stream 就是通过数据库内的一个 Collection(集合)来实现的,因此您也能够构建出一些高性能通信队列的场景。


而当您使用后面两种消息格式时,那么在发送和接收消息的过程中,您能够使用一些编解码库来压缩消息体的内容,以此减少传输的信息大小。比如针对 JSON,您可以使用 cJSON、HPACK 这类的压缩算法来压缩传输数据,通常压缩效率还是比较高的。


OK,在选好了合适的通信队列实现,并完成了消息的高效编码之后,是不是就能够确保通信性能出色,不会对系统业务的性能造成影响呢?答案当然是否定的,配置和使用消息队列以及与软件的设计相匹配,对性能的影响同样极为重大。所以接下来,我们一同来看看,如何在通信设计的过程中保障系统的高性能。

消息通信过程设计

其中,生产者和消费者均能够涵盖多个运行实例,而消息队列属于消息传输通道的一种抽象载体。在某些消息队列服务框架或服务里,像是 Kafka、RabbitMQ 等,会存在独立的运行实体对其进行处理。然而在一些场景中,消息队列并无独立的运行实体可供处理,而是依靠在生产者或消费者进程当中予以处理。


另外在图中,我还着重标注了几个对性能影响较为关键的决策点。在理解了这些核心决策点以及背后的原理之后,您就能够针对分布式架构的通信部分,设计出高性能的方案:
批量模式:指的是从消息队列读取和写入消息的模式,我们应当优先选取批量或者 Batch 模式。
队列个数:即基于性能设计来选定消息队列的数量。
队列容量:即消息队列中能够保存消息的数量或者字节的长度限制。
并发映射:即在消息队列与业务架构中,生产者和消费者对于运行实例之间的映射关系设计。
速度优势:即消费者处理消息的速度应当大于生产者生产消息的速度。

那么我们该怎么理解以上这 5 个决策点呢?

首先是批量模式,通常来讲,对于消息队列来说,单个消息与批量消息在读取和写入方面的性能差别极大,所以众多消息队列在这方面都提供了极为灵活的配置能力,例如 Kafka 客户端的 batch 大小和最大时延,或者 RabbitMQ 中接收端的 prefetch 等配置。也就是说,在我们于业务代码中设计消息队列通信之前,务必要针对业务模型,对批处理模式中的相关配置进行调整和分析。同时,您还需要清楚的一点是,即便业务中是按照单条消息进行处理的,而发送消息和接收消息采用批量消息模式,这二者其实并不冲突。在上节课我也介绍过,从业务使用的角度来看,消息队列也属于 IO 交互,所以您还需要依据 IO 异步交互设计来进一步提升性能。


然后是消息队列的个数,对于具备独立的 Broker 节点的消息队列服务而言,实际上也是相当重要的。因为在消息队列服务的设计实现当中,通常每个队列都是由独立的线程来处理的。所以,增加消息队列个数,也就是增加消息通信中能够使用的 CPU 资源。比如,在 RabbitMQ 中,一个队列对应一个线程,其上限吞吐量是确定的,所以您能够通过增加消息队列个数,来提升消息传输的性能。

此外,消息队列的容量设置同样是对性能影响颇为关键的一个因素,然而却常常被我们所忽略。要明白,消息队列通常能够作为生产者与消费者之间的流控手段,当消费者处理能力下降时,我们能够通过消息队列的容量来限制生产者继续添加消息,进而限制生产者接收处理消息。而倘若这个容量设置过大,首先会致使流控机制失效,其次还有可能造成消息队列占用资源过多,对整个系统的性能产生影响。实际上,在一些高性能的嵌入式分布式系统中,消息队列长度都是需要进行严格设计的。

那并发映射主要解决的是什么问题呢?其实,它主要解决的是同步互斥的问题。

并发映射的关键在于防止消费者或生产者的运行实例与消息队列之间的映射关系出现同一消息队列被多个运行实例并发访问的情况,从而避免引入同步互斥的问题,这一点对于进程间的消息队列设计尤为关键。另外,对于跨服务间存在 Broker 节点的消息队列而言,我们同样能够运用并发映射,这样也能够降低 Broker 节点中处理消息队列的复杂度。

最后我想提及的一点是速度优势,在设计系统时,我们应当尽可能保证消费者的处理速度大于生产者,这是一项重要的原则,因为这样能够避免消息队列出现被阻塞的情形。但在此需要留意的是,处理速度大并不一定意味着进程实体多,由于不同的业务逻辑,在一个进程中的处理速度都是有所不同。

请使用浏览器的分享功能分享到微信等