消息队列(MQ)介绍

本文主要介绍消息队列(Message Queue,即MQ)的相关知识。

1 WHY

首先需要弄清楚,为什么需要消息队列。

目前需要消息队列的三个主要应用场景为:解耦、异步和削峰。下面分别介绍一下在这三个应用场景中消息队列的作用。

1.1 解耦

传统模式的缺点:系统间耦合性太强,如下图所示。系统A在代码中直接调用系统B和系统C的代码,如果将来D系统接入,系统A还需要修改代码,过于麻烦!

中间件模式的优点:将消息写入消息队列,需要消息的系统自己从消息队列中订阅,从而系统A不需要做任何修改,如下图所示:

1.2 异步

传统模式的缺点:一些非必要的业务逻辑也以同步的方式运行,太耗费时间,如下图所示:

中间件模式的优点:将消息写入消息队列,对于非必要的业务逻辑,以异步的方式运行,加快响应速度。如下图所示:

1.3 削峰

传统模式的缺点:并发量大的时候,所有的请求直接怼到数据库,可能会造成数据库连接异常,如下图所示:

中间件模式的优点:系统A慢慢的按照数据库能处理的并发量,从消息队列中慢慢拉取消息。在生产中,这个短暂的高峰期积压是允许的。如下图所示:

此场景的详细解释:在高并发场景下,由于服务端来不及同步处理数量过多的请求,可能会导致请求堵塞。例如,大量的INSERT、UPDATE之类的请求同时到达MySQL服务端,在执行这些请求的过程中,会出现大量的行锁、表锁,到最后,由于请求堆积过多,可能就会触发“too many connections”错误。在这类高并发场景下,通过使用消息队列,就可以依照业务规则读取这些请求,从而缓解系统的压力。

2 消息队列的缺点

当准备在项目中引入一个技术时,要对这个技术的弊端有充分的认识,才能做好预防,对于消息队列,也是如此。

消息队列的缺点可以从下列两个方面来回答:

  • 系统可用性降低。在系统中加入了消息队列之后,如果消息队列出故障了,那么整个系统可能就不可用了。因此,系统可用性是降低了;
  • 系统复杂性增加。加入了消息队列后,要多考虑很多方面的问题。比如一致性问题、如何保证消息不被重复消费、如何保证保证消息可靠传输等。简单说,就是系统复杂性增加了。

3 几种常见的消息队列

在此列出ActiveMQ、RabbitMQ、RocketMQ、Kafka四种消息队列的对比信息,如下表:

特性ActiveMQRabbitMQRocketMQKafka
开发语言JavaErlangJavaScala
单机吞吐量万级万级10万级10万级
时效性ms级us级ms级ms级以内
可用性高(主从架构)高(主从架构)非常高(分布式架构)非常高(分布式架构)
功能特性成熟的产品,在很多公司得到应用;有较多的文档;各种协议支持较好基于Erlang开发,所以并发能力很强,性能极其好,延时很低;管理界面较丰富MQ功能比较完备,扩展性佳只支持主要的MQ功能,像一些消息查询,消息回溯等功能没有提供,毕竟是为大数据准备的,在大数据领域应用广。

综合上面的材料得出以下两点:

  • 中小型软件公司,建议选RabbitMQ。一方面,Erlang语言天生具备高并发的特性,而且它的管理界面用起来十分方便。正所谓,成也萧何,败也萧何!它的弊端也在这里,虽然RabbitMQ是开源的,然而国内有几个能定制化开发Erlang的程序员呢?不过,RabbitMQ的社区十分活跃,可以解决开发过程中遇到的bug,这点对于中小型公司来说十分重要。不考虑Kafka的原因是,一方面中小型软件公司不如互联网公司,数据量没那么大,选消息中间件,应首选功能比较完备的,所以Kafka排除。不考虑RocketMQ的原因是,RocketMQ是阿里出品,如果阿里放弃维护RocketMQ,中小型公司一般抽不出人来进行RocketMQ的定制化开发,因此不推荐;
  • 大型软件公司,根据具体使用在RocketMQ和Kafka之间二选一。一方面,大型软件公司,具备足够的资金搭建分布式环境,也具备足够大的数据量。针对RocketMQ,大型软件公司也可以抽出人手对RocketMQ进行定制化开发,毕竟国内有能力改JAVA源码的人还是相当多的。至于Kafka,根据业务场景选择,如果有日志采集功能,肯定是首选Kafka了。具体该选哪个,看使用场景。

总的来说,选择哪种MQ,需要从社区活跃度和性能两个方面,结合公司的具体使用场景,最终做出选择。

4 消息队列的高可用性

在前面引入消息队列的缺点时说过,引入消息队列后,系统的可用性下降。在生产中,没人使用单机模式的消息队列。因此,作为一个合格的程序员,应该对消息队列的高可用有很深刻的了解。

要保证消息队列的高可用性,需要对每种消息队列的集群模式要有深刻了解。

以RcoketMQ为例,它的集群就有多Master模式、多Master多Slave异步复制模式、多Master多Slave同步双写模式。多Master多Slave模式部署架构,如下图所示:

image

这张图与Kafka相似,只是NameServer集群在Kafka中是用Zookeeper代替,都是用来保存和发现Master和Slave用的。

上图的通信过程如下:

  1. Producer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息;
  2. Producer与提供Topic服务的Broker Master建立长连接,且定时向Broker发送心跳,Producer只能将消息发送到Broker master;
  3. 与Producer不同,Consumer同时和提供Topic服务的Master及Slave建立长连接,既可以从Broker Master订阅消息,也可以从Broker Slave订阅消息。

kafka的拓补架构图,如下图所示:

image

如上图所示,一个典型的Kafka集群中包含若干Producer(可以是Web前端产生的Page View,或者是服务器日志,系统CPU、Memory等),若干Broker(Kafka支持水平扩展,一般Broker数量越多,集群吞吐率越高),若干Consumer Group,以及一个Zookeeper集群。Kafka通过Zookeeper管理集群配置,选举leader,以及在Consumer Group发生变化时进行rebalance。Producer使用push模式将消息发布到broker,Consumer使用pull模式从Broker订阅并消费消息。

