Category Archives: 分布式

消息队列设计精要

消息队列已经逐渐成为企业IT系统内部通信的核心手段。它具有低耦合、可靠投递、广播、流量控制、最终一致性等一系列功能,成为异步RPC的主要手段之一。

当今市面上有很多主流的消息中间件,如老牌的ActiveMQ、RabbitMQ,炙手可热的Kafka,阿里巴巴自主开发的Notify、MetaQ、RocketMQ等。

本文不会一一介绍这些消息队列的所有特性,而是探讨一下自主开发设计一个消息队列时,你需要思考和设计的重要方面。过程中我们会参考这些成熟消息队列的很多重要思想。

本文首先会阐述什么时候你需要一个消息队列,然后以Push模型为主,从零开始分析设计一个消息队列时需要考虑到的问题,如RPC、高可用、顺序和重复消息、可靠投递、消费关系解析等。

也会分析以Kafka为代表的pull模型所具备的优点。最后是一些高级主题,如用批量/异步提高性能、pull模型的系统设计理念、存储子系统的设计、流量控制的设计、公平调度的实现等。其中最后四个方面会放在下篇讲解。

何时需要消息队列

当你需要使用消息队列时,首先需要考虑它的必要性。可以使用mq的场景有很多,最常用的几种,是做业务解耦/最终一致性/广播/错峰流控等。反之,如果需要强一致性,关注业务逻辑的处理结果,则RPC显得更为合适。

解耦

解耦是消息队列要解决的最本质问题。所谓解耦,简单点讲就是一个事务,只关心核心的流程。而需要依赖其他系统但不那么重要的事情,有通知即可,无需等待结果。换句话说,基于消息的模型,关心的是“通知”,而非“处理”。

比如在美团旅游,我们有一个产品中心,产品中心上游对接的是主站、移动后台、旅游供应链等各个数据源;下游对接的是筛选系统、API系统等展示系统。当上游的数据发生变更的时候,如果不使用消息系统,势必要调用我们的接口来更新数据,就特别依赖产品中心接口的稳定性和处理能力。但其实,作为旅游的产品中心,也许只有对于旅游自建供应链,产品中心更新成功才是他们关心的事情。而对于团购等外部系统,产品中心更新成功也好、失败也罢,并不是他们的职责所在。他们只需要保证在信息变更的时候通知到我们就好了。

而我们的下游,可能有更新索引、刷新缓存等一系列需求。对于产品中心来说,这也不是我们的职责所在。说白了,如果他们定时来拉取数据,也能保证数据的更新,只是实时性没有那么强。但使用接口方式去更新他们的数据,显然对于产品中心来说太过于“重量级”了,只需要发布一个产品ID变更的通知,由下游系统来处理,可能更为合理。

再举一个例子,对于我们的订单系统,订单最终支付成功之后可能需要给用户发送短信积分什么的,但其实这已经不是我们系统的核心流程了。如果外部系统速度偏慢(比如短信网关速度不好),那么主流程的时间会加长很多,用户肯定不希望点击支付过好几分钟才看到结果。那么我们只需要通知短信系统“我们支付成功了”,不一定非要等待它处理完成。

最终一致性

最终一致性指的是两个系统的状态保持一致,要么都成功,要么都失败。当然有个时间限制,理论上越快越好,但实际上在各种异常的情况下,可能会有一定延迟达到最终一致状态,但最后两个系统的状态是一样的。

业界有一些为“最终一致性”而生的消息队列,如Notify(阿里)、QMQ(去哪儿)等,其设计初衷,就是为了交易系统中的高可靠通知。

以一个银行的转账过程来理解最终一致性,转账的需求很简单,如果A系统扣钱成功,则B系统加钱一定成功。反之则一起回滚,像什么都没发生一样。

然而,这个过程中存在很多可能的意外:

  1. A扣钱成功,调用B加钱接口失败。
  2. A扣钱成功,调用B加钱接口虽然成功,但获取最终结果时网络异常引起超时。
  3. A扣钱成功,B加钱失败,A想回滚扣的钱,但A机器down机。

可见,想把这件看似简单的事真正做成,真的不那么容易。所有跨VM的一致性问题,从技术的角度讲通用的解决方案是:

  1. 强一致性,分布式事务,但落地太难且成本太高,后文会具体提到。
  2. 最终一致性,主要是用“记录”和“补偿”的方式。在做所有的不确定的事情之前,先把事情记录下来,然后去做不确定的事情,结果可能是:成功、失败或是不确定,“不确定”(例如超时等)可以等价为失败。成功就可以把记录的东西清理掉了,对于失败和不确定,可以依靠定时任务等方式把所有失败的事情重新搞一遍,直到成功为止。
  3. 回到刚才的例子,系统在A扣钱成功的情况下,把要给B“通知”这件事记录在库里(为了保证最高的可靠性可以把通知B系统加钱和扣钱成功这两件事维护在一个本地事务里),通知成功则删除这条记录,通知失败或不确定则依靠定时任务补偿性地通知我们,直到我们把状态更新成正确的为止。
  4. 整个这个模型依然可以基于RPC来做,但可以抽象成一个统一的模型,基于消息队列来做一个“企业总线”。
  5. 具体来说,本地事务维护业务变化和通知消息,一起落地(失败则一起回滚),然后RPC到达broker,在broker成功落地后,RPC返回成功,本地消息可以删除。否则本地消息一直靠定时任务轮询不断重发,这样就保证了消息可靠落地broker。
  6. broker往consumer发送消息的过程类似,一直发送消息,直到consumer发送消费成功确认。
  7. 我们先不理会重复消息的问题,通过两次消息落地加补偿,下游是一定可以收到消息的。然后依赖状态机版本号等方式做判重,更新自己的业务,就实现了最终一致性。

最终一致性不是消息队列的必备特性,但确实可以依靠消息队列来做最终一致性的事情。另外,所有不保证100%不丢消息的消息队列,理论上无法实现最终一致性。好吧,应该说理论上的100%,排除系统严重故障和bug。

像Kafka一类的设计,在设计层面上就有丢消息的可能(比如定时刷盘,如果掉电就会丢消息)。哪怕只丢千分之一的消息,业务也必须用其他的手段来保证结果正确。

广播

消息队列的基本功能之一是进行广播。如果没有消息队列,每当一个新的业务方接入,我们都要联调一次新接口。有了消息队列,我们只需要关心消息是否送达了队列,至于谁希望订阅,是下游的事情,无疑极大地减少了开发和联调的工作量。

比如本文开始提到的产品中心发布产品变更的消息,以及景点库很多去重更新的消息,可能“关心”方有很多个,但产品中心和景点库只需要发布变更消息即可,谁关心谁接入。

错峰与流控

试想上下游对于事情的处理能力是不同的。比如,Web前端每秒承受上千万的请求,并不是什么神奇的事情,只需要加多一点机器,再搭建一些LVS负载均衡设备和Nginx等即可。但数据库的处理能力却十分有限,即使使用SSD加分库分表,单机的处理能力仍然在万级。由于成本的考虑,我们不能奢求数据库的机器数量追上前端。

这种问题同样存在于系统和系统之间,如短信系统可能由于短板效应,速度卡在网关上(每秒几百次请求),跟前端的并发量不是一个数量级。但用户晚上个半分钟左右收到短信,一般是不会有太大问题的。如果没有消息队列,两个系统之间通过协商、滑动窗口等复杂的方案也不是说不能实现。但系统复杂性指数级增长,势必在上游或者下游做存储,并且要处理定时、拥塞等一系列问题。而且每当有处理能力有差距的时候,都需要单独开发一套逻辑来维护这套逻辑。所以,利用中间系统转储两个系统的通信内容,并在下游系统有能力处理这些消息的时候,再处理这些消息,是一套相对较通用的方式。

总而言之,消息队列不是万能的。对于需要强事务保证而且延迟敏感的,RPC是优于消息队列的。

对于一些无关痛痒,或者对于别人非常重要但是对于自己不是那么关心的事情,可以利用消息队列去做。

支持最终一致性的消息队列,能够用来处理延迟不那么敏感的“分布式事务”场景,而且相对于笨重的分布式事务,可能是更优的处理方式。

当上下游系统处理能力存在差距的时候,利用消息队列做一个通用的“漏斗”。在下游有能力处理的时候,再进行分发。

如果下游有很多系统关心你的系统发出的通知的时候,果断地使用消息队列吧。

如何设计一个消息队列

综述

我们现在明确了消息队列的使用场景,下一步就是如何设计实现一个消息队列了。

 

基于消息的系统模型,不一定需要broker(消息队列服务端)。市面上的的Akka(actor模型)、ZeroMQ等,其实都是基于消息的系统设计范式,但是没有broker。

我们之所以要设计一个消息队列,并且配备broker,无外乎要做两件事情:

  1. 消息的转储,在更合适的时间点投递,或者通过一系列手段辅助消息最终能送达消费机。
  2. 规范一种范式和通用的模式,以满足解耦、最终一致性、错峰等需求。
  3. 掰开了揉碎了看,最简单的消息队列可以做成一个消息转发器,把一次RPC做成两次RPC。发送者把消息投递到服务端(以下简称broker),服务端再将消息转发一手到接收端,就是这么简单。

一般来讲,设计消息队列的整体思路是先build一个整体的数据流,例如producer发送给broker,broker发送给consumer,consumer回复消费确认,broker删除/备份消息等。

利用RPC将数据流串起来。然后考虑RPC的高可用性,尽量做到无状态,方便水平扩展。

之后考虑如何承载消息堆积,然后在合适的时机投递消息,而处理堆积的最佳方式,就是存储,存储的选型需要综合考虑性能/可靠性和开发维护成本等诸多因素。

为了实现广播功能,我们必须要维护消费关系,可以利用zk/config server等保存消费关系。

在完成了上述几个功能后,消息队列基本就实现了。然后我们可以考虑一些高级特性,如可靠投递,事务特性,性能优化等。

下面我们会以设计消息队列时重点考虑的模块为主线,穿插灌输一些消息队列的特性实现方法,来具体分析设计实现一个消息队列时的方方面面。

实现队列基本功能

RPC通信协议

刚才讲到,所谓消息队列,无外乎两次RPC加一次转储,当然需要消费端最终做消费确认的情况是三次RPC。既然是RPC,就必然牵扯出一系列话题,什么负载均衡啊、服务发现啊、通信协议啊、序列化协议啊,等等。在这一块,我的强烈建议是不要重复造轮子。利用公司现有的RPC框架:Thrift也好,Dubbo也好,或者是其他自定义的框架也好。因为消息队列的RPC,和普通的RPC没有本质区别。当然了,自主利用Memchached或者Redis协议重新写一套RPC框架并非不可(如MetaQ使用了自己封装的Gecko NIO框架,卡夫卡也用了类似的协议)。但实现成本和难度无疑倍增。排除对效率的极端要求,都可以使用现成的RPC框架。

简单来讲,服务端提供两个RPC服务,一个用来接收消息,一个用来确认消息收到。并且做到不管哪个server收到消息和确认消息,结果一致即可。当然这中间可能还涉及跨IDC的服务的问题。这里和RPC的原则是一致的,尽量优先选择本机房投递。你可能会问,如果producer和consumer本身就在两个机房了,怎么办?首先,broker必须保证感知的到所有consumer的存在。其次,producer尽量选择就近的机房就好了。

高可用

其实所有的高可用,是依赖于RPC和存储的高可用来做的。先来看RPC的高可用,美团的基于MTThrift的RPC框架,阿里的Dubbo等,其本身就具有服务自动发现,负载均衡等功能。而消息队列的高可用,只要保证broker接受消息和确认消息的接口是幂等的,并且consumer的几台机器处理消息是幂等的,这样就把消息队列的可用性,转交给RPC框架来处理了。

那么怎么保证幂等呢?最简单的方式莫过于共享存储。broker多机器共享一个DB或者一个分布式文件/kv系统,则处理消息自然是幂等的。就算有单点故障,其他节点可以立刻顶上。另外failover可以依赖定时任务的补偿,这是消息队列本身天然就可以支持的功能。存储系统本身的可用性我们不需要操太多心,放心大胆的交给DBA们吧!

对于不共享存储的队列,如Kafka使用分区加主备模式,就略微麻烦一些。需要保证每一个分区内的高可用性,也就是每一个分区至少要有一个主备且需要做数据的同步,关于这块HA的细节,可以参考下篇pull模型消息系统设计。

服务端承载消息堆积的能力

消息到达服务端如果不经过任何处理就到接收者了,broker就失去了它的意义。为了满足我们错峰/流控/最终可达等一系列需求,把消息存储下来,然后选择时机投递就显得是顺理成章的了。

只是这个存储可以做成很多方式。比如存储在内存里,存储在分布式KV里,存储在磁盘里,存储在数据库里等等。但归结起来,主要有持久化和非持久化两种。

持久化的形式能更大程度地保证消息的可靠性(如断电等不可抗外力),并且理论上能承载更大限度的消息堆积(外存的空间远大于内存)。

但并不是每种消息都需要持久化存储。很多消息对于投递性能的要求大于可靠性的要求,且数量极大(如日志)。这时候,消息不落地直接暂存内存,尝试几次failover,最终投递出去也未尝不可。

