数据库分布式消息队列

作者:vincentchma,腾讯 IEG 后台开发工程师

一、消息队列的演进

分布式消息队列中间件是是大型分布式系统中常见的中间件。消息队列主要解决应用耦合、异步消息、流量削锋等问题,具有高性能、高可用、可伸缩和最终一致性等特点。消息队列已经逐渐成为企业应用系统内部通信的核心手段,使用较多的消息队列有 RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、Pulsar 等,此外,利用数据库(如 Redis、MySQL 等)也可实现消息队列的部分基本功能。

1.基于 OS 的 MQ

单机消息队列可以通过操作系统原生的进程间通信机制来实现,如消息队列、共享内存等。比如我们可以在共享内存中维护一个双端队列:

mysql 做 消息 队列 消息队列与数据库_mysql 做 消息 队列

消息产出进程不停地往队列里添加消息,同时消息消费进程不断地从队尾有序地取出这些消息。添加消息的任务我们称为 producer,而取出并使用消息的任务,我们称之为 consumer。这种模式在早期单机多进程模式中比较常见, 比如 IO 进程把收到的网络请求存入本机 MQ,任务处理进程从本机 MQ 中读取任务并进行处理。

单机 MQ 易于实现,但是缺点也很明显:因为依赖于单机 OS 的 IPC 机制,所以无法实现分布式的消息传递,并且消息队列的容量也受限于单机资源。

2.基于 DB 的 MQ

即使用存储组件(如 Mysql 、 Redis 等)存储消息, 然后在消息的生产侧和消费侧实现消息的生产消费逻辑,从而实现 MQ 功能。以 Redis 为例, 可以使用 Redis 自带的 list 实现。Redis list 使用 lpush 命令,从队列左边插入数据;使用 rpop 命令,从队列右边取出数据。与单机 MQ 相比, 该方案至少满足了分布式, 但是仍然带有很多无法接受的缺陷。

  • 热 key 性能问题:不论是用 codis 还是 twemproxy 这种集群方案,对某个队列的读写请求最终都会落到同一台 redis 实例上,并且无法通过扩容来解决问题。如果对某个 list 的并发读写非常高,就产生了无法解决的热 key,严重可能导致系统崩溃
  • 没有消费确认机制:每当执行 rpop 消费一条数据,那条消息就被从 list 中永久删除了。如果消费者消费失败,这条消息也没法找回了。
  • 不支持多订阅者:一条消息只能被一个消费者消费,rpop 之后就没了。如果队列中存储的是应用的日志,对于同一条消息,监控系统需要消费它来进行可能的报警,BI 系统需要消费它来绘制报表,链路追踪需要消费它来绘制调用关系……这种场景 redis list 就没办法支持了
  • 不支持二次消费:一条消息 rpop 之后就没了。如果消费者程序运行到一半发现代码有 bug,修复之后想从头再消费一次就不行了。

针对上述缺点,redis 5.0 开始引入 stream 数据类型,它是专门设计成为消息队列的数据结构,借鉴了很多 kafka 的设计,但是随着很多分布式 MQ 组件的出现,仍然显得不够友好, 毕竟 Redis 天生就不是用来做消息转发的。

3. 专用分布式 MQ 中间件

随着时代的发展,一个真正的消息队列,已经不仅仅是一个队列那么简单了,业务对 MQ 的吞吐量、扩展性、稳定性、可靠性等都提出了严苛的要求。因此,专用的分布式消息中间件开始大量出现。常见的有 RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、Pulsar 等等。

二、消息队列设计要点

消息队列本质上是一个消息的转发系统, 把一次 RPC 就可以直接完成的消息投递,转换成多次 RPC 间接完成,这其中包含两个关键环节:

1.消息转储;

2.消息投递:时机和对象;