至于RabbitMQ,也有普通集群和镜像集群模式,比较简单,此处不做介绍。

因此,在回答消息队列的高可用问题时,应该能逻辑清晰的画出自己的MQ集群架构或清晰的叙述出来

5 重复消费问题

消息队列的重复消息问题,实际上就是如何保证消息队列的幂等性。这个问题是消息队列领域的基本问题。

先来说一下为什么会造成重复消费。其实无论是那种消息队列,造成重复消费原因其实都是类似的。正常情况下,消费者在消费消息的过程中,消费完毕后,会发送一个确认信息给消息队列,消息队列就知道该消息被消费了,就会将该消息从消息队列中删除。只是不同的消息队列发送的确认信息形式不同。例如RabbitMQ是发送一个ACK确认消息;RocketMQ是返回一个CONSUME_SUCCESS成功标志;Kafka实际上有个offset的概念,简单说就是每一个消息都有一个offset,Kafka消费过消息后,需要提交offset,让消息队列知道自己已经消费过了。那造成重复消费的原因呢?就是因为网络传输等故障,消费者的确认信息没有传送到消息队列,导致消息队列不知道消费者已经消费过该消息了,就再次将该消息分发给消费者。

那么该如何解决此问题呢?这个问题针对业务场景,分以下几点来回答:

  • 比如,你拿到这个消息做数据库的insert操作,那就容易了,给这个消息做一个唯一主键,那么就算出现重复消费的情况,就会导致主键冲突,避免数据库出现脏数据;
  • 再比如,你拿到这个消息做Redis的set的操作,那就容易了,不用解决,因为你无论set几次结果都是一样的,set操作本来就算幂等操作;
  • 如果上面两种情况还不行,就准备一个第三方介质,来做消费记录。以Redis为例,给消息分配一个全局id,只要消费过该消息,将<id,message>以K-V形式写入Redis,那消费者开始消费前,先去Redis中查询有没消费记录即可。

6 消息的可靠性传输

在使用消息队列的过程中,应该做到消息不能多消费,也不能少消费。如果无法做到可靠性传输,可能给公司带来千万级别的财产损失。

其实这个可靠性传输,每种MQ都要从三个角度来分析:生产者弄丢数据消息队列弄丢数据消费者弄丢数据

下面分别针对每种MQ进行分析。

6.1 RabbitMQ

6.1.1 生产者丢数据

从生产者弄丢数据这个角度来看,RabbitMQ提供transaction和confirm模式来确保生产者不丢消息。

transaction机制就是说,发送消息前,开启事物(channel.txSelect()),然后发送消息。如果发送过程中出现什么异常,事物就会回滚 (channel.txRollback()),如果发送成功则提交事物(channel.txCommit())。

然而上述模式的缺点就是吞吐量下降了。因此,按照博主的经验,生产上用confirm模式的居多。一旦channel进入confirm模式,所有在该信道上面发布的消息都将会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,RabbitMQ就会发送一个Ack给生产者(包含消息的唯一 ID),这就使得生产者知道消息已经正确到达目的队列了。如果RabiitMQ没能处理该消息,则会发送一个Nack消息给生产者,生产者可以进行重试操作。处理Ack和Nack的代码如下所示:

channel.addConfirmListener(new ConfirmListener() {
    @Override  
    public void handleNack(long deliveryTag, boolean multiple) throws IOException {  
        System.out.println("nack: deliveryTag = "+deliveryTag+" multiple: "+multiple);  
    }  
    @Override  
    public void handleAck(long deliveryTag, boolean multiple) throws IOException {  
        System.out.println("ack: deliveryTag = "+deliveryTag+" multiple: "+multiple);  
    }  
}); 

6.1.2 消息队列丢数据

处理消息队列丢数据的情况,一般是开启持久化磁盘的配置。这个持久化配置可以和confirm机制配合使用。你可以在消息持久化磁盘后,再给生产者发送一个Ack信号。这样,如果消息持久化磁盘之前,RabbitMQ阵亡了,那么生产者收不到Ack信号,生产者就会自动重发。

那么如何持久化呢,这里顺便说一下吧,其实也很容易,就下面两步:

  1.  将queue的持久化标识durable设置为true,则代表是一个会进行持久化操作的队列;
  2. 发送消息的时候设置“deliveryMode=2”。

这样设置以后,就算RabbitMQ挂了,重启后也能恢复数据。

6.1.3 消费者丢数据

消费者丢数据一般是因为采用了自动确认消息模式。这种模式下,消费者会自动确认收到信息。此时RabbitMQ会立即将消息删除。这种情况下,如果消费者出现异常而没能处理该消息,就会丢失该消息。至于解决方案,采用手动确认消息即可。

6.2 Kafka

这里先引一张Kafka Replication的数据流向图:

image

Producer在发布消息到某个Partition时,先通过ZooKeeper找到该Partition的Leader,然后无论该Topic的Replication Factor为多少(也即该Partition有多少个Replica),Producer只将该消息发送到该Partition的Leader。Leader会将该消息写入其本地Log,每个Follower都从Leader中pull数据。

针对上述情况,得出如下分析。

6.2.1 生产者丢数据

在Kafka生产中,基本都有一个leader和多个follwer。follwer会去同步leader的信息。因此,为了避免生产者丢数据,做如下两点配置:

  • 第一个配置要在producer端设置acks=all。这个配置保证了follwer同步完成后,才认为消息发送成功;
  • 在producer端设置“retries=MAX”,一旦写入失败,就无限重试。

6.2.2 消息队列丢数据

针对消息队列丢数据的情况,无外乎就是,数据还没同步,leader就挂了,这时Zookpeer会将其他的follwer切换为leader,那数据就丢失了。针对这种情况,应该做两个配置:

  • “replication.factor”参数,这个值必须大于1,即要求每个partition必须有至少2个副本;
  • “min.insync.replicas”参数,这个值必须大于1,这个是要求一个leader感知到至少有一个follower还跟自己保持联系。

这两个配置加上前面生产者的配置联合起来用,基本可确保Kafka不丢数据。

