显示标签为“分布式系统”的博文。显示所有博文
显示标签为“分布式系统”的博文。显示所有博文

2013年6月12日星期三

案例分析:基于消息的分布式架构

美国计算机科学家,LaTex的作者Leslie Lamport说:“分布式系统就是这样一个系统,系统中一个你甚至都不知道的计算机出了故障,却可能导致你自己的计算机不可用。”一语道破了开发分布式系统的玄机,那就是它的复杂与不可控。所以Martin Fowler强调:分布式调用的第一原则就是不要分布式。这句话看似颇具哲理,然而就企业应用系统而言,只要整个系统在不停地演化,并有多个子系统共同存在时,这条原则就会被迫打破。盖因为在当今的企业应用系统中,很难寻找到完全不需要分布式调用的场景。Martin Fowler提出的这条原则,一方面是希望设计者能够审慎地对待分布式调用,另一方面却也是分布式系统自身存在的缺陷所致。无论是CORBA,还是EJB 2;无论是RPC平台,还是Web Service,都因为驻留在不同进程空间的分布式组件,而引入额外的复杂度,并可能对系统的效率、可靠性、可预测性等诸多方面带来负面的影响。
然而,不可否认的是在企业应用系统领域,我们总是会面对不同系统之间的通信、集成与整合,尤其当面临异构系统时,这种分布式的调用与通信变得越重要,它在架构设计中就更加凸显其价值。并且,从业务分析与架构质量的角度来讲,我们也希望在系统架构中尽可能地形成对服务的重用,通过独立运行在进程中服务的形式,彻底解除客户端与服务端的耦合。这常常是架构演化的必然道路。在我的同事陈金洲发表在InfoQ上的文章《架构腐化之谜》中,就认为可以通过“将独立的模块放入独立的进程”来解决架构因为代码规模变大而腐化的问题。
随着网络基础设施的逐步成熟,从RPC进化到Web Service,并在业界开始普遍推行SOA,再到后来的RESTful平台以及云计算中的PaaS与SaaS概念的推广,分布式架构在企业应用中开始呈现出不同的风貌,然而殊途同归,这些分布式架构的目标仍然是希望回到建造巴别塔的时代,系统之间的交流不再为不同语言与平台的隔阂而产生障碍。正如Martin Fowler在《企业集成模式》一书的序中写道:“集成之所以重要是因为相互独立的应用是没有生命力的。我们需要一种技术能将在设计时并未考虑互操作的应用集成起来,打破它们之间的隔阂,获得比单个应用更多的效益”。这或许是分布式架构存在的主要意义。

1、集成模式中的消息模式

归根结底,企业应用系统就是对数据的处理,而对于一个拥有多个子系统的企业应用系统而言,它的基础支撑无疑就是对消息的处理。与对象不同,消息本质上是一种数据结构(当然,对象也可以看做是一种特殊的消息),它包含消费者与服务双方都能识别的数据,这些数据需要在不同的进程(机器)之间进行传递,并可能会被多个完全不同的客户端消费。在众多分布式技术中,消息传递相较文件传递与远程过程调用(RPC)而言,似乎更胜一筹,因为它具有更好的平台无关性,并能够很好地支持并发与异步调用。对于Web Service与RESTful而言,则可以看做是消息传递技术的一种衍生或封装。在《面向模式的软件架构(卷四)》一书中,将关于消息传递的模式划归为分布式基础设施的范畴,这是因为诸多消息中间件产品的出现,使得原来需要开发人员自己实现的功能,已经可以直接重用。这极大地降低了包括设计成本、实现成本在内的开发成本。因此,对于架构师的要求也就从原来的设计实现,转变为对业务场景和功能需求的判断,从而能够正确地进行架构决策、技术选型与模式运用。
常用的消息模式
在我参与过的所有企业应用系统中,无一例外地都采用(或在某些子系统与模块中部分采用)了基于消息的分布式架构。但是不同之处在于,让我们做出架构决策的证据却迥然而异,这也直接影响我们所要应用的消息模式。
消息通道(Message Channel)模式
我们常常运用的消息模式是Message Channel(消息通道)模式,如图1所示。
图1 Message Channel模式(图片来自eaipatterns 
消息通道作为在客户端(消费者,Consumer)与服务(生产者,Producer)之间引入的间接层,可以有效地解除二者之间的耦合。只要实现规定双方需要通信的消息格式,以及处理消息的机制与时机,就可以做到消费者对生产者的“无知”。事实上,该模式可以支持多个生产者与消费者。例如,我们可以让多个生产者向消息通道发送消息,因为消费者对生产者的无知性,它不必考虑究竟是哪个生产者发来的消息。
虽然消息通道解除了生产者与消费者之间的耦合,使得我们可以任意地对生产者与消费者进行扩展,但它又同时引入了各自对消息通道的依赖,因为它们必须知道通道资源的位置。要解除这种对通道的依赖,可以考虑引入Lookup服务来查找该通道资源。例如,在JMS中就可以通过JNDI来获取消息通道Queue。若要做到充分的灵活性,可以将与通道相关的信息存储到配置文件中,Lookup服务首先通过读取配置文件来获得通道。
消息通道通常以队列的形式存在,这种先进先出的数据结构无疑最为适合这种处理消息的场景。微软的MSMQ、IBM MQ、JBoss MQ以及开源的RabbitMQApache ActiveMQ都通过队列实现了Message Channel模式。因此,在选择运用Message Channel模式时,更多地是要从质量属性的层面对各种实现了该模式的产品进行全方位的分析与权衡。例如,消息通道对并发的支持以及在性能上的表现;消息通道是否充分地考虑了错误处理;对消息安全的支持;以及关于消息持久化、灾备(fail over)与集群等方面的支持。因为通道传递的消息往往是一些重要的业务数据,一旦通道成为故障点或安全性的突破点,对系统就会造成灾难性的影响。在本文的第二部分,我将给出一个实际案例来阐释在进行架构决策时应该考虑的架构因素,并由此做出正确地决策。
发布者-订阅者(Publisher-Subscriber)模式
一旦消息通道需要支持多个消费者时,就可能面临两种模型的选择:拉模型与推模型。拉模型是由消息的消费者发起的,主动权把握在消费者手中,它会根据自己的情况对生产者发起调用。如图2所示:
图2 拉模型
拉模型的另一种体现则由生产者在状态发生变更时,通知消费者其状态发生了改变。但得到通知的消费者却会以回调方式,通过调用传递过来的消费者对象获取更多细节消息。
在基于消息的分布式系统中,拉模型的消费者通常以Batch Job的形式,根据事先设定的时间间隔,定期侦听通道的情况。一旦发现有消息传递进来,就会转而将消息传递给真正的处理器(也可以看做是消费者)处理消息,执行相关的业务。在本文第二部分介绍的医疗卫生系统,正是通过引入Quartz.NET实现了Batch Job,完成对消息通道中消息的处理。
推模型的主动权常常掌握在生产者手中,消费者被动地等待生产者发出的通知,这就要求生产者必须了解消费者的相关信息。如图3所示:
图3 推模型
对于推模型而言,消费者无需了解生产者。在生产者通知消费者时,传递的往往是消息(或事件),而非生产者自身。同时,生产者还可以根据不同的情况,注册不同的消费者,又或者在封装的通知逻辑中,根据不同的状态变化,通知不同的消费者。
两种模型各有优势。拉模型的好处在于可以进一步解除消费者对通道的依赖,通过后台任务去定期访问消息通道。坏处是需要引入一个单独的服务进程,以Schedule形式执行。而对于推模型而言,消息通道事实上会作为消费者观察的主体,一旦发现消息进入,就会通知消费者执行对消息的处理。无论推模型,拉模型,对于消息对象而言,都可能采用类似Observer模式的机制,实现消费者对生产者的订阅,因此这种机制通常又被称为Publisher-Subscriber模式,如图4所示:
图4 Publisher-Subscriber模式(图片来自eaipatterns )
通常情况下,发布者和订阅者都会被注册到用于传播变更的基础设施(即消息通道)上。发布者会主动地了解消息通道,使其能够将消息发送到通道中;消息通道一旦接收到消息,会主动地调用注册在通道中的订阅者,进而完成对消息内容的消费。
对于订阅者而言,有两种处理消息的方式。一种是广播机制,这时消息通道中的消息在出列的同时,还需要复制消息对象,将消息传递给多个订阅者。例如,有多个子系统都需要获取从CRM系统传来的客户信息,并根据传递过来的客户信息,进行相应的处理。此时的消息通道又被称为Propagation通道。另一种方式则属于抢占机制,它遵循同步方式,在同一时间只能有一个订阅者能够处理该消息。实现Publisher-Subscriber模式的消息通道会选择当前空闲的唯一订阅者,并将消息出列,并传递给订阅者的消息处理方法。
目前,有许多消息中间件都能够很好地支持Publisher-Subscriber模式,例如JMS接口规约中对于Topic对象提供的MessagePublisher与MessageSubscriber接口。RabbitMQ也提供了自己对该模式的实现。微软的MSMQ虽然引入了事件机制,可以在队列收到消息时触发事件,通知订阅者。但它并非严格意义上的Publisher-Subscriber模式实现。由微软MVP Udi Dahan作为主要贡献者的NServiceBus,则对MSMQ以及WCF做了进一层包装,并能够很好地实现这一模式。
消息路由(Message Router)模式
无论是Message Channel模式,还是Publisher-Subscriber模式,队列在其中都扮演了举足轻重的角色。然而,在企业应用系统中,当系统变得越来越复杂时,对性能的要求也会越来越高,此时对于系统而言,可能就需要支持同时部署多个队列,并可能要求分布式部署不同的队列。这些队列可以根据定义接收不同的消息,例如订单处理的消息,日志信息,查询任务消息等。这时,对于消息的生产者和消费者而言,并不适宜承担决定消息传递路径的职责。事实上,根据S单一职责原则,这种职责分配也是不合理的,它既不利于业务逻辑的重用,也会造成生产者、消费者与消息队列之间的耦合,从而影响系统的扩展。
既然这三种对象(组件)都不宜承担这样的职责,就有必要引入一个新的对象专门负责传递路径选择的功能,这就是所谓的Message Router模式,如图5所示:
图5 Message Router模式(图片来自eaipatterns )
通过消息路由,我们可以配置路由规则指定消息传递的路径,以及指定具体的消费者消费对应的生产者。例如指定路由的关键字,并由它来绑定具体的队列与指定的生产者(或消费者)。路由的支持提供了消息传递与处理的灵活性,也有利于提高整个系统的消息处理能力。同时,路由对象有效地封装了寻找与匹配消息路径的逻辑,就好似一个调停者(Meditator),负责协调消息、队列与路径寻址之间关系。
除了以上的模式之外,Messaging模式提供了一个通信基础架构,使得我们可以将独立开发的服务整合到一个完整的系统中。 Message Translator模式则完成对消息的解析,使得不同的消息通道能够接收和识别不同格式的消息。而且通过引入这样的对象,也能够很好地避免出现盘根错节,彼此依赖的多个服务。Message Bus模式可以为企业提供一个面向服务的体系架构。它可以完成对消息的传递,对服务的适配与协调管理,并要求这些服务以统一的方式完成协作。

2、消息模式的应用场景

基于消息的分布式架构总是围绕着消息来做文章。例如可以将消息封装为对象,或者指定消息的规范例如SOAP,或者对实体对象的序列化与反序列化。这些方式的目的只有一个,就是将消息设计为生产者和消费者都能够明白的格式,并能通过消息通道进行传递。
场景一:基于消息的统一服务架构
在制造工业的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)
                                          ——……
