消息队列高手课——基础篇

消息队列高手课

基础篇,以讲解消息队列的使用方法和最佳实践为主,包括消息队列基础知识、技术选型、高级功能等,给出消息队列应用过程中常见问题的解决策略。通过基础篇的学习,希望你能对消息队列和相关生态系统有比较深入的认识,成为消息队列“小达人”。

进阶篇,是这个课程的核心内容,我们会深入到源码中去,探讨消息队列的实现原理,帮助你拓展知识深度。成为消息队列领域的“技术高手”;掌握从源码分析、解决问题的方法;将你的综合技术能力提升到一个新的高度,具备成为开源软件项目开发者的能力。在这个模块的前半部分,每篇会围绕一个知识点来深入探讨,比如像异步模型、高性能的底层网络通信等,其中每一个知识点不仅是中间件开发人员必须掌握的,而且是各大厂面试题中的常考内容,希望你每个知识点都不要放过。

案例篇,我会和你一起做两个微型的项目,带你体验实际的代码开发。这两个微项目会用到我们在基础篇和进阶篇中学习的知识。

第一个微项目,一起用消息队列和流计算框架来实现一个流计算任务;

第二个微项目,一起来实现一个最简单的RPC框架,因为开发中间件用到的很多技术都是互通的,开发消息队列的技术同样可以用于开发RPC框架。

消息队列生态全景图”,涵盖了消息队列产品、标准和协议、应用场景、编程语言以及实现技术,

img

学习资源推荐

  • RocketMQ官方文档:https://rocketmq.apache.org/docs/quick-start/
  • RocketMQ中国开发者中心:http://rocketmq.cloud/zh-cn/
  • Kafka官方文档:http://kafka.apache.org/documentation/
  • RabbitMQ官方文档:https://www.rabbitmq.com/documentation.html
  • StackOverflow:https://stackoverflow.com/

什么时候用消息队列

消息队列适用范围

1.异步处理

秒杀系统需要解决的核心问题是,如何利用有限的服务器资源,尽可能多地处理短时间内的海量请求。

不需要即时响应的,放入消息队列中异步地进行后续的操作。

img

消息队列被用于实现服务的异步处理。这样做的好处是:

  • 可以更快地返回结果;
  • 减少等待,自然实现了步骤之间的并发,提升系统总体的性能
网关在发送消息之后,是如何来接收后端服务的秒杀结果,又如何来给 APP 返回响应的呢?

在这个例子中,网关接收后端服务秒杀结果,实现的方式也不只一种,这里我给大家提供一个比较简单的方案。

比如说,用 Java 语言来举例子:

public class RequestHandler {
  
  // ID 生成器
  @Inject
  private IdGenerator idGenerator;
  // 消息队列生产者
  @Inject
  private Producer producer;
  // 保存秒杀结果的 Map
  @Inject
  private Map<Long, Result> results;
 
  // 保存 mutex 的 Map
  private Map<Long, Object> mutexes = new ConcurrentHashMap<>();
  // 这个网关实例的 ID
  @Inject
  private long myId;
 
  @Inject
  private long timeout;
 
  // 在这里处理 APP 的秒杀请求
  public Response onRequest(Request request) {
    // 获取一个进程内唯一的 UUID 作为请求 id
    Long uuid = idGenerator.next();
    try {
 
      Message msg = composeMsg(request, uuid, myId);
 
      // 生成一个 mutex,用于等待和通知
      Object mutex = new Object();
      mutexes.put(uuid, mutex)
 
      // 发消息
      producer.send(msg);
 
      // 等待后端处理
      synchronized(mutex) {
        mutex.wait(timeout);
      }
 
      // 查询秒杀结果
      Result result = results.remove(uuid);
 
      // 检查秒杀结果并返回响应
      if(null != result && result.success()){
        return Response.success();
      }
 
    } catch (Throwable ignored) {}
    finally {
      mutexes.remove(uuid);
    }
    // 返回秒杀失败
    return Response.fail();
  }
 
  // 在这里处理后端服务返回的秒杀结果
  public void onResult(Result result) {
 
    Object mutex = mutexes.get(result.uuid());
    if(null != mutex) { // 如果查询不到,说明已经超时了,丢弃 result 即可。
      // 登记秒杀结果
      results.put(result.uuid(), result);
      // 唤醒处理 APP 请求的线程
      synchronized(mutex) {
        mutex.notify();
      }
    }
  }
}