6.2.3 消费者丢数据

这种情况一般是自动提交了offset,然后你处理程序过程中挂了,Kafka以为你处理好了。再强调一次offset是干嘛的:offset指的是Kafka的Topic中的每个消费组消费的下标。简单的来说就是一条消息对应一个offset下标。每次消费数据的时候,如果提交了offset,那么下次消费就会从提交的offset加一的那里开始消费。比如一个Topic中有100条数据,我消费了50条并且提交了,那么此时的Kafka服务端记录提交的offset就是49(offset从0开始),那么下次消费的时候offset就从50开始消费。解决方案也很简单,在消费者处将offset改成手动提交即可。

7 消息的顺序性

为了保证消息的顺序性,可以通过某种算法,将需要保持先后顺序的消息放到同一个消息队列中(Kafka中就是partition;RabbitMQ中就是queue),然后只用一个消费者去消费该队列

有的人会问:那如果为了吞吐量,有多个消费者去消费怎么办?

这个问题,没有固定回答的套路。比如我们有一个微博的操作:发微博、写评论、删除微博,这三个异步操作。如果是这样一个业务场景,那只要重试就行。比如一个消费者先执行了写评论的操作,但是这时候,微博都还没发,写评论一定是失败的,等一段时间再重试。

总之,针对这个问题,我的观点是保证入队有序就行,出队以后的顺序交给消费者自己去保证,没有固定套路。

8 分布式系统(架构)

为了进一步探讨使用消息队列的意义,这里需要介绍一下分布式系统的相关知识。

美国计算机科学家、LaTex的作者Leslie Lamport曾说:“分布式系统就是这样的一个系统:系统中一个你甚至都不知道的计算机出了故障,却可能导致你自己的计算机不可用。”Leslie Lamport一语道破了分布式系统的玄机,那就是它的复杂性与不可控性,所以Martin Fowler强调:“分布式调用的第一原则就是不要分布式。”这句话看似颇具哲理,然而就企业应用系统而言,只要整个系统在不停地演化,并有多个子系统共同存在时,这条原则就会被迫打破。毕竟在当今的企业应用系统中,很难找到完全不需要分布式调用的场景。Martin Fowler提出这条原则,一方面是希望系统设计者能够审慎地对待分布式调用,另一方面分布式系统自身确实也存在缺陷。无论是CORBA、还是EJB 2,无论是RPC平台、还是Web Service,都因为使用了驻留在不同进程空间的分布式组件,而引入了额外的复杂度,并可能对系统的效率、可靠性、可预测性等诸多方面带来负面影响。

然而,不可否认的是,在企业应用系统领域,我们总是会面对不同系统之间的通信、集成与整合,尤其当面临异构系统时,这种分布式调用与通信越重要,它(分布式)在架构设计中就更能凸显其价值。并且,从业务分析与架构质量的角度来说,我们也希望在系统架构中尽可能地重用服务,通过独立运行在进程中服务的形式,我们就可以彻底解除客户端与服务端的耦合,进而达到服务重用的目的——这常常是架构演化的必然道路。在陈金洲发表的文章《架构腐化之谜》中,就认为可以通过“将独立的模块放入独立的进程”来解决架构因为代码规模变大而腐化的问题。

随着网络基础设施的逐渐成熟,从RPC进化到Web Service,到业界开始普遍推行SOA,再到后来的RESTful平台,以及云计算中的PaaS与SaaS概念的推广,分布式架构在企业应用中逐渐呈现出不同的风貌。然而殊途同归,这些分布式架构的目标仍是希望回到建造巴别塔的时代:系统之间的交流不再因为不同语言或平台的隔阂而产生障碍。正如Martin Fowler在《企业集成模式》一书的序中写道:“集成之所以重要,是因为相互独立的应用是没有生命力的。我们需要一种技术,能将设计时并未考虑互相操作的应用集成起来,打破它们之间的隔阂,从而获得比单个应用更多的效益”。这,或许是分布式架构存在的主要意义。

9 消息模式

归根结底,企业应用系统就是对数据的处理。而对于一个拥有多个子系统的企业应用系统而言,它的基础无疑就是对消息的处理。与对象不同,消息本质上是一种数据结构(当然,对象也可以看做是一种特殊的消息),它包含消费者(客户端)与生产者(服务端)双方都能识别的数据,这些数据需要在不同的进程(机器)之间传递,并可能会被多个完全不同的客户端消费。在众多的分布式技术中,消息传递相对于文件传递和远程过程调用(RPC)而言,似乎更胜一筹,因为它具有更好的平台无关性,并能够很好地支持并发与异步调用。对于Web Service与RESTful而言,则可以看作是消息传递技术的一种衍生或封装。在《面向模式的软件架构(卷四)》一书中,将关于消息传递的模式划归为分布式基础设施的范畴,这是因为诸多消息中间件产品的出现,使得原来需要开发人员自己实现的功能,现在可以直接重用了,这极大地降低了包括设计成本、实现成本在内的开发成本。因此,对于架构师的要求也就从原来的设计实现,转变为对业务场景和功能需求的判断、进而能够正确地进行架构决策、技术选型与模式运用了。

9.1 常用的消息模式

在我参与过的所有企业应用系统中,无一例外都采用(或在某些子系统和模块中部分采用)了基于消息的分布式架构。但是在此过程中,让我们做出架构决策的原因却迥然而异,这也直接决定了我们所要选用的消息模式。

9.1.1 消息通道(Message Channel)模式

我们经常运用的消息模式是消息通道模式,消息通道模式如下图所示:

消息通道作为在客户端(消费者,Consumer)与服务端(生产者,Producer)之间引入的间接层,可以有效地解除二者之间的耦合:只要规定了双方通信的消息格式,以及处理消息的机制与时机,就可以做到消费者对生产者的“无感知”。事实上,该模式可以支持多个生产者与消费者。例如,我们可以让多个生产者向消息通道发送消息,因为消费者对生产者的无感知性,它不必考虑消息通道中的消息究竟是哪个生产者发来的。