各个消息对象之间的关系如图6所示:
图6 消息对象之间的关系
在实现服务进程通信之前,我们必须定义好各个服务或各个业务的消息格式。通过消息体的方法在服务的一端设置消息的值,然后发送,并在服务的另一端获得这些值。例如发送消息端定义如下的消息体:
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)将消息转换为领域实体,使得服务的开发者能够以完全面向对象的思想开发各个服务组件,并通过调用持久层实现消息数据的持久化。同时,利用消息总线(此时的消息总线可以看做是各个服务组件的连接器)连接不同的服务,并允许异步地传递消息,对消息进行编码。这样一个基于消息的分布式架构如图7所示:
图7 基于Message Bus的CIMS分布式架构
场景二:消息中间件的架构决策
在一个医疗卫生系统中,我们面临了客户对系统性能/可用性的非功能需求。在我们最初启动该项目时,客户就表达了对性能与可用性的特别关注。客户希望最终用户在进行复杂的替换删除操作时,能够具有很好的用户体验,简言之,就是希望能够快速地得到操作的响应。问题在于这样的替换删除操作需要处理比较复杂的业务逻辑,同时牵涉到的关联数据量非常大,整个操作若需完成,最坏情况下可能需要几分钟的时间。我们可以通过引入缓存、索引、分页等多种方式对数据库操作进行性能调优,但整个操作的耗时始终无法达到客户的要求。由于该系统是在一个遗留系统的基础上开发,如果要引入Map-Reduce来处理这些操作,以满足质量需求,则对架构的影响太大,且不能很好地重用之前系统的某些组件。显然,付出的成本与收益并不成正比。
通过对需求进行分析,我们注意到最终客户并不需要实时获得结果,只要能够保证最终结果的一致性和完整性即可。关键在于就用户体验而言,他们不希望经历漫长的等待,然后再通知他们操作究竟是成功还是失败。这是一个典型需要通过后台任务进行异步处理的场景。
在企业应用系统中,我们常常会遭遇这样的场景。我们曾经在一个金融系统中尝试通过自己编写任务的方式来控制后台线程的并发访问,并完成对任务的调度。事实证明,这样的设计并非行之有效。对于这种典型的异步处理来说,基于消息传递的架构模式才是解决这一问题的最佳办法。
因为消息中间件的逐步成熟,对于这一问题的架构设计,已经由原来对设计实现的关注转为如何进行产品选型和技术决策。例如,在.NET平台下,架构师需要重点考虑的是应该选择哪种消息中间件来处理此等问题?这就需要我们必须结合具体的业务场景,来识别这种异步处理方式的风险,然后再根据这些风险去比较各种技术,以求寻找到最适合的方案。
通过分析业务场景以及客户性质,我们发现该业务场景具有如下特征:
  • 在一些特定情形下,可能会集中发生批量的替换删除操作,使得操作的并发量达到高峰;例如FDA要求召回一些违规药品时,就需要删除药品库中该药品的信息;
  • 操作结果不要求实时性,但需要保证操作的可靠性,不能因为异常失败而导致某些操作无法进行;
  • 自动操作的过程是不可逆转的,因此需要记录操作历史;
  • 基于性能考虑,大多数操作需要调用数据库的存储过程;
  • 操作的数据需要具备一定的安全性,避免被非法用户对数据造成破坏;
  • 与操作相关的功能以组件形式封装,保证组件的可重用性、可扩展性与可测试性;
  • 数据量可能随着最终用户的增多而逐渐增大;
针对如上的业务需求,我们决定从以下几个方面对各种技术方案进行横向的比较与考量。
  • 并发:选择的消息队列一定要很好地支持用户访问的并发性;
  • 安全:消息队列是否提供了足够的安全机制;
  • 性能伸缩:不能让消息队列成为整个系统的单一性能瓶颈;
  • 部署:尽可能让消息队列的部署更为容易;
  • 灾备:不能因为意外的错误、故障或其他因素导致处理数据的丢失;
  • API易用性:处理消息的API必须足够简单、并能够很好地支持测试与扩展;