市面上的消息队列普遍两种形式都支持。当然具体的场景还要具体结合公司的业务来看。

存储子系统的选择

我们来看看如果需要数据落地的情况下各种存储子系统的选择。理论上,从速度来看,文件系统>分布式KV(持久化)>分布式文件系统>数据库,而可靠性却截然相反。还是要从支持的业务场景出发作出最合理的选择,如果你们的消息队列是用来支持支付/交易等对可靠性要求非常高,但对性能和量的要求没有这么高,而且没有时间精力专门做文件存储系统的研究,DB是最好的选择。

但是DB受制于IOPS,如果要求单broker 5位数以上的QPS性能,基于文件的存储是比较好的解决方案。整体上可以采用数据文件+索引文件的方式处理,具体这块的设计比较复杂,可以参考下篇的存储子系统设计。

分布式KV(如MongoDB,HBase)等,或者持久化的Redis,由于其编程接口较友好,性能也比较可观,如果在可靠性要求不是那么高的场景,也不失为一个不错的选择。

消费关系解析

现在我们的消息队列初步具备了转储消息的能力。下面一个重要的事情就是解析发送接收关系,进行正确的消息投递了。

市面上的消息队列定义了一堆让人晕头转向的名词,如JMS 规范中的Topic/Queue,Kafka里面的Topic/Partition/ConsumerGroup,RabbitMQ里面的Exchange等等。抛开现象看本质,无外乎是单播与广播的区别。所谓单播,就是点到点;而广播,是一点对多点。当然,对于互联网的大部分应用来说,组间广播、组内单播是最常见的情形。

消息需要通知到多个业务集群,而一个业务集群内有很多台机器,只要一台机器消费这个消息就可以了。

当然这不是绝对的,很多时候组内的广播也是有适用场景的,如本地缓存的更新等等。另外,消费关系除了组内组间,可能会有多级树状关系。这种情况太过于复杂,一般不列入考虑范围。所以,一般比较通用的设计是支持组间广播,不同的组注册不同的订阅。组内的不同机器,如果注册一个相同的ID,则单播;如果注册不同的ID(如IP地址+端口),则广播。

至于广播关系的维护,一般由于消息队列本身都是集群,所以都维护在公共存储上,如config server、zookeeper等。维护广播关系所要做的事情基本是一致的:

  1. 发送关系的维护。
  2. 发送关系变更时的通知。

队列高级特性设计

上面都是些消息队列基本功能的实现,下面来看一些关于消息队列特性相关的内容,不管可靠投递/消息丢失与重复以及事务乃至于性能,不是每个消息队列都会照顾到,所以要依照业务的需求,来仔细衡量各种特性实现的成本,利弊,最终做出最为合理的设计。

可靠投递(最终一致性)

这是个激动人心的话题,完全不丢消息,究竟可不可能?答案是,完全可能,前提是消息可能会重复,并且,在异常情况下,要接受消息的延迟。

方案说简单也简单,就是每当要发生不可靠的事情(RPC等)之前,先将消息落地,然后发送。当失败或者不知道成功失败(比如超时)时,消息状态是待发送,定时任务不停轮询所有待发送消息,最终一定可以送达。

具体来说:

  1. producer往broker发送消息之前,需要做一次落地。
  2. 请求到server后,server确保数据落地后再告诉客户端发送成功。
  3. 支持广播的消息队列需要对每个待发送的endpoint,持久化一个发送状态,直到所有endpoint状态都OK才可删除消息。

对于各种不确定(超时、down机、消息没有送达、送达后数据没落地、数据落地了回复没收到),其实对于发送方来说,都是一件事情,就是消息没有送达。

重推消息所面临的问题就是消息重复。重复和丢失就像两个噩梦,你必须要面对一个。好在消息重复还有处理的机会,消息丢失再想找回就难了。

Anyway,作为一个成熟的消息队列,应该尽量在各个环节减少重复投递的可能性,不能因为重复有解决方案就放纵的乱投递。

最后说一句,不是所有的系统都要求最终一致性或者可靠投递,比如一个论坛系统、一个招聘系统。一个重复的简历或话题被发布,可能比丢失了一个发布显得更让用户无法接受。不断重复一句话,任何基础组件要服务于业务场景。

消费确认

当broker把消息投递给消费者后,消费者可以立即响应我收到了这个消息。但收到了这个消息只是第一步,我能不能处理这个消息却不一定。或许因为消费能力的问题,系统的负荷已经不能处理这个消息;或者是刚才状态机里面提到的消息不是我想要接收的消息,主动要求重发。

把消息的送达和消息的处理分开,这样才真正的实现了消息队列的本质-解耦。所以,允许消费者主动进行消费确认是必要的。当然,对于没有特殊逻辑的消息,默认Auto Ack也是可以的,但一定要允许消费方主动ack。

对于正确消费ack的,没什么特殊的。但是对于reject和error,需要特别说明。reject这件事情,往往业务方是无法感知到的,系统的流量和健康状况的评估,以及处理能力的评估是一件非常复杂的事情。举个极端的例子,收到一个消息开始build索引,可能这个消息要处理半个小时,但消息量却是非常的小。所以reject这块建议做成滑动窗口/线程池类似的模型来控制,

消费能力不匹配的时候,直接拒绝,过一段时间重发,减少业务的负担。

但业务出错这件事情是只有业务方自己知道的,就像上文提到的状态机等等。这时应该允许业务方主动ack error,并可以与broker约定下次投递的时间。

重复消息和顺序消息

上文谈到重复消息是不可能100%避免的,除非可以允许丢失,那么,顺序消息能否100%满足呢? 答案是可以,但条件更为苛刻:

  1. 允许消息丢失。
  2. 从发送方到服务方到接受者都是单点单线程。

所以绝对的顺序消息基本上是不能实现的,当然在METAQ/Kafka等pull模型的消息队列中,单线程生产/消费,排除消息丢失,也是一种顺序消息的解决方案。

一般来讲,一个主流消息队列的设计范式里,应该是不丢消息的前提下,尽量减少重复消息,不保证消息的投递顺序。

谈到重复消息,主要是两个话题:

  1. 如何鉴别消息重复,并幂等的处理重复消息。
  2. 一个消息队列如何尽量减少重复消息的投递。

先来看看第一个话题,每一个消息应该有它的唯一身份。不管是业务方自定义的,还是根据IP/PID/时间戳生成的MessageId,如果有地方记录这个MessageId,消息到来是能够进行比对就

能完成重复的鉴定。数据库的唯一键/bloom filter/分布式KV中的key,都是不错的选择。由于消息不能被永久存储,所以理论上都存在消息从持久化存储移除的瞬间上游还在投递的可能(上游因种种原因投递失败,不停重试,都到了下游清理消息的时间)。这种事情都是异常情况下才会发生的,毕竟是小众情况。两分钟消息都还没送达,多送一次又能怎样呢?幂等的处理消息是一门艺术,因为种种原因重复消息或者错乱的消息还是来到了,说两种通用的解决方案:

  1. 版本号。
  2. 状态机。

版本号

举个简单的例子,一个产品的状态有上线/下线状态。如果消息1是下线,消息2是上线。不巧消息1判重失败,被投递了两次,且第二次发生在2之后,如果不做重复性判断,显然最终状态是错误的。

但是,如果每个消息自带一个版本号。上游发送的时候,标记消息1版本号是1,消息2版本号是2。如果再发送下线消息,则版本号标记为3。下游对于每次消息的处理,同时维护一个版本号。

每次只接受比当前版本号大的消息。初始版本为0,当消息1到达时,将版本号更新为1。消息2到来时,因为版本号>1.可以接收,同时更新版本号为2.当另一条下线消息到来时,如果版本号是3.则是真实的下线消息。如果是1,则是重复投递的消息。

如果业务方只关心消息重复不重复,那么问题就已经解决了。但很多时候另一个头疼的问题来了,就是消息顺序如果和想象的顺序不一致。比如应该的顺序是12,到来的顺序是21。则最后会发生状态错误。

参考TCP/IP协议,如果想让乱序的消息最后能够正确的被组织,那么就应该只接收比当前版本号大一的消息。并且在一个session周期内要一直保存各个消息的版本号。

如果到来的顺序是21,则先把2存起来,待2到来后,再处理1,这样重复性和顺序性要求就都达到了。

状态机

基于版本号来处理重复和顺序消息听起来是个不错的主意,但凡事总有瑕疵。使用版本号的最大问题是:

  1. 对发送方必须要求消息带业务版本号。
  2. 下游必须存储消息的版本号,对于要严格保证顺序的。

还不能只存储最新的版本号的消息,要把乱序到来的消息都存储起来。而且必须要对此做出处理。试想一个永不过期的”session”,比如一个物品的状态,会不停流转于上下线。那么中间环节的所有存储

就必须保留,直到在某个版本号之前的版本一个不丢的到来,成本太高。

就刚才的场景看,如果消息没有版本号,该怎么解决呢?业务方只需要自己维护一个状态机,定义各种状态的流转关系。例如,”下线”状态只允许接收”上线”消息,“上线”状态只能接收“下线消息”,如果上线收到上线消息,或者下线收到下线消息,在消息不丢失和上游业务正确的前提下。要么是消息发重了,要么是顺序到达反了。这时消费者只需要把“我不能处理这个消息”告诉投递者,要求投递者过一段时间重发即可。而且重发一定要有次数限制,比如5次,避免死循环,就解决了。

举例子说明,假设产品本身状态是下线,1是上线消息,2是下线消息,3是上线消息,正常情况下,消息应该的到来顺序是123,但实际情况下收到的消息状态变成了3123。

那么下游收到3消息的时候,判断状态机流转是下线->上线,可以接收消息。然后收到消息1,发现是上线->上线,拒绝接收,要求重发。然后收到消息2,状态是上线->下线,于是接收这个消息。

此时无论重发的消息1或者3到来,还是可以接收。另外的重发,在一定次数拒绝后停止重发,业务正确。

中间件对于重复消息的处理

回归到消息队列的话题来讲。上述通用的版本号/状态机/ID判重解决方案里,哪些是消息队列该做的、哪些是消息队列不该做业务方处理的呢?其实这里没有一个完全严格的定义,但回到我们的出发点,我们保证不丢失消息的情况下尽量少重复消息,消费顺序不保证。那么重复消息下和乱序消息下业务的正确,应该是由消费方保证的,我们要做的是减少消息发送的重复。

我们无法定义业务方的业务版本号/状态机,如果API里强制需要指定版本号,则显得过于绑架客户了。况且,在消费方维护这么多状态,就涉及到一个消费方的消息落地/多机间的同步消费状态问题,复杂度指数级上升,而且只能解决部分问题。

减少重复消息的关键步骤:

  1. broker记录MessageId,直到投递成功后清除,重复的ID到来不做处理,这样只要发送者在清除周期内能够感知到消息投递成功,就基本不会在server端产生重复消息。
  2. 对于server投递到consumer的消息,由于不确定对端是在处理过程中还是消息发送丢失的情况下,有必要记录下投递的IP地址。决定重发之前询问这个IP,消息处理成功了吗?如果询问无果,再重发。

事务

持久性是事务的一个特性,然而只满足持久性却不一定能满足事务的特性。还是拿扣钱/加钱的例子讲。满足事务的一致性特征,则必须要么都不进行,要么都能成功。

解决方案从大方向上有两种:

  1. 两阶段提交,分布式事务。
  2. 本地事务,本地落地,补偿发送。

分布式事务存在的最大问题是成本太高,两阶段提交协议,对于仲裁down机或者单点故障,几乎是一个无解的黑洞。对于交易密集型或者I/O密集型的应用,没有办法承受这么高的网络延迟,系统复杂性。

并且成熟的分布式事务一定构建与比较靠谱的商用DB和商用中间件上,成本也太高。

那如何使用本地事务解决分布式事务的问题呢?以本地和业务在一个数据库实例中建表为例子,与扣钱的业务操作同一个事务里,将消息插入本地数据库。如果消息入库失败,则业务回滚;如果消息入库成功,事务提交。

然后发送消息(注意这里可以实时发送,不需要等定时任务检出,以提高消息实时性)。以后的问题就是前文的最终一致性问题所提到的了,只要消息没有发送成功,就一直靠定时任务重试。

这里有一个关键的点,本地事务做的,是业务落地和消息落地的事务,而不是业务落地和RPC成功的事务。这里很多人容易混淆,如果是后者,无疑是事务嵌套RPC,是大忌,会有长事务死锁等各种风险。

而消息只要成功落地,很大程度上就没有丢失的风险(磁盘物理损坏除外)。而消息只要投递到服务端确认后本地才做删除,就完成了producer->broker的可靠投递,并且当消息存储异常时,业务也是可以回滚的。

本地事务存在两个最大的使用障碍:

  1. 配置较为复杂,“绑架”业务方,必须本地数据库实例提供一个库表。
  2. 对于消息延迟高敏感的业务不适用。