虽然消息通道解除了生产者与消费者之间的耦合,使得我们可以任意地对生产者与消费者进行扩展,但消息通道同时也引入了两者对它的依赖,因为生产者和消费者必须要知道消息通道资源的位置。要解除这种对消息通道的依赖,可以考虑引入Lookup服务来查找该通道资源。例如,在JMS中就可以通过JNDI来获取消息通道Queue。如果要做到充分的灵活性,则可以将与通道相关的信息存储到配置文件中,Lookup服务首先通过读取配置文件来获得通道。

消息通道通常以队列的形式存在,这种先进先出的数据结构无疑最适合这种处理消息的场景。微软的MSMQ、IBM MQ、JBoss MQ以及开源的RabbitMQ、Apache ActiveMQ,都通过队列的形式实现了消息通道模式。因此,在选择使用消息通道模式时,更多地是要从质量属性的层面对各种实现了该模式的产品(如RabbitMQ)进行全方位的分析与权衡。例如,消息通道对并发的支持以及在性能上的表现;消息通道是否充分地考虑了错误处理;消息通道对消息安全的支持;以及关于消息持久化、灾备(fail over)与集群等方面的支持。因为消息通道传递的消息往往是一些重要的业务数据,一旦消息通道成为故障点或者安全性的突破点,那么就可能会对系统造成灾难性的后果。

9.1.2 发布者-订阅者(Publisher-Subscriber)模式

在介绍Publisher-Subscriber模式之前,首先介绍拉模型和推模型。

继承前文所述,一旦消息通道需要支持多个消费者时,就可能面临两种模型的选择:拉模型推模型

拉模型是由消息的消费者发起的,主动权把握在消费者手中,它会根据自身的情况对生产者发起调用。如下图所示:

拉模型的另一种体现是,当生产者的状态发生变更时,通知消费者其状态发生了改变,但得到通知的消费者却会以回调方式,通过调用(生产者)传递过来的消费者对象,获取更多细节消息。

在基于消息的分布式系统中,拉模型的消费者通常以“Batch Job”的形式,根据事先设定的时间间隔,定期侦听通道的情况。一旦发现有消息传递进来,就会将消息传递给真正的处理器(也可以看做是消费者)处理消息,执行相关的业务。

推模型的主动权常常掌握在生产者手中,消费者被动地等待生产者发出的通知,这就要求生产者必须了解消费者的相关信息。如下图所示:

对于推模型而言,消费者无需了解生产者。当生产者通知消费者时,传递的内容往往是消息(或事件),而非生产者自身。同时,生产者还可以根据不同的情况,注册不同的消费者,然后在封装的通知逻辑中,根据不同的状态变化,通知不同的消费者。

拉模型和推模型两种模型都有各自的优势。拉模型的优势在于可以进一步解除消费者对通道的依赖,通过后台任务(如 Batch Job)去定期访问消息通道,坏处是需要引入一个单独的服务进程,以Schedule形式执行。而对于推模型而言,消息通道事实上会作为消费者观察的主体,一旦发现消息进入,就会通知消费者对消息进行处理。

无论推模型还是拉模型,对于消息对象而言,都可能采用类似Observer模式的机制,实现消费者对生产者的订阅,因此这种机制通常又被称为Publisher-Subscriber模式,如下图所示:

通常情况下,发布者和订阅者都会被注册到用于传播变更的基础设施(即消息通道)上。发布者会主动地了解消息通道,使其能够将消息发送到通道中;消息通道一旦接收到消息,会主动地调用注册在通道中的订阅者,进而完成对消息内容的消费。

对于订阅者而言,有两种处理消息的方式。一种是广播机制,这时消息通道中的消息在出列的同时,还需要复制消息对象,将消息传递给多个订阅者。例如,有多个子系统都需要获取从CRM系统传来的客户信息,并根据传递过来的客户信息,进行相应的处理,此时的消息通道又被称为Propagation通道。另一种方式属于抢占机制,它遵循同步方式,在同一时间只能有一个订阅者能够处理该消息,实现Publisher-Subscriber模式的消息通道会选择当前空闲的唯一订阅者,并将消息出列,并传递给订阅者的消息处理方法。

目前,有许多消息中间件都能够很好地支持Publisher-Subscriber模式,例如JMS接口规约中对于Topic对象提供的MessagePublisher与MessageSubscriber接口。RabbitMQ也提供了对该模式的实现。微软的MSMQ虽然引入了事件机制,可以在队列收到消息时触发事件,以通知订阅者,但它并非严格意义上的Publisher-Subscriber模式实现。由微软MVP Udi Dahan作为主要贡献者的NServiceBus,则对MSMQ以及WCF做了进一层封装,并能够很好地实现这一模式。

9.1.3 消息路由(Message Router)模式

无论是Message Channel模式,还是Publisher-Subscriber模式,队列在其中都扮演了举足轻重的角色。然而,在企业应用系统中,当系统变得越来越复杂时,对性能的要求也会越来越高,此时对于系统而言,可能就需要支持同时部署多个队列,并可能要求分布式部署不同的队列。这些队列可以根据定义,接收不同的消息,例如订单处理的消息,日志信息,查询任务消息等。这时,对于消息的生产者和消费者而言,并不适宜承担决定消息传递路径的职责。事实上,根据单一职责原则,这种职责分配也是不合理的,它既不利于业务逻辑的重用,也会造成生产者、消费者与消息队列之间的耦合,从而影响系统的扩展。

既然这三种对象(组件)都不宜承担这样的职责,那么就有必要引入一个新的对象,专门负责传递路径选择的功能,这就是所谓的消息路由(Message Router)模式,如下图所示:

通过消息路由,我们可以通过配置路由规则,来指定消息传递的路径,以及指定具体的消费者对应的生产者。例如,指定路由的关键字,并由它来绑定具体的队列及指定的生产者(或消费者)。路由的支持提供了消息传递和处理的灵活性,也有利于提高整个系统的消息处理能力。同时,路由对象有效地封装了寻找和匹配消息路径的逻辑,就好似一个调停者(Meditator),负责协调消息、队列与路径寻址之间关系。

