本文要点
- 冗余和隔离是同一枚硬币的两面。
- 防范自己的 Bug,而不仅仅是环境因素。
- 减速比崩溃更容易跨越服务边界。
- 同步 API 为故障传播提供了充足的机会——务必避免!
- 已建立的云模式是有效的,即使没有调度程序和网格——第一天的架构应该简单到极点。
弹性是容忍失败,而不是消除它。
你不能把所有的时间都花在避免失败上。如果你这样做了,你将构建出一个脆弱到无可救药的系统。如果你真希望建立一个弹性系统,就必须构建一个能够吸收冲击并继续或恢复的系统。
在“斯塔林银行(Starling Bank)”,在现有银行公共服务大量中断的背景下,我们在一年的时间里从零开始创建了一家银行;我们知道,我们需要可靠的弹性方法。我们需要来自混沌工程的保障,以及使最好的创业公司得以茁壮成长的对简单性和严格优先级的执着追求。
以下是我们的弹性方法背后的原则和信念。
顺便说一下,我们的方法有一些特性,我在这里不做介绍,但它们仍然是必不可少的:
- 混沌工程——因为弹性必须测试;
- 监控——因为弹性需要可支持性;
- 事件响应——因为弹性适用于系统和运行它的组织。
相反,我们关注的是从基础设施往上的弹性架构。
弹性架构
为客户提供弹性服务意味着,要确保在故障发生时,受错误影响的系统部分与系统整体相比很小。
有两种方法可以确保这一点。冗余是指确保系统作为一个整体扩展到故障范围之外。不管有多少受损,我们都有其他备用的。隔离是指确保故障局限在一个很小的范围内,而不能扩展到我们系统的边界。
无论你如何设计系统,都必须对这两个问题有很好的答案。有鉴于此,你必须针对你能想象到的每一个错误在脑海中测试你的设计。
请考虑以下方面的失败,希望对你有所帮助:
- 基础设施层故障(如网络故障)以及应用程序层的故障(如未捕获的异常或终止);
- 我们构建的软件的内部故障(由我们即 Bug 导致)以及外部故障(由其他人如非法消息导致)
仅仅假定通过测试可以消除内部故障是不够的。保护自己不受外界干扰和保护自己不受自己干扰一样重要。
设计的简单性是影响失败归因难易程度的最重要因素,所以总是选择满足你需求的最简单设计。
冗余
基础设施冗余在云中很容易实现。基础架构即代码使我们在创建多个时可以和创建一个一样容易。
云提供商,如 AWS 和 GCP,提供了 IaaS 原语,如负载均衡器和缩放组,并在文档中清楚记录了它们的使用模式。像 Kubernetes 这样的执行环境提供了部署和复制控制器等资源,其中,复制控制器持续保证服务的多个副本正在运行并可用。无服务器技术将在不进行任何配置的情况下为你扩展服务。
但也有一些限制。
状态是老敌人了。当实例假设本地状态准确时,扩展服务的难度就大许多。要么通过一个高可用的数据存储和外部共享状态,要么引入集群协调机制来保证每个实例的本地状况与对等实例的一致,总之,将服务器转化为分布式缓存。
我的建议是,至少对于创业公司来说,在第一天要保持非常简单。数据库用于共享状态。用它共享所有状态,直到达到其极限。本地内存仅用于瞬态状态。这为你提供了一个简洁的“不可变的”计算层和一个具有明确操作需求的数据库。你知道,你需要在某个时候改进这个基础设施,同步修改架构并监控指标。到那一天,你就从简单的扩展和冗余受益了。
无服务器环境通常提供一个无状态函数服务,并针对状态提供单独的数据库服务,这实际上是默认提供了简单巧妙的分离。
共享资源限制可能是另外一个问题。当后端数据存储仅支持接受 3 个连接时,将服务扩展到 10 个实例是没有意义的。事实上,当服务公开其他组件以加载它们无法支持的组件时,扩展服务可能是危险的。增加系统某一部分的性能会降低系统的整体性能。实际上,如果没有高强度的测试,你就无法了解所有这些限制。
隔离
在云基础设施层,AWS 在每个区域内提供单独的可用区,在基础设施组件之间提供强隔离。负载均衡器和伸缩组可以跨越多个可用区。
在 AWS 中已建立的构建模式可以确保你做好基础工作。其他云也有类似的功能。
在应用程序层,微服务、自包含系统、甚至传统的 SOA 都是可以在服务之间引入强隔离带的设计方法,允许这些组件独立失败,而不影响整体的服务能力。
如果服务的单个实例崩溃,则服务不会中断。如果服务的每个实例都崩溃,客户体验可能会在某种程度上受到影响,但其他服务必须继续运行。
使用这种模式,系统分割得越细,单个服务故障影响的半径就越小。但还是那句话,魔鬼是在细节当中。服务之间可以通过许多微妙的方式相互依赖,这些方式跨越服务边界,使得错误会在系统中扩散。当服务密切相关时,通常可以简单地将它们视为单个服务,并将它们构建为单个服务。
有些方式可能不太明显,比如隐藏协议;服务实例可能通过负责 Master 选举的集群协议进行连接,或者通过针对每个事务(如果你参与了这类事务)进行网络投票的两阶段提交协议。工程师们常常会完全脱离这些网络交互,但它们用错误可以传播的方式将服务连接在一起。
就像在生活中一样,同居是一个不可掉以轻心的决定。如果你在同一个实例上运行多个服务,无论是经过深思熟虑的静态分组,还是使用像 Kubernetes 这样的调度器,这些服务的隔离程度都要比在单独的机器上低。它们都受许多相同故障条件的影响——尤其是硬件故障。
如果服务 X、Y 和 Z 的实例总是在同一台主机上一起运行,那么如果 X 可以任意运行并“毒害”主机,那么不管有多少个实例,X 的邻居都是 Y 和 Z。把它们分到一组,就侵蚀了服务 X、Y、Z 之间的隔离——即使你的技术应该保证这种“中毒”情况不会发生。
调度程序可以通过避免在任何地方部署相同的服务集来缓解这一问题。因此,如果一个有 Bug 的服务毒害了它的主机,你不会看到任何其他服务的完全失败,而是在更广泛的服务范围内产生较为分散的局部影响,而这可能根本不会影响客户体验。
在我们推出的架构中,斯塔林银行选择了一个简单的单服务 EC2 实例模型。这是一种权衡,特别注重简单性而不是经济性,但也提供了非常强的隔离。失去一个实例是很难察觉的。即使我们部署了一个流氓服务,对它的主机做了一些有害的事情,它也不容易影响到其他服务。
在应用层代码中传播错误的方法也很多。
也许最有害的因素是时间本身。
在任何事务型系统中,时间都是最有价值的资源。即使你没有在处理器上进行主动调度,你也可能在完成工作的时间内持有重要的资源:连接、锁、文件、套接字。如果你磨磨蹭蹭,就会让别人挨饿。如果另一个线程无法继续,它可能不会释放它所持有的资源。它可能迫使其他服务进入等待状态,在整个系统中传播不良影响。
虽然死锁经常被描述为并发编程中的邪恶反派,但在现实中,由于事务运行缓慢而导致的资源匮乏可能是一个更常见而又同样严重的问题。
这一点太容易忽视了。工程师的第一直觉是安全而正确的:获取锁,进入一个事务,也许简单考虑了下超时设置然后就跳过了它——(工程师讨厌任何看起来随意的东西,对于如何处理超时,会有尴尬的选择)……日常开发中的这些简单诱惑能给生产环境造成严重伤害。
不能允许等待和延迟在服务之间传播。但是,除非你对这种不当行为有一些全局的预防措施,否则不难想象,你的系统容易受到攻击。你有固定大小的数据库连接池吗?或者线程池?你确定在进行 REST 调用时从不持有数据库连接吗?如果这样做了,当网络通信降级时,是否会耗尽连接池?其他还有什么会耗费时间?那些你几乎看不到的东西呢,比如日志调用?或者,你可能有一些需要反复收集的指标需要数据库访问,但由于池中的连接被用光了,所以数据库访问开始失败,导致大量的垃圾被写到日志中?然后,你也许会突破日志架构的限制,从而失去对正在发生的一切的可见性。构建一个噩梦般的场景是一件很有趣的事情,而这个场景是以一点点的拖延开始的。
预防措施有很多:断路器、“隔板(bulkheads)”、重试设置、最后传播期限……还有一些库可以帮助你在软件( hystrix 、 resilience4j )中实现这些模式,也有一些服务网格或中间件( istio 、 conduit 、 linkerd )在底层提供了其中部分模式。但如果不投入时间和精力,它们都不会做正确的事情。和往常一样,任何代码的性能和故障模式都将反馈给工程师。
同步调用借用了别人的时间。而且,作为一个同步 API,按照合理的设计原则,你通常不知道这段时间对调用者有多么宝贵,也不知道它们多么容易受你的拖延或失败影响。你把他们扣为了人质。因此,我们更喜欢在服务之间使用异步 API,并且只在绝对必要或者是非常简单的只读调用中使用同步 API。
前端可以进行“乐观”的 UI 更新,而不是等待确认,并在离线时显示过时的信息。在大多数情况下,根据过时的信息进行决策需要付出一定的代价,但这种代价不一定很高。Pat Helland 曾经写道:计算由记忆、猜测和道歉组成。当系统表示偏离现实时总是有代价的,当现实在你背后发生变化时,它总会在某个时候偏离现实。在分布式系统中没有真正的“现在”。在决定需要完美的事务语义或同步响应之前,评估下“错误”的代价。
对于银行系统来说,容忍失败意味着容忍失败而不丢失信息。因此,虽然实现异步系统不一定需要队列,但你可能希望系统作为一个整体表现出一些类似队列的属性——至少一次或最多一次交付,即使存在错误。通过这种方式,你可以确保某些服务始终拥有数据或命令的所有权。
你可以通过在服务中同时使用幂等性和追踪处理来达到这两个目的(如斯塔林的“DITTO”架构——有很多自治服务不断尝试做互相幂等的事情),或使用队列保证至少一次,使用幂等载荷保证至多一次(如 Nubank 的 Kafka 基础设施)、或设法把它们都委托给“企业服务总线”(如许多传统银行的架构)。
使用这些技术,完全有可能在异步交互服务的基础上创建响应性应用程序。
总结
编写弹性系统比以往任何时候都容易。在前 12 个月的运营中,斯塔林银行经历了许多局部故障和系统部分退化——从来没有出现过整个系统不可用。这主要是因为我们在每个层面上都强调冗余和隔离。
我们设计了一个能够容忍服务中断的架构,并特别重视这样一种观点,即慢服务对最终用户的威胁可能比死服务更大。
如果一开始我们对于上面列出的各项——混沌工程、监控和事件响应——没有可靠的方法,那也没有关系。但这得再一天介绍了。
关于作者
Greg Hawkins 是技术、金融科技、云计算和 DevOps 等领域的一名独立顾问。他在 2016 年至 2018 年期间担任斯塔林银行的首席技术官,这是英国一家仅限移动端的挑战者银行。在此期间,这家金融科技初创企业获得了银行牌照,两大移动平台上的下载量都突破 10 万次。现在,他仍然是斯塔林银行的高级顾问。斯塔林从零开始构建了全栈银行系统,成为英国第一个完全部署在云上、普通公众可以使用的经常账户,并满足了适用于零售银行的所有监管、安全和可用性预期。
查看英文原文: Resilient Systems in Banking
评论 1 条评论