话说回来,不是每个业务都需要强事务的。扣钱和加钱需要事务保证,但下单和生成短信却不需要事务,不能因为要求发短信的消息存储投递失败而要求下单业务回滚。所以,一个完整的消息队列应该定义清楚自己可以投递的消息类型,如事务型消息,本地非持久型消息,以及服务端不落地的非可靠消息等。对不同的业务场景做不同的选择。另外事务的使用应该尽量低成本、透明化,可以依托于现有的成熟框架,如Spring的声明式事务做扩展。业务方只需要使用@Transactional标签即可。

性能相关

异步/同步

首先澄清一个概念,异步,同步和oneway是三件事。异步,归根结底你还是需要关心结果的,但可能不是当时的时间点关心,可以用轮询或者回调等方式处理结果;同步是需要当时关心

的结果的;而oneway是发出去就不管死活的方式,这种对于某些完全对可靠性没有要求的场景还是适用的,但不是我们重点讨论的范畴。

回归来看,任何的RPC都是存在客户端异步与服务端异步的,而且是可以任意组合的:客户端同步对服务端异步,客户端异步对服务端异步,客户端同步对服务端同步,客户端异步对服务端同步。

对于客户端来说,同步与异步主要是拿到一个Result,还是Future(Listenable)的区别。实现方式可以是线程池,NIO或者其他事件机制,这里先不展开讲。服务端异步可能稍微难理解一点,这个是需要RPC协议支持的。参考servlet 3.0规范,服务端可以吐一个future给客户端,并且在future done的时候通知客户端。整个过程可以参考下面的代码:

客户端同步服务端异步。

 

Future<Result> future = request(server);//server立刻返回future

synchronized(future){

while(!future.isDone()){

 future.wait();//server处理结束后会notify这个future,并修改isdone标志

}

}

return future.get();

 

客户端同步服务端同步。

Result result = request(server);

客户端异步服务端同步(这里用线程池的方式)。

Future<Result> future = executor.submit(new Callable(){public void call<Result>(){
    result = request(server);
}})
return future;

客户端异步服务端异步。

Future<Result> future = request(server);//server立刻返回future


return future

上面说了这么多,其实是想让大家脱离两个误区:

  1. RPC只有客户端能做异步,服务端不能。
  2. 异步只能通过线程池。

那么,服务端使用异步最大的好处是什么呢?说到底,是解放了线程和I/O。试想服务端有一堆I/O等待处理,如果每个请求都需要同步响应,每条消息都需要结果立刻返回,那么就几乎没法做I/O合并

(当然接口可以设计成batch的,但可能batch发过来的仍然数量较少)。而如果用异步的方式返回给客户端future,就可以有机会进行I/O的合并,把几个批次发过来的消息一起落地(这种合并对于MySQL等允许batch insert的数据库效果尤其明显),并且彻底释放了线程。不至于说来多少请求开多少线程,能够支持的并发量直线提高。

来看第二个误区,返回future的方式不一定只有线程池。换句话说,可以在线程池里面进行同步操作,也可以进行异步操作,也可以不使用线程池使用异步操作(NIO、事件)。

回到消息队列的议题上,我们当然不希望消息的发送阻塞主流程(前面提到了,server端如果使用异步模型,则可能因消息合并带来一定程度上的消息延迟),所以可以先使用线程池提交一个发送请求,主流程继续往下走。

但是线程池中的请求关心结果吗?Of course,必须等待服务端消息成功落地,才算是消息发送成功。所以这里的模型,准确地说事客户端半同步半异步(使用线程池不阻塞主流程,但线程池中的任务需要等待server端的返回),server端是纯异步。客户端的线程池wait在server端吐回的future上,直到server端处理完毕,才解除阻塞继续进行。

总结一句,同步能够保证结果,异步能够保证效率,要合理的结合才能做到最好的效率。

批量

谈到批量就不得不提生产者消费者模型。但生产者消费者模型中最大的痛点是:消费者到底应该何时进行消费。大处着眼来看,消费动作都是事件驱动的。主要事件包括:

  1. 攒够了一定数量。
  2. 到达了一定时间。
  3. 队列里有新的数据到来。

对于及时性要求高的数据,可用采用方式3来完成,比如客户端向服务端投递数据。只要队列有数据,就把队列中的所有数据刷出,否则将自己挂起,等待新数据的到来。

在第一次把队列数据往外刷的过程中,又积攒了一部分数据,第二次又可以形成一个批量。伪代码如下:

Executor executor = Executors.newFixedThreadPool(4);
final BlockingQueue<Message> queue = new ArrayBlockingQueue<>();
private Runnable task = new Runnable({//这里由于共享队列,Runnable可以复用,故做成全局的
   public void run(){
      List<Message> messages  = new ArrayList<>(20);
      queue.drainTo(messages,20);
      doSend(messages);//阻塞,在这个过程中会有新的消息到来,如果4个线程都占满,队列就有机会囤新的消息
   }
});
public void send(Message message){
    queue.offer(message);
    executor.submit(task)
}

这种方式是消息延迟和批量的一个比较好的平衡,但优先响应低延迟。延迟的最高程度由上一次发送的等待时间决定。但可能造成的问题是发送过快的话批量的大小不够满足性能的极致。

Executor executor = Executors.newFixedThreadPool(4);
final BlockingQueue<Message> queue = new ArrayBlockingQueue<>();
volatile long last = System.currentMills();
Executors.newSingleThreadScheduledExecutor().submit(new Runnable(){
   flush();
},500,500,TimeUnits.MILLS);
private Runnable task = new Runnable({//这里由于共享队列,Runnable可以复用,顾做成全局的。
   public void run(){
      List<Message> messages  = new ArrayList<>(20);
      queue.drainTo(messages,20);
      doSend(messages);//阻塞,在这个过程中会有新的消息到来,如果4个线程都占满,队列就有机会屯新的消息。
   }
});
public void send(Message message){
    last = System.currentMills();
    queue.offer(message);
    flush();
}
private void flush(){
 if(queue.size>200||System.currentMills()-last>200){
       executor.submit(task)
  }
}

相反对于可以用适量的延迟来换取高性能的场景来说,用定时/定量二选一的方式可能会更为理想,既到达一定数量才发送,但如果数量一直达不到,也不能干等,有一个时间上限。

具体说来,在上文的submit之前,多判断一个时间和数量,并且Runnable内部维护一个定时器,避免没有新任务到来时旧的任务永远没有机会触发发送条件。对于server端的数据落地,使用这种方式就非常方便。

最后啰嗦几句,曾经有人问我,为什么网络请求小包合并成大包会提高性能?主要原因有两个:

  1. 减少无谓的请求头,如果你每个请求只有几字节,而头却有几十字节,无疑效率非常低下。
  2. 减少回复的ack包个数。把请求合并后,ack包数量必然减少,确认和重发的成本就会降低。

push还是pull

上文提到的消息队列,大多是针对push模型的设计。现在市面上有很多经典的也比较成熟的pull模型的消息队列,如Kafka、MetaQ等。这跟JMS中传统的push方式有很大的区别,可谓另辟蹊径。

我们简要分析下push和pull模型各自存在的利弊。

慢消费

慢消费无疑是push模型最大的致命伤,穿成流水线来看,如果消费者的速度比发送者的速度慢很多,势必造成消息在broker的堆积。假设这些消息都是有用的无法丢弃的,消息就要一直在broker端保存。当然这还不是最致命的,最致命的是broker给consumer推送一堆consumer无法处理的消息,consumer不是reject就是error,然后来回踢皮球。

反观pull模式,consumer可以按需消费,不用担心自己处理不了的消息来骚扰自己,而broker堆积消息也会相对简单,无需记录每一个要发送消息的状态,只需要维护所有消息的队列和偏移量就可以了。所以对于建立索引等慢消费,消息量有限且到来的速度不均匀的情况,pull模式比较合适。

消息延迟与忙等

这是pull模式最大的短板。由于主动权在消费方,消费方无法准确地决定何时去拉取最新的消息。如果一次pull取到消息了还可以继续去pull,如果没有pull取到则需要等待一段时间重新pull。

但等待多久就很难判定了。你可能会说,我可以有xx动态pull取时间调整算法,但问题的本质在于,有没有消息到来这件事情决定权不在消费方。也许1分钟内连续来了1000条消息,然后半个小时没有新消息产生,

可能你的算法算出下次最有可能到来的时间点是31分钟之后,或者60分钟之后,结果下条消息10分钟后到了,是不是很让人沮丧?

当然也不是说延迟就没有解决方案了,业界较成熟的做法是从短时间开始(不会对broker有太大负担),然后指数级增长等待。比如开始等5ms,然后10ms,然后20ms,然后40ms……直到有消息到来,然后再回到5ms。

即使这样,依然存在延迟问题:假设40ms到80ms之间的50ms消息到来,消息就延迟了30ms,而且对于半个小时来一次的消息,这些开销就是白白浪费的。

在阿里的RocketMq里,有一种优化的做法-长轮询,来平衡推拉模型各自的缺点。基本思路是:消费者如果尝试拉取失败,不是直接return,而是把连接挂在那里wait,服务端如果有新的消息到来,把连接notify起来,这也是不错的思路。但海量的长连接block对系统的开销还是不容小觑的,还是要合理的评估时间间隔,给wait加一个时间上限比较好~

顺序消息

如果push模式的消息队列,支持分区,单分区只支持一个消费者消费,并且消费者只有确认一个消息消费后才能push送另外一个消息,还要发送者保证全局顺序唯一,听起来也能做顺序消息,但成本太高了,尤其是必须每个消息消费确认后才能发下一条消息,这对于本身堆积能力和慢消费就是瓶颈的push模式的消息队列,简直是一场灾难。

反观pull模式,如果想做到全局顺序消息,就相对容易很多:

  1. producer对应partition,并且单线程。
  2. consumer对应partition,消费确认(或批量确认),继续消费即可。

所以对于日志push送这种最好全局有序,但允许出现小误差的场景,pull模式非常合适。如果你不想看到通篇乱套的日志~~

Anyway,需要顺序消息的场景还是比较有限的而且成本太高,请慎重考虑。

总结

本文从为何使用消息队列开始讲起,然后主要介绍了如何从零开始设计一个消息队列,包括RPC、事务、最终一致性、广播、消息确认等关键问题。并对消息队列的push、pull模型做了简要分析,最后从批量和异步角度,分析了消息队列性能优化的思路。下篇会着重介绍一些高级话题,如存储系统的设计、流控和错峰的设计、公平调度等。希望通过这些,让大家对消息队列有个提纲挈领的整体认识,并给自主开发消息队列提供思路。另外,本文主要是源自自己在开发消息队列中的思考和读源码时的体会,比较不”官方”,也难免会存在一些漏洞,欢迎大家多多交流。

后续我们还会推出消息队列设计高级篇,内容会涵盖以下方面:

  • pull模型消息系统设计理念
  • 存储子系统设计
  • 流量控制
  • 公平调度

敬请期待哦~

作者简介

王烨,现在是美团旅游后台研发组的程序猿,之前曾经在百度、去哪和优酷工作过,专注Java后台开发。对于网络编程和并发编程具有浓厚的兴趣,曾经做过一些基础组件,也翻过一些源码,属于比较典型的宅男技术控。期待能够与更多知己,在coding的路上并肩前行~

from:https://zhuanlan.zhihu.com/p/21649950

 

大型分布式网站术语分析

1. I/O优化

  1. 增加缓存,减少磁盘的访问次数。
  2. 优化磁盘的管理系统,设计最优的磁盘方式策略,以及磁盘的寻址策略,这是在底层操作系统层面考虑的。
  3. 设计合理的磁盘存储数据块,以及访问这些数据库的策略,这是在应用层面考虑的。例如,我们可以给存放的数据设计索引,通过寻址索引来加快和减少磁盘的访问量,还可以采用异步和非阻塞的方式加快磁盘的访问速度。
  4. 应用合理的RAID策略提升磁盘I/O。

2. Web前端调优

  1. 减少网络交互的次数(多次请求合并)
  2. 减少网络传输数据量的大小(压缩)
  3. 尽量减少编码(尽量提前将字符转化为字节,或者减少从字符到字节的转化过程。)
  4. 使用浏览器缓存
  5. 减少Cookie传输
  6. 合理布局页面
  7. 使用页面压缩
  8. 延迟加载页面
  9. CSS在最上面,JS在最下面
  10. CDN
  11. 反向代理
  12. 页面静态化
  13. 异地部署

3.服务降级(自动优雅降级)

拒绝服务和关闭服务

4.幂等性设计

有些服务天然具有幂等性,比如讲用户性别设置为男性,不管设置多少次,结果都一样。但是对转账交易等操作,问题就会比较复杂,需要通过交易编号等信息进行服务调用有效性校验,只有有效的操作才能继续执行。

(注:幂等性是系统的接口对外一种承诺(而不是实现), 承诺只要调用接口成功, 外部多次调用对系统的影响是一致的. 声明为幂等的接口会认为外部调用失败是常态, 并且失败之后必然会有重试.)

5.失效转移

若数据服务器集群中任何一台服务器宕机,那么应用程序针对这台服务器的所有读写操作都需要重新路由到其他服务器,保证数据访问不会失败,这个过程叫失效转移。
失效转移包括:失效确认(心跳检测和应用程序访问失败报告)、访问转移、数据恢复。
失效转移保证当一个数据副本不可访问时,可以快速切换访问数据的其他副本,保证系统可用。