除了以上的模式之外,Messaging模式提供了一个通信基础架构,使得我们可以将独立开发的服务整合到一个完整的系统中。 Message Translator模式则完成对消息的解析,使得不同的消息通道能够接收和识别不同格式的消息,而且通过引入这样的对象,也能够很好地避免系统内部盘根错节,彼此依赖多个服务。Message Bus模式可以为企业提供一个面向服务的体系架构,它可以完成对消息的传递,对服务的适配与协调管理,并要求这些服务以统一的方式完成协作。

9.2 消息模式的应用场景

在介绍过几种常见的消息模式后,接下来看一下这些消息模式的应用场景。

基于消息的分布式架构总是围绕着消息来做文章。例如,可以将消息封装为对象,或者指定消息的规范(如SOAP),或者对实体对象进行序列化与反序列化,这些方式的目的只有一个,就是将消息设计为生产者和消费者都能够识别的格式,并且能够通过消息通道进行传递

9.2.1 场景一:基于消息的统一服务架构

在制造工业的CIMS系统中,我们尝试将各种业务以服务的形式公开给客户端的调用者,例如,定义如下接口:

public interface IService {
    IMessage Execute(IMessage aMessage);
    void SendRequest(IMessage aMessage);
}

之所以能够设计这样的服务,原因在于我们对业务信息进行了高度的抽象,以消息的形式在服务之间传递。此时的消息其实算是生产者与消费者之间的契约(或接口)了,只要遵循这样的契约,按照规定的格式对消息进行转换和抽取,就能很好地支持系统的分布式处理。

在这个CIMS系统中,我们将消息划分为ID,Name和Body。通过定义如下接口方法,可以获得消息主体的相关属性:

public interface IMessage:ICloneable
{
     string MessageID { get; set; }
     string MessageName() { get; set; }
     IMessageItemSequence CreateMessageBody();
     IMessageItemSequence GetMessageBody();
}

消息主体类Message实现了IMessage接口。在该类中,消息体Body为IMessageItemSequence类型。这个类型用于获取和设置消息的内容,即Value和Item,如下:

public interface IItemValueSetting {
     string getSubValue(string name);
     void setSubValue(string name, string value);  
}
public interface IMessageItemSequence:IItemValueSetting, ICloneable
{      
     IMessageItem GetMessageItem(string aName);
     IMessageItem CreateMessageItem(string aName);       
}

Value为字符串类型,它利用了HashTable存储Key和Value的键值对。Item则为IMessageItem类型,在IMessageItemSequence的实现类中,同样利用了HashTable存储Key和Item的键值对。

IMessageItem支持消息体的嵌套,它包含了两部分:SubValue和SubItem。实现的方式和IMessageItemSequence相似。通过定义这样的嵌套结构,使得消息的扩展成为可能。一般的消息结构如下所示:

        IMessage——Name
                       ——ID
                       ——Body(IMessageItemSequence)
                               ——Value
                               ——Item(IMessageItem)
                                   ——SubValue
                                   ——SubItem(IMessageItem)
                                          ——……

各个消息对象之间的关系如下图所示:

在实现服务进程通信之前,我们必须定义好各个服务或各个业务的消息格式,通过消息体的方法在服务的一端设置消息的值,然后发送,并在服务的另一端获得这些值。例如发送消息端定义如下的消息体:

IMessageFactory factory = new MessageFactory();
IMessage message = factory.CreateMessage();
message.SetMessageName("service1");

IMessageItemSequence body = message.CreateMessageBody();
body.SetSubValue("subname1","subvalue1");
body.SetSubValue("subname2","subvalue2");

IMessageItem item1 = body.CreateMessageItem(”item1”);
item1.SetSubValue("subsubname11","subsubvalue11");
item1.SetSubValue("subsubname12","subsubvalue12");

//Send Request Message
MyServiceClient service = new MyServiceClient("Client");
service.SendRequest(message);

我们在客户端引入了一个ServiceLocator对象,它通过MessageQueueListener对消息队列进行侦听,一旦接收到消息,就获取该消息中的name去定位它所对应的服务,然后调用服务的Execute(aMessage)方法,执行相关的业务。

ServiceLocator承担的定位职责,其实是对存储在ServiceContainer容器中的服务进行查询。ServiceContainer容器可以读取配置文件,在启动服务的时候初始化所有的分布式服务(注意,这些服务都是无状态的),并对这些服务进行管理。它封装了服务的基本信息,诸如服务所在的位置,服务的部署方式等,从而避免服务的调用者直接依赖于服务的细节,既减轻了调用者的负担,还能够较好地实现服务的扩展与迁移。

在这个系统中,我们主要引入了Messaging模式,通过定义的IMessage接口,使得我们更好地对服务进行抽象,并以一种扁平的格式存储数据信息,从而解除服务之间的耦合。只要各个服务就共用的消息格式达成一致,请求者就可以不依赖于接收者的具体接口。通过引入的Message对象,我们就可以建立一种在行业中通用的消息模型与分布式服务模型。事实上,基于这样的一个框架与平台,在对制造行业的业务进行开发时,开发人员最主要的活动是与领域专家就各种业务的消息格式进行讨论,这样一种面向领域的消息语言,很好地扫清了技术人员与业务人员的沟通障碍;同时在各个子系统之间,我们也只需要维护服务间相互传递的消息接口表。每个服务的实现都是完全隔离的,有效地做到了对业务知识基础设施的合理封装与隔离。

对于消息的格式和内容,我们考虑引入了Message Translator模式,负责对前面定义的消息结构进行翻译和解析。为了进一步减轻开发人员的负担,我们还可以基于该平台搭建一个“消息-对象-关系”的映射框架,引入实体引擎(Entity Engine),将消息转换为领域实体,使得服务的开发者能够以完全面向对象的思想开发各个服务组件,并通过调用持久层实现消息数据的持久化。同时,利用消息总线(此时的消息总线可以看作是各个服务组件的连接器)连接不同的服务,并允许异步地传递消息,对消息进行编码。这样一个基于消息的分布式架构如下图(基于Message Bus的CIMS分布式架构)所示:

9.2.2 场景二:消息中间件的架构决策