基于此,消息队列的整体设计思路是:

  • 确定整体的数据流向:如 producer 发送给 MQ,MQ 转发给 consumer,consumer 回复消费确认,消息删除、消息备份等。
  • 利用 RPC 将数据流串起来,最好基于现有的 RPC 框架,尽量做到无状态,方便水平扩展。
  • 存储选型,综合考虑性能、可靠性和开发维护成本等诸多因素。
  • 消息投递,消费模式 push、pull。
  • 消费关系维护,单播、多播等,可以利用 zk、config server 等保存消费关系。
  • 高级特性,如可靠投递,重复消息,顺序消息等, 很多高级特性之间是相互制约的关系,这里要充分结合应用场景做出取舍。

mysql 做 消息 队列 消息队列与数据库_分布式_02

1.MQ 基本特性

RPC 通信

MQ 组件要实现和生产者以及消费者进行通信功能, 这里涉及到 RPC 通信问题。消息队列的 RPC,和普通的 RPC 没有本质区别。对于负载均衡、服务发现、序列化协议等等问题都可以借助现有 RPC 框架来实现,避免重复造轮子。

存储系统

存储可以做成很多方式。比如存储在内存里,存储在分布式 KV 里,存储在磁盘里,存储在数据库里等等。但归结起来,主要有持久化和非持久化两种。

持久化的形式能更大程度地保证消息的可靠性(如断电等不可抗外力),并且理论上能承载更大限度的消息堆积(外存的空间远大于内存)。但并不是每种消息都需要持久化存储。很多消息对于投递性能的要求大于可靠性的要求,且数量极大(如日志)。这时候,消息不落地直接暂存内存,尝试几次 failover,最终投递出去也未尝不可。常见的消息队列普遍两种形式都支持。

从速度来看,理论上,文件系统>分布式 KV(持久化)>分布式文件系统>数据库,而可靠性却相反。还是要从支持的业务场景出发作出最合理的选择。

高可用

MQ 的高可用,依赖于 RPC 和存储的高可用。通常 RPC 服务自身都具有服务自动发现,负载均衡等功能,保证了其高可用。存储的高可用, 例如 Kafka,使用分区加主备模式,保证每一个分区内的高可用性,也就是每一个分区至少要有一个备份且需要做数据的同步。

推拉模型

push 和 pull 模型各有利弊,两种模式也都有被市面上成熟的消息中间件选用。

1.慢消费

慢消费是 push 模型最大的致命伤,如果消费者的速度比发送者的速度慢很多,会出现两种恶劣的情况:

1.消息在 broker 的堆积。假设这些消息都是有用的无法丢弃的,消息就要一直在 broker 端保存。

2.broker 推送给 consumer 的消息 consumer 无法处理,此时 consumer 只能拒绝或者返回错误。

而 pull 模式下,consumer 可以按需消费,不用担心自己处理不了的消息来骚扰自己,而 broker 堆积消息也会相对简单,无需记录每一个要发送消息的状态,只需要维护所有消息的队列和偏移量就可以了。所以对于慢消费,消息量有限且到来的速度不均匀的情况,pull 模式比较合适。

2.消息延迟与忙等

这是 pull 模式最大的短板。由于主动权在消费方,消费方无法准确地决定何时去拉取最新的消息。如果一次 pull 取到消息了还可以继续去 pull,如果没有 pull 取到则需要等待一段时间重新 pull。

消息投放时机

即消费者应该在什么时机消费消息。一般有以下三种方式:

  1. 攒够了一定数量才投放。
  2. 到达了一定时间就投放。
  3. 有新的数据到来就投放。

至于如何选择,也要结合具体的业务场景来决定。比如,对及时性要求高的数据,可用采用方式 3 来完成。

消息投放对象

不管是 JMS 规范中的 Topic/Queue,Kafka 里面的 Topic/Partition/ConsumerGroup,还是 AMQP(如 RabbitMQ)的 Exchange 等等, 都是为了维护消息的消费关系而抽象出来的概念。本质上,消息的消费无外乎点到点的一对一单播,或一对多广播。另外比较特殊的情况是组间广播、组内单播。比较通用的设计是,不同的组注册不同的订阅,支持组间广播。组内不同的机器,如果注册一个相同的 ID,则单播;如果注册不同的 ID(如 IP 地址+端口),则广播。