6.性能优化

根据网站分层架构,性能优化可分为:web前端性能优化、应用服务器性能优化、存储服务器性能优化。

  1. web前端性能优化
    • 浏览器访问优化:减少http请求;使用浏览器缓存;启用压缩;css放在页面最上面、javaScript放在页面最下面;减少Cookie传输
    • CDN加速
    • 反向代理
  2. 应用服务器性能优化
    • 分布式缓存(Redis等)
    • 异步操作(消息队列)
    • 使用集群(负载均衡)
    • 代码优化
  3. 存储性能优化
    • 机械硬盘vs固态硬盘
    • B+树 vs LSM树
    • RAID vs HDFS

7. 代码优化

  • 多线程(Q:怎么确保线程安全?无锁机制有哪些?)
  • 资源复用(单例模式,连接池,线程池)
  • 数据结构
  • 垃圾回收

8. 负载均衡

  • HTTP重定向负载均衡
    当用户发来请求的时候,Web服务器通过修改HTTP响应头中的Location标记来返回一个新的url,然后浏览器再继续请求这个新url,实际上就是页面重定向。通过重定向,来达到“负载均衡”的目标。例如,我们在下载PHP源码包的时候,点击下载链接时,为了解决不同国家和地域下载速度的问题,它会返回一个离我们近的下载地址。重定向的HTTP返回码是302。
    优点:比较简单。
    缺点:浏览器需要两次请求服务器才能完成一次访问,性能较差。重定向服务自身的处理能力有可能成为瓶颈,整个集群的伸缩性国模有限;使用HTTP302响应码重定向,有可能使搜索引擎判断为SEO作弊,降低搜索排名。
  • DNS域名解析负载均衡
    DNS(Domain Name System)负责域名解析的服务,域名url实际上是服务器的别名,实际映射是一个IP地址,解析过程,就是DNS完成域名到IP的映射。而一个域名是可以配置成对应多个IP的。因此,DNS也就可以作为负载均衡服务。
    事实上,大型网站总是部分使用DNS域名解析,利用域名解析作为第一级负载均衡手段,即域名解析得到的一组服务器并不是实际提供Web服务的物理服务器,而是同样提供负载均衡服务的内部服务器,这组内部负载均衡服务器再进行负载均衡,将请求分发到真是的Web服务器上。
    优点:将负载均衡的工作转交给DNS,省掉了网站管理维护负载均衡服务器的麻烦,同时许多DNS还支持基于地理位置的域名解析,即会将域名解析成举例用户地理最近的一个服务器地址,这样可以加快用户访问速度,改善性能。
    缺点:不能自由定义规则,而且变更被映射的IP或者机器故障时很麻烦,还存在DNS生效延迟的问题。而且DNS负载均衡的控制权在域名服务商那里,网站无法对其做更多改善和更强大的管理。
  • 反向代理负载均衡
    反向代理服务可以缓存资源以改善网站性能。实际上,在部署位置上,反向代理服务器处于Web服务器前面(这样才可能缓存Web相应,加速访问),这个位置也正好是负载均衡服务器的位置,所以大多数反向代理服务器同时提供负载均衡的功能,管理一组Web服务器,将请求根据负载均衡算法转发到不同的Web服务器上。Web服务器处理完成的响应也需要通过反向代理服务器返回给用户。由于web服务器不直接对外提供访问,因此Web服务器不需要使用外部ip地址,而反向代理服务器则需要配置双网卡和内部外部两套IP地址。
    优点:和反向代理服务器功能集成在一起,部署简单。
    缺点:反向代理服务器是所有请求和响应的中转站,其性能可能会成为瓶颈。
  • LVS-NAT:修改IP地址
  • LVS-TUN: 一个IP报文封装在另一个IP报文的技术。
  • LVS-DR:将数据帧的MAC地址改为选出服务器的MAC地址,再将修改后的数据帧在与服务器组的局域网上发送。

9.缓存

缓存就是将数据存放在距离计算最近的位置以加快处理速度。缓存是改善软件性能的第一手段,现在CPU越来越快的一个重要因素就是使用了更多的缓存,在复杂的软件设计中,缓存几乎无处不在。大型网站架构设计在很多方面都使用了缓存设计。

  • CDN: 及内容分发网络,部署在距离终端用户最近的网络服务商,用户的网络请求总是先到达他的网络服务商哪里,在这里缓存网站的一些静态资源(较少变化的数据),可以就近以最快速度返回给用户,如视频网站和门户网站会将用户访问量大的热点内容缓存在CDN中。
  • 反向代理:反向代理属于网站前端架构的一部分,部署在网站的前端,当用户请求到达网站的数据中心时,最先访问到的就是反向代理服务器,这里缓存网站的静态资源,无需将请求继续转发给应用服务器就能返回给用户。
  • 本地缓存:在应用服务器本地缓存着热点数据,应用程序可以在本机内存中直接访问数据,而无需访问数据库。
  • 分布式缓存:大型网站的数据量非常庞大,即使只缓存一小部分,需要的内存空间也不是单机能承受的,所以除了本地缓存,还需要分布式缓存,将数据缓存在一个专门的分布式缓存集群中,应用程序通过网络通信访问缓存数据。

使用缓存有两个前提条件,一是数据访问热点不均衡,某些数据会被更频繁的访问,这些数据应该放在缓存中;二是数据在某个时间段内有效,不会很快过期,否则缓存的数据就会因已经失效而产生脏读,影响结果的正确性。网站应用中,缓存处理可以加快数据访问速度,还可以减轻后端应用和数据存储的负载压力,这一点对网站数据库架构至关重要,网站数据库几乎都是按照有缓存的前提进行负载能力设计的。

10. 负载均衡算法

轮询 Round Robin
加强轮询 Weight Round Robin
随机 Random
加强随机 Weight Random
最少连接 Least Connections
加强最少连接
源地址散列 Hash
其他算法

  • 最快算法(Fastest):传递连接给那些响应最快的服务器。当其中某个服务器发生第二到第7 层的故障,BIG-IP 就把其从服务器队列中拿出,不参加下一次的用户请求的分配,直到其恢复正常。
  • 观察算法(Observed):连接数目和响应时间以这两项的最佳平衡为依据为新的请求选择服务器。当其中某个服务器发生第二到第7 层的故障,BIG-IP就把其从服务器队列中拿出,不参加下一次的用户请求的分配,直到其恢复正常。
  • 预测算法(Predictive):BIG-IP利用收集到的服务器当前的性能指标,进行预测分析,选择一台服务器在下一个时间片内,其性能将达到最佳的服务器相应用户的请求。(被BIG-IP 进行检测)
  • 动态性能分配算法(Dynamic Ratio-APM):BIG-IP 收集到的应用程序和应用服务器的各项性能参数,动态调整流量分配。
  • 动态服务器补充算法(Dynamic Server Act.):当主服务器群中因故障导致数量减少时,动态地将备份服务器补充至主服务器群。
  • 服务质量算法(QoS):按不同的优先级对数据流进行分配。
  • 服务类型算法(ToS): 按不同的服务类型(在Type of Field中标识)负载均衡对数据流进行分配。
  • 规则模式算法:针对不同的数据流设置导向规则,用户可自行

11. 扩展性和伸缩性的区别

扩展性:指对现有系统影响最小的情况下,系统功能可持续扩展或替身的能力。表现在系统基础设施稳定不需要经常变更,应用之间较少依赖和耦合,对需求变更可以敏捷响应。它是系统架构设计层面的开闭原则(对扩展开放,对修改关闭),架构设计考虑未来功能扩展,当系统增加新功能时,不需要对现有系统的结构和代码进行修改。

衡量网站架构扩展性好坏的主要标准就是在网站增加新的业务产品时,是否可以实现对现有产品透明无影响,不需要任何改动或者很少改动既有业务功能就可以上线新产品。不同产品之间是否很少耦合,一个产品改动对其他产品无影响,其他产品和功能不需要受牵连进行改动。

伸缩性:所谓网站的伸缩性指是不需要改变网站的软硬件设计,仅仅通过改变部署的服务器数量就可以扩大或者缩小网站的服务处理能力。

指系统能够增加(减少)自身资源规模的方式增强(减少)自己计算处理事务的能力。如果这种增减是成比例的,就被称作线性伸缩性。在网站架构中,通常指利用集群的方式增加服务器数量、提高系统的整体事务吞吐能力。

衡量架构伸缩性的主要标准就是可以用多台服务器构建集群,是否容易向集群中添加新的服务器。加入新的服务器后是否可以提供和原来服务无差别的服务、集群中的可容纳的总的服务器数量是否有限制。

12.分布式缓存的一致性hash

具体算法过程:先构造一个长度为2^32的整数环(这个环被称作一致性Hash环)根据节点名称的Hash值(其分布范围为[0,2^32 – 1])将缓存服务器阶段设置在这个Hash环上。然后根据需要缓存的数据的Key值计算得到Hash值(其分布范围也同样为[0,2^32 – 1]),然后在Hash环上顺时针查找举例这个KEY的hash值最近的缓存服务器节点,完成KEY到服务器的Hash映射查找。

优化策略:将每台物理服务器虚拟为一组虚拟缓存服务器,将虚拟服务器的Hash值放置在Hash环上,key在换上先找到虚拟服务器节点,再得到物理服务器的信息。

一台物理服务器设置多少个虚拟服务器节点合适呢?经验值:150。

13. 网络安全

  1. XSS攻击
    跨站点脚本攻击(Cross Site Script),指黑客通过篡改网页,注入恶意的HTML脚本,在用户浏览网页时,控制用户浏览器进行恶意操作的一种攻击方式。
    防范手段:消毒(XSS攻击者一般都是通过在请求中嵌入恶意脚本大道攻击的目的,这些脚本是一般用户输入中不使用的,如果进行过滤和消毒处理,即对某些html危险字符转移,如“>”转译为“& gt;”);HttpOnly(防止XSS攻击者窃取Cookie).
  2. 注入攻击:SQL注入和OS注入
    SQL防范:预编译语句PreparedStatement; ORM;避免密码明文存放;处理好相应的异常。
  3. CSRF(Cross Site Request Forgery,跨站点请求伪造)。听起来与XSS有点相似,事实上两者区别很大,XSS利用的是站内的信任用户,而CSRF则是通过伪装来自受信任用户的请求来利用受信任的网站。
    防范:httpOnly;增加token;通过Referer识别。
  4. 文件上传漏洞
  5. DDos攻击

14. 加密技术

  1. 摘要加密:MD5, SHA
  2. 对称加密:DES算法,RC算法, AES
  3. 非对称加密:RSA
    非对称加密技术通常用在信息安全传输,数字签名等场合。
    HTTPS传输中浏览器使用的数字证书实质上是经过权威机构认证的非对称加密的公钥。

15. 流控(流量控制)

  1. 流量丢弃
  2. 通过单机内存队列来进行有限的等待,直接丢弃用户请求的处理方式显得简单而粗暴,并且如果是I/O密集型应用(包括网络I/O和磁盘I/O),瓶颈一般不再CPU和内存。因此,适当的等待,既能够替身用户体验,又能够提高资源利用率。
  3. 通过分布式消息队列来将用户的请求异步化。

参考资料
1. LVS:三种负载均衡方式比较+另三种负载均衡方式
2. 《大型网站技术架构——核心原理与技术分析》李智慧 著。
3. 亿级Web系统搭建:单机到分布式集群
4. 《大型分布式网站架构设计与实现》陈康贤 著。

from:http://www.importnew.com/24198.html

分布式服务化系统一致性的“最佳实干”

1 背景

一致性是一个抽象的、具有多重含义的计算机术语,在不同应用场景下,有不同的定义和含义。在传统的IT时代,一致性通常指强一致性,强一致性通常体现在你中有我、我中有你、浑然一体;而在互联网时代,一致性的含义远远超出了它原有的含义,在我们讨论互联网时代的一致性之前,我们先了解一下互联网时代的特点,互联网时代信息量巨大、需要计算能力巨大,不但对用户响应速度要求快,而且吞吐量指标也要向外扩展(既:水平伸缩),于是单节点的服务器无法满足需求,服务节点开始池化,想想那个经典的故事,一只筷子一折就断,一把筷子怎么都折不断,可见人多力量大的思想是多么的重要,但是人多也不一定能解决所有事情,还得进行有序、合理的分配任务,进行有效的管理,于是互联网时代谈论最多的话题就是拆分,拆分一般分为“水平拆分”和“垂直拆分”(大家不要对应到数据库或者缓存拆分,这里主要表达一种逻辑)。这里,“水平拆分”指的是同一个功能由于单机节点无法满足性能需求,需要扩展成为多节点,多个节点具有一致的功能,组成一个服务池,一个节点服务一部分的请求量,团结起来共同处理大规模高并发的请求量。“垂直拆分”指的是按照功能拆分,秉着“专业的人干专业的事儿”的原则,把一个复杂的功能拆分到多个单一的简单的元功能,不同的元功能组合在一起,和未拆分前完成的功能是一致的,由于每个元功能职责单一、功能简单,让维护和变更都变得更简单、安全,更易于产品版本的迭代,在这样的一个互联网的时代和环境,一致性指分布式服务化系统之间的弱一致性,包括应用系统一致性和数据一致性。