在这个方案中,网关在收到 APP 的秒杀请求后,直接给消息队列发消息。至于消息的内容,并不一定是 APP 请求的 Request,只要包含足够的字段就行了,比如用户 ID、设备 ID、请求时间等等。另外,还需要包含这个请求的 ID 和网关的 ID,这些后面我们会用到。

如果发送消息失败,可以直接给 APP 返回秒杀失败结果,成功发送消息之后,线程就阻塞等待秒杀结果。这里面不可能无限等待下去,需要设定一个等待的超时时间。

等待结束之后,去存放秒杀结果的 Map 中查询是否有返回的秒杀结果,如果有就构建 Response,给 APP 返回秒杀结果,如果没有,按秒杀失败处理。

这是处理 APP 请求的线程,接下来我们来看一下,网关如何来接收从后端秒杀服务返回的秒杀结果。

我们可以选择用 RPC 的方式来返回秒杀结果,这里网关节点是 RPC 服务端,后端服务为客户端。之前网关发出去的消息中包含了网关的 ID,后端服务可以通过这个网关 ID 来找到对应的网关实例,秒杀结果中需要包含请求 ID,这个请求 ID 也是从消息中获取的。

网关收到后端服务的秒杀结果之后,用请求 ID 为 Key,把这个结果保存到秒杀结果的 Map 中,然后通知对应的处理 APP 请求的线程,结束等待。我刚刚说过,处理 APP 请求的线程,在结束等待之后,会去秒杀的结果 Map 中查询这个结果,然后再给 APP 返回响应。

我把这个处理过程的流程图放在这里,便于你理解:

img

这个解决方案还不是一个性能最优的方案,处理 APP 请求的线程需要同步等待秒杀结果。后面课程中我们会专门来讲,如何使用异步方式来提升程序的性能。

2.流量控制:隔离网关/令牌桶

如何避免过多的请求压垮我们的秒杀系统?

隔离网关

我们的设计思路是,使用消息队列隔离网关和后端服务,以达到流量控制和保护后端服务的目的。

加入消息队列后,整个秒杀流程变为:

  1. 网关在收到请求后,将请求放入请求消息队列;
  2. 后端服务从请求消息队列中获取APP请求,完成后续秒杀处理过程,然后返回结果。

img

秒杀开始后,当短时间内大量的秒杀请求到达网关时,不会直接冲击到后端的秒杀服务,而是先堆积在消息队列中,后端服务按照自己的最大处理能力,从消息队列中消费请求进行处理。

对于超时的请求可以直接丢弃,APP将超时无响应的请求处理为秒杀失败即可。运维人员还可以随时增加秒杀服务的实例数量进行水平扩容,而不用对系统的其他部分做任何更改。

这种设计的优点是:能根据下游的处理能力自动调节流量,达到“削峰填谷”的作用。但这样做同样是有代价的:增加了系统调用链环节,导致总体的响应时延变长。上下游系统都要将同步调用改为异步消息,增加了系统的复杂度。

令牌桶

用消息队列实现一个令牌桶,更简单地进行流量控制。

令牌桶控制流量的原理是:单位时间内只发放固定数量的令牌到令牌桶中,规定服务在处理请求之前必须先从令牌桶中拿出一个令牌,如果令牌桶中没有令牌,则拒绝请求。这样就保证单位时间内,能处理的请求不超过发放令牌的数量,起到了流量控制的作用。

img

只要网关在处理APP请求时增加一个获取令牌的逻辑。

令牌桶可以简单地用一个有固定容量的消息队列加一个“令牌发生器”来实现:令牌发生器按照预估的处理能力,匀速生产令牌并放入令牌队列(如果队列满了则丢弃令牌),网关在收到请求时去令牌队列消费一个令牌,获取到令牌则继续调用后端秒杀服务,如果获取不到令牌则直接返回秒杀失败。

3.服务解耦

引入消息队列后,订单服务在订单变化时发送一条消息到消息队列的一个主题Order中,所有下游系统都订阅主题Order,这样每个下游系统都可以获得一份实时完整的订单数据。

当然,消息队列的适用范围不仅仅局限于这些场景,还有包括:

  • 作为发布/订阅系统实现一个微服务级系统间的观察者模式;
  • 连接流计算任务和数据;
  • 用于将消息广播给大量接收者。

简单的说,我们在单体应用里面需要用队列解决的问题,在分布式系统中大多都可以用消息队列来解决。

消息队列也有它自身的一些问题和局限性

  • 引入消息队列带来的延迟问题;
  • 增加了系统的复杂度;
  • 可能产生数据不一致的问题。

选择哪种消息队列?