我们先后考察了MSMQ、Resque、ActiveMQ和RabbitMQ,通过查询相关资料,以及编写Spike代码验证相关质量,我们最终选择了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节点,承担起消息传递的职责。如图8所示:
图8 Active/Passive Cluster(图片来自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()方法,并将获得的消息(即JobId)传递给该方法。它会根据JobId到数据库中查询该Job对应的信息,并执行真正的业务处理。
在对基于消息处理的架构进行决策时,除了前面提到的考虑因素外,还需要就许多设计细节进行多方位的判断与权衡。例如针对Job的执行以及队列的管理,就需要考虑如下因素:
  • 对Queue中Job状态的监控与查询;
  • 对Job优先级的管理;
  • 能否取消或终止执行时间过长的Job;
  • 是否能够设定Job的执行时间;
  • 是否能够设定Poll的间隔时间;
  • 能否跨机器分布式的放入Job;
  • 对失败Job的处理;
  • 能否支持多个队列,命名队列;
  • 能否允许执行Job的工作进程对应特定的队列;
  • 对Dead Message的支持。

3、选择的时机

究竟在什么时候,我们应该选择基于消息处理的分布式架构?根据我参与的多个企业应用系统的经验,窃以为需要满足如下几个条件:
  • 对操作的实时性要求不高,而需要执行的任务极为耗时;
  • 存在企业内部的异构系统间的整合;
  • 服务器资源需要合理分配与利用;
对于第一种情况,我们常常会选择消息队列来处理执行时间较长的任务。此时引入的消息队列就成了消息处理的缓冲区。消息队列引入的异步通信机制,使得发送方和接收方都不用等待对方返回成功消息,就可以继续执行下面的代码,从而提高了数据处理的能力。尤其是当访问量和数据流量较大的情况下,就可以结合消息队列与后台任务,通过避开高峰期对大数据进行处理,就可以有效降低数据库处理数据的负荷。前面提到的医疗卫生系统正是这样一种适用场景。
对于不同系统乃至于异构系统的整合,恰恰是消息模式善于处理的场景。只要规定了消息的格式与传递方式,就可以有效地实现不同系统之间的通信。在为某汽车制造商开发一个大型系统时,分销商作为.NET客户端,需要将数据传递到管理中心。这些数据将被Oracle的EBS(E-Business Suite)使用。分销商管理系统(Dealer Management System,DMS)采用了C/S结构,数据库为SQL Server,汽车制造商管理中心的EBS数据库为Oracle 10g。我们需要解决两种不同数据库间数据的传递。解决方案就是利用MSMQ,将数据转换为与数据库无关的消息数据,并在两端部署MSMQ服务器,建立消息队列以便于存储消息数据。实现架构如图9所示。
图10 利用MSMQ实现的分布式处理架构
首先,分销商的数据通过MSMQ传递到MSMQ Server,再将数据插入到SQL Server数据库的同时,利用FTP将数据传送到专门的文件服务器上。EBS App Server会将文件服务器中的文件,基于接口规范写入到Oracle数据库,从而实现.NET系统与Oracle系统之间的整合。
分布式系统通常能够缓解单个服务器的压力,通过将不同的业务操作与数据处理以不同的服务形式部署并运行在不同的服务器上,就可以有效地分配与利用服务器资源。在这种情况下,部署在不同服务器上的服务,既可能作为服务端,用以处理客户端调用的请求,也可能作为客户端,在处理完自己的业务后,将其余业务请求委派给其他服务。在早期的CORBA系统中,通过建立统一的Naming Service,用以管理和分派服务,并通过Event Service实现事件的分发与处理。但CORBA系统采用的是RPC的方式,需要将服务设计和部署为远程对象,并建立代理。如果通过消息通道的方式,则既可以解除这种对远程对象的依赖,又可以很好地支持异步调用模型。在前面提到的CIMS系统,就是通过消息总线提供消息传递的基础设施,并建立统一的消息处理服务模型,解除服务见的依赖,使得各个服务能够独立地部署到不同服务器上。

4、面临的困难

由于消息模式自身的特殊性,我们在运用消息模式建立基于消息的分布式架构时,常常会面临许多困难。
首先是系统集成的问题。由于系统之间的通信靠消息进行传递,就必须保证消息的一致性,同时,还需要维护系统之间(主要是服务之间)接口的稳定性。一旦接口发生变化,就可能影响到该接口的所有调用者。即使服务通过接口进行了抽象,由于消息持有双方服务规定的业务数据,在一定程度上违背了封装的要义。换言之,生产与消费消息的双方都紧耦合于消息。消息的变化会直接影响到各个服务接口的实现类。然而,为了尽可能保证接口的抽象性,我们所要处理的消息都不是强类型的,这就使得我们在编译期间很难发现因为消息内容发生变更产生的错误。在我之前提到的汽车零售商管理系统就存在这样的问题。当时我负责的CRM模块需要同时与多个子系统进行通信,而每个子系统又是由不同的团队进行开发。团队之间因为沟通原因,常常未能及时地同步接口表。虽然各个子系统的单元测试和功能测试都已通过,但直到对CRM进行集成测试,才发现存在大量消息不匹配的集成问题,这些问题的起因都是因为消息的变更。
解决的方案是引入充分的集成测试,甚至是回归测试,并需要及时运行这些测试,以快速地获得反馈。我们可以将集成测试作为提交代码的验证们,要求每次提交代码都必须运行集成测试与指定的回归测试 。这正是持续集成的体现。通过在本地构建与远程构建运行集成测试与回归测试,有效地保证本地版本与集成后的版本不会因为消息的改变使得功能遭受破坏。一旦遭受破坏,也能够及时获得反馈,发现问题,即刻解决这些问题,而不是等到项目后期集中进行集成测试。
另一个问题是后台任务的非实时性带来的测试困难。由于后台任务是定期对消息队列中的消息进行处理,因而触发的时机是不可预测的 。对于这种情况,我们通常会同时运用两种方案,双管其下地解决问题。首先,我们会为系统引入一个同步实现功能的版本,并通过在配置文件中引入toggle的开关机制,随时可以在同步功能与异步功能之间进行切换。如果我们能够保证消息队列处理与后台任务执行的正确性,就可以设置为同步功能,这样就能快速而准确地对该任务所代表的功能进行测试,并及时收获反馈。同时,我们可以在持续集成服务器上建立一个专门的管道(pipeline),用以运行基于消息处理的异步版本。这个管道对应的任务可以通过手动执行,也可以对管道设置定时器,在指定时间执行(例如在凌晨两点执行一次,这样在第二天开始工作之前可以获得反馈)。我们需要为该管道准备特定的执行环境,并将后台任务的侦听与执行时间修改为可以接受的值。这样既能够及时了解功能是否正确,又能保证基于消息的系统是工作正常的。
当然,分布式系统还存在解析消息、网络传递的性能损耗。对于这些问题,需要架构师审慎地分析业务场景,正确地选择架构方案与架构模式。相比较本地系统而言,分布式系统的维护难度可能成倍递增。这既需要我们在进行架构决策与设计时,充分考虑系统架构的稳定性,同时还需要引入系统日志处理。更好的做法是为日志处理增加错误通知的功能,只要发生消息处理的错误信息,就通过邮件、短信等方式通知系统管理员,及时地处理错误。因为只有在发生错误的当时查询错误日志,才能够更好对问题进行定位。同时,还可以为系统引入Error Message Queue以及Dead Message Queue,以便于处理错误和异常情况。
对于分布式系统而言,还需要考虑服务执行结果的一致性,尤其是当某个业务需要多个服务参与到一个会话中时,一旦某个服务发生故障,就可能导致应用出现状态不一致的情况,因为只有所有参与者都成功执行了任务,才能视为完全成功。这就牵涉到分布式事务的问题,此时任务的执行就变成了事务型的:即任务必须是原子的,结果状态必须保持一致。在任务处理过程中,状态修改是彼此隔离的,成功的状态修改在整个事务执行过程中是持久的。这就是事务的ACID(Atomic,Consistent,Isolated与Durable)属性。
一种方案是引入分布式事务协调器,即DTC(Distributed Transaction Coordinator),将事务分为两段式甚至三段式提交,要求整个事务的所有参与者以投票形式决定事务是完全成功还是失败。另一种方案是降低对结果一致性的要求。根据eBay的最佳实践,考虑到分布式事务的成本,获得分布式资源即时的一致性是不必要的,也是不现实的。在Randy Shoup的文章《可伸缩性最佳实践:来自eBay的经验》中提到了Eric Brewer的CAP公理:分布式系统的三项重要指标——一致性(Consistency)、可用性(Availability)和 分区耐受性(Partition-tolerance)——在任意时刻,只有两项能同时成立。我们应该根据不同的应用场景,权衡这三个要素。在不必要保证即时的一致性前提下,我们可以考虑合理地划分服务,尽量将可能作用在同一个事务范围的业务操作部署在同一个进程中,以避免分布式部署。如果确实需要多个分布式服务之间保持执行结果的一致,可以考虑引入数据核对,异步恢复事件或集中决算等手段。

感谢马国耀对本文的审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。

大流量网站的底层系统架构

动态应用,是相对于网站静态内容而言, 是指以c/c++、php、Java、perl、.net等 服务器端语言开发的网络应用软件,比如论坛、网络相册、交友、BLOG等常见应用。动态应用系统通 常与数据库系统、缓存系统、分布式存储系统等密不可分。
  大型动态应用系统平台主要是针对于大流 量、高并发网站建立的底层系统架构。大型网站的运行需要一个可靠、安全、可扩展、易维护的应用系统平台做为支撑,以保证网站应用的平稳运行。
  大型动态应用系统又可分为几个子系统:
  l Web前 端系统
  l 负 载均衡系统
  l 数 据库集群系统
  l 缓 存系统
  l 分 布式存储系统
  l 分 布式服务器管理系统
  l 代 码分发系统
  Web前端系统
  结构图:
  为了达到不同应用的服务器共享、避免单点故障、集中管理、统一配置等目的,不以应用划分服 务器,而是将所有服务器做统一使用,每台服务器都可以对多个应用提供服务,当某些应用访问量升高时,通过增加服务器节点达到整个服务器集群的性能提高,同 时使他应用也会受益。该Web前端系统基于Apache/Lighttpd/Eginx等 的虚拟主机平台,提供PHP程序运行环境。服务器对开发人员是透明的,不需要开发人员介入服务器管理
  负载均衡系统
  负载均衡系统分为硬件和软件两种。硬件负载均衡效率高,但是价格贵,比如F5等。软件负载均衡系统价格较低或者免费,效率较硬件负载均衡系统 低,不过对于流量一般或稍大些网站来讲也足够使用,比如lvs,nginx。大多数网站都是硬件、软件负载均衡系统并用。
  数据库集群系统
  结构图:
  由于Web前端采用了负载均衡集群结构提高了服务的有效性和扩展性,因此数据库必须也是高可靠的才能保证整个服务体系的高可靠性,如何构建一个高可靠的、可以提供大规模并发处理的数据库体系?
  我们可以采用如上图所示的方案:
  1) 使用 MySQL 数据库,考虑到Web应用的数据库读多写少的特点,我们主要对读数据库做了优化,提供专用的读数据库和写数据库,在应用程序中实现读操作和写操作分别访问不同的数据库。
  2) 使用 MySQL Replication 机制实现快速将主库(写库)的数据库复制到从库(读库)。一个主库对应多个从库,主库数据实时同步到从库。
  3) 写数据库有多台,每台都可以提供多个应用共同使用,这样可以解决写库的性能瓶颈问题和单点故障问题。
  4) 读数据库有多台,通过负载均衡设备实现负载均衡,从而达到读数据库的高性能、高可靠和高可扩展性。
  5) 数据库服务器和应用服务器分离。
  6) 从数据库使用BigIP做负载均衡。
  缓存系统
  缓存分为文件缓存、内存缓存、数据库缓存。在大型Web应用中使用最多且效率最高的是内存缓存。最常用的内存缓存工具是Memcachd。使用正确的缓存系统可以达到实现以下目标:
  1、 使用缓存系统可以提高访问效率,提高服务器吞吐能力,改善用户体验。
  2、 减轻对数据库及存储集服务器的访问压力
  3、Memcached服务器有多台,避免单点故障,提供高可靠性和可扩展性,提高性能。
  分布式存储系统
  结构图:
  WEB系统平台中的存储需求有下面两个特点:
  1) 存储量很大,经常会达到单台服务器无法提供的规模,比如相册、视频等应用。因此需要专业的大规模存储系统。
  2) 负载均衡cluster中的每个节点都有可能访问任何一个数据对象,每个节点对数据的处理也能被其他节点共享,因此这些节点要操作的数据从逻辑上看只能是一个整体,不是各自独立的数据资源。
  因此高性能的分布式存储系统对于大型网站应用来说是非常重要的一环。(这个地方需要加入对某个分布式存储系统的简单介绍。)
  分布式服务器管理系统
  结构图:
  随着网站访问流量的不断增加,大多的网络服务都是以负载均衡集群的方式对外提供服务,随之集群规模的扩大,原来基于单机的服务器管理模式已经不能够满足我们的需求,新的需求必须能够集中式的、分组的、批量的、自动化的对服务器进行管理,能够批量化的执行计划任务。
  在分布式服务器管理系统软件中有一些比较优秀的软件,其中比较理想的一个是 Cfengine。它可以对服务器进行分组,不同的分组可以分别定制系统配置文件、计划任务等配置。它是基于C/S 结构的,所有的服务器配置和管理脚本程序都保存在Cfengine Server上,而被管理的服务器运行着 Cfengine Client 程序,Cfengine Client通过SSL加密的连接定期的向服务器端发送请求以获取最新的配置文件和管理命令、脚本程序、补丁安装等任务。
  有了Cfengine 这种集中式的服务器管理工具,我们就可以高效的实现大规模的服务器集群管理,被管理服务器和 Cfengine Server 可以分布在任何位置,只要网络可以连通就能实现快速自动化的管理。
  代码发布系统
  结构图: 
  随着网站访问流量的不断增加,大多的网络服务都是以负载均衡集群的方式对外提供服务,随之集群规模的扩大,为了满足集群环境下程序代码的批量分发和更新,我们还需要一个程序代码发布系统。
  这个发布系统可以帮我们实现下面的目标:
  1) 生产环境的服务器以虚拟主机方式提供服务,不需要开发人员介入维护和直接操作,提供发布系统可以实现不需要登陆服务器就能把程序分发到目标服务器。
  2) 我们要实现内部开发、内部测试、生产环境测试、生产环境发布的4个开发阶段的管理,发布系统可以介入各个阶段的代码发布。
  3) 我们需要实现源代码管理和版本控制,SVN可以实现该需求。
  这里面可以使用常用的工具Rsync,通过开发相应的脚本工具实现服务器集群间代码同步分发。