无论是水平拆分还是垂直拆分,都解决了特定场景下的特定问题,凡事有好的一面,都会有坏的一面,拆分后的系统或者服务化的系统最大的问题就是一致性问题,这么多个具有元功能的模块,或者同一个功能池中的多个节点之间,如何保证他们的信息是一致的、工作步伐是一致的、状态是一致的、互相协调有序的工作呢?

本文根据作者在互联网企业的实际项目经验,对服务化系统中最难解决的一致性问题进行研究和探讨,试图从实践经验中找到规律,抽象出模式,分享给大家,希望对大家的项目实施有所帮助,在对实践的总结中也会对相关的一致性术语做最朴实的解释,希望能帮助大家彻底理解一致性的本质,并能将其应用到实践,解决读者现实中遇到的服务化系统的一致性问题,本文使用理论与实践相结合的方法,突出在实践中解决问题的模式,因此叫做《分布式服务化系统一致性的“最佳实干”》。

2 问题

本节列举不一致会导致的种种问题,这也包括一例生活中的问题。

案例1:买房

假如你想要享受生活的随意,只想买个两居,不想让房贷有太大压力,而你媳妇却想要买个三居,还得带花园的,那么你们就不一致了,不一致导致生活不愉快、不协调,严重情况下还会吵架,可见生活中的不一致问题影响很大。

案例2:转账

转账是经典的不一致案例,设想一下银行为你处理一笔转账,扣减你账户上的余额,然后增加别人账户的余额;如果扣减你的账户余额成功,增加别人账户余额失败,那么你就会损失这笔资金。反过来,如果扣减你的账户余额失败,增加别人账户余额成功,那么银行就会损失这笔资金,银行需要赔付。对于资金处理系统来说,上面任何一种场景都是不允许发生的,一旦发生就会有资金损失,后果是不堪设想的,严重情况会让一个公司瞬间倒闭,可参考案例

案例3:下订单和扣库存

电商系统中也有一个经典的案例,下订单和扣库存如何保持一致,如果先下订单,扣库存失败,那么将会导致超卖;如果下订单没有成功,扣库存成功,那么会导致少卖。两种情况都会导致运营成本的增加,严重情况下需要赔付。

案例4:同步超时

服务化的系统间调用常常因为网络问题导致系统间调用超时,即使是网络很好的机房,在亿次流量的基数下,同步调用超时也是家常便饭。系统A同步调用系统B超时,系统A可以明确得到超时反馈,但是无法确定系统B是否已经完成了预定的功能或者没有完成预定的功能。于是,系统A就迷茫了,不知道应该继续做什么,如何反馈给使用方。(曾经的一个B2B产品的客户要求接口超时重新通知他们,这个在技术上是难以实现的,因为服务器本身可能并不知道自己超时,可能会继续正常的返回数据,只是客户端并没有接受到结果罢了,因此这不是一个合理的解决方案)。

案例5:异步回调超时

此案例和上一个同步超时案例类似,不过这个场景使用了异步回调,系统A同步调用系统B发起指令,系统B采用受理模式,受理后则返回受理成功,然后系统B异步通知系统A。在这个过程中,如果系统A由于某种原因迟迟没有收到回调结果,那么两个系统间的状态就不一致,互相认知不同会导致系统间发生错误,严重情况下会影响核心事务,甚至会导致资金损失。

案例6:掉单

分布式系统中,两个系统协作处理一个流程,分别为对方的上下游,如果一个系统中存在一个请求,通常指订单,另外一个系统不存在,则导致掉单,掉单的后果很严重,有时候也会导致资金损失。

案例7:系统间状态不一致

这个案例与上面掉单案例类似,不同的是两个系统间都存在请求,但是请求的状态不一致。

案例8:缓存和数据库不一致

交易相关系统基本离不开关系型数据库,依赖关系型数据库提供的ACID特性(后面介绍),但是在大规模高并发的互联网系统里,一些特殊的场景对读的性能要求极高,服务于交易的数据库难以抗住大规模的读流量,通常需要在数据库前垫缓存,那么缓存和数据库之间的数据如何保持一致性?是要保持强一致呢还是弱一致性呢?

案例9:本地缓存节点间不一致

一个服务池上的多个节点为了满足较高的性能需求,需要使用本地缓存,使用了本地缓存,每个节点都会有一份缓存数据的拷贝,如果这些数据是静态的、不变的,那永远都不会有问题,但是如果这些数据是半静态的或者常被更新的,当被更新的时候,各个节点更新是有先后顺序的,在更新的瞬间,各个节点的数据是不一致的,如果这些数据是为某一个开关服务的,想象一下重复的请求走进了不同的节点(在failover或者补偿导致的场景下,重复请求是一定会发生的,也是服务化系统必须处理的),一个请求走了开关打开的逻辑,同时另外一个请求走了开关关闭的逻辑,这导致请求被处理两次,最坏的情况下会导致灾难性的后果,就是资金损失。

案例10:缓存数据结构不一致

这个案例会时有发生,某系统需要种某一数据结构的缓存,这一数据结构有多个数据元素组成,其中,某个数据元素都需要从数据库中或者服务中获取,如果一部分数据元素获取失败,由于程序处理不正确,仍然将不完全的数据结构存入缓存,那么缓存的消费者消费的时候很有可能因为没有合理处理异常情况而出错。

3 模式

3.1 生活中不一致问题的解决

大家回顾一下上一节列举的生活中的案例1-买房,如果置身事外来看,解决这种不一致的办法有两个,一个是避免不一致的发生,如果已经是媳妇了就不好办了:),还有一种方法就是慢慢的补偿,先买个两居,然后慢慢的等资金充裕了再换三居,买比特币赚了再换带花园的房子,于是问题最终被解决了,最终大家处于一致的状态,都开心了。这样可以解决案例1的问题,很自然由于有了过渡的方法,问题在不经意间就消失了,可见“过渡”也是解决一致性问题的一个模式。

从案例1的解决方案来看,我们要解决一致性问题,一个最直接最简单的方法就是保持强一致性,对于案例1的情况,尽量避免在结婚前两个人能够互相了解达成一致,避免不一致问题的发生;不过有些事情事已至此,发生了就是发生了,出现了不一致的问题,我们应该考虑去补偿,尽最大的努力从不一致状态修复到一致状态,避免损失全部或者一部分,也不失为一个好方法。

因此,避免不一致是上策,出现了不一致及时发现及时修复是中策,有问题不积极解决留给他人解决是下策。

3.2 酸碱平衡理论

ACID在英文中的意思是“酸”,BASE的意识是“碱”,这一段讲的是“酸碱平衡”的故事。

1. ACID(酸)

如何保证强一致性呢?计算机专业的童鞋在学习关系型数据库的时候都学习了ACID原理,这里对ACID做个简单的介绍。如果想全面的学习ACID原理,请参考ACID

关系型数据库天生就是解决具有复杂事务场景的问题,关系型数据库完全满足ACID的特性。

ACID指的是:

  • A: Atomicity,原子性
  • C: Consistency,一致性
  • I: Isolation,隔离性
  • D: Durability,持久性

具有ACID的特性的数据库支持强一致性,强一致性代表数据库本身不会出现不一致,每个事务是原子的,或者成功或者失败,事物间是隔离的,互相完全不影响,而且最终状态是持久落盘的,因此,数据库会从一个明确的状态到另外一个明确的状态,中间的临时状态是不会出现的,如果出现也会及时的自动的修复,因此是强一致的。

3个典型的关系型数据库Oracle、Mysql、Db2都能保证强一致性,Oracle和Mysql使用多版本控制协议实现,而DB2使用改进的两阶段提交协议来实现。

如果你在为交易相关系统做技术选型,交易的存储应该只考虑关系型数据库,对于核心系统,如果需要较好的性能,可以考虑使用更强悍的硬件,这种向上扩展(升级硬件)虽然成本较高,但是是最简单粗暴有效的方式,另外,Nosql完全不适合交易场景,Nosql主要用来做数据分析、ETL、报表、数据挖掘、推荐、日志处理等非交易场景。

前面提到的案例2-转账案例3-下订单和扣库存都可以利用关系型数据库的强一致性解决。

然而,前面提到,互联网项目多数具有大规模高并发的特性,必须应用拆分的理念,对高并发的压力采取“大而化小、小而化了”的方法,否则难以满足动辄亿级流量的需求,即使使用关系型数据库,单机也难以满足存储和TPS上的需求。为了保证案例2-转账可以利用关系型数据库的强一致性,在拆分的时候尽量的把转账相关的账户放入一个数据库分片,对于案例3,尽量的保证把订单和库存放入同一个数据库分片,这样通过关系型数据库自然就解决了不一致的问题。

然而,有些时候事与愿违,由于业务规则的限制,无法将相关的数据分到同一个数据库分片,这个时候我们就需要实现最终一致性。

对于案例2-转账场景,假设账户数量巨大,对账户存储进行了拆分,关系型数据库一共分了8个实例,每个实例8个库,每个库8个表,共512张表,假如要转账的两个账户正好落在了一个库里,那么可以依赖关系型数据库的事务保持强一致性。

如果要转账的两个账户正好落在了不同的库里,转账操作是无法封装在同一个数据库事务中的,这个时候会发生一个库的账户扣减余额成功,另外一个库的账户增加余额失败的情况。

对于这种情况,我们需要继续探讨解决之道,CAP原理和BASE原理,BASE原理通过记录事务的中间的临时状态,实现最终一致性。

2. CAP(帽子理论)

如果想深入的学习CAP理论,请参考CAP

由于对系统或者数据进行了拆分,我们的系统不再是单机系统,而是分布式系统,针对分布式系的帽子理论包含三个元素:

  • C:Consistency,一致性, 数据一致更新,所有数据变动都是同步的
  • A:Availability,可用性, 好的响应性能,完全的可用性指的是在任何故障模型下,服务都会在有限的时间处理响应
  • P:Partition tolerance,分区容错性,可靠性

帽子理论证明,任何分布式系统只可同时满足二点,没法三者兼顾。关系型数据库由于关系型数据库是单节点的,因此,不具有分区容错性,但是具有一致性和可用性,而分布式的服务化系统都需要满足分区容错性,那么我们必须在一致性和可用性中进行权衡,具体表现在服务化系统处理的异常请求在某一个时间段内可能是不完全的,但是经过自动的或者手工的补偿后,达到了最终的一致性。

3. BASE(碱)

BASE理论解决CAP理论提出了分布式系统的一致性和可用性不能兼得的问题,如果想全面的学习BASE原理,请参考Eventual consistency

BASE在英文中有“碱”的意思,对应本节开头的ACID在英文中“酸”的意思,基于这两个名词提出了酸碱平衡的结论,简单来说是在不同的场景下,可以分别利用ACID和BASE来解决分布式服务化系统的一致性问题。

BASE模型与ACID模型截然不同,满足CAP理论,通过牺牲强一致性,获得可用性,一般应用在服务化系统的应用层或者大数据处理系统,通过达到最终一致性来尽量满足业务的绝大部分需求。

BASE模型包含个三个元素:

  • BA:Basically Available,基本可用
  • S:Soft State,软状态,状态可以有一段时间不同步
  • E:Eventually Consistent,最终一致,最终数据是一致的就可以了,而不是时时保持强一致

BASE模型的软状态是实现BASE理论的方法,基本可用和最终一致是目标。按照BASE模型实现的系统,由于不保证强一致性,系统在处理请求的过程中,可以存在短暂的不一致,在短暂的不一致窗口请求处理处在临时状态中,系统在做每步操作的时候,通过记录每一个临时状态,在系统出现故障的时候,可以从这些中间状态继续未完成的请求处理或者退回到原始状态,最后达到一致的状态。

案例1-转账为例,我们把用户A给用户B转账分成四个阶段,第一个阶段用户A准备转账,第二个阶段从用户A账户扣减余额,第三个阶段对用户B增加余额,第四个阶段完成转账。系统需要记录操作过程中每一步骤的状态,一旦系统出现故障,系统能够自动发现没有完成的任务,然后,根据任务所处的状态,继续执行任务,最终完成任务,达到一致的最终状态。

在实际应用中,上面这个过程通常是通过持久化执行任务的状态和环境信息,一旦出现问题,定时任务会捞取未执行完的任务,继续未执行完的任务,直到执行完成为止,或者取消已经完成的部分操作回到原始状态。这种方法在任务完成每个阶段的时候,都要更新数据库中任务的状态,这在大规模高并发系统中不会有太好的性能,一个更好的办法是用Write-Ahead Log(写前日志),这和数据库的Bin Log(操作日志)相似,在做每一个操作步骤,都先写入日志,如果操作遇到问题而停止的时候,可以读取日志按照步骤进行恢复,并且继续执行未完成的工作,最后达到一致。写前日志可以利用机械硬盘的追加写而达到较好性能,因此,这是一种专业化的实现方式,多数业务系系统还是使用数据库记录的字段来记录任务的执行状态,也就是记录中间的“软状态”,一个任务的状态流转一般可以通过数据库的行级锁来实现,这比使用Write-Ahead Log实现更简单、更快速。

