如何保证消息不丢、有序和避免消息堆积、重复消费

1,531 阅读11分钟

参考资料

MQ的3大功能

  1. 异步处理
  2. 应用解耦
  3. 流量削峰

消息队列模型

消息队列有两种模型

  1. 队列模型
  2. 发布/订阅模型

队列模型

队列模型中,一条消息只能被一个消费者消费。

发布/订阅模型

发布/订阅模型中,一条消息能被多个消费者消费。

MQ中间件产品

目前主流的 MQ 中间件产品包括

  1. RabbitMQ

    • 采用 Erlang 语言开发(一种面向并发的编程语言)。
    • 对消息堆积的支持不算太好,当大量消息积压的时候,会导致 RabbitMQ 性能下降。
    • 每秒钟可以处理几万到几十万条消息。
  2. RocketMQ

    • 阿里系下开源的一款分布式、队列模型的消息中间件产品。
    • 采用 Java 开发,便于定制化扩展。
    • 面向互联网集群化功能丰富,对在线业务的响应时延做了很多的优化。
  3. kafka

    • 采用 Scala 开发。
    • 面向日志功能丰富。
  4. ActiveMQ

    • 采用 Java 开发,简单、稳定,但性能一般。

RabbitMQ、RocketMQ、kafka、ActiveMQ,这几种 MQ 中间件产品的对比如下表。

对比项RabbitMQRocketMQKafkaActiveMQ
协议AMQPAMQP自行设计AMQP
开发语言ErlangJavaScalaJava
跨语言支持支持支持支持
单机吞吐量万级十万级百万级万级
消息事务支持支持支持支持
可用性高(主从)非常高(分布式)非常高(分布式)高(主从)
所属社区/公司Mozilla Public License阿里巴巴,后捐赠给 ApacheApacheApache
优点跨平台,功能完备,高扩展性功能完备,高扩展性面向日志功能丰富,支持消息大量堆积MQ 功能完备,高扩展性
缺点Elang语言难度大,研发人员较少目前只支持 Java 及 C++严格的顺序机制,不支持消息优先级,不支持标准协议项目比较陈旧,官方社区在 5.X 之后对其维护越来越少
综合评价适用于稳定性要求优先的企业级应用阿里系下开源的一款分布式、队列模型的消息中间件产品,国内互联网公司使用居多在日志和大数据方向使用较多小型系统比较适用,但是因为维护越来越少,不建议使用

RocketMQ

  1. RocketMQ 是阿里系下开源的一款分布式、队列模型的消息中间件,是阿里参照 kafka 设计思想并使用 Java 实现的一套 MQ。
  2. 2016年11月,阿里将 RocketMQ 捐献给 Apache 软件基金会,正式成为孵化项目。
  3. 2017年2月20日,RocketMQ 正式发布 4.0 版本,新版本更适用于电商领域,金融领域,大数据领域,兼有物联网领域的编程模型。

RocketMQ基本概念

RocketMQ 主要由 Producer、Broker、Consumer 三部分组成,其中 Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。

  • 代理服务器(Broker Server) 消息中转角色,负责存储消息、转发消息。代理服务器在 RocketMQ 系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。

  • 名字服务(Name Server) 名称服务充当路由消息的提供者。生产者或消费者能够通过名字服务(Name Server)查找各主题相应的 Broker IP列表。多个 Name Server 实例组成集群,但相互独立,没有信息交换。

Name Server 的角色类似 Dubbo中的 Zookeeper,但 NameServer 与 Zookeeper 相比更轻量,主要是因为每个 NameServer 节点互相之间是独立的,没有任何信息交互。NameServer 的主要开销是在维持心跳和提供 Topic-Broker 的关系数据。

  • 拉取式消费(Pull Consumer) Consumer 消费的一种类型,应用通常主动调用 Consumer 的拉消息方法从 Broker 服务器拉消息。主动权由应用控制,一旦获取了批量消息,应用就会启动消费过程。

  • 推动式消费(Push Consumer) Consumer 消费的一种类型,该模式下 Broker 收到数据后会主动推送给消费端,该消费模式一般实时性较高。

RocketMQ特性

  • 订阅与发布
  • 消息顺序:RocketMQ 可以严格的保证消息有序
  • 消息过滤
  • 消息可靠性
  • 至少一次(At least Once):每个消息必须投递一次。Consumer 先 Pull 消息到本地,消费完成后,才向服务器返回 Ack;如果没有消费一定不会 Ack 消息,所以 RocketMQ 可以很好的支持此特性。

消息领域有一个对消息投递的服务质量(Quality of Service,简称QoS),分为

  1. 最多一次(At most once)
  2. 至少一次(At least once)
  3. 仅一次( Exactly once)
  • 回溯消费
  • 事务消息
  • 定时消息
  • 消息重试:RocketMQ 对于重试消息的处理是先保存至 Topic 名称为 SCHEDULE_TOPIC_XXXX 的延迟队列中,后台定时任务按照对应的时间进行 Delay 后重新保存至 %RETRY%+consumerGroup 的重试队列中。
  • 消息重投
  • 流量控制
  • 死信队列

引入MQ带来的问题

MQ 的三大应用是「异步处理」、「应用解耦」、「流量削峰」。在系统中引入MQ,会带来如下问题

  1. 引入 MQ 中间件实现「应用解耦」,会影响系统之间数据传输的一致性,即消息生产端和消息消费端的消息数据一致性问题,所以要思考「如何保证消息不会丢失」。
  2. 引入 MQ 中间件实现「流量削峰」,容易出现消费端处理能力不足从而导致消息积压,所以要思考「如何处理消息堆积」。
  3. 发送的多条消息在达到消费端时,由于网络等原因,消息的消费的顺序,可消息发送的顺序会不一致,所以要思考「如何保证消息按顺序执行」。
  4. 在消息消费的过程中,如果出现失败的情况,会通过补偿的机制执行重试。重试的过程就有可能产生重复的消息,所以要思考「如何保证消息不被重复消费」。