构建高可扩Web架构和分布式系统实战(下)

构建快速可伸缩的数据访问块
在讨论完设计分布式系统的核心考虑因素后,下面让我们再一起讨论难点部分:可扩展的数据访问。
大多数简单的Web应用程序,例如LAMP堆栈应用程序,看起来如图5所示:
图5:简单的Web应用程序
随着系统渐渐扩大,他们将面临两大主要挑战:构建可扩展的应用程序服务器和数据访问机制。在一 个高可扩的应用程序设计中,通常最小化的应用程序(或Web)服务往往能体现一种无共享的架构。这就使得应用程序服务器层要进行横向扩展,这种设计的结果 就是把繁重的工作转移到堆栈下层的数据库服务和配置服务上,这才是这一层上真正的可扩展和性能挑战。
本文的剩余部分专门讨论一些常见策略和方法来使这些服务可以快速和可扩展,提升数据的访问速度。
图6 过于简化的的Web应用程序
大多数系统可能会简化成图6那样,这是个非常不错的起点。如果你有大量的数据,想要快速便捷地访问,就好比在你书桌抽屉的最上面有一堆糖果。虽然过于简化,但也暗示了两个难题:可扩展存储和快速的数据访问。
为了这个例子,让我们假设有许多太字节(TB)数据,并且允许用户随机访问一小部分数据(见图7)。这与本文图片应用程序里的在文件服务器上定位一个图片文件非常相似。
图7 访问特定数据
这也是个非常大的挑战,把TB级数据加载到内存中的成本比较昂贵,这可以直接转化到磁盘上进行 IO。从磁盘上读取要比内存慢的多——内存访问和Chuck Norris一样快,而磁盘的访问速度要比在DMV上慢。这个速度不同于大数据集上的合计,实数内存访问大概要比顺序访问快6倍,或者比随机从磁盘上读取 快10万倍(参考The Pathologies of Big Data)。此外,即使是unique ID,想要在较少的数据中查找确切的位置也是一项艰巨的任务。
幸运的是,能找到许多方法让这个问题变的简单,这里提供4个非常重要的方法:缓存、代理、索引和负载均衡器。在下面将会详细讨论这4个内容来提升数据访问速度。
缓存
缓存就是利用本地参考原则:当CPU要读取一个数据时,首先从缓存中查找,找到就立即读取并送给CPU处理;没有找到,就用相对慢的速率从内存中读 取并送给CPU处理,同时把这个数据所在的数据块调入缓存中,可以使得以后对整块数据的读取都从缓存中进行,不必再调用内存。它们几乎被用在每一个计算层 上:硬件、操作系统、Web浏览器、Web应用程序等。一个缓存就相当于是一个临时内存:它有一个有限的空间量,但访问它比访问原始数据速度要快。缓存也 可以存在于各个层的架构中,但经常在离前端最近的那个层上发现,在那里可以快速实现并返回数据,无需占用下游层数据。
那么如何在我们的API例子里利用缓存使数据访问更快呢?在这种情况下,有许多地方可以插入缓存。一种是在请求层节点上插入缓存,如图8所示。
图8 在请求层节点插入缓存
在请求层节点上放置一个缓存,即可响应本地的存储数据。当对服务器发送一个请求时,如果本地存 在所请求数据,那么该节点即会快速返回本地缓存数据。如果本地不存在,那么请求节点将会查询磁盘上的数据。请求层节点缓存即可以存在于内存中(这个非常快 速)也可以位于该节点的本地磁盘上(比访问网络存储要快)。