选择消息队列产品的基本标准

  1. 必须是开源的产品
  2. 是近年来比较流行并且有一定社区活跃度的产品
  3. 最后,作为一款及格的消息队列产品,必须具备的几个特性包括:
    • 消息的可靠传递:确保不丢消息;
    • Cluster:支持集群,确保不会因为某个节点宕机导致服务不可用,当然也不能丢消息;
    • 性能:具备足够好的性能,能满足绝大多数场景的性能要求。

可供选择的消息队列产品

1.RabbitMQ

轻量级、迅捷,开箱即用。

RabbitMQ一个比较有特色的功能是支持非常灵活的路由配置,和其他消息队列不同的是,它在生产者(Producer)和队列(Queue)之间增加了一个Exchange模块,你可以理解为交换机。

RabbitMQ的几个问题。

第一个问题是,RabbitMQ对消息堆积的支持并不好,在它的设计理念里面,消息队列是一个管道,大量的消息积压是一种不正常的情况,应当尽量去避免。当大量消息积压的时候,会导致RabbitMQ的性能急剧下降。

第二个问题是,RabbitMQ的性能是我们介绍的这几个消息队列中最差的

最后一个问题是RabbitMQ使用的编程语言Erlang

2.RocketMQ

有着不错的性能,稳定性和可靠性,响应时延低

Java

3.Kafka

Kafka最初的设计目的是用于处理海量的日志

它的同步收发消息的响应时延比较高,因为当客户端发送一条消息的时候,Kafka并不会立即发送出去,而是要等一会儿攒一批再发送,在它的Broker中,很多地方都会使用这种“先攒一波再一起处理”的设计。当你的业务场景中,每秒钟消息数量没有那么多的时候,Kafka的时延反而会比较高。

Kafka不太适合在线业务场景。

如果你需要处理海量的消息,像收集日志、监控信息或是前端的埋点这类数据,或是你的应用场景大量使用了大数据、流计算相关的开源产品,那Kafka是最适合你的消息队列。

消息模型

队列(Queue)、主题(Topic)或是分区(Partition)这些名词概念

主题和队列有什么区别?

最初的一种消息模型:队列模型

img

如果有多个生产者往同一个队列里面发送消息,这个队列中可以消费到的消息,就是这些生产者生产的所有消息的合集。消息的顺序就是这些生产者发送消息的自然顺序。如果有多个队列是先进先出(FIFO,First-In-First-Out)的线性表(LinearList)。

在具体应用中通常用链表或者数组来实现。队列只允许在后端(称为rear)进行插入操作,在前端(称为front)进行删除操作。消费者接收同一个队列的消息,这些消费者之间实际上是竞争的关系,每个消费者只能收到队列中的一部分消息,也就是说任何一条消息只能被其中的一个消费者收到。

发布-订阅模型(Publish Subscribe Pattern)

解决:如果需要将一份消息数据分发给多个消费者,要求每个消费者都能收到全量的消息,例如,对于一份订单数据,风控系统、分析系统、支付系统等都需要接收消息。

img

在发布-订阅模型中

  • 消息的发送方称为发布者(Publisher),

  • 消息的接收方称为订阅者(Subscriber),

  • 服务端存放消息的容器称为主题(Topic)。

发布者将消息发送到主题中,订阅者在接收消息之前需要先“订阅主题”。“订阅”在这里既是一个动作,同时还可以认为是主题在消费时的一个逻辑副本,每份订阅中,订阅者都可以接收到主题的所有消息。

队列模式和发布-订阅模式是并存的,有些消息队列同时支持这两种消息模型,比如ActiveMQ。

我们仔细对比一下这两种模型,生产者就是发布者,消费者就是订阅者,队列就是主题,并没有本质的区别。它们最大的区别其实就是,一份消息数据能不能被消费多次的问题。

RabbitMQ的消息模型

坚持使用队列模型的产品之一

怎么解决多个消费者的问题呢?

RabbitMQ的一个特色Exchange模块吗?在RabbitMQ中,Exchange位于生产者和队列之间,生产者并不关心将消息发送给哪个队列,而是将消息发送给Exchange,由Exchange上配置的策略来决定将消息投递到哪些队列中。

img

同一份消息如果需要被多个消费者来消费,需要配置Exchange将消息发送到多个队列,每个队列中都存放一份完整的消息数据,可以为一个消费者提供消费服务。这也可以变相地实现新发布-订阅模型中,“一份消息数据可以被多个订阅者来多次消费”这样的功能。

RocketMQ的消息模型

img

是标准的发布-订阅模型

在RocketMQ也有队列(Queue)这个概念,为了解决消息空洞,提高性能。

