《大型网站系统与JAVA中间件实践》读书笔记-消息中间件
消息中间件
1.消息中间件的价值
1.1 透过示例看消息中间件对应用的解耦
1.1.1.通过服务调用让其他系统感知事件发生的方式
假设我们要做一个用户登录系统,其中需要支持的一个功能是,用户登录成功 后发送一条短信到用户的手机,算是一个用户安全的选项,如图6-2所示。
然后,我们需要把用户登录的信息(时间、IP、用户名等数据)传给我们的安全 系统,安全系统会进行安全策略相关的处理和判断。此时的结构会变成图6-3所示的 样子。
这样看起来还好,但是如果再增加一些登录成功后需要被调用的系统呢?如图 6-4所示。
这会让登录系统变得非常复杂。每增加一个在登录成功后需要被调用的系统, 就需要修改登录系统来进行相关的调用。优雅一点的实现是把这个登录成功后的服 务调用变为一种可扩展的配置,甚至可以动态生效,但这只能降低变更时的开发和 部署成本,并没有降低复杂性。登录系统要被迫依赖非常多的系统。
1.1.2.通过引入消息中间件解耦服务调用
我们思考一下,如果从登录系统的角度来看,这些系统是登录系统必须依赖的 吗?答案是否定的,.登录系统只需要验证用户名和密码的合法性(也许还会包含验证码等登录验证合法性的必需要素),所以登录系统必须依赖的是能够提供用户名、 密码的系统,而图6-4中的系统其实都不是登录系统必须依赖的系统。相反,这些系统是必须依赖登录系统的,因为它们都关心登录是否成功这件事情。
在这样的场景中,我们需要通过消息中间件把上面的结构解耦,上面结构中的服务调用将会被固定格式的消息的传递所取代。登录系统负责向消息中间件发送消 息,而其他的系统则向消息中间件来订阅这个消息,然后完成自己的工作,如图6-5 所示。
通过消息中间件解耦,登录系统就不用关心到底有多少个系统需要知道登录成 功这件事了,也不用关心如何通知它们,只需要把登录成功这件事转化为一个消息 发送到消息中间件就可以了。这样,需要了解登录成功这件事的系统自己去消息中 间件订阅就行了。并且各个系统之间也是互不影响的。
这里需要注意一点,就是当登录成功时需要向消息中间件发送一个消息,那么 我们必须保证这个消息发送到了消息中间件,否则依赖这个消息的系统就无法工作 了。这个问题有一个不太优雅的解决方式,如图6-6所示。
图6-6所示的思路是,我们在数据库中记录状态,然后让用到这个状态的系统自己来查。这个记录状态的数据库是操作中一定会依赖的数据库,如果它出问题就会导致对状态的记录不成功,业务操作也就不会成功了。图6-6所示是把状态记录在用户库的用户记录上了。不过这个例子是有一个小问题的,就是如果用户读信息时数据库正常,这时就能完成密码的验证,但是如果去记录状态时数据库不可用, 那就还是有问题的(后面我们会看到如何解决)。如果这里只是对数据库的写操作 的话,那就没有问题了,例如修改用户信息的操作,那么是可以在一个SQL中完成用户信息的更改并设置要发送短信这个状态,这样可以保证操作本身和状态更新的原子性。
对于需要感知状态的应用来说,需要定时轮询数据库以查看状态,并且在做完操作后,需要更改状态从而使得下次就不用再处理了。
可以说这是一个能解决问题的work around方法,实现也比较简单。不过也存在以下几个问题:
•增加了业务数据库的负担。一个状态字段所占的空间还可以接受,但是这个数据库需要被其他系统持续地定时轮询,并且进行更新,这就大大增加了数据库的负担。
•依赖的复杂和不安全。该方案使得发送短信的服务要依赖业务数据库,这导致依赖复杂并且不合理,另外,发送短信的服务对数据库记录有修改的权限, 这也不安全。
•扩展性不好。对于前面的多个需要在业务动作成功后来做后续工作的系统, 如果把该方式用于这样的系统的话,我们就需要增加很多个字段,或者使这 些字段变得可共享又相互不能影响。并且会增加大量的定时对业务数据库的 轮询请求。
对于这些问题,我们也期望通过消息中间件来解决。
2.互联网时代的消息中间件
在开始介绍互联网时代的消息中间件前,我们必须讲一下JMS.JMS是Java Message Service的缩写,它是Java EE (企业版Java)中的一个关于消息的规范,而Hometq、 ActiveMQ等产品是对这个规范的实现。如果是企业内部或者一些小型的系统,直接使 用JMS的实现产品是一个经济的选择,而在大型系统中有一些场景不适合使用JMS。
在大型互联网中,我们采用消息中间件可以进行应用之间的解耦以及操作的异步,这是消息中间件的两个最基础的特点,也正是我们需要的。在此基础上,我们着重思考的是消息的顺序保证、扩展性、可靠性、业务操作与消息发送一致性,以 及多集群订阅者等方面的问题,这些内容会在后面的小节中呈现给读者。我们从上 一节提到的保证消息一定被处理开始介绍。
2.1 如何解决消息发送一致性
2.1.1消息发送一致性的定义
首先,我们需要弄清楚消息发送一致性究竟是什么。消息发送一致性是指产生消息的业务动作与消息发送的一致,就是说,如果业务操作成功了,那么由这个操作产生的消息一定要发送出去,否则就丢失消息了。而另一方面,如果这个业务行为没有发生或者失败,那么就不应该把消息发出去。
2.1.2消息发送一致性很难保证吗
如果要写处理业务逻辑的代码和发送消息的代码,该怎么写呢?
下面是一段伪代码,是在某些实践中的用法。从中可以看到以下两个问题。
void fool (){
//业务操作
//例如写数据库,调用服务等
//发送消息 }
•业务操作在前,发送消息在后,如果业务失败了还好(当然业务自己不觉得好),如果成功了,而这时这个应用出问题,那么消息就发不出去了。
•如果业务成功,应用也没有挂掉,但是这时消息系统挂掉了,也会导致消息发不出去。 我们来看另外一种做法,伪代码如下:
void fool () {
//发送消息 //业务操作
//例如写数据库,调用服务等 }
这种方式更不可靠,在业务还没有做时消息就发出了。
在具体的工程实践中,第一种做法丢失消息的比例相对是很低的。当然,对于要求必须保证一致性的场景,上面的两种方案都不能接受。
2.1.3大家熟知的JMS有办法吗
使用JMS可以实现消息发送一致性吗?我们来看看JMS发送消息的部分。首先看看JMS中几个比较重要的要素。
- Destination,是指消息所走通道的目标定义,也就是用来定义消息从发送端 出后要走的通道,而不是最终接收方。Destination属于管理类的对象。
- ConnectionFactory,从名字就能看出来,是指用于创建连接的对象 ConnectionFactory属于管理类的对象。
- Connection,连接接口,所负责的重要工作是创建Session。
- Session,会话接口,这是一个非常重要的对象,消息的发送者、接收者以及 消息对象本身,都是由这个会话对象创建的。
- MessageConsumer,消息的消费者,也就是订阅消息并处理消息的对象。
- MessageProducer,消息的生产者,就是用来发送消息的对象。
- XXXMessage,是指各种类型的消息对象,包括BytesMessage、MapMessage、 ObjectMessage、StreamMessage 和 TextMessage 5 种。
在JMS消息模型中,有Queue和Topic (在后面会详细介绍)之分,所以,前面的 Destination、ConnectionFactory、Connection、Session、MessageConsumer、 MessageProducer都有对应的子接口。表6-1显示了前面各要素在Queue模型(PTP Domain)和 Topic 模型(Pub/Sub Domain)下的对应关系。
此外,在JMS的API中,我们看到很多以XA开头的接口,它们其实就是支持 XA协议的接口,它们与表6-1中各要素的对应关系如表6-2所示。
可以看到,XA 系列的接口集中在 ConnectionFactory、Connection 和 Session 上, 而 MessageProducer、QueueSender、TopicPublisher、MessageConsumer、QueueReceiver 和TopicSubscriber则没有对应的XA对象。这是因为事务的控制是在Session层面上 的,而 Session 是通过 Connection 创建的,Connection 是通过 ConnectionFactory 创建 的,所以,这三个接口需要有XA系列对应的接口的定义。Session、Connection、 ConnectionFactory在Queue模型和Topic模型下对应的各个接口也存在相应的XA系 列的对应接口。
下面展示了消息最重要的要素(消息、发送者、接收者)与几个基本元素之间 的关系。
ConnectionFactory->Connection->Session->Message Destination + Session-^ MessageProducer Destination + Sessoin-^ MessageConsumer
在JMS中,如果不使用XA系列的接口实现,那么我们就无法直接得到发送消息给消息中间件及业务操作这两个事情的事务保证,而JMS中定义的XA系列的接口就是为了实现分布式事务的支持(发送消息和业务操作很难做在一个本地事务中, 后面会讲到一些变通的做法)。但是这会带来如下问题。
•引入了分布式事务,这会带来一些开销并增加复杂性。
•对于业务操作有限制,要求业务操作的资源必须支持XA协议,才能够与发送消息一起来做分布式事务。这会成为一个限制,因为并不是所有需要与发送消息一起做成分布式事务的业务操作都支持XA协议。
2.1.4 有其他的办法吗
从上节可以看到,jMS是可以解决消息发送一致性的问题的,但是存在一 些限制并且成本相对较高。那么,我们有没有其他的办法呢?
我们来思考一下要解决的问题,我们希望保证业务操作与发送相关消息的动作是一致的,而前面的简单方案不能完全保证,但是出现问题的概率并不大,所以, 我们希望找到一种解决方案,这种方案对正常流程的影响要尽可能小,而在有问题 的场景能解决问题。
从这个方面看,即便可以做到业务操作都是支持XA的,如果采用这样的方式引人两阶段提交的话,那么还是把方案做得有些重了。
针对这个问题,我们可以用图6-7所示的方案来解决,流程介绍如下。
(1) 业务处理应用首先把消息发给消息中间件,标记消息的状态为待处理。
(2) 消息中间件收到消息后,把消息存储在消息存储中,并不投递该消息。
(3) 消息中间件返回消息处理的结果(仅是入库的结果),结果是成功或者失败。
(4) 业务方收到消息中间件返回的结果并进行处理:
a) 如果收到的结果是失败,那么就放弃业务处理,结束。
b) 如果收到的结果是成功,则进行业务自身的操作。
(5) 业务操作完成,把业务操作的结果发送给消息中间件。
(6) 消息中间件收到业务操作结果,根据结果进行处理:
a) 如果业务失败,则删除消息存储中的消息,结束。
b) 如果业务成功,则更新消息存储中的消息状态为可发送,并且进行调度,进行消息的投递。
这就是整个流程。在这里读者一定会有一个疑问,即在最简单的版本中,我们 只有业务操作和发消息两步,仍然会可能产生很多异常,那么现在这个过程的步骤 更多,产生异常的可能点更多,是如何能够保证业务操作和发送消息到消息中间件 是一致的呢?
我们对每一个步骤可能产生的异常情况来进行分析。
(1) 业务应用发消息给消息中间件。如果这一步失败了,无论是网络的原因还 是消息中间件的原因,或是业务应用自身的原因,我们都会看到业务操作没有做, 消息也没有被存储在消息中间件中,业务操作和消息的状态是一样的,没有问题。
(2) 消息中间件把消息入库。如果这一步失败,无论是消息存储有问题,还是 消息中间件收到业务消息后有问题,或是网络问题,可能造成的结果有两个。一个 是消息中间件失效,那么业务应用是收不到消息中间件的返回结果的;二是消息中 间件插人消息失败,并且有能力返回结果给应用,这时消息存储中都没有消息。
(3) 业务应用接收消息中间件返回结果异常。这里出现异常的原因可能是网络、 消息中间件的问题,也可能是业务应用自身的问题。如果业务应用自身没问题,那 么业务应用并不知道消息在消息中间件的处理结果,就会按照消息发送失败来处理, 如果这时消息在消息中间件那里入库成功的话,就会造成不一致。如果是业务应用 有问题,那么如果消息在消息中间件中处理成功的话,也就会造成不一致了;如果 未处理成功,则还是一致的。
(4) 业务应用进行业务操作。这一步不会产生太大问题。
(5) 业务应用发送业务操作结果给消息中间件。如果这一步出现问题,那么消 息中间件将不知道该如何处理已经存储在消息存储中的消息,可能会造成不一致。
(6) 消息中间件更新消息状态。如果这一步出现问题,与上一步所造成的结果 是类似的。
从上面的分析可以看出,需要了解的两个主要的控制状态和流程的节点就是业务应用和消息中间件,我们可以分别从业务应用和消息中间件的视角来梳理一下, 如表6-3和表6-4所示。
从上面的梳理和分析可以看到,对于各种异常情况我们遇到的状态有如下三种:
- 业务操作未进行,消息未入存储。
- 业务操作未进行,消息存人存储,状态为待处理。
- 业务操作成功,消息存人存储,状态为待处理。
这三种情况中,第一种情况不需要进行额外的处理,因为本身就是一致的;第 二种和第三种都需要了解业务操作的结果,然后来处理已经在消息存储中、状态是待处理的消息。
那么如何了解业务操作的结果呢?
图6-8展示了这个过程。由消息中间件主动询问业务应用,获取待处理消息所对应的业务操作的结果,然后业务应用需要对业务操作的结果进行检查,并且把结果发送给消息中间件(业务处理结果有失败、成功、等待三种,等待是多出来的一种状态,代表业务操作还在处理中),然后消息中间件根据这个处理结果,更新消息状态。可以说这是发送消息的一个反向的流程。
同样,这个流程也会出现很多异常。不过这个4步的流程就是为了确认业务处理操作结果,真正的操作只是根据业务处理结果来更改消息的状态,所以,前面3 步都与查询相关,如果失败就失败了,而最后一步的更新状态如果失败了,那么就定时重复这个反向流程,重复查询就可以了。
发送消息的正向流程和检查业务操作结果的反向流程合起来,就是解决业务操作与发送消息一致性的方案。在大多数的情况下,反向流程是不需要工作的。我们来看看正向流程是否带来了额外的负担,对比如表6-5所示。
从上面的对比可以看到,解决一致性的方案是只增加了一次网络操作和一次更新存储中消息状态的操作,就是第5步和第6步两步。而前面4步和传统方式所做的事情都一样,只是顺序有所不同。所以,整体上带来的额外开销并不大,而且还 有可优化的点。
接着来看一下使用方式。可以看到解决一致性的方案中,在业务应用那里是有一个固化的流程的,可以提供一个封装来方便业务应用的使用,伪代码如下。
Result postMessage(Message, PostMessageCallback){
//发送消息给消息中间件 //获取返回结果 //如果失败,返回失败 //进行业务操作
//获取业务操作结果 / /发送业务操作结果给消息中间件 //返回处理结果 }
可以看到,我们可以把实现逻辑封装在一个调用中,然后把业务的操作包装成 一个对象传进来,然后整个流程就可以控制在这个方法中了。当然,除了发送一致性的消息之外,也应该提供一个传统的发送消息的接口, 也就是不支持发送一致性的发送接口。此外,为了适应其他的场景(例如与现有的事务处理流程结合等),也会提供独立的接口,就会把这个流程的控制权交给业务应用自身。
2.2 如何解决消息中间件与使用者的强依赖问题
回顾一下解决业务操作和发送消息一致性的方案,会发现我们更多地关注了如何保持和解决一致性的问题,但是忽略了一个问题,那就是消息中间件变成了业务应用的必要依赖。也就是说,如果消息中间件系统(包括使用的消息存储、业务应用到消息中间件的网络等)出现问题,就会导致业务操作无法继续进行,即便当时业务应用和业务操作的资源都是可用的。
我们需要思考如何解决这个问题,思路有如下三种:
•提供消息中间件系统的可靠性,但是没有办法保证百分之百可靠。
•对于消息中间件系统中影响业务操作进行的部分,使其可靠性与业务自身的可靠性相同。
•可以提供弱依赖的支持,能够较好地保证一致性。
第一种方案,提升消息中间件系统的可靠性是必须要做的事情,但是我们无法保证百分之百可靠。
第二种方案,让消息中间件系统中影响业务操作的部分与业务自身具有同样的可靠性,其实就是要保证如果业务能操作成功,就需要消息能够入库成功。因为如 果消息中间件出问题了,可以接受投递的延迟,但是需要保证消息入库,这样业务操作才可以继续进行。那么,可行的方式只有一种,如图6-9所示。
我们把消息中间件所需要的消息表与业务数据表放到同一个业务数据库中,这 样,业务应用就可以把业务操作和写人消息作为一个本地事务来完成,然后再通知消息中间件有消息可以发送,这样就解决了一致性的问题。从图6-9中可以看到这一 步是虚线表示的,代表它不是一个必要的操作和依赖。消息中间件会定时去轮询业务数据库,找到需要发送的消息,取出内容后进行发送。这个方案对业务系统有如 下三个影响:
•需要用业务自己的数据库承载消息数据。
•需要让消息中间件去访问业务数据库。
•需要业务操作的对象是一个数据库,或者说支持事务的存储,并且这个存储必须能够支持消息中间件的需求。
我们在上面的基础上进行一下变通,如图6-10所示。这个方案和图6-9中方案的区别是,消息中间件不再直接与业务数据库打交道。消息表还是放在业务数据库 中,完全由业务数据库来控制消息的生成、获取、发送及重试的策略。这样,消息 中间件就不需要与众多使用这种消息一致性发送的业务方的数据库打交道了,不过 比较多的逻辑是从消息中间件的服务端移动到消息中间件的客户端,并且在业务应 用上执行。消息中间件更多的是管理接收消息的应用,并且当有消息从业务应用发 过来后就只管理投递,把原来的调度、重投、投递等逻辑分到了客户端和服务端 两边。
图6-9和图6-10中的两种方式虽然已经解决了大部分问题,但是它们都要求业务操作是支持事务的数据库操作,具有一定的限制性,这里我们可以再进行一 下变通。
我们考虑把本地磁盘作为一个消息存储,也就是如果消息中间件不可用,又不愿或不能侵入业务自己的数据库时,可以把本地磁盘作为存储消息的地方,等待消息中间件回复后,再把消息送到消息中间件中(如图6-11所示)。所有的投递、重试等管理,仍然是在消息中间件进行,而本地磁盘的定位只是对业务应用上发送消息 一定成功的一个保证。
这种方式存在的风险是,如果消息中间件不可用,而且写入本地磁盘的数据也 坏了的话,那么消息就丢失了。这确实是个问题,所以,从业务数据上进行消息补发才是最彻底的容灾的手段,因为这样才能保证只要业务数据在,就一定可以有办法恢复消息了。
将本地磁盘作为消息存储的方式有两种用法,一是作为一致性发送消息的解决方案的容灾手段,也就是说该方式平时不工作,出现问题时才切换到该方式上,二 是直接使用该方式来工作,这样可以控制业务操作本身调用发送消息的接口的处理时间,此外也有机会在业务应用与消息中间件之间做一些批处理的工作。
最后,我们来看一下业务操作与发送消息一致性的方案所带来的两个限制。
•需要确定要发送的消息的内容。因为我们在业务操作做之前会把状态标记为待处理,这要求先能确定消息内容;这里可以有一个变通,即先把主要内容也就是能够标记该次业务操作特点的信息发过来,然后等业务操作结束后需要更新状态时再补全内容。不过这还是要求在业务操作之前能够确定一些索引性质的信息。
•需要实现对业务的检查。也就是说为了支持反向流程的工作,业务应用必须能够根据反向流程中发回来的消息内容进行业务操作检查,确认这个消息所指向的业务操作的状态是完成、待处理,还是进行中,否则,待处理状态的消息就无法被处理了。
2.3 消息模型对消息接收的影响
前面讲述了消息发送端的内容,我们接下来看一下消息模型。在JMS中,有Queue (点对点)和Topic (发布/订阅)两种模型,我们来看看这两种模型的特点。
2.3.1 JMS Queue模型
图6-12显示的是JMS Queue模型,可以看到,应用1和应用2发送消息到JMS 服务器,这些消息根据到达的顺序形成一个队列,应用3和应用4进行消息的消费。 这里需要注意的是,应用3和应用4收到的消息是不同的,也就是说在JMS Queue 的方式下,如果Queue里面的消息被一个应用处理了,那么连接到JMS Queue上的 另一个应用是收不到这个消息的,也就是说所有连接到这个JMS Queue上的应用共同消费了所有的消息。消息从发送端发送出来时不能确定最终会被哪个应用消费, 但是可以明确的是只有一个应用会去消费这条消息,所以JMS Queue模型也被称为 Peer To Peer (PTP)方式。
2.3.2 JMS Topic模型
图6-13显示的是JMS Topic模型。从发送消息的部分和JMS Topic内部的逻辑 来看,JMS Topic和JMS Queue是一样的,二者最大的差别在于消息接收的部分,在 Topic模型中,接收消息的应用3和应用4是可以独立收到所有到达Topic的消息的。 JMS Topic模型也被称为Pub/Sub方式。
2.3.3 JMS中客户端连接的处理和带来的限制
在使用JMS时,每个Connection都有一个唯一的Clientld,用于标记连接的唯一性,也就是说刚才对Queue和Topic的介绍中,我们是默认一个接收应用只用了一个连接。现在来看一下多连接的情况,如图6-14所示。