在一个医疗卫生系统中,我们面临了客户对系统性能/可用性的非功能性需求。在我们最初启动该项目时,客户就表达了对性能与可用性的特别关注,客户希望最终用户在进行复杂的替换、删除操作时,能够具有很好的用户体验,简言之,就是希望能够快速地得到操作的响应。问题在于这样的替换、删除操作需要处理比较复杂的业务逻辑,同时牵涉的关联数据量非常大,若需完成整个操作,最坏情况下可能需要几分钟的时间。我们通过引入缓存、索引、分页等多种方式,对数据库操作进行性能调优,但整个操作的耗时却始终无法达到客户的要求。同时,由于该系统是在一个遗留系统的基础上开发的,如果要引入Map-Reduce来处理这些操作(以满足质量需求),则会对架构产生太大影响,并且不能很好地重用之前系统的某些组件,显然,付出的成本与收益并不成正比。

通过对需求进行分析,我们注意到最终客户并不需要实时获得结果,只要能够保证最终结果的一致性和完整性即可。关键在于就用户体验而言,他们不希望经历漫长的等待,然后再通知他们操作究竟是成功还是失败——这是一个典型的需要通过后台任务进行异步处理的场景。

在企业应用系统中,我们常常会遇到这样的场景。

我们曾经在一个金融系统中尝试通过自己编写任务的方式来控制后台线程的并发访问,并完成对任务的调度。事实证明,这样的设计并非行之有效,对于这种典型的异步处理来说,基于消息传递的架构模式才是解决这一问题的最佳办法

随着消息中间件的逐步成熟,对于这一问题的架构设计,已经由原来对设计实现的关注转为如何进行产品选型和技术决策。例如,在.NET平台下,架构师需要重点考虑的是:应该选择哪种消息中间件来处理此等问题。这就需要我们必须结合具体的业务场景,来识别这种异步处理方式的风险,然后再根据这些风险去比较各种技术,以找到最适合的方案。

回到前述案例中,通过分析业务场景以及客户性质,我们发现该业务场景具有如下特征:

  • 在一些特定情形下,可能会集中发生批量的替换、删除操作,使得操作的并发量达到高峰。例如,FDA要求召回一些违规药品时,就需要删除药品库中该药品的信息;
  • 操作结果不要求实时性,但需要保证操作的可靠性,不能因为异常失败而导致某些操作无法进行;
  • 自动操作的过程是不可逆转的,因此需要记录操作历史;
  • 基于性能考虑,大多数操作需要调用数据库的存储过程;
  • 操作的数据需要具备一定的安全性,避免被非法用户对数据造成破坏;
  • 与操作相关的功能以组件形式封装,保证组件的可重用性、可扩展性与可测试性;
  • 数据量可能随着最终用户的增多而逐渐增大。

针对如上的业务需求,我们决定从以下几个方面对各种技术方案进行横向的比较与考量:

  • 并发:选择的消息队列一定要很好地支持用户访问的并发性;
  • 安全:消息队列是否提供了足够的安全机制;
  • 性能伸缩:不能让消息队列成为整个系统的单一性能瓶颈;
  • 部署:尽可能让消息队列的部署更为容易;
  • 灾备:不能因为意外的错误、故障或其他因素导致处理数据的丢失;
  • API易用性:处理消息的API必须足够简单、并能够很好地支持测试与扩展。

我们先后考察了MSMQ、Resque、ActiveMQ和RabbitMQ,通过查询相关资料,以及编写Spike代码验证相关质量,我们最终选择了RabbitMQ。下面说说选择RabbitMQ的具体原因。

我们选择放弃MSMQ,是因为它严重依赖Windows操作系统,它虽然提供了易用的GUI方便管理人员对其进行安装和部署,但若要编写自动化部署脚本,却非常困难;同时,MSMQ的队列容量不能查过4M字节,这也是我们无法接受的。Resque的问题是目前仅支持Ruby的客户端调用,不能很好地与.NET平台集成,此外,Resque对消息持久化的处理方式是写入到Redis中,因而需要在已有RDBMS的前提下,引入新的Storage,这显然会增加系统的复杂度。我们比较倾心于ActiveMQ与RabbitMQ,但通过编写测试代码,采用循环发送大数据消息以验证消息中间件的性能与稳定性时,我们发现ActiveMQ的表现并不太让人满意。至少,在我们的询证调研过程中,ActiveMQ会因为频繁发送大数据消息而偶尔出现崩溃的情况。相对而言,RabbitMQ在各个方面都比较适合我们的架构要求。

例如,在灾备与稳定性方面,RabbitMQ提供了可持久化的队列,能够在队列服务崩溃的时候,将未处理的消息持久化到磁盘上。为了避免因为发送消息到写入消息之间的延迟而导致的信息丢失,RabbitMQ引入了Publisher Confirm机制,以确保消息被真正地写入到磁盘中。另外,它对Cluster的支持提供了Active/Passive与Active/Active两种模式。例如,在Active/Passive模式下,一旦一个节点失败,Passive节点就会马上被激活,并迅速替代失败的Active节点,承担起消息传递的职责。如下图(图片来自RabbitMQ官方网站)所示:

在并发处理方面,RabbitMQ本身是基于Erlang编写的消息中间件。作为一门面向并发处理的编程语言,Erlang对并发处理的天生优势,使得我们对RabbitMQ的并发特性抱有信心。RabbitMQ可以非常容易地部署到Windows、Linux等操作系统下。同时,它也可以很好地部署到服务器集群中。它的队列容量是没有限制的(取决于安装RabbitMQ的磁盘容量),发送与接收信息的性能表现也非常好。RabbitMQ提供了Java、.NET、Erlang以及C语言的客户端API,调用非常简单,并且不会给整个系统引入太多第三方库的依赖。例如,.NET客户端只需要依赖一个程序集。

