消息队列
消息队列是计算机系统中常用的通信模型,本地使用队列解决的问题对应到分布式系统中都可以用消息队列来解决。消息队列可以应用在异步处理、流量控制、服务解耦等场景,比如同时调用多个服务但不需要同步所有服务响应结果就可以使用消息队列,设计令牌队列实现对请求的流量控制,通过消息队列解耦多个服务避免扩展性僵化。
消息队列在互联网、云原生、IOT、大数据等各领域都有着广泛应用,涉及到的协议就有 MQTT、AMQP、JMS、OpenMessaging 等,但并没有一个业界完全统一的标准。因此在技术选型上就要根据应用场景以及产品特性进行筛选。
但较为通用的,消息队列都会面临几个通用场景选题,比如消息模型、分布式事务、消息积压、消息丢失、消息重复等,这些问题不局限于某个消息队列,因此有了这篇文章,聊聊消息队列常见问题并以消息队列早期标准 RabbitMQ 的解决方案。
消息模型
RabbitMQ 是目前为数不多仍然使用队列模型的消息中间件,Kafka 或者 RocketMQ 是基于主题模型的消息中间件。他们两者的区别是一份消息是否可被多次消费,队列模型如果需要一份消息被多个消费者消费就要重复发送到多个队列中,而主题模型只需要发一份到主题中即可,通过消费组来控制消息的消费进度。
这是 RabbitMQ 官网中对消息模型的介绍, RabbitMQ 是对 AMQP 协议(0-9-1 版本)的标准实现。
生产者(Publisher)将消息发往消息队列(Broker)只需要发到消息队列的交换器(Exchange)中,由交换器按照路由规则(Routing Key)进行转发,依据绑定键(Binding Key)与队列(Queue)的对应关系发往到指定队列中,最后再由消费者(Consumer)从指定队列中消费到消息。
交换器有四种类型,分别是 Direct(路由键完全匹配)、Topic(路由键模糊匹配)、Fanout(广播)、Headers(消息头匹配,基本废弃),默认使用 Direct 交换器类型。
消息最终存储在队列中,如果多个消费者同时订阅了一个队列,那么就会按照 Round Robin 轮询分发消息,而不会每条消息发给所有消费者。这就是上面所讲的队列模型与主题模型的区别。
消息在 RabbitMQ 的运转流程就是:生产者与消息队列建立基于 HTTP 协议的长连接(Connection)并开启信道(Channel),声明交换器、队列和路由键建立绑定关系;生产者发送消息,消息队列根据绑定关系匹配到队列并存储;消费者与消息队列建立长连接并开启信道,向消息队列请求消息设置回调函数,消息队列有消息会按照订阅关系投递,消费者接收到消息后进行确认;最后不再需要消息传输生产者与消费者分别关闭信道与连接。
分布式事务
解决消息从生产者到消费者过程中的数据一致性问题,也就是数据分发给消息队列中和其他服务或中间件时要保持一致,要么都成功要么都失败。要实现数据的 ACID 特性,但是这种源于单体数据库领域的模型并不太适合分布式系统。因为分布式系统是要保证分区容错性的,也就是 CAP 中的 P(不然也无法称之为分布式),按照 CAP 最多满足两条边的结论一致性和可用性需要二选一。
分区容错性(Partition Tolerance)讲的是系统中可以通过集群结构解决单点故障导致的系统故障,如果不考虑 P 就相当于退化成单体系统;一致性(Consistency)讲的是对于任意时刻包括新数据写入与修改,系统中的任意节点都可以读取到最新数据,如果读取不到就会报错;可用性(Availability)讲的是正常访问系统中的任意节点,虽然不同节点获取的数据可能存在不一致。
除了金融、通信等领域优先保证一致性,很多高并发场景会选择可用性,通过最终一致性方案替代强一致性方案。也就是可以容忍系统在短暂时间出现数据不一致的情况,通过数据补偿提供最终服务能力,最大限度保留系统的吞吐量与性能。
相对应 ACID 理论强调一致性,BASE 更加强调可用性,通过牺牲部分可用性实现整体可用性。它的核心是基本可用(Basically Available)和最终一致性(Eventually consistent),对于基本可用的实现可使用流量削峰、延迟响应、限流熔断、过载保护等手段,对于最终一致性可通过读时修复、写时修复、异步修复等方式完成。
对于消息队列而言,RocketMQ 与 Kafka 都借助半消息实现了分布式事务。半消息也包含了完整的消息内容,只是在事务提交前对消费者来说是不可见的。
比如 RocketMQ 在生产者将半消息发往消息队列的同时也开启了本地事务,只有本地事务执行成功后才会通知消息队列继续投递消息,否则就要对事务回滚,发生了通信超时消息队列还会反查生产者再次确认本地事务是否执行成功,确保数据一致。
对于 RabbitMQ 而言,它实现了 AMQP 中的事务机制,在消息从生产者发往消息队列时开启事务,没有得到投递确认就会一直阻塞。再有强数据一致性要求的情况下可以这么做,但大多还是会基于系统业务实现一套分布式事务。
最常用的就是借助数据库的事务机制建立本地消息表,通过生产者的本地事务控制消息发送和调用其他服务的数据一致性。生产者在执行本地事务时包含记录到数据库中的消息,通过定时任务查找待发送消息,通过 RabbitMQ 的消息确认机制更新本地消息采取重试或删除等策略。后续流程需要借助消息可靠性机制来完成,也就是消息丢失与重复的处理。
消息丢失
RabbitMQ 除了自身集群保证消息可靠性外,预防消息丢失也有多种方法。
从生产者来看,发送消息可以开启 AMQP 事务或者消息确认机制,同时本地记录待发送消息与状态,用于回查与对账。
从消息队列来看,可以开启 mandatory 参数,避免消息没有找到路由而丢失;开启队列、交换器、消息持久化(集群复制),避免宕机导致消息丢失;开启镜像队列,对可靠性要求高的队列执行镜像。
从消费者来看,关闭 autoAck,执行完业务逻辑后再对消息队列执行消息确认,避免自动确认造成消息丢失。
也可以通过设计消息自增 id,对消息顺序消费出现消息空洞就可检查出消息丢失。
同时,还可以基于 firehose 等实现消息追踪,将生产者发送的消息以及发往消费者的消息都记录到一个 topic 类型的默认交换器(amq.rabbitmq.trace)里,实现消息对账。
消息重复
消息服务质量(QoS)对消息可靠性做了三个级别的划分,每条消息最少被消费一次、正好被消费一次以及最多被消息一次。消息丢失会有以上处理方法以及通过消息队列集群的可靠性来保障,正好消费一次对消息队列的吞吐量影响会很严重,因此消息队列基本都会提供至少消费一次作为消息服务质量指标。最少被消费一次加上消息幂等性就完成了正好被消费一次(Exactly Once),消息幂等指的是对于消费者面对重复消息,消费一次和消费多次效果是一样的。
幂等性设计不局限于 RabbitMQ 本身,针对业务而言是比较通用的设计方法。可以借助消费者消息存储数据库唯一性约束,在消息落入消费者数据库后,通过主键或联合主键判断下一条消息是否已经存在数据库中,从而执行后续业务或者丢弃。还可以在消息头附带全局唯一 id,也就是 Token 或者 GUID,在分布式系统中又会有消息状态更新以及全局唯一 id 生成的问题,需要借助分布式事务和分布式锁来解决。也可以借用 Redis 缓存已经消费过的消息唯一标识,通过自带的原子性判断是否已经重复消费了。
消息积压
对于绝大多数使用消息队列的业务来说,消息队列本身的处理能力要远大于业务系统的处理能力,那么避免消息积压的大致思路就是让消费者处理能力匹配上生产者。
从系统本质复杂度考虑,如果消息系统吞吐量正常的情况下(生产与消费速率平稳),消息积压是由于消息激增导致的,那调整消费者处理逻辑可能也不太奏效,这时就要考虑服务降级或者提升消息处理并发,从消息队列和消费者数量进行横向扩容,增加消费者数量以及提高 prefetch 数量提高消息处理并行度。对于已经发生消息积压场景,开启死信队列减少无法投递到消费者的消息积压,或者转发投递到另一个更大容量的消息队列中进行低优先级异步处理。
另一方面,如果监控到消费者出现大量消息处理异常,就要觉察是否消费者存在问题。比如反复消费同一消息,优化消费逻辑或限制重复消费,启用消息优先级队列或者死信队列。再比如消费者性能受损,死锁、数据库写入并发下降、服务调用超时等。