有了BASE理论作为基础,我们对复杂的分布式事务进行拆解,对其中的每一步骤都记录其状态,有问题的时候可以根据记录的状态来继续执行任务,达到最终的一致,通过这个方法我们可以解决案例2-转账案例3-下订单和扣库存中遇到的问题。

4. 酸碱平衡的总结

  1. 使用向上扩展(强悍的硬件)运行专业的关系型数据库(例如:Oracle或者DB2)能够保证强一致性,钱能解决的问题就不是问题
  2. 如果钱是问题,可以对廉价硬件运行的开源关系型数据库(例如:Mysql)进行分片,将相关的数据分到数据库的同一个片,仍然能够使用关系型数据库保证事务
  3. 如果业务规则限制,无法将相关的数据分到同一个片,就需要实现最终一致性,通过记录事务的软状态(中间状态、临时状态),一旦处于不一致,可以通过系统自动化或者人工干预来修复不一致的情况

3.3 分布式一致性协议

国际开放标准组织Open Group定义了DTS(分布式事务处理模型),模型中包含4个角色:应用程序、事务管理器、资源管理器、通信资源管理器四部分。事务处理器是统管全局的管理者,资源处理器和通信资源处理器是事务的参与者。

J2EE规范也包含此分布式事务处理模型的规范,并在所有的AppServer中进行实现,J2EE规范中定义了TX协议和XA协议,TX协议定义应用程序与事务管理器之间的接口,而XA协议定义了事务管理器与资源处理器之间的接口,在过去,大家使用AppServer,例如:Websphere、Weblogic、Jboss等配置数据源的时候会看见类似XADatasource的数据源,这就是实现了DTS的关系型数据库的数据源。企业级开发JEE中,关系型数据库、JMS服务扮演资源管理器的角色,而EJB容器则扮演事务管理器的角色。

下面我们就介绍两阶段提交协议三阶段提交协议以及阿里巴巴提出的TCC,它们都是根据DTS这一思想演变出来的。

1. 两阶段提交协议

上面描述的JEE的XA协议就是根据两阶段提交来保证事务的完整性,并实现分布式服务化的强一致性。

两阶段提交协议把分布式事务分成两个过程,一个是准备阶段,一个是提交阶段,准备阶段和提交阶段都是由事务管理器发起的,为了接下来讲解方便,我们把事务管理器称为协调者,把资管管理器称为参与者。