虽然我们选择了RabbitMQ,但仍有必要对系统与具体的消息中间件进行解耦,这就要求我们对消息的生产者与消费者进行抽象,例如定义如下的接口:

    public interface IQueueSubscriber
    {
        void ListenTo<T>(string queueName, Action<T> action);
        void ListenTo<T>(string queueName, Predicate<T> messageProcessedSuccessfully);
        void ListenTo<T>(string queueName, Predicate<T> messageProcessedSuccessfully, bool requeueFailedMessages);
    }

    public interface IQueueProvider
    {
        T Pop<T>(string queueName);
        T PopAndAwaitAcknowledgement<T>(string queueName, Predicate<T> messageProcessedSuccessfully);
        T PopAndAwaitAcknowledgement<T>(string queueName, Predicate<T> messageProcessedSuccessfully, bool requeueFailedMessages);
        void Push(FunctionalArea functionalArea, string routingKey, object payload);
    }

在这两个接口的实现类中,我们封装了RabbitMQ的调用类,如下:

    public class RabbitMQSubscriber : IQueueSubscriber
    {
        public void ListenTo<T>(string queueName, Action<T> action)
        {
            using (IConnection connection = _factory.OpenConnection())
            using (IModel channel = connection.CreateModel())
            {
                var consumer = new QueueingBasicConsumer(channel);
                string consumerTag = channel.BasicConsume(queueName, AcknowledgeImmediately, consumer);

                var response = (BasicDeliverEventArgs) consumer.Queue.Dequeue();
                var serializer = new JavaScriptSerializer();
                string json = Encoding.UTF8.GetString(response.Body);
                var message = serializer.Deserialize<T>(json);

                action(message);
            }
        }       
    }
    public class RabbitMQProvider : IQueueProvider
    {

        public T Pop<T>(string queueName)
        {
            var returnVal = default(T);
            const bool acknowledgeImmediately = true;

            using (var connection = _factory.OpenConnection())
            using (var channel = connection.CreateModel())
            {
                var response = channel.BasicGet(queueName, acknowledgeImmediately);

                if (response != null)
                {
                    var serializer = new JavaScriptSerializer();
                    var json = Encoding.UTF8.GetString(response.Body);
                    returnVal = serializer.Deserialize<T>(json);
                }
            }

            return returnVal;
        }
    }

我们使用Quartz.Net来实现Batch Job。通过定义一个实现了IStatefulJob接口的Job类,在Execute()方法中完成对队列的侦听。Job中RabbitMQSubscriber类的ListenTo()方法会调用Queue的Dequeue()方法,当接收的消息到达队列时,Job就会侦听到消息达到的事件,然后以同步的方式使得消息弹出队列,并将消息作为参数传递给Action委托。因此,在Batch Job的Execute()方法中,可以定义消息处理的方法,并调用RabbitMQSubscriber类的ListenTo()方法,如下所示(注意,这里传递的消息事实上是Job的Id):

        public void Execute(JobExecutionContext context)
        {
            string queueName = queueConfigurer.GetQueueProviders().Queue.Name;
            try
            {
                queueSubscriber.ListenTo<MyJob>(
queueName,
                    job => request.MakeRequest(job.Id.ToString()));
            }
            catch(Exception err)
            {
                Log.WarnFormat("Unexpected exception while processing queue '{0}', Details: {1}", queueName, err);
            }
        }

队列的相关信息(例如,队列名)都存储在配置文件中。Execute()方法调用了request对象的MakeRequest()方法,并将获得的消息(即Job Id)传递给该方法,它会根据该Job Id到数据库中查询对应的信息,并执行真正的业务处理。

在对基于消息处理的架构进行决策时,除了前面提到的考虑因素外,还需要就许多设计细节进行多方位的判断与权衡。例如,针对Job的执行以及队列的管理,就需要考虑如下因素:

  • 对Queue中Job状态的监控与查询;
  • 对Job优先级的管理;
  • 能否取消或终止执行时间过长的Job;
  • 是否能够设定Job的执行时间;
  • 是否能够设定Poll的间隔时间;
  • 能否跨机器分布式的放入Job;
  • 对失败Job的处理;
  • 能否支持多个队列,命名队列;
  • 能否允许执行Job的工作进程对应特定的队列;
  • 对Dead Message的支持。

10 选择的时机

究竟在什么时候,我们才应该选择基于消息处理的分布式架构?通常需要满足如下几个条件:

  • 对操作的实时性要求不高,而需要执行的任务极为耗时;
  • 存在企业内部的异构系统间的整合;
  • 服务器资源需要合理分配与利用。

对于第一种情况,常常会选择消息队列来处理执行时间较长的任务,此时引入的消息队列就成了消息处理的缓冲区。消息队列引入的异步通信机制,使得发送方和接收方都不用等待对方返回成功消息,就可以继续执行下面的代码,从而提高了数据处理的能力。尤其是当访问量和数据流量较大的情况下,结合消息队列与后台任务,通过避开高峰期,对大数据进行处理,就可以有效降低数据库处理数据的负荷。前面提到的医疗卫生系统正是这样一种适用场景。

对于第二种情况,对于不同系统乃至于异构系统的整合,恰恰是消息模式善于处理的场景。只要规定了消息的格式与传递方式,就可以有效地实现不同系统之间的通信。在为某汽车制造商开发一个大型系统时,分销商(Dealer)作为.NET客户端,需要将数据传递到管理中心,这些数据将被Oracle的EBS(E-Business Suite)使用,分销商管理系统(Dealer Management System,DMS)采用了C/S结构,数据库为SQL Server,汽车制造商管理中心的EBS数据库为Oracle 10g。在这种情况下,我们需要解决两种不同的数据库之间数据的传递。解决方案就是利用MSMQ,将数据转换为与数据库无关的消息数据,并在两端部署MSMQ服务器,建立消息队列以便于存储消息数据。实现架构如下图(利用MSMQ实现的分布式处理架构)所示:

首先,分销商的数据通过MSMQ传递到MSMQ Server,再将数据插入到SQL Server数据库,与此同时,利用FTP将数据传送到专门的文件服务器上,EBS App Server会将文件服务器中的文件,基于接口规范写入到Oracle数据库,从而实现.NET系统与Oracle系统之间的整合。

