可伸缩性的最差实践
相关文章:可伸缩性原则
英文原文:Scalability Worst Practices
引言
在扩展大量大型的分布式系统期间,我有机会观察(并实践)了一些最差实践。这些最差实践中的大部分在开始时都没有危害,但如果疏忽大意,它们就会对系统的发展和可伸缩性构成危害。很多文章都聚焦于最佳实践,以确保拥有一个易于维护和可伸缩的系统,但在本文中,我主要强调的则是一些应该规避的最差实践。
技术
没有任何一种技术或架构能实现所有的需求。了解何时该反思现有的方法、如何拓宽视野以超越局部范围、或如何进行依赖的有效控制,这些都是可伸缩性的关键特性。让我们进一步分别研究一下。
金锤子
金锤子起源于一条古老的谚语:如果你只有一把锤子,那么任何东西在你眼里都是一枚钉子。很多开发人员都局限在仅使用一种技术的观念中——其代价是不得不使用选定的技术来构建和维护基础设施,即便已经存在另一种技术更适用于特定问题域的功能和抽象。强行把一种技术用在它所不擅长的方面,有时会适得其反。
举例来说,持久化键-值对问题的常见解决方案是使用数据库。之所以常常这样选择是因为组织或开发者有坚实的数据库实践,针对许多问题自然而然就会沿用同样的解决途径。当数据库的特性(关系完整性、锁、连接和方案)成为瓶颈或阻碍了其伸缩扩展时,问题也就出现了。这是因为应用基于数据库的解决方案要发展,其成本通常要比使用其它可用技术更为昂贵。随着键-值存储访问率的增加,数据库并发模式的性能就开始降低,而数据库具备的高级特性却被闲置。许多传统关系数据库的替代方案都是针对这些缺点的,比如CouchDB、SimpleDB或BigTable。
另一个常见的“锤子”就是总利用线程来进行并发编程。尽管线程确实是针对并发的,但它们也带来了成本,这些成本包括代码复杂性的增加、以及由于目前线程的的锁定和访问模型造成的组件编排(composability )方面的固有不足。由于如今最流行的编程语言都使用线程处理并发,因此数千行代码都含有竞态条件、潜在的死锁和不一致的数据访问管理。有些正在成长的社区提出了另一些并发方案,这些方案不存在线程的可伸缩性问题,也就是由Erlang或Stackless Python提倡的并发模型。即便不在实际生产中选择那些语言,研究一下它们的概念(比如消息传递或异步I/O)仍然是一种不错的实践。
资源滥用
小范围的问题开发者们一般都能处理得得心应手:使用分析工具、了解算法的空间和时间复杂度、或者了解哪种场合应该用哪种列表实现。但并非每个人都善于认识到大型系统的约束条件,比如识别共享资源的性能要求、了解服务的各种客户、或发掘数据库的访问模式。
应用程序实现伸缩性的普遍方法是不断横向部署冗余的、无状态的、彼此不共享内容的服务,以此作为最理想的体系架构。但以我的经验看来,这种扩展往往会忽视新增服务对共享资源的影响。
比如说,如果一个特定的服务使用数据库作为持久存储,它通常通过一个线程池来管理数据库连接。使用池是不错的方法,有助于避免进行过多的数据库连接处理。然而数据库仍然是共享资源,除了单个池配置,还必须对所有池从总体上进行管理。下面两个实践就会导致失败:
- 持续增加服务数,但并不减小池的最大数。
- 增大单个池的大小,而不减小服务数量。
以上两种情况中,除了按性能要求配置应用之外,连接的总数也必须加以管理。此外,还要持续监控数据库的容量,以保持连接均衡。
处理共享资源的可用性至关重要,准确的说,这是因为它们一旦失效,由于其“共享”的本质,失效会对系统造成全面的影响,而非孤立存在。
大泥球
依赖是很多系统讨厌却又必不可少的东西,不积极地处理好依赖及其版本会损害灵活性和可伸缩性。
代码的依赖管理有多种不同的模式:
- 同时编译整个代码集
- 基于已知版本选取构件和服务
- 发布的模型和服务所有变更都向后兼容
让我们看看这些情形。首先,在大泥球模式下,整个系统作为一个单元编译和部署。这种模式拥有明显的优势,也就是将依赖管理交给编译器处理,并能提前捕获一些问题,但它会因每次都部署整个系统(包括测试、交付和大范围变化引起的风险)而引发可伸缩性的问题。在这种模式下,会更难隔离系统的变化。
在第二种模式中,依赖都是按需挑选的,但是变化经过依赖传递之后依旧出现第一种模式一样的难题。
第三种模式中,服务负责依赖的版本化,并向客户端提供向后兼容的接口。这明显减轻了客户端的负担,从而允许逐步升级到新的模型和服务接口。此外,当数据需要转换的时候,它是依靠服务而不是客户端完成的——这进一步稳固了隔离性。向后兼容的变更意味着打补丁、升级和回滚都不能干扰客户端操作。
采用变更能向后兼容的服务体系架构在最大程度上避免了依赖问题。它同时方便了在受控环境下进行独立测试,隔离了客户端和版本化数据的变化。这三个优点对隔离变化来说都很重要。最近发布的Google Protocol Buffers项目也在倡导向后兼容的服务模型和接口。
全部打包还是部分打包
处理依赖时要考虑的另一件事情是如何对应用内容打包。
在一些场景中,比如Amazon Machine Images或Google AppEngine应用,它们的整个应用和所有的依赖都一起打包发布。这种囊括一切的打包方法保持了应用的自包含,但它增加了包的总大小,而且应用中任何地方的一个小小改变,都会迫使系统重新部署整个应用包(甚至对同一台物理机器上许多应用使用的共享库也是如此)。
替代方案是将应用的依赖移出主机系统,令应用包只包含依赖图的若干部分。这控制了包的大小,但由于应用在能提供服务之前需要将特定的组件传递到每台机器上,所以增加了部署配置。依赖项目没有立即准备好、机器没有经常测试、抑或是依赖错误,由于以上种种,不将整个包部署为自包含的方式会制约将应用部署到异构的、非标准化的机器上。
后一种方案——分成不同范围(全局的、机器的、应用的)去处理依赖——必然会增加疏漏和复杂性。它减少了配置和依赖隔离,增加了操作的复杂性。一般而言,隔离能增加可伸缩性,所以尽可能选用囊括一切的方法,除非有例外情况。
无论在代码还是在依赖处理中,最差实践就是不清楚模块间的关系,没有规划好模块以便于对其进行管理。未能增强控制是可伸缩性的一大绊脚石。
忘记检查时间
在分布式系统中,通常的目标是尽可能地将开发者和负责分布式调用的复杂方法隔离开来。这使主要的开发工作集中于核心的业务逻辑上,而不用担心失效恢复、超时以及其它分布式系统必需的需求。但是,让远程调用看起来像本地调用一样就意味着开发者要像本地调用一样编码。
我常发现很多代码都期望所有的远程请求能及时完成,但这样的期望是不合理的。比如说,Java在JDK1.5中仅为HTTPURLConnection
类引入了读超时,而让开发者要么创建线程去杀死进程,要么天真地等待响应。
Java中,另一个潜在的时间处理不合理的例子是DNS查找。在一个长时间运行的典型系统中,执行完最初的DNS查找之后,如果不进行明确的配置,结果会缓存在JVM的生命期内。如果外部系统更改了主机的IP地址,将不能正确处理该条目,而且在很多情况下,因为编程时没有设置连接超时时间,连接就会被挂起。
为了对系统进行合适的伸缩扩展,为请求处理分配好时间是极其重要的。有很多方法可以实现,有一些是语言内置的(像Erlang),其它的则作为库的形式提供,比如libevent或Java的NIO
。抛开实现语言或架构不谈,正确地管理操作等待时间是非常必要的。
运行时
建立一个符合成本效益的可扩展方案、处理好依赖、预先考虑到失效都是创建优秀架构的各方面要求。而在生产环境中,系统易于部署和运维的能力也同等重要。这里同样有很多不利于系统可伸缩性的最差实践。
英雄模式
运维问题普遍的解决方案是有一个“英雄”(关键性人物),他能处理、并经常处理大部分的操作需求。在小规模环境中,当某个人有天赋和能力熟悉整个系统(包括保持系统正常运行的许多细节之处),英雄模式可以正常运行。尽管这是最常见的实施方案之一,但对拥有许多组件的大型系统而言,这种方法就不能进行伸缩扩展了。
在没有形式说明的情况下,“英雄”往往要理解服务依赖,牢记如何开、关特性,或了解其他人已经遗忘了的系统。“英雄”虽然至关重要,但他不应该是一个个体。
我认为英雄模式最好的解决方案是自动化。如果组织的情况允许,让个人在团队之间轮换也有帮助。在银行里,休假有时是强制性的,好让“你这里不行,要到我的机器上做”之类的问题及时暴露出来。
非自动化
系统过度依赖于人工干预往往是存在“英雄”的后果,这面临着可重复生产能力的问题和“英雄”出现意外情况带来的问题。能重现特定的构建、部署和环境很重要,而明确定义的元数据控制下的自动化是实现可重复能力的成功关键。
在一些开源项目中,工件的发布过程依赖于个体开发者在自己工作站上构建工件,没有任何措施保证产生出来的工件版本能实际对应到源码控制系统中的某个分支。在这些情况下,完全有可能发布软件,其代码从未被提交到源码控制系统。
综上所述,“英雄”的活动应该由自动化取代,从而确保个人(或许多人)可以相对容易地替换其他人。自动化的替代方案是增加流程——Clay Shirky为流程给出了一个有趣的定义:流程是对先前蠢行的内在反应。
先前的蠢行在所难免——自动化应该吸取教训。
监控
当时间紧迫时,监控(比如测试)往往是第一个牺牲的环节。有时,在我问及有关组件的运行时表现方面的细节问题时,总没有答案。缺乏对运行系统内部的深入了解和迅速切入问题的能力,不利于对从哪里入手和着手做什么做出正确攸关的决策。
Orbitz很幸运地拥有久经考验的监控软件,它们既能提供服务调用的细粒度详细信息,也能精确显现出问题域的数据。来自监控基础设施的可用度量数据有利于快速有效地解决问题。
总结
在不久前Amazon的S3出现服务中断之后,Jeff Bezos说道:遇到问题的时候,我们知道直接原因,我们从那里入手分析并找到了根本原因,然后从根本上进行了修复,又向前迈进了一步。
软件和系统的开发是一个迭代的过程,在这个过程中,失败和成功的机会并存。简单但较难伸缩的解决方案有其一席之地,特别是计划或应用尚处于不成熟的阶段。“好”和“完美”不是对立的。但随着系统的日臻完善,应该除去其中的那些最差实践,这样,成功也就是理所当然的了。
非常感谢Monika Szymanski对本文初稿提出的建议。
关于作者
Brian Zimmer是旅游业新创企业Yapta的架构师,是一位受人尊敬的开源社区成员,也是Python软件基金会的成员。他之前作为高级架构师服务于Orbitz。他的博客在http://bzimmer.ziclix.com。