为什么有队列概念——消息队列的消费机制

几乎所有的消息队列产品都使用一种非常朴素的“请求-确认”机制,确保消息不会在传递过程中由于网络或服务器故障丢失。

具体的做法也非常简单。在生产端,生产者先将消息发送给服务端,也就是Broker,服务端在收到消息并将消息写入主题或者队列中后,会给生产者发送确认的响应。

如果生产者没有收到服务端的确认或者收到失败的响应,则会重新发送消息;在消费端,消费者在收到消息并完成自己的消费业务逻辑(比如,将数据保存到数据库中)后,也会给服务端发送消费成功的确认,服务端只有收到消费确认后,才认为一条消息被成功消费,否则它会给消费者重新发送这条消息,直到收到对应的消费成功确认。

为了确保消息的有序性,在某一条消息被成功消费之前,下一条消息是不能被消费的,否则就会出现消息空洞,违背了有序性这个原则。

每个主题在任意时刻,至多只能有一个消费者实例在进行消费,那就没法通过水平扩展消费者的数量来提升消费端总体的消费性能。为了解决这个问题,RocketMQ在主题下面增加了队列的概念。

每个主题包含多个队列,通过多个队列来实现多实例并行生产和消费。RocketMQ只在队列上保证消息的有序性,主题层面是无法保证消息的严格顺序的。

img

RocketMQ中,订阅者的概念是通过消费组(ConsumerGroup)来体现的。每个消费组都消费主题中一份完整的消息,不同消费组之间消费进度彼此不受影响,也就是说,一条消息被ConsumerGroup1消费过,也会再给ConsumerGroup2消费。

消费组中包含多个消费者,同一个组内的消费者是竞争消费的关系,每个消费者负责消费组内的一部分消息。如果一条消息被消费者Consumer1消费了,那同组的其他消费者就不会再收到这条消息。

在Topic的消费过程中,由于消息需要被不同的组进行多次消费,所以消费完的消息并不会立即被删除,这就需要RocketMQ为每个消费组在每个队列上维护一个消费位置(ConsumerOffset),这个位置之前的消息都被消费过,之后的消息都没有被消费过,每成功消费一条消息,消费位置就加一。这个消费位置是非常重要的概念,我们在使用消息队列的时候,丢消息的原因大多是由于消费位置处理不当导致的

Kafka的消息模型

Kafka的消息模型和RocketMQ是完全一样的,唯一的区别是,在Kafka中,队列这个概念的名称不一样,Kafka中对应的名称是“分区(Partition)”,含义和功能是没有任何区别的。

详解 RocketMQ 和 Kafka 的消息模型

RocketMQ 和 kafka 的消息模型理解的还不是很透彻,这两个消息队列产品的消息模型是一样的,我在这里,再把这个模型相关的概念,通过一个例子详细地说一说。

假设有一个主题 MyTopic,我们为主题创建 5 个队列,分布到 2 个 Broker 中。

img

先说消息生产这一端,假设我们有 3 个生产者实例:Produer0,Produer1 和 Producer2。

这 3 个生产者是如何对应到 2 个 Broker 的,又是如何对应到 5 个队列的呢?这个很简单,不用对应,随便发。每个生产者可以在 5 个队列中轮询发送,也可以随机选一个队列发送,或者只往某个队列发送,这些都可以。比如 Producer0 要发 5 条消息,可以都发到队列 Q0 里面,也可以 5 个队列每个队列发一条。

然后说消费端,很多同学没有搞清楚消费组、消费者和队列这几个概念的对应关系。

每个消费组就是一份订阅,它要消费主题 MyTopic 下,所有队列的全部消息。注意,队列里的消息并不是消费掉就没有了,这里的“消费”,只是去队列里面读了消息,并没有删除,消费完这条消息还是在队列里面。

多个消费组在消费同一个主题时,消费组之间是互不影响的。比如我们有 2 个消费组:G0 和 G1。G0 消费了哪些消息,G1 是不知道的,也不用知道。G0 消费过的消息,G1 还可以消费。即使 G0 积压了很多消息,对 G1 来说也没有任何影响。

然后我们再说消费组的内部,一个消费组中可以包含多个消费者的实例。比如说消费组 G1,包含了 2 个消费者 C0 和 C1,那这 2 个消费者又是怎么和主题 MyTopic 的 5 个队列对应的呢?