图9 多个缓存
当扩展到许多节点的时候,会发生什么呢?如图9所示,如果请求层被扩展为多个节点,它仍然有可 能访问每个节点所在的主机缓存。然而,如果你的负载均衡器随机分布节点之间的请求,那么请求将会访问各个不同的节点,因此缓存遗漏将会增加。这里有两种方 法可以克服这个问题:全局缓存和分布式缓存。
全局缓存
顾名思义,全局缓存是指所有节点都使用同一个缓存空间。这包含添加一台服务器或某种类型的文件存储,所有请求层节点访问该存储要比原始存储快。每个 请求节点会以同种方式查询缓存,这种缓存方案可能有点复杂,随着客户机和请求数量的增加,单个缓存(Cache)很容易溢出,但在某些结构中却是非常有效 的(特别是那些特定的硬件,专门用来提升全局缓存速度,或者是需要被缓存的特定数据集)。
在图10中描述了全局缓存常见的两种方式。当一个Cache响应在高速缓存中没有发现时,Cache自己会从底层存储中检索缺少的那块数据。如图11所示,请求节点去检索那些在高速缓存中没有发现的数据。
图10 负责检索数据的全局缓存
图11 全局缓存里负责检索的请求节点
大多使用全局缓存的应用程序都倾向于使用第一种类型,利用Cache本身来驱逐和获取数据以防 止来自客户端的同一个数据区发出大量的请求。然而,在某些情况下,使用第二种实现反而更有意义。例如,如果该缓存用于存储大量的文件,低缓存的命中率会导 致高速缓冲区不堪重负和缓存遗漏,在这种情况下, it helps to have a large percentage of the total data set (or hot data set) in the cache.
分布式缓存
分布式缓存即缓存在分布式系统各节点内存中的缓存数据。如图12所示,每个节点都有自己的缓存数据,所以如果冰箱扮演着缓存杂货店的角色,那么分布 式缓存就是把食物放置在不同的地方——冰箱、橱柜和饭盒——当索取的时候,方便拿哪个就拿哪个,而无需特地往商店跑一趟。通常情况下,会使用一致性哈希函 数对缓存进行划分,例如,一个请求节点正在寻找一个特定块的数据,在分布式缓存中,它很快就会知道去哪里找,并确保这些数据是可用的。这种情况下,每个节 点都会有一小块缓存,然后在向另一个节点发送数据请求。因此分布式缓存的优点之一就是只需向请求池添加节点即可增加缓存空间,减少对数据库的访问负载量。
当然,分布式缓存也存在缺点,例如单点实效,当该节点出现故障或宕机,那么该节点保存的数据就会丢失。
图12 分布式缓存
分布式缓存的突出优点是提高运行速度(前提当然是正确实现)。选择不同的方法也会有不一样的效果,如果方法正确,即使请求数很多,也不会对速度有所影响。然而,缓存的维护需要额外的存储空间,这些通常需要购买存储器实现,但价格都很昂贵。
其中一个非常流行的开源缓存产品:Memcached(即可以在本地缓存上工作也可以在分布式缓存上工作);然而,这里还有许多其他选项(包括许多语言——或者是框架——特定选项)。
Memcached用于许多大型Web站点,其非常强大。Memcached基于一个存储键/值对的hashmap,优化数据存储和实现快速搜索(O(1))。
Facebook采用不同类型的缓存技术来提升他们的网站性能(参考“Facebook caching and performance”)。在语言层面上使用$GLOBALS和APC(在PHP里提供函数调用),这有助于使中间函数调用更快(大多数语言都使用这些类型库来提升网站页面性能)。Facebook使用全局缓存并且通过多台服务器对缓存进行分布(参考“Scaling memcached at Facebook”),这就允许他们通过配置用户文件数据来获得更好的性能和吞吐量,并且还可以有一个中心位置更新数据(这是非常重要的,当运行成千上万台服务器时,缓存实效和一致性维护都是非常大的挑战)。
下面让我们谈谈当数据不在缓存中时,我们该做什么……
代理
简单点讲,代理服务器就是硬件/软件的中间件,接受客户端请求并且将他们转发到后端的源服务器上。通常,代理服务器用于过滤请求、记录请求日志或有时转换请求(通过添加/删除头结点、加密/解密或压缩)。
图13 代理服务器
代理可以优化请求,并且从整个系统的角度来优化请求通信量。一方面,使用代理可以加速数据访问,可以把相同(或相似的)请求重叠压缩成一个请求,然后返回单个结果到请求客户端,这就是压缩转发(collapsed forwarding)。
在一个局域网代理中,例如,客户端无需使用它们自己的IP去连接互联网,对于相同的内容,局域网将压缩来自客户端的请求。它很容易造成混淆,因为很多代理同样也是缓存(它是一个非常合乎逻辑放置缓存的地方),但并非所有缓存都扮演代理这一角色。
图14 使用一个代理服务器压缩请求
使用代理服务器的另一伟大方式是通过压缩请求对空间数据进行加密。采用这种策略最大化数据本地 化的请求会导致减少请求的延迟。例如这里有一大串的节点请求B:partB1、partB2等等。我们可以设置代理来识别个人空间的位置请求,把它们压缩 成单一的请求并只返回bigB,大大减少了从数据源处读取数据次数(如图15所示)。在高负载的情况下,代理也特别有用,或者当你具有有限的缓存时,它们 基本上可以把多个请求批处理成一个。
图15 使用代理对空间数据请求进行压缩
如果你正在为系统寻找代理,这里提供几个供你选择:SquidVarnish,它们都做过非常全面的测试并且被广泛用在许多大型网站上。这些代理解决方案对客户端——服务器端通信提供了许多优化方案。在Web服务器层作为反向代理安装可以大大提高Web服务性能,减少处理传入客户机请求所需的工作量。
索引
使用索引来快速访问和优化数据是一个众所周知的策略,最有名的莫过于数据库索引。
图16 索引
一个索引就是数据库表的目录,表中数据和相应的存储位置的列表。好比是一篇文章的目录,可以加 快数据表的。例如让我们来查找一块数据,B中的第二部分——如何发现它的位置?如果你通过数据类型存储了一个索引——例如数据A、B、C——它将告诉你数 据B的原始位置。然后你只需去查看B并且根据需要阅读B的数据即可(参考图16)。
这些索引通常存储在内存或者是传入客户端请求的本地中。Berkeley DBs(BDBs)和树数据结构常常被用在有序列表中存储数据,这是访问索引的理想选择。
通常,会把许多层索引作为一个映射,从一个位置移到下一个,以此类推,直到你得到想要的特定块数据(参照图17)。
图17 多层索引
索引也可以对相同的数据创建多个不同的视图。对大型数据集来说,这种方法是非常好的,无需创建多个额外的数据副本就可以定义不同的过滤和排序,
例如,早期的图像托管系统实际上是托管图像书本内容,允许客户端查询这些图像中的内容,输入一个主题,就可以把所有相关的内容搜索出来。此外,采用 同样的方式,搜索引擎还允许你搜索出HTML内容。在这种情况下,需要很多的服务器来存储这些文件,查找其中一个页面可能会很麻烦。首先,反向索引查询任 意个单词或字元祖都需要可以轻松地访问;再有就是导航到正确的页面和位置,检索到正确的图像结果也是项挑战。因此,在这种情况下,反向索引会映射到一个位 置(例如书B),然后书B可能会有一个包含所有内容、位置和各个部分出现次数的索引。
这种中间级索引只包含了Words、位置和书B的信息。与所有的信息不得不存储到一个大的反向索引中相比,这种嵌套的索引架构允许每个索引占用较少的空间。在大型系统中,这是非常关键的,即使采用压缩,这些索引也需要占用相当昂贵的存储空间。
例如,让我们假设这个世界上有——100,000,000本书(参考Inside Google Books官 方博客)——每本书只有10页,每页只有250个单词,这也就意味着有2500亿个单词。如果每个单词只有5个字节,每个字节占用8 bits(或1个byte,甚至有些字符占用2 bytes),所以5 bytes/单词,那么一个索引所包含的单词就有可能超过一个TB的存储。此外,索引还有可能包含其他信息,例如元祖单词、数据位置等。
能够快速、轻松地找到数据是非常重要的,而使用索引就可以简单高效的实现。
负载均衡器
分布式系统的另一个关键部分是负载均衡。负载均衡器几乎是每个架构的主要组成部分,他们的角色是负责把网络请求分散到一个服务器集群中的可用服务器上去,通过管理进入的Web数据流量和增加有效的网络带宽,从而使网络访问者获得尽可能最佳的联网体验的硬件设备。
图18 负载均衡器
这里有许多种算法可用于为请求提供服务,包括随机选择一个节点、循环或者甚至是基于某个特定的标准来选择节点,例如内存或CPU利用率。负载均衡器即可以以硬件的方式表现出来,也可以以软件的方式。HAProxy是一个开源的负载均衡器,并且得到了非常广泛的使用。
在一个分布式系统中,负载均衡器通常处于系统的前端位置,所有传入的请求会相应地被路由。在一个复杂的分布式系统中,一个请求被路由到多个负载均衡器上并不常见,如图19所示:
图19 多个负载平衡器
和代理一样,有些负载均衡器也可以基于请求的类型路由到不同的服务器集群上。(技术上来讲,这也被称为反向代理。)
负载均衡器所面临的挑战之一是管理用户特有的会话(user-session-specific)数据。在一个电子商务网站上,当你只有一个客户端 时,是很容易让用户把商品放入购物车并且继续访问(这是非常重要的,因为商品很有可能在继续出售,而用户退出时,商品仍然留在购物车里)。然而,如果用户 本次会话路由了一个节点,那么当他下次访问的时候会路由一个不同的节点,这样,就很有可能使购物车里的商品不一致,因为新的节点有可能会丢失该用户购物车 里原先的商品(当你先放6包Mountain Dew 在购物车里,等到再次登录后发现购物车为空了)。解决这个问题的方法之一是使用sticky sessions,来使用户一直被路由到相同的节点,但它很难利用到可靠性功能,像自动故障转移(automatic failover)。这种情况下,用户的购物车里将会一直有商品,但如果sticky node变的不可用,这就需要特殊情况来处理并且假设购物车里的商品将不再有效(尽管希望这种假设不会被内置于应用程序里)。当然解决这个问题还有许多其 他方法,例如本文提到的服务以及不包括(浏览器缓存、cookies和URL重写)。
在一个大型系统里会有各种不同类型的调度和负载均衡算法,包括简单点的像随机选择或循环以及更复杂的机制,例如利用率和容量。所有的这些算法都可以 分布流量和请求,并且提供有用的可靠性工具,像自动故障转移或者自动清除一个坏的节点(例如当它无法响应时)。然而,这种高级功能会把问题诊断的复杂。 例如,当遇到高负载情况时,负载均衡器将会移除变慢或超时的节点(因为请求太多,删除节点后会把请求分配到其他节点上),这无疑会加剧其他节点的工作量, 即负载加重。这种情况下,大量的监测变的非常重要,因为整个系统流量和吞吐量看起来可能会减少(因为节点服务更少的请求),但可能会累坏个别节点(处理更 多的请求)。
负载均衡器也是扩展系统容量的一种简单方式,像文中提到的其他技术,在分布式系统架构中发挥着非常重要的作用。负载均衡器也提供一些重要功能来测试节点的健康状况,例如,如果该节点响应迟钝或过载,它可能就会被删除,然后利用系统中不同的节点冗余。
队列
到目前为止,我们已经讨论了许多方法来加快数据读取速度,但扩展数据层的另一个重要组成部分是如何高效的写入数据。在简单的系统中,进程负载等都比 较少,并且数据库比较小,毋庸置疑,写的速度肯定不会慢。然而,在大型复杂的系统里,这个速度就很难把握了,可能会花费很长的时间。例如,数据有可能要写 到几个不同的地方,不同的服务器或索引、或者系统正处于高负载情况下。在这种情况,该在哪里进行写?或者其他任何任务都有可能花费很长时间,要想在系统实 现性能和可用性需要构建异步。处理这种异步的一种常见的方式就是采用队列。
图20 同步请求
想象在一个系统里,每个客户机都要把请求发送至远程服务器,那么服务器应该尽可能快的接收并完 成任务,然后把结果返回到相应的客户端。在小型系统中,一台服务器(或逻辑服务器)传入客户端数据会与客户端发出时一样快,这样就比较完美了。然而,当服 务器接收到的请求多余它的处理能力时,那么每个客户端必须排队等待服务器处理其他客户端请求,直到轮到你了,服务器才会处理你的请求,直到最终完成。这就 是一个同步请求的例子,如图20所示。
这种同步行为会严重降低客户端性能,客户端被迫等待,而通过添加额外的服务器来满足负载并不能解决问题,即使采用最有效的负载均衡也很难保证分配公 平,在大客户端下。进一步讲,如果处理请求的服务器不可用或者瘫痪,那么客户端上游也将失败。有效的解决这个问题需要抽象客户端请求以及服务请求的实际工 作。
图21 使用队列来管理请求
队列就像听起来那样简单,一个任务进来,就添加到队列里去,然后the workers挑选有能力处理的下一个任务。(参考图21)这些任务有可能仅是简单的写入,也有可能是复杂的,如把文档生成图像预览。当一个客户端把任务 请求提交的队列中时,他们不需要被迫等待结果,相反,他们只需确认请求是否被正确接收。
队列使客户端能够以异步的方式工作,对客户端请求和响应提供战略抽象。另一方面,在一个同步系统中,请求和回应是没有分化的,因此他们不能被分开管 理。在异步系统中,客户端发出请求任务,服务器对收到的消息进行响应并确认任务被接收,然后客户端可以定期检查任务状态,一旦任务完成,即可看到结果。当 客户端在等待异步请求是否完成时,它还可以自由执行其他任务,甚至是向其他服务器发出异步请求。下面要介绍的是消息和队列在分布式系统中的杠杆作用。
队列也对服务中断或失败提供一种保护机制。例如,它很容易创建一个高度健壮的队列,当服务器瞬间失败时,该队列可以把刚刚失败的请求重新发送至服务器。相比直接暴露客户端来间断服务供应,使用队列来保证服务质量更可取,要求必须有复杂且矛盾性的客户端差错处理。
队列是管理分布式通信与任何大规模分布式系统中各个部分之间的基础,并且有许多实现方式。这里有许多开源的队列,如RabbitMQActiveMQBeanstalkD,但也有一些当做服务使用,如Zookeeper,甚至是用来数据存储,像Redis
总结
设计出一个高效的分布式系统是令人兴奋的事情,尤其是拥有快速的数据访问速度。本文只是讨论了几个实际的例子,希望对你能有所帮助。