例如 pulsar 支持的订阅模型有:

  • Exclusive:独占型,一个订阅只能有一个消息者消费消息。
  • Failover:灾备型,一个订阅同时只有一个消费者,可以有多个备份消费者。一旦主消费者故障则备份消费者接管。不会出现同时有两个活跃的消费者。
  • Shared:共享型,一个订阅中同时可以有多个消费者,多个消费者共享 Topic 中的消息。
  • Key_Shared:键共享型,多个消费者各取一部分消息。

通常会在公共存储上维护广播关系,如 config server、zookeeper 等。

2.队列高级特性

常见的高级特性有可靠投递、消息丢失、消息重复、事务等等,他们并非是 MQ 必备的特性。由于这些特性可能是相互制约的,所以不可能完全兼顾。所以要依照业务的需求,来仔细衡量各种特性实现的成本、利弊,最终做出最为合理的设计。

可靠投递

如何保证消息完全不丢失?

直观的方案是,在任何不可靠操作之前,先将消息落地,然后操作。当失败或者不知道结果(比如超时)时,消息状态是待发送,定时任务不停轮询所有待发送消息,最终一定可以送达。但是,这样必然导致消息可能会重复,并且在异常情况下,消息延迟较大。

例如:

  • producer 往 broker 发送消息之前,需要做一次落地。
  • 请求到 server 后,server 确保数据落地后再告诉客户端发送成功。
  • 支持广播的消息队列需要对每个接收者,持久化一个发送状态,直到所有接收者都确认收到,才可删除消息。

即对于任何不能确认消息已送达的情况,都要重推消息。但是,随着而来的问题就是消息重复。在消息重复和消息丢失之间,无法兼顾,要结合应用场景做出取舍。

消费确认

当 broker 把消息投递给消费者后,消费者可以立即确认收到了消息。但是,有些情况消费者可能需要再次接收该消息(比如收到消息、但是处理失败),即消费者主动要求重发消息。所以,要允许消费者主动进行消费确认。

顺序消息

对于 push 模式,要求支持分区且单分区只支持一个消费者消费,并且消费者只有确认一个消息消费后才能 push 另外一个消息,还要发送者保证发送顺序唯一。

对于 pull 模式,比如 kafka 的做法:

  1. producer 对应 partition,并且单线程。
  2. consumer 对应 partition,消费确认(或批量确认),单线程消费。

但是这样也只是实现了消息的分区有序性,并不一定全局有序。总体而言,要求消息有序的 MQ 场景还是比较少的。

三、Kafka

Kafka 是一个分布式发布订阅消息系统。它以高吞吐、可持久化、可水平扩展、支持流数据处理等多种特性而被广泛使用(如 Storm、Spark、Flink)。在大数据系统中,数据需要在各个子系统中高性能、低延迟的不停流转。传统的企业消息系统并不是非常适合大规模的数据处理,但 Kafka 出现了,它可以高效的处理实时消息和离线消息,降低编程复杂度,使得各个子系统可以快速高效的进行数据流转,Kafka 承担高速数据总线的作用。

kafka 基础概念

  • BrokerKafka 集群包含一个或多个服务器,这种服务器被称为 broker。
  • TopicTopic 在逻辑上可以被认为是一个 queue,每条消费都必须指定它的 Topic,可以简单理解为必须指明把这条消息放进哪个 queue 里。为了使得 Kafka 的吞吐率可以线性提高,物理上把 Topic 分成一个或多个 Partition,每个 Partition 在物理上对应一个文件夹,该文件夹下存储这个 Partition 的所有消息和索引文件。
  • PartitionParition 是物理上的概念,每个 Topic 包含一个或多个 Partition。
  • Producer负责发布消息到 Kafka broker。
  • Consumer消息消费者,向 Kafka broker 读取消息的客户端。
  • Consumer Group每个 Consumer 属于一个特定的 Consumer Group(可为每个 Consumer 指定 group name,若不指定 group name 则属于默认的 group)。

mysql 做 消息 队列 消息队列与数据库_分布式_03

kafka实现原理6