下面,将分别对这 4 个问题进行介绍。

如何保证消息不会丢失

要解决「如何保证消息不会丢失」问题,首先要知道哪些环节可能造成消息丢失。一个消息从产生到消费的过程如下图所示,共3个阶段。所以,消息丢失可能出现在

  1. 消息生产阶段产生消息丢失
    • 网络传输中丢失消息
    • MQ发送异常
  2. 消息存储阶段产生消息丢失
    • MQ 成功接收消息后,内部处理出错
    • Broker 宕机
  3. 消息消费阶段产生消息丢失
    • 采用消息自动确认模式,消费者取到消息后未完成消费(或业务逻辑未执行完)

理清楚了「哪些环节可能造成消息丢失」,就可对症下药,分析「如何保证消息不会丢失」。

1.消息生产阶段保证消息不丢失

主流的 MQ 都有确认(Confirm)或事务机制,可以保证生产者将消息送达到 MQ。

方案1:采用事务机制

  • 生产者在发送消息之前开启事物,然后发送消息。如果消息没有成功被 Broker 接收到,那么生产者会收到异常报错,此时生产者可以回滚事物,然后尝试重新发送;如果收到了消息,那么生产者就可以提交事物。
  • 采用事务机制,在消息发送时,生产者会产生阻塞,等待是否发送成功,这会影响性能,造成吞吐量下降。
  channel.txSelect();//开启事物
  try{
      //发送消息
  }catch(Exection e){
      channel.txRollback();//回滚事物
      //重新提交
  }

方案2:采用确认(Confirm)

  • 以 RabbitMQ 为例,生产者可以开启确认(Confirm)模式,每次写的消息都会分配一个唯一的 ID。Broker 在收到消息后,会返回一个 Ack 信号给生产者,确认消息发送成功。
  • 事务机制是同步的,会造成阻塞。确认机制是异步的,生产者发送一条消息后可以接着发送下一个消息,不会产生阻塞。
    //开启confirm
    channel.confirm();
    //发送成功回调
    public void ack(String messageId){
      
    }

    // 发送失败回调
    public void nack(String messageId){
        //重发该消息
    }

2.消息存储阶段保证消息不丢失

  • 开启 MQ 的持久化配置。
  • 如果 Broker 是集群部署,有多副本机制,则消息不仅仅要写入当前 Broker,还需要写入副本机中,至少写入两台机子后,再给生产者返回确认 Ack 信号。

3.消息消费阶段保证消息不丢失

改为手动确认模式,消费者成功消费消息后,再确认。

如何保证消息不被重复消费

解决该问题,有两个思路

  1. 保证消息不会重复(实际不可行)
  2. 保证消费重复消费不会产生影响(即保证消费端的幂等性)

为了保证消息不丢失,「失败重试」机制是必不可少的,所以消息被重复发送的现象,是无法避免的。既然消息一定会出现重复发送,因此,只能考虑「保证消费重复消费不会产生影响」,即「如何保证消费端的幂等性」。

关于「如何保实现幂等性」,参见「架构-Notes-03-如何实现接口幂等性」。

如何保证消息按顺序执行

保证消息按顺序执行,即保证「有序性」。「有序性」可分为

  1. 全局有序
  2. 部分有序

1.全局有序

若要保证消息的全局有序,需要

  1. 只能由一个生产者向 Topic 发送消息,并且一个 Topic 内部只能有一个队列(分区)
  2. 消费者也必须是单线程消费这个队列

一般情况下我们都不需要全局有序,保证局部有效即可。

2.局部有序

若要保证消息的局部有序,需要

  1. 将 Topic 内部划分成我们需要的队列数,把消息通过特定的策略发往固定的队列中
  2. 每个队列对应一个单线程处理的消费者

这样即完成了部分有序的需求,又可以通过队列数量的并发来提高消息处理效率。

如何处理消息堆积

消息的堆积往往是因为生产者的生产速度与消费者的消费速度不匹配,原因可能是

  1. 消息消费失败并反复重试,造成其余消息产生堆积
  2. 消费者消费能力较弱,渐渐地产生消息堆积

因此,我们需要先定位消费慢的原因

  • 如果是 bug 则处理 bug
  • 如果是因为本身消费能力较弱,我们可以优化下消费逻辑,比如之前是一条一条消息消费处理的,可以改为批量处理,如数据库的单条数据插入和批量插入
  • 如果上面解决手段都无效,消费能力还是较弱,则需要通过「水平扩容」来提升消费端的并发处理能力。增加 Topic 的队列数和消费者数量。注意队列数也要增加,不然新增加的消费者是没东西消费的。一个 Topic 中,一个队列只会分配给一个消费者。

在扩容消费者的实例数的同时,必须同步扩容主题 Topic 的队列(分区)数量,确保消费者的实例数和分区数相等。如果消费者的实例数超过了分区数,由于分区是单线程消费,所以这样的扩容就没有效果。

比如在 Kafka 中,一个 Topic 可以配置多个 Partition(分区),数据会被写入到多个分区中。但在消费的时候,Kafka 约定一个分区只能被一个消费者消费,Topic 的分区数量决定了消费的能力,所以,可以通过增加分区来提高消费者的处理能力。

MQ集群部署