构建高可扩Web架构和分布式系统实战(上)

本文作者Kate Matsudaira是一位美丽的女工程副总裁,曾在Sun Microsystems、微软、亚马逊这些一流的IT公司任职。她有着非常丰富的工作经验和团队管理经验,当过程序员、项目经理、产品经理以及人事经 理。专注于构建和操作大型Web应用程序/网站,目前她的主要研究方向是SaaS(软件即服务)应用程序和云计算(如大家所说的大数据)。
本文是作者在AOSA一书介绍如何构建可扩展的分布式系统里的内容,在此翻译并分享给大家。
开源软件已经成为许多大型网站的基本组成部分,随着这些网站的逐步壮大,他们的网站架构和一些指导原则也开放在开发者们的面前,给予大家切实有用的指导和帮助。
这篇文章主要侧重于Web系统,并且也适用于其他分布式系统。
Web分布式系统设计的原则
构建并运营一个可伸缩的Web站点或应用程序到底是指什么?在最初,仅是通过互联网连接用户和访问远程资源。
和大多数事情一样,当构建一个Web服务时,需要提前抽出时间进行规划。了解大型网站创建背后的注意事项以及学会权衡,会给你带来更加明智的决策。下面是设计大型Web系统时,需要注意的一些核心原则:
  • 可用性
  • 性能
  • 可靠性
  • 可扩展
  • 易管理
  • 成本