由于消费确认机制的限制,这里面有一个原则是,在同一个消费组里面,每个队列只能被一个消费者实例占用。至于如何分配,这里面有很多策略,我就不展开说了。总之保证每个队列分配一个消费者就行了。比如,我们可以让消费者 C0 消费 Q0,Q1 和 Q2,C1 消费 Q3 和 Q4,如果 C0 宕机了,会触发重新分配,这时候 C1 同时消费全部 5 个队列。

再强调一下,队列占用只是针对消费组内部来说的,对于其他的消费组来说是没有影响的。比如队列 Q2 被消费组 G1 的消费者 C1 占用了,对于消费组 G2 来说,是完全没有影响的,G2 也可以分配它的消费者来占用和消费队列 Q2。

最后说一下消费位置,每个消费组内部维护自己的一组消费位置,每个队列对应一个消费位置。消费位置在服务端保存,并且,消费位置和消费者是没有关系的。每个消费位置一般就是一个整数,记录这个消费组中,这个队列消费到哪个位置了,这个位置之前的消息都成功消费了,之后的消息都没有消费或者正在消费。

我把咱们这个例子的消费位置整理成下面的表格,便于你理解。

img

你可以看到,这个表格中并没有消费者这一列,也就是说消费者和消费位置是没有关系的。

如何实现单个队列的并行消费

如果不要求严格顺序,如何实现单个队列的并行消费?关于这个问题,有很多的实现方式,在 JMQ(京东自研的消息队列产品)中,它实现的思路是这样的。

比如说,队列中当前有 10 条消息,对应的编号是 0-9,当前的消费位置是 5。同时来了三个消费者来拉消息,把编号为 5、6、7 的消息分别给三个消费者,每人一条。过了一段时间,三个消费成功的响应都回来了,这时候就可以把消费位置更新为 8 了,这样就实现并行消费。

这是理想的情况。还有可能编号为 6、7 的消息响应回来了,编号 5 的消息响应一直回不来,怎么办?这个位置 5 就是一个消息空洞。为了避免位置 5 把这个队列卡住,可以先把消费位置 5 这条消息,复制到一个特殊重试队列中,然后依然把消费位置更新为 8,继续消费。再有消费者来拉消息的时候,优先把重试队列中的那条消息给消费者就可以了。

这是并行消费的一种实现方式。需要注意的是,并行消费开销还是很大的,不应该作为一个常规的,提升消费并发的手段,如果消费慢需要增加消费者的并发数,还是需要扩容队列数。

如何保证消息的严格顺序

主题层面是无法保证严格顺序的,只有在队列上才能保证消息的严格顺序。

如果说,你的业务必须要求全局严格顺序,就只能把消息队列数配置成 1,生产者和消费者也只能是一个实例,这样才能保证全局严格顺序。

大部分情况下,我们并不需要全局严格顺序,只要保证局部有序就可以满足要求了。比如,在传递账户流水记录的时候,只要保证每个账户的流水有序就可以了,不同账户之间的流水记录是不需要保证顺序的。

如果需要保证局部严格顺序,可以这样来实现。在发送端,我们使用账户 ID 作为 Key,采用一致性哈希算法计算出队列编号,指定队列来发送消息。一致性哈希算法可以保证,相同 Key 的消息总是发送到同一个队列上,这样可以保证相同 Key 的消息是严格有序的。如果不考虑队列扩容,也可以用队列数量取模的简单方法来计算队列编号。

消息队列实现分布式事务(生产者消费者数据一致性)

消息队列中的“事务”,主要解决的是消息生产者和消息消费者的数据一致性问题。

比较常见的分布式事务实现有2PC(Two-phaseCommit,也叫二阶段提交)、TCC(Try-Confirm-Cancel)和事务消息。每一种实现都有其特定的使用场景,也有各自的问题,都不是完美的解决方案。

一个严格意义的事务实现,应该具有4个属性:原子性、一致性、隔离性、持久性。

这四个属性通常称为ACID特性。

  • 原子性,是指一个事务操作不可分割,要么成功,要么失败,不能有一半成功一半失败的情况。
  • 一致性,是指这些数据在事务执行完成这个时间点之前,读到的一定是更新前的数据,之后读到的一定是更新后的数据,不应该存在一个时刻,让用户读到更新过程中的数据。
  • 隔离性,是指一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对正在进行的其他事务是隔离的,并发执行的各个事务之间不能互相干扰,这个有点儿像我们打网游中的副本,我们在副本中打的怪和掉的装备,与其他副本没有任何关联也不会互相影响。
  • 持久性,是指一个事务一旦完成提交,后续的其他操作和故障都不会对事务的结果产生任何影响。

大部分传统的单体关系型数据库都完整的实现了ACID,但是,对于分布式系统来说,严格的实现ACID这四个特性几乎是不可能的,或者说实现的代价太大,大到我们无法接受。

