高并发系统设计消息队列

272 阅读11分钟

个人理解:一直把消息队列看作暂时存储数据的一个容器,认为它是一个平衡低速系统和高速系统处理任务时间差的工具。其实你在一些组件中都会看到消息队列的影子:

  • 在 Java 线程池中我们就会使用一个队列来暂时存储提交的任务,等待有空闲的线程处理这些任务;

  • 在实现一个 RPC 框架时,也会将从网络上接收到的请求写到队列里,再启动若干个工作线程来处理。

消息队列是在系统设计时一种常见的组件。

一、秒杀场景

  • 削峰填谷是消息队列最主要的作用,但是会造成请求处理的延迟。
  • 异步处理是提升系统性能的神器,但是你需要分清同步流程和异步流程的边界,同时消息存在着丢失的风险,我们需要考虑如何确保消息一定到达。
  • 解耦合可以提升你的整体系统的鲁棒性。

关于上述的三个场景应用,网上案例很多,这里面就不做介绍了。

二、保证消息仅被消费一次

1、消息丢失

如果要保证消息只被消费一次,首先就要保证消息不会丢失。那么消息从被写入到消息队列到被消费者消费完成,这个链路上会有哪些地方存在丢失消息的可能呢?其实主要存在三个场景:

  • 消息从生产者写入到消息队列的过程;
  • 消息在消息队列中的存储场景;
  • 消息被消费者消费的过程。

生产消息过程

如果生产消息的服务器与消息队列的服务器不是同一个,即便是内网也可能出现网络抖动的可能,进而导致生产的消息丢失。解决方式就是消息重传,但是会导致消息重复

消息队列存储过程

拿 Kafka 举例,消息在 Kafka 中是存储在本地磁盘上的,而为了减少消息存储时对磁盘的随机 I/O,我们一般会将消息先写入到操作系统的 Page Cache 中, Kafka 可以配置当达到某一时间间隔或者累积一定的消息数量的时候再刷盘,也就是所说的异步刷盘。不过如果发生机器掉电或者机器异常重启,Page Cache 中还没有来得及刷盘的消息就会丢失了。这种情况的数据丢失,只能通过尽可能缩短刷盘时间间隔的方式来一定程度上减少消息丢失的数量,但不能杜绝。

也可以考虑以集群方式部署 Kafka 服务,通过部署多个副本备份数据保证消息尽量不丢失。原理实现如下:Kafka 集群中有一个 Leader 负责消息的写入和消费,可以有多个 Follower 负责数据的备份。Follower 中有一个特殊的集合叫做 ISR(in-sync replicas),当 Leader 故障时,新选举出来的 Leader 会从 ISR 中选择,默认 Leader 的数据会异步地复制给 Follower,这样在 Leader 发生掉电或者宕机时,Kafka 会从 Follower 中消费消息,减少消息丢失的可能。由于默认消息是异步地从 Leader 复制到 Follower 的,所以一旦 Leader 宕机,那些还没有来得及复制到 Follower 的消息还是会丢失。为了解决这个问题,Kafka 为生产者提供一个选项叫做“acks”,当这个选项被设置为“all”时,生产者发送的每一条消息除了发给 Leader 外还会发给所有的 ISR,并且必须得到 Leader 和所有 ISR 的确认后才被认为发送成功。这样,只有 Leader 和所有的 ISR 都挂了消息才会丢失。

消费过程

消费的过程分为三步:接收消息、处理消息、更新消费进度。这里面接收消息和处理消息的过程都可能会发生异常或者失败,比如消息接收时网络发生抖动,导致消息并没有被正确地接收到;处理消息时可能发生一些业务的异常导致处理流程未执行完成,这时如果更新消费进度,这条失败的消息就永远不会被处理了,也可以认为是丢失了。

所以,在这里你需要注意的是,一定要等到消息接收和处理完成后才能更新消费进度,但是这也会造成消息重复的问题,比方说某一条消息在处理之后消费者恰好宕机了,那么因为没有更新消费进度,所以当这个消费者重启之后还会重复消费这条消息。

2、保证消息只被消费一次

为了避免消息丢失我们需要付出两方面的代价:一方面是性能的损耗,一方面可能造成消息重复消费。性能的损耗我们还可以接受,因为一般业务系统只有在写请求时才会有发送消息队列的操作,而一般系统的写请求的量级并不高,但是消息一旦被重复消费就会造成业务逻辑处理的错误。那么我们要如何避免消息的重复呢?

想要完全地避免消息重复的发生是很难做到的,因为网络的抖动、机器的宕机和处理的异常都是比较难以避免的,在工业上并没有成熟的方法,因此我们会把要求放宽,只要保证即使消费到了重复的消息,从消费的最终结果来看和只消费一次是等同的就好了,也就是保证在消息的生产和消费的过程是“幂等”的。

幂等的定义

幂等是一个数学上的概念,它的含义是多次执行同一个操作和执行一次操作,最终得到的结果是相同的。消息在生产和消费的过程中都可能会产生重复,所以你要做的是在生产过程和消费过程中增加消息幂等性的保证,这样就可以认为从“最终结果上来看”消息实际上是只被消费了一次的。