上面的这些原则给设计分布式Web架构提供了一定的基础和理论指导。然而,它们也可能彼此相左,例如实现这个目标的代价是牺牲成本。一个简单的例 子:选择地址容量,仅通过添加更多的服务器(可伸缩性),这个可能以易管理(你不得不操作额外的服务器)和成本作为代价(服务器价格)。
无论你想设计哪种类型的Web应用程序,这些原则都是非常重要的,甚至这些原则之间也会互相羁绊,做好它们之间的权衡也非常重要。
基础
当涉及到系统架构问题时,这几件事情是必须要考虑清楚的:什么样的模块比较合适?如何把它们组合在一起?如何进行恰当地权衡?在扩大投资之前,它通常需要的并不是一个精明的商业命题,然而,一些深谋远虑的设计可以帮你在未来节省大量的时间和资源。
本文讨论的重点几乎是构建所有大型Web应用程序的核心:服务、冗余、分区和故障处理能力。这里的每个因素都会涉及到选择和妥协,特别是前面所讨论的那些原则。解释这些核心的最佳办法就是举例子。
图片托管应用程序
有时,你会在线上传图片,而一些大型网站需要托管和传送大量的图片,这对于构建一个具有成本效益、高可用性并具有低延时(快速检索)的架构是一项挑战。
在一个图片系统中,用户可以上传图片到一个中央服务器里,通过网络连接或API对这些图片进行请求,就像Flickr或者Picasa。简单点,我 们就假设这个应用程序只包含两个核心部分:上传(写)图片和检索图片。图片上传时最好能够做到高效,传输速度也是我们最关心的,当有人向图片发出请求时 (例如是一个Web页面或其他应用程序)。这是非常相似的功能,提供Web服务或内容分发网络(一个CDN服务器可以在许多地方存储内容,所以无论是在地 理上还是物理上都更加接近用户,从而导致更快的性能)边缘服务器。
该系统需要考虑的其他重要方面:
  • 图片存储的数量是没有限制的,所以存储应具备可伸缩,另外图片计算也需要考虑
  • 下载/请求需要做到低延迟
  • 用户上传一张图片,那么图片就应该始终在那里(图片数据的可靠性)
  • 系统应该易于维护(易管理)
  • 由于图片托管不会有太高的利润空间,所以系统需要具备成本效益