事务消息适用的场景主要是那些需要异步更新数据,并且对数据实时性要求不太高的场景。

消息队列是如何实现分布式事务的?

回到订单和购物车这个例子,我们一起来看下如何用消息队列来实现分布式事务。

img

首先,订单系统在消息队列上开启一个事务。然后订单系统给消息服务器发送一个“半消息”,这个半消息不是说消息内容不完整,它包含的内容就是完整的消息内容,半消息和普通消息的唯一区别是,在事务提交之前,对于消费者来说,这个消息是不可见的。

半消息发送成功后,订单系统就可以执行本地事务了,在订单库中创建一条订单记录,并提交订单库的数据库事务。然后根据本地事务的执行结果决定提交或者回滚事务消息。如果订单创建成功,那就提交事务消息,购物车系统就可以消费到这条消息继续后续的流程。如果订单创建失败,那就回滚事务消息,购物车系统就不会收到这条消息。这样就基本实现了“要么都成功,要么都失败”的一致性要求。

这个实现过程中,有一个问题是没有解决的。如果在第四步提交事务消息时失败了怎么办?对于这个问题,Kafka和RocketMQ给出了2种不同的解决方案。

Kafka的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。RocketMQ则给出了另外一种解决方案。

RocketMQ中的分布式事务实现

img

在RocketMQ中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。

如果Producer也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ的Broker没有收到提交或者回滚的请求,Broker会定期去Producer上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。

为了支撑这个事务反查机制,我们的业务代码需要实现一个反查本地事务状态的接口,告知RocketMQ本地事务是成功还是失败。

反查本地事务的实现,并不依赖消息的发送方,也就是订单服务的某个实例节点上的任何数据。这种情况下,即使是发送事务消息的那个订单服务节点宕机了,RocketMQ依然可以通过其他订单服务的节点来执行反查,确保事务的完整性。

消息丢失:消息的可靠性传输

检测消息丢失的方法

我们可以利用消息队列的有序性来验证是否有消息丢失。原理非常简单,在Producer端,我们给每个发出的消息附加一个连续递增的序号,然后在Consumer端来检查这个序号的连续性。

如果没有消息丢失,Consumer收到消息的序号必然是连续递增的,或者说收到的消息,其中的序号必然是上一条消息的序号+1。如果检测到序号不连续,那就是丢消息了。还可以通过缺失的序号来确定丢失的是哪条消息,方便进一步排查原因。

大多数消息队列的客户端都支持拦截器机制,你可以利用这个拦截器机制,在Producer发送消息之前的拦截器中将序号注入到消息中,在Consumer收到消息的拦截器中检测序号的连续性,这样实现的好处是消息检测的代码不会侵入到你的业务代码中,待你的系统稳定后,也方便将这部分检测的逻辑关闭或者删除。

如果是在一个分布式系统中实现这个检测方法,有几个问题需要你注意。

主题不是严格顺序,分区队列是有序,需要指定分区,每个分区单独检测消息序号连续性。

首先,像Kafka和RocketMQ这样的消息队列,它是不保证在Topic上的严格顺序的,只能保证分区上的消息是有序的,所以我们在发消息的时候必须要指定分区,并且,在每个分区单独检测消息序号的连续性。

如果你的系统中Producer是多实例的,由于并不好协调多个Producer之间的发送顺序,所以也需要每个Producer分别生成各自的消息序号,并且需要附加上Producer的标识,在Consumer端按照每个Producer分别来检测序号的连续性。

Consumer实例的数量最好和分区数量一致,做到Consumer和分区一一对应,这样会比较方便地在Consumer内检测消息序号的连续性。

确保消息可靠传递

一条消息从生产到消费完成这个过程,可以划分三个阶段,为了方便描述,我给每个阶段分别起了个名字。

img

  • 生产阶段:在这个阶段,从消息在Producer创建出来,经过网络传输发送到Broker端。
  • 存储阶段:在这个阶段,消息在Broker端存储,如果是集群,消息会在这个阶段被复制到其他的副本上。
  • 消费阶段:在这个阶段,Consumer从Broker上拉取消息,经过网络传输发送到Consumer上。

如何确保消息的可靠性