分布式系统通常能够缓解单个服务器的压力,通过将不同的业务操作和数据处理以不同的服务的形式部署并运行在不同的服务器上,就可以有效地分配并利用服务器资源。在这种情况下,部署在不同服务器上的服务,既可能作为服务端,用以处理客户端调用的请求,也可能作为客户端,在处理完自己的业务后,将其余业务请求委派给其他服务。在早期的CORBA系统中,通过建立统一的Naming Service来管理和分派服务,并通过Event Service实现事件的分发与处理,但CORBA系统采用的是RPC的方式,需要将服务设计和部署为远程对象,并建立代理。但是,若通过消息通道的方式,则既可以解除这种对远程对象的依赖,又可以很好地支持异步调用模型。在前面提到的CIMS系统,就是通过消息总线提供消息传递的基础设施,并建立统一的消息处理服务模型,解除了服务间的依赖,使得各个服务能够独立地部署到不同服务器上。

11 面临的困难

由于消息模式自身的特殊性,我们在运用消息模式建立基于消息的分布式架构时,常常会面临许多困难。

首先是系统集成的问题。由于系统之间的通信需要靠消息进行传递,所以就必须要保证消息的一致性。同时,还需要维护系统之间(主要是服务之间)接口的稳定性,一旦接口发生了变化,就可能影响到该接口的所有调用者。即使服务通过接口进行了抽象,由于消息持有双方规定的业务数据的存在,导致了这种设计在一定程度上违背了封装的要义。换言之,生产者与消费者双方都紧耦合于消息,消息的变化会直接影响到各个服务接口的实现类。所以,为了尽可能保证接口的抽象性,我们所要处理的消息都不是强类型的,这就使得我们在编译期间很难发现因为消息内容发生变更产生的错误。在我之前提到的汽车零售商管理系统就存在这样的问题。当时我负责的CRM模块需要同时与多个子系统进行通信,而每个子系统又是由不同的团队开发的,团队之间因为沟通原因,常常未能及时地同步接口表,所以,虽然各个子系统的单元测试和功能测试都已通过,但直到对CRM进行集成测试时,才发现存在大量消息不匹配的集成问题,这些问题的起因都是因为消息的变更。

解决的方案是引入充分的集成测试,甚至是回归测试,并需要及时运行这些测试,以快速地获得反馈。我们可以将集成测试作为提交代码的验证,要求每次提交代码都必须运行集成测试与指定的回归测试 ——这正是持续集成的体现。通过在本地构建与远程构建运行集成测试与回归测试,有效地保证本地版本与集成后的版本不会因为消息的改变使得功能遭受破坏,一旦功能遭受破坏,也能够及时获得反馈、发现问题,及时解决这些问题,而不是等到项目后期集中进行集成测试。

另一个问题是后台任务的非实时性带来的测试困难。由于后台任务是定期对消息队列中的消息进行处理,因而触发的时机是不可预测的 。对于这种情况,我们通常会同时运用两种方案,双管齐下地解决问题。首先,我们会为系统引入一个同步实现功能的版本,并通过在配置文件中引入toggle的开关机制,随时可以在同步功能与异步功能之间进行切换。如果我们能够保证消息队列处理与后台任务执行的正确性,就可以设置为同步功能,这样就能快速而准确地对该任务所代表的功能进行测试,并及时收获反馈。同时,我们可以在持续集成服务器上建立一个专门的管道(pipeline),用以运行基于消息处理的异步版本。这个管道对应的任务可以通过手动执行,也可以对管道设置定时器,在指定时间执行(例如在凌晨两点执行一次,这样在第二天开始工作之前可以获得反馈)。我们需要为该管道准备特定的执行环境,并将后台任务的侦听与执行时间修改为可以接受的值,这样既能够及时了解功能是否正确,又能保证基于消息的系统是工作正常的。

当然,分布式系统还存在解析消息、网络传递的性能损耗。对于这些问题,需要架构师审慎地分析业务场景,正确地选择架构方案与架构模式。相比较本地系统而言,分布式系统的维护难度可能成倍递增。这就需要我们在进行架构决策与设计时,充分考虑系统架构的稳定性,同时还需要引入系统日志处理。更好的做法是为日志处理增加错误通知的功能,只要发生消息处理的错误信息,就通过邮件、短信等方式通知系统管理员,以便及时地处理错误。因为只有在发生错误时查询错误日志,才能够更好对问题进行定位。同时,还可以为系统引入Error Message Queue以及Dead Message Queue,以便于处理错误和异常情况。

另外,对于分布式系统而言,还需要考虑服务执行结果的一致性,尤其是当某个业务需要多个服务参与到一个会话中时,一旦某个服务发生故障,就可能导致应用出现状态不一致的情况。因为只有所有参与者都成功执行了任务,才能视为完全成功,这就牵涉到分布式事务的问题,此时任务的执行就变成了事务型的:即任务必须是原子的,结果状态必须保持一致。在任务处理过程中,状态修改是彼此隔离的,成功的状态修改在整个事务执行过程中是持久的,这就是事务的ACID(Atomic,Consistent,Isolated and Durable)属性。

一种方案是引入分布式事务协调器,即DTC(Distributed Transaction Coordinator),将事务分为两段式甚至三段式提交,要求整个事务的所有参与者以投票形式决定事务是完全成功还是失败。另一种方案是降低对结果一致性的要求。根据eBay的最佳实践,考虑到分布式事务的成本,获得分布式资源即时的一致性是不必要的,也是不现实的。在Randy Shoup的文章《可伸缩性最佳实践:来自eBay的经验》中提到了Eric Brewer的CAP公理:分布式系统的三项重要指标——一致性(Consistency)、可用性(Availability)和分区耐受性(Partition-tolerance)——在任意时刻,只有两项能同时成立。我们应该根据不同的应用场景,权衡这三个要素。在不必要保证即时的一致性前提下,我们可以考虑合理地划分服务,尽量将可能作用在同一个事务范围的业务操作部署在同一个进程中,以避免分布式部署。如果确实需要多个分布式服务之间保持执行结果的一致,可以考虑引入数据核对,异步恢复事件或集中决算等手段。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

liitdar

赠人玫瑰,手有余香,君与吾共勉

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值