图1是个简化的功能图
图1 图片托管系统的简化结构图
在这个例子中,系统必须具备快速、数据存储必须做到可靠和高度可扩展。构建一个小型的应用程序就微不足道了,一台服务器即可实现托管。如果这样,这篇文章就毫无兴趣和吸引力了。假设我们要做的应用程序会逐渐成长成Flickr那么大。
服务
当我们考虑构建可伸缩的系统时,它应有助于解耦功能,系统的每个部分都可以作为自己的服务并且 拥有清晰的接口定义。在实践中,这种系统设计被称作面向服务的体系结构(SOA)。对于此类系统,每个服务都有它自己的独特功能,通过一个抽象接口可以与 外面的任何内容进行互动,通常是面向公众的另一个服务API。
把系统分解成一组互补性的服务,在互相解耦这些操作块。这种抽象有助于在服务、基本环境和消费者服务之间建立非常清晰的关系。这种分解可以有效地隔离问题,每个块也可以互相伸缩。这种面向服务的系统设计与面向对象设计非常相似。
在我们的例子中,所有上传和检索请求都在同一台服务器上处理。然而,因为系统需要具备可伸缩性,所以把这两个功能打破并集成到自己的服务中是有意义的。
快进并假设服务正在大量使用;在这种情况下,很容易看到写图片的时间对读图片时间会产生多大影响(他们两个功能在彼此竞争共享资源)。根据各自体 系,这种影响会是巨大的。即使上传和下载速度相同(这是不可能的,对于大多数的IP网络来说,下载速度:上传速度至少是3:1),通常,文件可以从缓存中 读取,而写入,最终是写到磁盘中(也许在最终一致的情况下,可以被多写几次)。即使是从缓存或者磁盘(类似SSD)中读取,数据写入都会比读慢(Pole Position,一个开源DB基准的开源工具和结果)。
这种设计的另一个潜在问题是像Apache或者Lighttpd这些Web服务器通常都会有一个并发连接数上限(默认是500,但也可以更多),这 可能会花费高流量,写可能会迅速消掉所有。既然读可以异步或利用其他性能优化,比如gzip压缩或分块传输代码,Web服务可以快速切换读取和客户端来服 务于更多的请求,超过每秒的最大连接数(Apache的最大连接数设置为500,这种情况并不常见,每秒可以服务几千个读取请求)。另一方面,写通常倾向 于保持一个开放的链接进行持续上传,所以,使用家庭网络上传一个1 MB的文件花费的时间可能会超过1秒,所以,这样的服务器只能同时满足500个写请求。
图2:读取分离
规划这种瓶颈的一个非常好的做法是把读和写进行分离,如图2所示。这样我们就可以对它们单独进行扩展(一直以来读都比写多)但也有助于弄明白每个点的意思。这种分离更易于排除故障和解决规模方面问题,如慢读。
这种方法的优点就是我们能够彼此独立解决问题——在同种情况下,无需写入和检索操作。这两种服务仍然利用全球语料库的图像,但是他们可以自由地优化 性能和服务方法(例如排队请求或者缓存流行图片——下面会介绍更多)。从维护和成本角度来看,每一个服务都可以根据需要独立进行扩展,但如果把它们进行合 并或交织在一起,那么有可能无意中就会对另一个性能产生影响,如上面讨论的情景。
当然,如果你有两个不同的端点,上面的例子可能会运行的很好(事实上,这非常类似于几个云存储供应商之间的实现和内容分发网络)。虽然有很多种方法可以解决这些瓶颈,但每个人都会有不同的权衡,所以采用适合你的方法才是最重要的。
例如,Flickr解决这个读/写问题是通过分发用户跨越不同的碎片,每个碎片只能处理一组用户,但是随着用户数的增加,更多的碎片也会相应的添加到群集里(请参阅Flickr的扩展介绍)。 在第一个例子中,它更容易基于硬件的实际用量进行扩展(在整个系统中的读/写数量),而Flickr是基于其用户群进行扩展(but forces the assumption of equal usage across users so there can be extra capacity)。而前面的那个例子,任何一个中断或者问题都会降低整个系统功能(例如任何人都没办法执行写操作),而Flickr的一个中断只会影响 到其所在碎片的用户数。在第一个例子中,它更容易通过整个数据集进行操作——例如,更新写服务,包括新的元数据或者通过所有的图片元数据进行搜索——而 Flickr架构的每个碎片都需要被更新或搜索(或者需要创建一个搜索服务来收集元数据——事实上,他们就是这样做的)。
当谈到这些系统时,其实并没有非常正确的答案,但有助于我们回到文章开始处的原则上看问题。确定系统需求(大量的读或写或者两个都进行、级别并发、跨数据查询、范围、种类等等),选择不同的基准、理解系统是如何出错的并且对以后的故障发生情况做些扎实的计划。
冗余
为了可以正确处理错误,一个Web架构的服务和数据必须具备适当的冗余。例如,如果只有一个副本文件存储在这台单独的服务器上,那么如果这台服务器 出现问题或丢失,那么该文件也随即一起丢失。丢失数据并不是什么好事情,避免数据丢失的常用方法就是多创建几个文件或副本或冗余。
同样也适用于服务器。如果一个应用程序有个核心功能,应确保有多个副本或版本在同时运行,这样可以避免单节点失败。
在系统中创建冗余,当系统发生危机时,如果需要,可以消除单点故障并提供备份或备用功能。例如,这里有两个相同的服务示例在生产环境中运行,如果其中一个发生故障或者降低,那么该系统容错转移至那个健康的副本上。容错转移可以自动发生也可以手动干预。
服务冗余的另一重要组成部分是创建一个无共享架构。在这种体系结构中,每个节点都能相互独立运行,并且没有所谓的中央“大脑”管理状态或协调活动其 他节点。这对系统的可扩展帮助很大,因为新节点在没有特殊要求或知识的前提下被添加。然而,最重要的是,这些系统是没有单点故障的,所以失败的弹性就更 大。
例如在我们的图片服务器应用程序中,所有的图片在另一个硬件上都有冗余副本(理想情况下是在不同的地理位置,避免在数据中心发生一些火灾、地震等自 然事故),服务去访问图片将被冗余,所有潜在的服务请求。(参见图3:采用负载均衡是实现这点的最好方法,在下面还会介绍更多方法)
图3 图片托管应用程序冗余
分区
数据集有可能非常大,无法安装在一台服务器上。也有可能这样,某操作需要太多的计算资源、性能降低并且有必要增加容量。在这两种情况下,你有两种选择:纵向扩展或横向扩展。
纵向扩展意味着在单个服务器上添加更多的资源。所以,对于一个非常大的数据集来说,这可能意味着添加更多(或更大)的硬件设备,来使一台服务器能容 下整个数据集。在计算操作下,这可能意味着移动计算到一个更大的服务器上,拥有更快的CPU或更大的内存。在各种情况下,纵向扩展可以通过提升单个资源的 处理能力来完成。
横向扩展在另一方面是添加更多的节点,在大数据集下,这可能会使用第二服务器来存储部分数据集,对于计算资源来说,这意味着分割操作或跨节点加载。为了充分利用横向扩展,它应作为一种内在的系统架构设计原则,否则修改或拆分操作将会非常麻烦。
当谈到横向扩展时,最常见的做法是把服务进行分区或碎片。分区可以被派发,这样每个逻辑组的功能就是独立的。可以通过地理界限或其他标准,如非付费与付费用户来完成分区。这些方案的优点是他们会随着容量的增加提供一个服务或数据存储。
在我们的图片服务器案例中,用来存储图片的单个文件服务器可能被多个文件服务器取代,每个里面都会包含一套自己独特的图像。(见图4)这种架构将允 许系统来填充每一个文件/图片服务器,当磁盘填满时会添加额外的服务器。这样的设计需要一个命名方案,用来捆绑图片文件名到其相应的服务器上。图像名字可 以形成一个一致的哈希方案并映射到整个服务器上;或者给每张图片分配一个增量ID,当客户端对图片发出请求时,图片检索服务只需要检索映射到每个服务器上 (例如索引)的ID。
图4 图片托管应用程序冗余和分区
当然,跨越多个服务器对数据或功能进行分区还是有许多挑战的。其中的关键问题是数据本地化。在分布式系统中,数据操作或计算点越接近,系统性能就会 越好。因此,它也可能是个潜在问题,当数据分散在多个服务器上时。有时数据不是在本地,那么就要迫使服务器通过网络来获取所需的信息,这个获取的过程就会 设计到成本。
另一潜在问题是不一致。当这里有多个服务对一个共享资源执行读写操作时,潜在可能会有另一个服务器或数据存储参与进来,作为竞选条件——一些数据需 要更新,但是读的优先级高于更新——在这种情况下,数据就是不一致的。例如在图片托管方案中,有可能出现的不一致是:如果一个客户端发送更新“狗”图片请 求,进行重新命名,把“Dog”改成“Gizmo”,但同时,另一个客户端正在读这张图片。在这种情况下,标题就是不清楚的。“Dog”或“Gizmo” 应该被第二个客户端接收。
当然,在进行数据分区时会产生一些障碍,但是分区允许把每个问题拆分到管理群里——通过数据、负载、使用模式等。这样对可扩展和易管理都是有帮助 的,但也不是没有风险的。这里有很多方式来降低风险和故障处理;然而,为了简便起见,并未在本文中详细说明,如果你有兴趣,可以访问我的博客
总结
以上介绍的都是设计分布式系统需要考虑的核心要素。可用性、性能、可靠性、可扩展、易管理、成本这几个原则非常重要,但在实际应用中可能会以牺牲某个原则来实现另外一个原则,在这个过程中就要做好权衡工作,做到因时制宜。
在下面的构建分布式系统实战中,我们将会深入介绍如何设计可扩展的数据访问,包括负载均衡、代理、全局缓存、分布式缓存等。