一条消息从发送到消费整个流程中,消息队列是如何确保消息的可靠性,不会丢失的。这个过程可以分为分三个阶段,每个阶段都需要正确的编写代码并且设置正确的配置项,才能配合消息队列的可靠性机制,确保消息不会丢失。

  • 在生产阶段,你需要捕获消息发送的错误,并重发消息。
  • 在存储阶段,你可以通过配置刷盘和复制相关的参数,让消息写入到多个副本的磁盘上,来确保消息不会因为某个Broker宕机或者磁盘损坏而丢失。
  • 在消费阶段,你需要在处理完全部消费业务逻辑之后,再发送消费确认

1.生产阶段

在生产阶段,消息队列通过最常用的请求确认机制,来保证消息的可靠传递。

有些消息队列在长时间没收到发送确认响应后,会自动重试,如果重试再失败,就会以返回值或者异常的方式告知用户。

你在编写发送消息代码时,需要注意,正确处理返回值或者捕获异常,就可以保证这个阶段的消息不会丢失。

以Kafka为例,我们看一下如何可靠地发送消息:

同步发送时,只要注意捕获异常即可。

try{
RecordMetadatametadata=producer.send(record).get();
System.out.println(“消息发送成功。”);
}catch(Throwablee){
System.out.println(“消息发送失败!”);
System.out.println(e);
}

异步发送时,则需要在回调方法里进行检查。

这个地方是需要特别注意的,很多丢消息的原因就是,我们使用了异步发送,却没有在回调中检查发送结果。

producer.send(record,(metadata,exception)->{
if(metadata!=null){
System.out.println(“消息发送成功。”);
}else{
System.out.println(“消息发送失败!”);
System.out.println(exception);
}
});

2.存储阶段

正常不丢,但是如果Broker出现了故障,比如进程死掉了或者服务器宕机了,还是可能会丢失消息的。

如果对消息的可靠性要求非常高,可以通过配置Broker参数来避免因为宕机丢消息。

配置:

  • 对于单个节点的Broker,需要配置Broker参数,在收到消息后,将消息写入磁盘后再给Producer返回确认响应,这样即使发生宕机,由于消息已经被写入磁盘,就不会丢失消息,恢复后还可以继续消费。例如,在RocketMQ中,需要将刷盘方式flushDiskType配置为SYNC_FLUSH同步刷盘。
  • 如果是Broker是由多个节点组成的集群,需要将Broker集群配置成:至少将消息发送到2个以上的节点,再给客户端回复发送确认响应。这样当某个Broker宕机时,其他的Broker可以替代宕机的Broker,也不会发生消息丢失。

3.消费阶段

和生产阶段类似的确认机制来保证消息的可靠传递

如果Broker没有收到消费确认响应,下次拉消息的时候还会返回同一条消息,确保消息不会在网络传输过程中丢失,也不会因为客户端在执行消费逻辑中出错导致丢失。

不要在收到消息后就立即发送消费确认,而是应该在执行完所有消费业务逻辑之后,再发送消费确认。

以用Python语言消费RabbitMQ消息为例,来看一下如何实现一段可靠的消费代码:

defcallback(ch,method,properties,body):

print(“[x]收到消息%r”%body)

#在这儿处理收到的消息

database.save(body)

print(“[x]消费完成”)

#完成消费业务逻辑后发送消费确认响应

ch.basic_ack(delivery_tag=method.delivery_tag)

channel.basic_consume(queue=‘hello’,on_message_callback=callback)

在消费的回调方法callback中,正确的顺序是,先是把消息保存到数据库中,然后再发送消费确认响应。这样如果保存消息到数据库失败了,就不会执行消费确认的代码,下次拉到的还是这条消息,直到消费成功。

消息重复消费:幂等性

消息重复的情况必然存在

在MQTT协议中,给出了三种传递消息时能够提供的服务质量标准,这三种服务质量从低到高依次是:

  • At most once:至多一次。消息在传递时,最多会被送达一次。换一个说法就是,没什么消息可靠性保证,允许丢消息。一般都是一些对消息可靠性要求不太高的监控场景使用,比如每分钟上报一次机房温度数据,可以接受数据少量丢失。
  • At least once:至少一次。消息在传递时,至少会被送达一次。也就是说,不允许丢消息,但是允许有少量重复消息出现。
  • Exactly once:恰好一次。消息在传递时,只会被送达一次,不允许丢失也不允许重复,这个是最高的等级。

大部分消息队列提供的服务质量都是At most once,包括RocketMQ、RabbitMQ和Kafka都是这样。也就是说,消息队列很难保证消息不重复。

用幂等性解决重复消息问题

其任意多次执行所产生的影响均与一次执行的影响相同。

从对系统的影响结果来说:Atleastonce+幂等消费=Exactlyonce。

从业务逻辑设计上入手,将消费的业务逻辑设计成具备幂等性的操作

