Microsevices陷阱: 层级演进(上)
通过过去的数篇文章,我们分别介绍了微服务架构从设计、开发到实施等各个方面,并以业界的前沿应用给出了一定实践案例。然而世界总不是你我想象中的美好,面临千奇百怪的业务需求,随着架构的不断变化,我们总会遇到各式各样的冲突和风险。因此,微服务架构并不适用于一拥而上——循序渐进才是可靠途径。
1. 从可用性(Availability)说起
分布式系统领域存在一个经典的谬误理论:Fallacies of distributed computing,基本总结了不可靠通信给系统架构带来的影响。由于网络通信在分布式系统中的极端重要性,使得网络可靠性成为此类系统永恒的梦魇。任何网络的不可靠都会引发各种灾难,且难以被察觉、定位和修复。即使有能力购买先进的硬件设施,以及最可靠的第三方系统,也不能消除出错的可能。
实际上,问题在于设置一个容错率。因为对跨功能的需求是永不枯竭的,根据业务特点设置容错率,然后按需堆叠架构,才是一个看起来合理的策略。在测试一文中我们已经提到跨功能需求,例如对于响应时间/延迟的需求,应根据现有设施进行合理评估,再和业务需求阈值比较,从而决定改进方案。对于可用性,是否必须是一个24/7类的业务?或者用户完全能够容忍某个偶尔的不可用?对于数据的持续性,业务需求的数据保存时限如何?只有业务需求明确,才有必要开始考虑进一步的演进。
当需求明确时,我们就可以考虑演进现有架构了——但别忘了前面提到的:分布式系统总会带来不可靠。那么如何度量当前系统的可靠性呢?最有效的手段就是利用现有的监控系统,统计出错率。在正常情况下,系统运行数据能够反映出一定的可靠度,但对某些意外情况,例如网络或其它硬件设备损坏,就很难真的实现了。例如,当服务A需要调用服务B的API时,B开始发生某些本地错误,且不能及时断开链接。那么A将持续保持对B的链接,直到链接超时自动断开,但随着A继续接收上游请求并继续发起对B的链接,从而造成服务A维持一个大规模HTTP链接池,当A的系统资源耗尽,灾难就会随之发生了。
上面提到的例子在分布式架构中已经非常常见,开发人员已经知道若干种解决方案,包括合理超时、改进链接池、以及开发断路器等。但首要问题是如何发现此类问题?
Google每年会组织一次灾难恢复测试(DiRT,Disaster Recovery Test)练习,练习内容包括了针对大规模不可抗拒灾难的演练(例如地震)。Netflix则更进一步,每天会通过工具随机破坏生产环境,用来测试系统的可靠性和恢复能力,此类工具被称作Simian Army。Chaos Monkey是其中比较著名的一种,其基本功能是每天随机性关闭AWS上的某些节点;Chaos Gorilla能够断开整个可用的数据中心;Latency Monkey能够模拟节点间通信缓慢的情况;上述工具都已经被Netflix贡献给了开源社区。
因此,在互联网分布式系统的世界中,首先需要改变的就是传统的思维模式:无论系统有多健壮,失败必然发生。那么接下来我们需要考虑的就是如何在失败发生时采取补救措施——大多数情况下这比减少失败来得更容易。
超时
超时是一项经常被人忽略的关键反馈,因为它预示着下游服务出现了不可预知的问题。但难点在于多长时间才算超时?其实也有简单解决办法,首先,可能引发超时的操作必须在进程外执行;超时时限应在全系统内保持唯一值;一旦发生超时,即记录进日志系统。定期查看超时部分的记录,进行有效改善。
断路器
想象一下家中电表里的空气开关,当电流达到峰值时,空开自动关闭,从而避免对家中电器造成损坏。当屋内长期无人时,也可以手动关闭空开,防止意外发生。空气开关就是断路器的一种,而在《Release It!》中,断路器则成为提高系统可用性的关键手段。
再思考一下超时的情况。一旦下游服务故障,服务调用将发生超时,由于时限是默认设置的,越来越多的超时将发生,并进一步拖慢系统,直至崩溃(前文已提过)。断路器的作用是,当超时发生次数超过一个阈值,就切断到某个服务的通信,即超时时限趋近于零。即“快速死亡”。
断路器能够避免错误发生时,故障蔓延至整个系统。在无法确定分布式系统可靠性的情况下,也确实是一个十分有效的解决方案。但这里需要注意,断路器并不是什么万能神器,只不过是起到“壮士断腕”的作用——想象一下,万一断路器“过早”启动了呢?在实践中,如果服务调用是异步的,那么采用消息队列进行处理也没什么问题。而如果是同步调用,就需要考虑一下引入断路器提高可用性了。
隔板
《Release It!》中提到一种隔板模式。这种模式可以解释成船舱中的隔板结构,当船舱进水时,为了避免整船沉没,通常会选择关闭已经进水的船舱。一个简单的例子是采用服务——连接池模式,即对于每一个下游服务都保持独立的连接池,即使某个下游服务出现问题,也只会导致对应的连接池拥塞,而不会影响整个系统。
在实际应用时,隔板方法几乎是标配的做法,而超时和断路器则可根据情况添加,因为前者是预防问题,而后者更多是收拾残局。例如对于同步调用,配置断路器则是必须。上述模式在实现起来也有很多开源选择,例如Netflix的Hystrix,.Net的Polly,以及Ruby下的circuit_breaker。
2. 幂等操作
无论同一个操作执行多少遍(大于等于1),输出结果不变,这就被称为幂等操作。幂等性在构建Web应用时非常重要,因为消息可能因为某些原因被重放多次,而当这些消息都到达服务器时,不应把这些消息认为是不同的请求进行多次处理。保持幂等操作可以提高应用的可靠性,这在实践时非常重要。
值得一提的是,HTTP协议天然定义了GET、PUT操作应是幂等性的,如果破坏了这一原则,则可能会给系统带来许多问题。
3. 应用层扩展
扩展的目的无非有二:容错和高性能。最直观的扩展方法就是更换CPU和I/O设备,被称作纵向扩展,这种方法实际上成本非常高昂,特别是当前芯片研发和生产已经陷入烧钱的地步,即使不差钱,要让应用充分利用多核和高速I/O架构也非易事。因此实际中更多采用的是横向扩展,特别是当虚拟化技术普及,若干廉价服务器组成的集群就可以很好解决扩展的问题。微服务架构的扩展技术实际上与单一系统类似,但也有一些区别。
分担负载
之前讨论过单服务-单主机的微服务部署方式,在实际应用中,为了节省成本更多团队选择在同一台主机上运行多个服务,但是随着负载需求的增加,采用单对单部署则是一种相对简单有效的解决方法。
另一方面,如果某个服务负载确实很高,单纯的主机数量增加不能直接解决微服务架构的问题,那就要进一步考虑做拆分了(这种拆分避免不了对业务的考量,如果是非核心功能,还是建议直接独立出来)。
平摊风险
横向扩展能把风险平摊到不同主机,从而提高可用性,但具体操作起来需要考虑许多因素。首先,基于现有的虚拟化平台,即使实现单对单部署,所谓的“主机”也不过是一台虚拟主机,不能保证“主机”不在同一台物理机上,那也就不能实现平摊风险的效果,不过还好许多平台目前已经支持了跨不同主机运行服务的功能。
在企业内部虚拟化平台中曾流行采用Storage Area Network,即SAN。SAN是一种十分昂贵的存储区域网络,旨在达到无错运行。而一旦采用SAN且后者挂掉,则整个虚拟集群就会受到影响,因此实际上还是不能避免单点失败的问题。
另一个常见做法是采用多数据中心运行,这其实和IaaS的提供商有关。例如AWS采用的多区域数据中心架构,其保证对EC2实现99.95%的正常服务概率每月每区域,为了实现更高可用性,在此基础上部署应用到不同区域即可实现该目的。
无论如何,对服务商的Service Level Agreement,SLA的熟悉也十分重要,当灾难真的发生,如何“维权”也就在于你对该协议的理解程度了。
负载均衡
负载均衡是一种十分简单有效的横向扩展技术,目前基本成为多实例架构的标配。负载均衡组件能够接收来源请求,根据自身算法把该请求发送至多个平行实例中的某一个,从而实现负载分担。
现在,负载均衡技术非常成熟,包括从硬件方案到软件方案(例如mod_proxy)。不过其功能都类似,当某个实例不可用时将其屏蔽,一旦恢复则将其重新开启。一些负载均衡设施提供更加高级的功能,例如SSL终端。我们已经讨论过采用HTTPS防止中间人攻击和消息泄密的问题,但对系统内部的服务间通信来说,HTTPS会影响其整体运行性能。因此SSL终端就起到了把HTTPS转换成HTTP从而在内网通信的作用。
商业负载均衡,如AWS的ELBs就支持上述功能,同时还可以在系统内部构建VLAN,从而实现内部的高效通信。
对于硬件负载均衡设备来说,自动化始终是一个问题。如果企业更信任硬件方案,也不妨在后端搭设一个软件负载均衡设施,从而提高自动化的能力。
基于Worker的架构
负载均衡并非分担系统负载的唯一选择,基于Worker的系统架构同样有效。Worker被用于如Hadoop批处理进程模型和共享队列的异步事件监听,擅长批处理和异步处理。如图片处理、发送邮件和生成报表等。一旦达到系统峰值,新的Worker能被添加进当前系统,从而增加吞吐量。
对Worker架构来说,尽管每一个Worker可以是不可靠的,但组织Worker的中央系统应保证可靠性。例如采用持久消息广播Zookeeper。
重新上路
当目前的架构无法应对越来越多的用户,重新设计可能是必然的选择,因此初期预留扩展接口是有必要的。当然,如今许多人会选择一开始就预估一个巨大的访问量,从而在此基础上直接构建大规模架构。这是一件非常危险的事,甚至有可能引发灾难。因为在初期我们更强调精益化,而非大而全。后者明显会引发无用的付出。
因此要时刻了解:架构的演进是必须的过程,我们能做的只是留个心眼儿。何况系统扩展的需求并非标志着失败,而是巨大的成功。
4. 数据层扩展
前文提到的扩展,通常只适用于针对无状态的服务。而如果是操作数据库的服务,情况则不同。最为直接的方法就是,借助数据库产品自身提供的扩展特性,而这就需要在项目初期就按需确定数据库产品的选择。
服务可用 vs 数据持久
应当明确的是,这两者完全不是一回事,因此解决问题的思路并不一致。例如当产品数据库出现问题,由于数据本身存有备份,因此持久性没太大问题。但数据库本身不可用,整个依赖于此的应用服务也就不可用。用现代运维技术的观点来看,即使设置了Primary和Replica,当Primary出现问题,如果缺少同步Replica到Primary的机制,就还是缺少可用性。
扩展读库
针对读操作密集的应用,其扩展复杂度要比针对写操作低得多。因为目前存在各种各样的缓存技术(下篇文章将讨论),这里给出另外一种数据库架构:读备库。
在关系型数据中,例如MySQL和Postgres,数据可以从主节点被复制到多个备库节点,这样一来确保了数据的安全性,同样也可被用于分担读操作。一般来说,写操作由一个主节点负责,读操作则由更多备库节点负责。复制操作发生在写操作的之后,从而在读库上存在一定的脏数据时间,当然最终能够保证一致性。这种系统其实是所谓的“最终一致”,通过牺牲严格的一致性来达到系统的扩展(参见分布式系统的CAP定律)。
然而随着缓存技术的发展,实现缓存要比搭建读库方便得多,成本也更低。一般也应该根据业务需求选择不同的扩展方式。
扩展写库
前面提到写操作扩展相对不那么容易,但也存在一般方案:分片。分片可以在写数据时,将数据按照某种哈希规则分布至多个节点上,从而实现横向扩展。在现代数据库技术中,分片几乎成为标配。
分片的难点在于处理查询问题。在单片数据库中,查询基本上是通过内存和索引一次完成的,但是对于分片数据库,查询可能分别发生在不同的节点中,而这种多次查询的方式最终用异步机制组织起来,并配置缓存。例如MongoDB,就采用Map/Reduce架构解决查询问题。
分片系统的另一个问题是,当要增加新的数据库节点时,系统需要整体停机,特别是针对大规模的分片+Replica系统。目前最新的数据库技术已经支持在线扩充节点,并且能在后台保证数据的重新平衡,例如Cassandra。
分片能够实现读操作的横向扩展,但无法满足扩展的另一个需求:可靠性。Cassandra先行一步,实现了环上多节点备份以提高可靠性。
CQRS模式
CQRS指命令查询职责隔离,这是一种极端的数据解耦模式。在标准数据库应用中,通常采用同一系统执行数据操作和查询。但是在CQRS模式下,负责处理命令并操作数据的模块和查询模块应当是分离的。
CQRS带来的隔离性使得扩展方式更加多样化,同时一些业务需求也确实符合操作/查询分离的情况。在CQRS中,一种更加极端的形式甚至是同一个数据仓库,分别存在基于图的表示和基于键值对的表示等各种读方案。然而需要提醒的是,CQRS的应用场景目前还比较狭窄,因为传统CRUD的方式与次存在很大程度的不同,对团队来说将是一个巨大的挑战。