两阶段如下:

  1. 准备阶段:协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,参与者会写redo或者undo日志(这也是前面提起的Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交
  2. 提交阶段:如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者明确返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源

两阶段提交协议成功场景示意图如下:

两阶段提交协议

我们看到两阶段提交协议在准备阶段锁定资源,是一个重量级的操作,并能保证强一致性,但是实现起来复杂、成本较高,不够灵活,更重要的是它有如下致命的问题:

  1. 阻塞:从上面的描述来看,对于任何一次指令必须收到明确的响应,才会继续做下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放
  2. 单点故障:如果协调者宕机,参与者没有了协调者指挥,会一直阻塞,尽管可以通过选举新的协调者替代原有协调者,但是如果之前协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接受,并且参与者接收后也宕机,新上任的协调者无法处理这种情况
  3. 脑裂:协调者发送提交指令,有的参与者接收到执行了事务,有的参与者没有接收到事务,就没有执行事务,多个参与者之间是不一致的

上面所有的这些问题,都是需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常情况下,当前处理的操作处于错误状态,需要管理员人工干预解决,因此可用性不够好,这也符合CAP协议的一致性和可用性不能兼得的原理。

2. 三阶段提交协议

三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题,并且把两个阶段增加为三个阶段:

  1. 询问阶段:协调者询问参与者是否可以完成指令,协调者只需要回答是还是不是,而不需要做真正的操作,这个阶段超时导致中止
  2. 准备阶段:如果在询问阶段所有的参与者都返回可以执行操作,协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作,但是不提交操作;如果在询问阶段任何参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议的的准备阶段是相似的,这个阶段超时导致成功
  3. 提交阶段:如果每个参与者在准备阶段返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源,这里的逻辑与两阶段提交协议的提交阶段一致

三阶段提交协议成功场景示意图如下:

三阶段提交协议

然而,这里与两阶段提交协议有两个主要的不同:

  1. 增加了一个询问阶段,询问阶段可以确保尽可能早的发现无法执行操作而需要中止的行为,但是它并不能发现所有的这种行为,只会减少这种情况的发生
  2. 在准备阶段以后,协调者和参与者执行的任务中都增加了超时,一旦超时,协调者和参与者都继续提交事务,默认为成功,这也是根据概率统计上超时后默认成功的正确性最大

三阶段提交协议与两阶段提交协议相比,具有如上的优点,但是一旦发生超时,系统仍然会发生不一致,只不过这种情况很少见罢了,好处就是至少不会阻塞和永远锁定资源。

3. TCC

上面两节讲解了两阶段提交协议和三阶段提交协议,实际上他们能解决案例2-转账案例3-下订单和扣库存中的分布式事务的问题,但是遇到极端情况,系统会发生阻塞或者不一致的问题,需要运营或者技术人工解决。无论两阶段还是三阶段方案中都包含多个参与者、多个阶段实现一个事务,实现复杂,性能也是一个很大的问题,因此,在互联网高并发系统中,鲜有使用两阶段提交和三阶段提交协议的场景。

阿里巴巴提出了新的TCC协议,TCC协议将一个任务拆分成Try、Confirm、Cancel,正常的流程会先执行Try,如果执行没有问题,再执行Confirm,如果执行过程中出了问题,则执行操作的逆操Cancel,从正常的流程上讲,这仍然是一个两阶段的提交协议,但是,在执行出现问题的时候,有一定的自我修复能力,如果任何一个参与者出现了问题,协调者通过执行操作的逆操作来取消之前的操作,达到最终的一致状态。

可以看出,从时序上,如果遇到极端情况下TCC会有很多问题的,例如,如果在Cancel的时候一些参与者收到指令,而一些参与者没有收到指令,整个系统仍然是不一致的,这种复杂的情况,系统首先会通过补偿的方式,尝试自动修复的,如果系统无法修复,必须由人工参与解决。

从TCC的逻辑上看,可以说TCC是简化版的三阶段提交协议,解决了两阶段提交协议的阻塞问题,但是没有解决极端情况下会出现不一致和脑裂的问题。然而,TCC通过自动化补偿手段,会把需要人工处理的不一致情况降到到最少,也是一种非常有用的解决方案,根据线人,阿里在内部的一些中间件上实现了TCC模式。

我们给出一个使用TCC的实际案例,在秒杀的场景,用户发起下单请求,应用层先查询库存,确认商品库存还有余量,则锁定库存,此时订单状态为待支付,然后指引用户去支付,由于某种原因用户支付失败,或者支付超时,系统会自动将锁定的库存解锁供其他用户秒杀。

TCC协议使用场景示意图如下:

TCC

总结一下,两阶段提交协议、三阶段提交协议、TCC协议都能保证分布式事务的一致性,他们保证的分布式系统的一致性从强到弱,TCC达到的目标是最终一致性,其中任何一种方法都可以不同程度的解决案例2:转账、案例3:下订单和扣库存的问题,只是实现的一致性的级别不一样而已,对于案例4:同步超时可以通过TCC的理念解决,如果同步调用超时,调用方可以使用fastfail策略,返回调用方的使用方失败的结果,同时调用服务的逆向cancel操作,保证服务的最终一致性。

3.4 保证最终一致性的模式

在大规模高并发服务化系统中,一个功能被拆分成多个具有单一功能的元功能,一个流程会有多个系统的多个元功能组合实现,如果使用两阶段提交协议和三阶段提交协议,确实能解决系统间一致性问题,除了这两个协议带来的自身的问题,这些协议的实现比较复杂、成本比较高,最重要的是性能并不好,相比来看,TCC协议更简单、容易实现,但是TCC协议由于每个事务都需要执行Try,再执行Confirm,略微显得臃肿,因此,在现实的系统中,底线要求仅仅需要能达到最终一致性,而不需要实现专业的、复杂的一致性协议,实现最终一致性有一些非常有效的、简单粗暴的模式,下面就介绍这些模式及其应用场景。

1. 查询模式

任何一个服务操作都需要提供一个查询接口,用来向外部输出操作执行的状态。服务操作的使用方可以通过查询接口,得知服务操作执行的状态,然后根据不同状态来做不同的处理操作。

为了能够实现查询,每个服务操作都需要有唯一的流水号标识,也可使用此次服务操作对应的资源ID来标志,例如:请求流水号、订单号等。

首先,单笔查询操作是必须提供的,我们也鼓励使用单笔订单查询,这是因为每次调用需要占用的负载是可控的,批量查询则根据需要来提供,如果使用了批量查询,需要有合理的分页机制,并且必须限制分页的大小,以及对批量查询的QPS需要有容量评估和流控等。

查询模式的示意图如下:

查询模式

对于案例4:同步超时、案例5:异步回调超时、案例6:掉单、案例7:系统间状态不一致,我们都需要使用查询模式来了解被调用服务的处理情况,来决定下一步做什么:补偿未完成的操作还是回滚已经完成的操作。

2. 补偿模式

有了上面的查询模式,在任何情况下,我们都能得知具体的操作所处的状态,如果整个操作处于不正常的状态,我们需要修正操作中有问题的子操作,这可能需要重新执行未完成的子操作,后者取消已经完成的子操作,通过修复使整个分布式系统达到一致,为了让系统最终一致而做的努力都叫做补偿。

对于服务化系统中同步调用的操作,业务操作发起的主动方在还没有得到业务操作执行方的明确返回或者调用超时,场景可参考案例4:同步超时,这个时候业务发起的主动方需要及时的调用业务执行方获得操作执行的状态,这里使用查询模式,获得业务操作的执行方的状态后,如果业务执行方已经完预设的工作,则业务发起方给业务的使用方返回成功,如果业务操作的执行方的状态为失败或者未知,则会立即告诉业务的使用方失败,然后调用业务操作的逆向操作,保证操作不被执行或者回滚已经执行的操作,让业务的使用方、业务发起的主动方、业务的操作方最终达成一致的状态。

补偿模式的示意图如下:

补偿模式

补偿操作根据发起形式分为:

  1. 自动恢复:程序根据发生不一致的环境,通过继续未完成的操作,或者回滚已经完成的操作,自动来达到一致
  2. 通知运营:如果程序无法自动恢复,并且设计时考虑到了不一致的场景,可以提供运营功能,通过运营手工进行补偿
  3. 通知技术:如果很不巧,系统无法自动回复,又没有运营功能,那必须通过技术手段来解决,技术手段包括走数据库变更或者代码变更来解决,这是最糟的一种场景

3. 异步确保模式

异步确保模式是补偿模式的一个典型案例,经常应用到使用方对响应时间要求并不太高,我们通常把这类操作从主流程中摘除,通过异步的方式进行处理,处理后把结果通过通知系统通知给使用方,这个方案最大的好处能够对高并发流量进行消峰,例如:电商系统中的物流、配送,以及支付系统中的计费、入账等。

实践中,将要执行的异步操作封装后持久入库,然后通过定时捞取未完成的任务进行补偿操作来实现异步确保模式,只要定时系统足够健壮,任何一个任务最终会被成功执行。

异步确保模式的示意图如下:

异步确保模式

对于案例5:异步回调超时,使用的就是异步确保模式,这种情况下对于某个操作,如果迟迟没有收到响应,我们通过查询模式和补偿模式来继续未完成的操作。

4. 定期校对模式

既然我们在系统中实现最终一致性,系统在没有达到一致之前,系统间的状态是不一致的,甚至是混乱的,需要补偿操作来达到一致的目的,但是我们如何来发现需要补偿的操作呢?

在操作的主流程中的系统间执行校对操作,我们可以事后异步的批量校对操作的状态,如果发现不一致的操作,则进行补偿,补偿操作与补偿模式中的补偿操作是一致的。

另外,实现定期校对的一个关键就是分布式系统中需要有一个自始至终唯一的ID,ID的生成请参考SnowFlake

在分布式系统中,全局唯一ID的示意图如下:

唯一ID

一般情况下,生成全局唯一ID有两种方法:

  1. 持久型:使用数据库表自增字段或者Sequence生成,为了提高效率,每个应用节点可以缓存一批次的ID,如果机器重启可能会损失一部分ID,但是这并不会产生任何问题
  2. 时间型:一般由机器号、业务号、时间、单节点内自增ID组成,由于时间一般精确到秒或者毫秒,因此不需要持久就能保证在分布式系统中全局唯一、粗略递增能特点

实践中,为了能在分布式系统中迅速的定位问题,一般的分布式系统都有技术支持系统,它能够跟踪一个请求的调用链,调用链是在二维的维度跟踪一个调用请求,最后形成一个调用树,原理可参考谷歌的论文Dapper, a Large-Scale Distributed Systems Tracing Infrastructure,一个开源的参考实现为pinpoint

在分布式系统中,调用链的示意图如下:

调用链

全局的唯一流水ID可以把一个请求在分布式系统中的流转的路径聚合,而调用链中的spanid可以把聚合的请求路径通过树形结构进行展示,让技术支持人员轻松的发现系统出现的问题,能够快速定位出现问题的服务节点,提高应急效率。

关于订单跟踪、调用链跟踪、业务链跟踪,我们会在后续文章中详细介绍。

在分布式系统中构建了唯一ID,调用链等基础设施,我们很容易对系统间的不一致进行核对,通常我们需要构建第三方的定期核对系统,以第三方的角度来监控服务执行的健康程度。

定期核对系统示意图如下:

定期核对模式

对于案例6:掉单、案例7:系统间状态不一致通常通过定期校对模式发现问题,并通过补偿模式来修复,最后完成系统间的最终一致性。

定期校对模式多应用在金融系统,金融系统由于涉及到资金安全,需要保证百分之百的准确性,所以,需要多重的一致性保证机制,包括:系统间的一致性对账、现金对账、账务对账、手续费对账等等,这些都属于定期校对模式,顺便说一下,金融系统与社交应用在技术上本质的区别在于社交应用在于量大,而金融系统在于数据的准确性。

到现在为止,我们看到通过查询模式、补偿模式、定期核对模式可以解决案例4到案例7的所有问题,对于案例4:同步超时,如果同步超时,我们需要查询状态进行补偿,对于案例5:异步回调超时,如果迟迟没有收到回调响应,我们也会通过查询状态进行补偿,对于案例6:掉单、案例7:系统间状态不一致,我们通过定期核对模式可以保证系统间操作的一致性,避免掉单和状态不一致导致问题。

5. 可靠消息模式

在分布式系统中,对于主流程中优先级比较低的操作,大多采用异步的方式执行,也就是前面提到的异步确保型,为了让异步操作的调用方和被调用方充分的解耦,也由于专业的消息队列本身具有可伸缩、可分片、可持久等功能,我们通常通过消息队列实现异步化,对于消息队列,我们需要建立特殊的设施保证可靠的消息发送以及处理机的幂等等。

消息的可靠发送

消息的可靠发送可以认为是尽最大努力发送消息通知,有两种实现方法:

第一种,发送消息之前,把消息持久到数据库,状态标记为待发送,然后发送消息,如果发送成功,将消息改为发送成功。定时任务定时从数据库捞取一定时间内未发送的消息,将消息发送。

消息发送模式1

第二种,实现方式与第一种类似,不同的是持久消息的数据库是独立的,并不耦合在业务系统中。发送消息之前,先发送一个预消息给某一个第三方的消息管理器,消息管理器将其持久到数据库,并标记状态为待发送,发送成功后,标记消息为发送成功。定时任务定时从数据库捞取一定时间内未发送的消息,回查业务系统是否要继续发送,根据查询结果来确定消息的状态。

消息发送模式2

一些公司把消息的可靠发送实现在了中间件里,通过Spring的注入,在消息发送的时候自动持久消息记录,如果有消息记录没有发送成功,定时会补偿发送。

消息处理器的幂等性

如果我们要保证消息可靠的发送,简单来说,要保证消息一定要发送出去,那么就需要有重试机制,有了重试机制,消息一定会重复,那么我们需要对重复做处理。

处理重复的最佳方式为保证操作的幂等性,幂等性的数学公式为:

f(f(x)) = f(x)

保证操作的幂等性常用的几个方法:

  1. 使用数据库表的唯一键进行滤重,拒绝重复的请求
  2. 使用分布式表对请求进行滤重
  3. 使用状态流转的方向性来滤重,通常使用行级锁来实现(后续在锁相关的文章中详细说明)
  4. 根据业务的特点,操作本身就是幂等的,例如:删除一个资源、增加一个资源、获得一个资源等

6. 缓存一致性模型

大规模高并发系统中一个常见的核心需求就是亿级的读需求,显然,关系型数据库并不是解决高并发读需求的最佳方案,互联网的经典做法就是使用缓存抗读需求,下面有一些使用缓存的保证一致性的最佳实践:

  1. 如果性能要求不是非常的高,尽量使用分布式缓存,而不要使用本地缓存
  2. 种缓存的时候一定种完全,如果缓存数据的一部分有效,一部分无效,宁可放弃种缓存,也不要把部分数据种入缓存
  3. 数据库与缓存只需要保持弱一致性,而不需要强一致性,读的顺序要先缓存,后数据库,写的顺序要先数据库,后缓存

这里的最佳实践能够解决案例8:缓存和数据库不一致、案例9:本地缓存节点间不一致、案例10:缓存数据结构不一致的问题,对于数据存储层、缓存与数据库、Nosql等的一致性是更深入的存储一致性技术,将会在后续文章单独介绍,这里的数据一致性主要是处理应用层与缓存、应用层与数据库、一部分的缓存与数据库的一致性。

3.5 专题模式

这一节介绍特殊场景下的一致性问题和解决方案。

迁移开关的设计

在大多数企业里,新项目和老项目一般会共存,大家都在努力的下掉老项目,但是由于种种原因总是下不掉,如果要彻底的下掉老项目,就必须要有非常完善的迁移方案,迁移是一项非常复杂而艰巨的任务,我会在将来的文章中详细探讨迁移方案、流程和技术,这里我们只对迁移中使用的开关进行描述。

迁移过程必须使用开关,开关一般都会基于多个维度来设计,例如:全局的、用户的、角色的、商户的、产品的等等,如果迁移过程中遇到问题,我们需要关闭开关,迁移回老的系统,这需要我们的新系统兼容老的数据,老的系统也兼容新的数据,从某种意义上来讲,迁移比实现新系统更加困难。

曾经看过很多简单的开关设计,有的开关设计在应用层次,通过一个curl语句调用,没有权限控制,这样的开关在服务池的每个节点都是不同步的、不一致的;还有的系统把开关配置放在中心化的配置系统、数据库或者缓存等,处理的每个请求都通过统一的开关来判断是否迁移等等,这样的开关有一个致命的缺点,服务请求在处理过程中,开关可能会变化,各个节点之间开关可能不同步、不一致,导致重复的请求可能走到新的逻辑又走了老的逻辑,如果新的逻辑和老的逻辑没有保证幂等性,这个请求就被重复处理了,如果是金融行业的应用,可能会导致资金损失,电商系统可能会导致发货并退款等问题。

这里面我们推荐使用订单开关,不管我们在什么维度上设计了开关,接收到服务请求后,我们在请求创建的关联实体(例如:订单)上标记开关,以后的任何处理流程,包括同步的和异步的处理流程,都通过订单上的开关来判断,而不是通过全局的或者基于配置的开关,这样在订单创建的时候,开关已经确定,不再变更,一旦一份数据不再发生变化,那么它永远是线程安全的,并且不会有不一致的问题。

这个模式在生产中使用比较频繁,建议每个企业都把这个模式作为设计评审的一项,如果不检查这一项,很多开发童鞋都会偷懒,直接在配置中或者数据库中做个开关就上线了。

4 总结

本文从一致性问题的实践出发,从大规模高并发服务化系统的实践经验中进行总结,列举导致不一致的具体问题,围绕着具体问题,总结出解决不一致的方法,并且抽象成模式,供大家在开发服务化系统的过程中参考。

另外,由于篇幅有限,还有一些关于分布式一致性的技术无法在一篇文章中与大家分享,包括:paxos算法、raft算法、zab算法、nwr算法、一致性哈希等,我会在后续文章中详细介绍。

5 反馈与建议

from:http://www.jianshu.com/p/1156151e20c8

分布式系统中唯一 ID 的生成方法

本文主要介绍在一个分布式系统中, 怎么样生成全局唯一的 ID

一, 问题描述

在分布式系统存在多个 Shard 的场景中, 同时在各个 Shard 插入数据时, 怎么给这些数据生成全局的 unique ID?

在单机系统中 (例如一个 MySQL 实例), unique ID 的生成是非常简单的, 直接利用 MySQL 自带的自增 ID 功能就可以实现.

但在一个存在多个 Shards 的分布式系统 (例如多个 MySQL 实例组成一个集群, 在这个集群中插入数据), 这个问题会变得复杂, 所生成的全局的 unique ID 要满足以下需求:

  1. 保证生成的 ID 全局唯一
  2. 今后数据在多个 Shards 之间迁移不会受到 ID 生成方式的限制
  3. 生成的 ID 中最好能带上时间信息, 例如 ID 的前 k 位是 Timestamp, 这样能够直接通过对 ID 的前 k 位的排序来对数据按时间排序
  4. 生成的 ID 最好不大于 64 bits
  5. 生成 ID 的速度有要求. 例如, 在一个高吞吐量的场景中, 需要每秒生成几万个 ID (Twitter 最新的峰值到达了 143,199 Tweets/s, 也就是 10万+/秒)
  6. 整个服务最好没有单点

如果没有上面这些限制, 问题会相对简单, 例如:

  1. 直接利用 UUID.randomUUID() 接口来生成 unique ID (http://www.ietf.org/rfc/rfc4122.txt). 但这个方案生成的 ID 有 128 bits, 另外, 生成的 ID 中也没有带 Timestamp
  2. 利用一个中心服务器来统一生成 unique ID. 但这种方案可能存在单点问题; 另外, 要支持高吞吐率的系统, 这个方案还要做很多改进工作 (例如, 每次从中心服务器批量获取一批 IDs, 提升 ID 产生的吞吐率)
  3. Flickr 的做法 (http://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/). 但他这个方案 ID 中没有带 Timestamp, 生成的 ID 不能按时间排序

在要满足前面 6 点要求的场景中, 怎么来生成全局 unique ID 呢?

Twitter 的 Snowflake 是一种比较好的做法. 下面主要介绍 Twitter Snowflake, 以及它的变种

二, Twitter Snowflake

https://github.com/twitter/snowflake

Snowflake 生成的 unique ID 的组成 (由高位到低位):

  • 41 bits: Timestamp (毫秒级)
  • 10 bits: 节点 ID (datacenter ID 5 bits + worker ID 5 bits)
  • 12 bits: sequence number

一共 63 bits (最高位是 0)

unique ID 生成过程:

  • 10 bits 的机器号, 在 ID 分配 Worker 启动的时候, 从一个 Zookeeper 集群获取 (保证所有的 Worker 不会有重复的机器号)
  • 41 bits 的 Timestamp: 每次要生成一个新 ID 的时候, 都会获取一下当前的 Timestamp, 然后分两种情况生成 sequence number:
  • 如果当前的 Timestamp 和前一个已生成 ID 的 Timestamp 相同 (在同一毫秒中), 就用前一个 ID 的 sequence number + 1 作为新的 sequence number (12 bits); 如果本毫秒内的所有 ID 用完, 等到下一毫秒继续 (这个等待过程中, 不能分配出新的 ID)
  • 如果当前的 Timestamp 比前一个 ID 的 Timestamp 大, 随机生成一个初始 sequence number (12 bits) 作为本毫秒内的第一个 sequence number

整个过程中, 只是在 Worker 启动的时候会对外部有依赖 (需要从 Zookeeper 获取 Worker 号), 之后就可以独立工作了, 做到了去中心化.

异常情况讨论:

  • 在获取当前 Timestamp 时, 如果获取到的时间戳比前一个已生成 ID 的 Timestamp 还要小怎么办? Snowflake 的做法是继续获取当前机器的时间, 直到获取到更大的 Timestamp 才能继续工作 (在这个等待过程中, 不能分配出新的 ID)

从这个异常情况可以看出, 如果 Snowflake 所运行的那些机器时钟有大的偏差时, 整个 Snowflake 系统不能正常工作 (偏差得越多, 分配新 ID 时等待的时间越久)

从 Snowflake 的官方文档 (https://github.com/twitter/snowflake/#system-clock-dependency) 中也可以看到, 它明确要求 “You should use NTP to keep your system clock accurate”. 而且最好把 NTP 配置成不会向后调整的模式. 也就是说, NTP 纠正时间时, 不会向后回拨机器时钟.

三, Snowflake 的其他变种

Snowflake 有一些变种, 各个应用结合自己的实际场景对 Snowflake 做了一些改动. 这里主要介绍 3 种.

1. Boundary flake

http://boundary.com/blog/2012/01/12/flake-a-decentralized-k-ordered-unique-id-generator-in-erlang/

变化:

  • ID 长度扩展到 128 bits:
  • 最高 64 bits 时间戳;
  • 然后是 48 bits 的 Worker 号 (和 Mac 地址一样长);
  • 最后是 16 bits 的 Seq Number
  • 由于它用 48 bits 作为 Worker ID, 和 Mac 地址的长度一样, 这样启动时不需要和 Zookeeper 通讯获取 Worker ID. 做到了完全的去中心化
  • 基于 Erlang

它这样做的目的是用更多的 bits 实现更小的冲突概率, 这样就支持更多的 Worker 同时工作. 同时, 每毫秒能分配出更多的 ID

2. Simpleflake

http://engineering.custommade.com/simpleflake-distributed-id-generation-for-the-lazy/

Simpleflake 的思路是取消 Worker 号, 保留 41 bits 的 Timestamp, 同时把 sequence number 扩展到 22 bits;

Simpleflake 的特点:

  • sequence number 完全靠随机产生 (这样也导致了生成的 ID 可能出现重复)
  • 没有 Worker 号, 也就不需要和 Zookeeper 通讯, 实现了完全去中心化
  • Timestamp 保持和 Snowflake 一致, 今后可以无缝升级到 Snowflake

Simpleflake 的问题就是 sequence number 完全随机生成, 会导致生成的 ID 重复的可能. 这个生成 ID 重复的概率随着每秒生成的 ID 数的增长而增长.

所以, Simpleflake 的限制就是每秒生成的 ID 不能太多 (最好小于 100次/秒, 如果大于 100次/秒的场景, Simpleflake 就不适用了, 建议切换回 Snowflake).

3. instagram 的做法

先简单介绍一下 instagram 的分布式存储方案:

  • 先把每个 Table 划分为多个逻辑分片 (logic Shard), 逻辑分片的数量可以很大, 例如 2000 个逻辑分片
  • 然后制定一个规则, 规定每个逻辑分片被存储到哪个数据库实例上面; 数据库实例不需要很多. 例如, 对有 2 个 PostgreSQL 实例的系统 (instagram 使用 PostgreSQL); 可以使用奇数逻辑分片存放到第一个数据库实例, 偶数逻辑分片存放到第二个数据库实例的规则
  • 每个 Table 指定一个字段作为分片字段 (例如, 对用户表, 可以指定 uid 作为分片字段)
  • 插入一个新的数据时, 先根据分片字段的值, 决定数据被分配到哪个逻辑分片 (logic Shard)
  • 然后再根据 logic Shard 和 PostgreSQL 实例的对应关系, 确定这条数据应该被存放到哪台 PostgreSQL 实例上

instagram unique ID 的组成:

  • 41 bits: Timestamp (毫秒)
  • 13 bits: 每个 logic Shard 的代号 (最大支持 8 x 1024 个 logic Shards)
  • 10 bits: sequence number; 每个 Shard 每毫秒最多可以生成 1024 个 ID

生成 unique ID 时, 41 bits 的 Timestamp 和 Snowflake 类似, 这里就不细说了.

主要介绍一下 13 bits 的 logic Shard 代号 和 10 bits 的 sequence number 怎么生成.

logic Shard 代号:

  • 假设插入一条新的用户记录, 插入时, 根据 uid 来判断这条记录应该被插入到哪个 logic Shard 中.
  • 假设当前要插入的记录会被插入到第 1341 号 logic Shard 中 (假设当前的这个 Table 一共有 2000 个 logic Shard)
  • 新生成 ID 的 13 bits 段要填的就是 1341 这个数字

sequence number 利用 PostgreSQL 每个 Table 上的 auto-increment sequence 来生成:

  • 如果当前表上已经有 5000 条记录, 那么这个表的下一个 auto-increment sequence 就是 5001 (直接调用 PL/PGSQL 提供的方法可以获取到)
  • 然后把 这个 5001 对 1024 取模就得到了 10 bits 的 sequence number

instagram 这个方案的优势在于:

  • 利用 logic Shard 号来替换 Snowflake 使用的 Worker 号, 就不需要到中心节点获取 Worker 号了. 做到了完全去中心化
  • 另外一个附带的好处就是, 可以通过 ID 直接知道这条记录被存放在哪个 logic Shard 上

同时, 今后做数据迁移的时候, 也是按 logic Shard 为单位做数据迁移的, 所以这种做法也不会影响到今后的数据迁移

from:http://blog.jobbole.com/110064/

消息系统在微服务间通讯的数据一致性

微服务是当下的热门话题,今天来聊下微服务中的一个敏感话题:如何保证微服务的数据一致性。谈到分布式事务,就避免不了CAP理论。

 

CAP理论是指对于一个分布式计算系统来说,不可能同时满足以下三点:

1. 一致性(Consistence) (等同于所有节点访问同一份最新的数据副本)

2. 可用性(Availability)(对数据更新具备高可用性)

3. 容忍网络分区(Partition tolerance)(以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。)

根据定理,分布式系统只能满足三项中的两项而不可能满足全部三项。以上关于CAP的理论介绍来自维基百科。同理,如何保证微服务间的数据一致性也一直是一个持续的话题,其实就是 如何在这三者中做一个权衡

就之前此公众号已经有一系列的文章来讨论,关于微服务架构下的事务一致性的话题,包括 BASE理论,两阶段提交,三阶段提交,可靠事件模式,TCC模式,补偿模式等,想进一步了解的话可以参考这里:微服务架构下的数据一致性保证(一),微服务架构下的数据一致性保证(二),微服务架构下的数据一致性保证(三)。今天只是谈一谈其中的一种场景:使用消息系统进行微服务间通讯,如何来保证微服务间的数据一致性。

1. 问题的引出:

微服务架构之数据一致性问题

这里我们先以下面的一个例子来引出问题:以 公有云市场1中的一个部署产品来说,当用户想要部署一个公有云中已有的产品,比如Redis产品,用户会先去公有云市场中找到对应的Redis产品,当用户点击发布时,市场中会进行相应的记录,同时后台有真正负责部署的模块,此处我们叫部署模块。当产品部署成功后,部署模块和市场都会进行最终状态的同步。

1、公有云市场:此处指一个简单的模型,类似阿里云的云镜像市场或亚马逊aws中的镜像市场。在云镜像市场中,用户会选择其中感兴趣的产品比如mysql,然后进行付费,发布,这样省去了用户在自己的云平台环境中手动下载安装包,然后安装,配置,启动等一系列繁琐的过程。在云平台市场中,用户所需要做的只是一些必要的配置,然后点击启动就能完成一个产品的发布。一般都是这样一个先购买镜像后启动实例的过程。

以上都是在理想的情况下进行的,大致流程如下图:

此时,市场和部署模块都是是独立的微服务,当平台用户申请开通产品后,公有云市场会先进行一系列的初始化工作,并向部署模块中发送部署请求,当部署模块部署成功或者失败后,会进行相应的记录,市场也会将状态记录到本地数据库。由于市场和部署都是以微服务形式存在,都有自己的本地事务,此时,我们已经无法通过本地事务的控制来保证操作的原子性了。那么问题就随之而来:

假如市场模块在向部署模块发送完请求之后,市场微服务出现了数据库的连接异常(比如连接数据库的网络异常,数据库漂移等),此时市场会向前端报错,提示部署过程中出错,导致部署失败,但实际上部署模块已经在后台默默的为用户开通了实例。
同样的问题也会出现在,当向部署模块发送完请求后市场微服务出现了宕机等意外情况,市场微服务的数据库中干脆直接没有保存用户的此次开通的请求,但实际上部署模块却已经在这个过程中开通过了产品实例。

如果公有云平台对用户资源的实例限制是5个,即一个用户(比如试用版的用户)最多只能开通5个产品实例,则用户此时在市场中最多只能开4个,因为有一个实例在部署模块成功部署,但是市场模块却并不清楚,此时就出现了数据不一致的严重问题。那么该如何解决此类问题呢?如何解决这类业务前后不一致的问题呢?

2. 引入消息框架,解决数据不一致问题

这里我们采用了 消息通信框架Kafka,通过事件机制来完成相应的需求。

在采用Kafka来完成消息的投递的同时,不可避免地也会面对消息的丢失的意外情况。这里我们先来看一下我们实现的主场景,然后在后面我们会接着探讨,如何在业务层面保证消息的绝对投递和消费。

消息发送方的处理

流程处理如下:

我们来分析一下此种设计如何能够满足我们的需求:

市场模块操作Product和Event是在本地事务进行,保证了本地操作的一致性。

如果开通产品时,市场领域在事件发布之前就发生了异常,宕机或者数据库无法连接,根据设计,事件发布定时器和开通产品的Service是分离操作,此时发生宕机等意外事件,并不会影响数据库中的数据,并 在下次服务器正常后事件发布定时器会去Event表中查找尚未发布的数据进行发布并更新消息状态为PUBLISHED.

如果是在更新库中的状态时发生了意外呢?此时消息已经发出到Kafka broker,则下次服务正常时,会将这些消息重新发送,但是因为有了Key的唯一性,部署模块判断这些是重复数据,直接忽略即可。

当产品部署成功后,Market事件监听器收到通知,准备更新数据库时发生了意外宕机等,下次服务正常启动后事件监听器会从上次的消息偏移量处进行监听并更新Event表。

消息接收方的处理

下面我们来看一下消息的接收方部署模块如何处理从Kafka Broker接收到的消息呢?

以下是部署模块对消息处理的流程图,此处部署模块的部署过程使用了简略的示意图。实际的场景中,部署动作以及更新状态是一个复杂的过程,同时可能依赖轮询来完成操作。

部署模块的事件监听器,在收到通知后,直接调用部署的Service,更新Deploy_table表中的业务逻辑,同时更新Event_Table中的消息状态。另一方面, 部署模块的 Event定时器,也会定时从Event_Table中读取信息并将结果发布到Kafka Broker, 市场模块收到通知后进行自己的业务操作。

至于采用这种模式的原理以及原因和市场领域类似,这里不再赘述。

3.引入补偿+幂等机制,

保证消息投递的可靠性

刚才也谈到,Kafka等市面上的大多数消息系统本身是无法保证消息投递的可靠性的。所以,我们也必须要从业务上对消息的意外情况进行保证。下面,我们探讨一下如何从业务上来保证消息投递的绝对可靠呢?

这里,我们就要引入 补偿机制+幂等操作,我们在前面的步骤中已经将Event进行了数据库持久化,我们还需要以下几个步骤来从业务上对消息的绝对可靠进行保证:

一、完善事件表字段

我们在Event表中增加两个新的字段count和updateTime,用来标识此消息发送或者重试的次数。正常情况下,count为1,表示只发送一次。

二、定时补偿加错误重试

同时 使用异常事件发布定时器,每隔2分钟(此时间只是一个示例,实际应用中应大于业务中正常业务处理逻辑的时间)去Event表中查询状态为PUBLISHED的消息,如果对应的消息记录更新时间为两分钟之前的时间,我们就悲观的认为此消息丢失,进行消息的重发,同时更新字段updateTime并将count计数加1。

三、最后一道防线:对账记录,人工干预

如果发现重发次数已经大于5,则认为此时已经无法依靠消息系统来完成此消息的投递,需要最后的一道保障,就是记录下来并在日常进行的人工对账中人工审核。

 

四、幂等去重

何为幂等呢?因为存在重试和错误补偿机制,不可避免的在系统中存在重复收到消息的场景,接口的幂等性能提高数据的一致性.在编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。

由于我们的定时补偿机制,消息的消费端也应该保证部署服务的操作是幂等的,即针对同一条消息多次发送的情况,我们应该保证这个消息实际上只会执行一次。这里如果发现消息是重复发送的,则直接将数据库中的执行结果读出并将结果推送到broker中,从而保证了消息的幂等性。

现在我们来分析一下此种策略如何保证的消息的绝对投递:

每条消息的产生都会在数据库中进行记录,保证消息的不丢失。

异常消息发布定时器会定时去Event表中查看异常消息,发现没有回应的数据则认为消息丢失,进行消息补偿,重新发送,如果连续5次依然失败则认为发生了异常,进行记录并人工干预对账。

对于部署模块(消息的消费端),如果消息丢失,则市场模块就无法收到回应(对应的Event表记录中的状态也不会修改),最终效果也会同#2情况,市场模块进行消息重发,如果重发次数超出了限制则会触发对账记录的业务逻辑。

4. 总结

本文通过采用消息系统进行微服务间的通信,加上一些设计上的变更,既保证了正常情况下(99.9%以上的情况)的逻辑的正确执行,也保证了极端情况下的数据一致性,满足了我们的业务需求,同时依赖市面上消息中间件强大的功能,极大的提高了系统的吞吐量。

针对Kafka等本身不可靠的问题,我们又通过修改业务场景的设计来保证了在极端情况下消息丢失时消息的可靠性,对应的也保证了业务的可靠性。此处只是以Kafka举例,如果是顾虑Kafka的本身消息不可靠的限制,可以考虑使用RabbitMQ或RocketMQ等市面上流行的消息通信框架。

概括来说,此方案主要保证了以下4个维度的一致性:

本地事务保证了业务持久化与消息持久化的一致性。

定时器保证了消息持久与消息投递的一致性。

消息中间件保证了消息的投递和消费的一致性。

业务补偿+幂等保证了消息失败下的一致性。

使用此种方案的弊端就是编码会大幅增加,为不同的微服务间增加不少额外的工作量,同时会产生较多的中间状态。对于业务中时间要求苛刻的场景,此方案不合适。(此处却符合本文中举例的场景,因为产品的开通,需要对容器进行操作,本身就是一个耗时的过程。)

数据一致性是微服务架构设计中唯恐避之不及却又不得不考虑的话题。通过保证最终数据的一致性,也是对CAP理论的一个折衷妥协方案,关于此方案的优劣,也不能简单的一言而概之,而是应该根据场景定夺,适合的才是最好的。

所以,我们在对微服务进行业务划分的时候就尽可能的避免“可能会产生一致性问题”的设计。如果这种设计过多,也许是时候考虑改改设计了。

from:http://windpoplar.iteye.com/blog/2353205