下面我给你介绍几种常用的设计幂等操作的方法:

1.利用数据库的唯一约束实现幂等

不光是可以使用关系型数据库,只要是支持类似“INSERTIFNOTEXIST”语义的存储类系统都可以用于实现幂等,比如,你可以用Redis的SETNX命令来替代数据库中的唯一约束,来实现幂等消费。

2.为更新的数据设置前置条件

更加通用的方法是,给你的数据增加一个版本号属性,每次更数据前,比较当前数据的版本号是否和消息中的版本号一致,如果不一致就拒绝更新数据,更新数据的同时将版本号+1,一样可以实现幂等更新。

3.记录并检查操作

记录并检查操作,也称为“Token机制或者GUID(全局唯一ID)机制”,实现的思路特别简单:在执行数据更新操作之前,先检查一下是否执行过这个更新操作。

在发送消息时,给每条消息指定一个全局唯一的ID,消费时,先根据这个ID检查这条消息是否有被消费过,如果没有消费过,才更新数据,然后将消费状态置为已消费。

在分布式系统中,这个方法其实是非常难实现的。首先,给每个消息指定一个全局唯一的ID就是一件不那么简单的事儿,方法有很多,但都不太好同时满足简单、高可用和高性能,或多或少都要有些牺牲。更加麻烦的是,在“检查消费状态,然后更新数据并且设置消费状态”中,三个操作必须作为一组操作保证原子性,才能真正实现幂等,否则就会出现Bug。

比如说,对于同一条消息:“全局ID为8,操作为:给ID为666账户增加100元”,有可能出现这样的情况:

t0时刻:ConsumerA收到条消息,检查消息执行状态,发现消息未处理过,开始执行“账户增加100元”;t1时刻:ConsumerB收到条消息,检查消息执行状态,发现消息未处理过,因为这个时刻,ConsumerA还未来得及更新消息执行状态

这样就会导致账户被错误地增加了两次100元,这是一个在分布式系统中非常容易犯的错误,一定要引以为戒。

对于这个问题,当然我们可以用事务来实现,也可以用锁来实现,但是在分布式系统中,无论是分布式事务还是分布式锁都是比较难解决问题。

消息积压

消息积压的直接原因,一定是系统中的某个部分出现了性能问题,来不及处理上游发送的消息,才会导致消息积压。

优化性能来避免消息积压

1.发送端性能优化

发消息之前业务逻辑太多,只需要注意设置合适的并发和批量大小

预防消息积压的方法有两种,增加批量或者是增加并发。

2.消费端性能优化

增加并发需要同步扩容分区数量,否则是起不到效果的。

如果消费的速度跟不上发送端生产消息的速度,就会造成消息积压。

一定要保证消费端的消费性能要高于生产端的发送性能,这样的系统才能健康的持续运行。

消费端的性能优化除了优化消费业务逻辑以外,也可以通过水平扩容,增加消费端的并发数来提升总体的消费性能。

特别需要注意的一点是,在扩容Consumer的实例数量的同时,必须同步扩容主题中的分区(也叫队列)数量,确保Consumer的实例数和分区数量是相等的。

如果Consumer的实例数量超过分区数量,这样的扩容实际上是没有效果的。原因我们之前讲过,因为对于消费者来说,在每个分区上实际上只能支持单线程消费。

消息积压了该如何处理?

能导致积压突然增加,最粗粒度的原因,只有两种:要么是发送变快了,要么是消费变慢了。

大部分消息队列都内置了监控的功能,只要通过监控数据,很容易确定是哪种原因。如果是单位时间发送的消息增多,比如说是赶上大促或者抢购,短时间内不太可能优化消费端的代码来提升消费性能,唯一的方法是通过扩容消费端的实例数来提升总体的消费能力。

如果短时间内没有足够的服务器资源进行扩容,没办法的办法是,将系统降级,通过关闭一些不重要的业务,减少发送方发送的数据量,最低限度让系统还能正常运转,服务一些重要业务。

还有一种不太常见的情况,你通过监控发现,无论是发送消息的速度还是消费消息的速度和原来都没什么变化,这时候你需要检查一下你的消费端,是不是消费失败导致的一条消息反复消费这种情况比较多,这种情况也会拖慢整个系统的消费速度。

如果监控到消费变慢了,你需要检查你的消费实例,分析一下是什么原因导致消费变慢。优先检查一下日志是否有大量的消费错误,如果没有错误的话,可以通过打印堆栈信息,看一下你的消费线程是不是卡在什么地方不动了,比如触发了死锁或者卡在等待某些资源上了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值