一个典型的 kafka 集群包含若干 Producer,若干个 Broker(kafka 支持水平扩展)、若干个 Consumer Group,以及一个 zookeeper 集群。Producer 使用 push 模式将消息发布到 broker。consumer 使用 pull 模式从 broker 订阅并消费消息。多个 broker 协同工作,producer 和 consumer 部署在各个业务逻辑中。kafka 通过 zookeeper 管理集群配置及服务协同。

这样就组成了一个高性能的分布式消息发布和订阅系统。Kafka 有一个细节是和其他 mq 中间件不同的点,producer 发送消息到 broker 的过程是 push,而 consumer 从 broker 消费消息的过程是 pull,主动去拉数据。而不是 broker 把数据主动发送给 consumer。

Producer 发送消息到 broker 时,会根据 Paritition 机制选择将其存储到哪一个 Partition。如果 Partition 机制设置合理,所有消息可以均匀分布到不同的 Partition 里,这样就实现了负载均衡。如果一个 Topic 对应一个文件,那这个文件所在的机器 I/O 将会成为这个 Topic 的性能瓶颈,而有了 Partition 后,不同的消息可以并行写入不同 broker 的不同 Partition 里,极大的提高了吞吐率。

Kafka 特点

优点:

  • 高性能:单机测试能达到 100w tps
  • 低延时:生产和消费的延时都很低,e2e 的延时在正常的 cluster 中也很低
  • 可用性高:replicate+ isr + 选举 机制保证
  • 工具链成熟:监控 运维 管理 方案齐全
  • 生态成熟:大数据场景必不可少 kafka stream

不足:

  • 无法弹性扩容:对 partition 的读写都在 partition leader 所在的 broker,如果该 broker 压力过大,也无法通过新增 broker 来解决问题
  • 扩容成本高:集群中新增的 broker 只会处理新 topic,如果要分担老 topic-partition 的压力,需要手动迁移 partition,这时会占用大量集群带宽
  • 消费者新加入和退出会造成整个消费组 rebalance:导致数据重复消费,影响消费速度,增加延迟
  • partition 过多会使得性能显著下降:ZK 压力大,broker 上 partition 过多让磁盘顺序写几乎退化成随机写

高吞吐机制

顺序存取

如果把消息以随机的方式写入到磁盘,那么磁盘首先要做的就是寻址,也就是定位到数据所在的物理地址,在磁盘上就要找到对应柱面、磁头以及对应的扇区;这个过程相对内存来说会消耗大量时间,为了规避随机读写带来的时间消耗,kafka 采用顺序写的方式存储数据。

页缓存

即使是顺序存取,但是频繁的 I/O 操作仍然会造成磁盘的性能瓶颈,所以 kafka 使用了页缓存零拷贝技术。当进程准备读取磁盘上的文件内容时, 操作系统会先查看待读取的数据是否在页缓存中,如果存在则直接返回数据, 从而避免了对物理磁盘的 I/O 操作;

如果没有命中, 则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存, 之后再将数据返回给进程。一个进程需要将数据写入磁盘, 那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在, 则会先在页缓存中添加相应的页, 最后将数据写入对应的页。被修改过后的页也就变成了脏页, 操作系统会在合适的时间把脏页中的数据写入磁盘, 以保持数据的 一 致性。

Kafka 中大量使用了页缓存, 这是 Kafka 实现高吞吐的重要因素之 一 。虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的, 但在 Kafka 中同样提供了同步刷盘及间断性强制刷盘(fsync),可以通过参数来控制。

同步刷盘能够保证消息的可靠性,避免因为宕机导致页缓存数据还未完成同步时造成的数据丢失。但是实际使用上,我们没必要去考虑这样的因素以及这种问题带来的损失,消息可靠性可以由多副本来解决,同步刷盘会带来性能的影响。

页缓存的好处:

  • I/O Scheduler 会将连续的小块写组装成大块的物理写从而提高性能;
  • I/O Scheduler 会尝试将一些写操作重新按顺序排好,从而减少磁头移动时间;
  • 充分利用所有空闲内存(非 JVM 内存);
  • 读操作可以直接在 Page Cache 内进行,如果消费和生产速度相当,甚至不需要通过物理磁盘交换数据;
  • 如果进程重启,JVM 内的 Cache 会失效,但 Page Cache 仍然可用。