生产者

在 Kafka0.11 版本和 Pulsar 中都支持“producer idempotency”的特性,翻译过来就是生产过程的幂等性,这种特性保证消息虽然可能在生产端产生重复,但是最终在消息队列存储时只会存储一份。

它的做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储 < 生产者 ID,最后一条消息 ID> 的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致就认为是重复的消息,服务端会自动丢弃

消费者

你可以在消息被生产的时候使用发号器给它生成一个全局唯一的消息 ID,消息被处理之后把这个 ID 存储在数据库中,在处理下一条消息之前先从数据库里面查询这个全局 ID 是否被消费过,如果被消费过就放弃消费。不过这样会有一个问题:如果消息在处理之后,还没有来得及写入数据库,消费者宕机了重启之后发现数据库中并没有这条消息,还是会重复执行两次消费逻辑,这时你就需要引入事务机制,保证消息处理和写入数据库必须同时成功或者同时失败,但是这样消息处理的成本就更高了,所以如果对于消息重复没有特别严格的要求,可以直接使用这种通用的方案,而不考虑引入事务。

三、消息时延

1、时延监控

  • Kafka 提供了工具叫做“kafka-consumer-groups.sh”(它在 Kafka 安装包的 bin 目录下) 图中的前两列是队列的基本信息,包括话题名和分区名;第三列是当前消费者的消费进度;第四列是当前生产消息的总数;第五列就是消费消息的堆积数(也就是第四列与第三列的差值)
  • Kafka Manager。Kafka Manager 是雅虎公司于 2015 年开源的一个 Kafka 监控框架。这个框架用 Scala 语言开发而成,主要用于管理和监控 Kafka 集群。应该说 Kafka Manager 是目前众多 Kafka 监控工具中最好的一个,无论是界面展示内容的丰富程度,还是监控功能的齐全性,它都是首屈一指的。

2、减少时延

想要减少消息的处理延迟,我们需要在消费端消息队列两个层面来完成。在消费端的目标是提升消费者的消息处理能力,你能做的是:

  • 优化消费代码提升性能;
  • 增加消费者的数量(这个方式比较简单)。不过这种方式会受限于消息队列的实现。如果消息队列使用的是 Kafka 就无法通过增加消费者数量的方式来提升消息处理能力。

kafka增加分区

因为在 Kafka 中,一个 Topic(话题)可以配置多个 Partition(分区),数据会被平均或者按照生产者指定的方式写入到多个分区中,那么在消费的时候,Kafka 约定一个分区只能被一个消费者消费,为什么要这么设计呢?在我看来,如果有多个 consumer(消费者)可以消费一个分区的数据,那么在操作这个消费进度的时候就需要加锁,可能会对性能有一定的影响。所以说,话题的分区数量决定了消费的并行度,增加多余的消费者也是没有用处的,你可以通过增加分区来提高消费者的处理能力。

kafka不增加分区,消费消息

那么,如何在不增加分区的前提下提升消费能力呢?虽然不能增加 consumer,但你可以在一个 consumer 中提升处理消息的并行度,所以可以考虑使用多线程的方式来增加处理能力:你可以预先创建一个或者多个线程池,在接收到消息之后把消息丢到线程池中来异步地处理,这样,原本串行的消费消息的流程就变成了并行的消费,可以提高消息消费的吞吐量,在并行处理的前提下,我们就可以在一次和消息队列的交互中多拉取几条数据,然后分配给多个线程来处理。

消费线程空转

消息队列中有一段时间没有新的消息,于是消费客户端拉取不到新的消息就会不间断地轮询拉取消息,这个线程就把 CPU 跑满了。所以,你在写消费客户端的时候要考虑这种场景,拉取不到消息可以等待一段时间再来拉取,等待的时间不宜过长,否则会增加消息的延迟。我一般建议固定的 10ms~100ms。

消息队列本身在读取性能优化方面做了哪些事情

  • 消息存储。使用本地磁盘作为存储介质。Page Cache 的存在就可以提升消息的读取速度,即使要读取磁盘中的数据,由于消息的读取是顺序的并且不需要跨网络读取数据,所以读取消息的 QPS 提升了一个数量级。
  • 零拷贝。其实我们不可能消灭数据的拷贝,只是尽量减少拷贝的次数。在读取消息队列的数据的时候,其实就是把磁盘中的数据通过网络发送给消费客户端,在实现上会有四次数据拷贝的步骤:数据从磁盘拷贝到内核缓冲区;系统调用将内核缓存区的数据拷贝到用户缓冲区;用户缓冲区的数据被写入到 Socket 缓冲区中;操作系统再将 Socket 缓冲区的数据拷贝到网卡的缓冲区中。操作系统提供了 Sendfile 函数可以减少数据被拷贝的次数。使用了 Sendfile 之后,在内核缓冲区的数据不会被拷贝到用户缓冲区而是直接被拷贝到 Socket 缓冲区,节省了一次拷贝的过程提升了消息发送的性能。高级语言中对于 Sendfile 函数有封装,比如说在 Java 里面的 java.nio.channels.FileChannel 类就提供了 transferTo 方法提供了 Sendfile 的功能。