我曾目睹的微服务灾难
在 2010 年代的早期,很多组织都面临着软件开发周期的挑战。与其他 50、100 或 200 名工程师一起工作的人,他们在开发环境、QA 过程和程序部署方面都很困难。Martin Fowler 的《Continuous Delivery》(译注:尚无中译本)一书给许多团队带来了曙光,他们开始意识到,他们那“雄伟”的单体应用正给他们带来组织问题。所以,微服务对软件工程师很有吸引力。在一个大项目中引入持续交付或部署,而不是一开始就引入,更具有挑战性。
于是,团队开始拆分三个、十个、一百个微服务。其中大部分都使用“JSON over HTTP”(其他人可能会称之为 RESTful)API 来在这些组件之间实现远程调用。人们对 HTTP 协议非常熟悉,这看起来是一种将单体应用转换成小程序块的简单方法。这时,团队在 15 分钟之内就开始将代码部署到生产环境中。再也没有“哦,团队 A 破坏了 CI 管道,我不能部署我的代码”这样的情况了,这种感觉棒极了!
但是,大多数工程师都忘了,在解决软件架构层面的组织问题的同时,他们也引入了许多复杂性。分布式系统的谬误变得越来越明显,并很快让这些团队感到头疼。甚至那些已经在做客户机 / 服务器架构的公司,当他们的系统中有超过 10 个移动部分时,也会出现这种情况。
做出重大的架构改变并非没有成本。团队开始认识到,共享数据库是一种单点故障。后来他们意识到,他们各自的领域创造了一个全新的世界:最终的一致性就是一件事。在你提取数据的服务失败后该怎么办?很多问题开始堆积如山。高速开发速度的承诺被寻找错误、事件和数据一致性问题等压得喘不过气。另外一个问题是,工程师需要一种集中的日志和可观察性解决方案,在几十个服务中发现并纠正这些缺陷。
随着开发人员创造力的爆发,每天都能创造出新的服务。一项新功能?咣当,让我们开始服务吧!突然之间,20 名工程师组成了维护 50 项服务的小组。一人负责一项服务还不够!一般而言,代码的问题在于它会“腐烂”。维护每一项服务都是要付出代价的。想象一下,在你的服务团队中传播一个库的升级。再想象一下,这些服务开始于不同的时间点,具有不同的架构、业务逻辑和所使用的框架之间的纠葛。那是多么可怕啊!解决这些问题的方法当然是有的。其中大部分都不能使用,而其他一些则需要花费很多 FTE 工作。
另外一种感觉是,我被告知,在服务 A 中部署新功能,并且在服务 B 中同时部署,或者当人们开始编写服务以生成 CSV 时。为什么会有人引入网络跳转,以产生世界上已知的文件格式?这东西谁来维护?有些团队正在受服务之苦。更糟的是,它在开发过程中会产生许多摩擦。与仅仅在 IDE 中查看一个项目不同,人们需要一次打开多个项目才能了解所有这些混乱的情况。
我已经记不清有多少次有人走近我说:
“嘿,João。你有时间吗?我们需要改善开发环境了!大家都在抱怨这些事,可是都没用!”
这一问题涉及各个层面。移动开发者不需要在开发环境中开发功能就可以实现,或者后端开发者想要尝试他们的服务而不会破坏任何业务流程。如果有人想在生产之前在移动应用中测试整个过程,这也是一个问题。
跨分布式系统的开发环境存在一些问题,尤其是规模方面:
在云供应商中启动 200 个服务需要花费多少钱?你能做到吗?你是否能够启动运行他们所需的基础设施呢?
这需要多长时间呢?加入一个移动工程师开始开发一项功能,在给定的版本中有一组服务,当这些服务完成之后,有 10 个新版本被部署到生产中,那会怎样?
测试数据怎么样?你是否拥有所有服务的测试数据?在整个 Fleet 中都保持一致,所以用户和其他实体相匹配?
当你开发一个多租户、多区域的应用时,如何配置和功能标志?怎样跟上生产进度?若同时更改缺省值呢?
这些只是冰山一角而已。你可能会考虑将工程技术应用于这个问题。那也许行得通。但是,我怀疑大多数组织是否有足够大的规模来完成这项工作。这样做既麻烦又费钱。
不难想象,端到端测试和开发环境有相似的问题。在此之前,使用虚拟机或容器创建新的开发环境相对简单。同样,使用 Selenium 创建测试套件非常简单,它可以在部署新版本之前通过业务流并判断它们是否在工作。有了微服务,即使我们能够解决以上关于构建环境的所有问题,我们也不能再次宣布系统正在运行。我们至多可以这样说,运行特定版本的服务和特定配置的系统可以在特定的时间点上正常工作。真是大不相同啊!
要让人们相信我们只能进行几次这样的测试是非常困难的。而且在持续集成(Continuous Integration)流程中运行这些测试并不够。它们应该持续运行。它们应该针对生产运行情况发出相应的警报。我已经分享了无数次 Cindy Sridharan 的文章《在生产中测试,安全的方法》(Testing in production, the safe way),试图让人们理解我的观点。
一种简单的方法就是继续使用共享数据库,这样就可以避免单体应用,同时保证数据的一致性。这种方法不会增加操作负荷,而且可以轻松地一步一步地切割单体应用。但它也有相当大的缺点。这不仅是一个明显的单点故障,而且违背了面向服务架构的一些原则。你是否为每项服务创建一个用户?你是否具有细粒度的权限,以便服务 A 只能读写特定的表?假如某人不小心删除了索引怎么办?怎样知道有多少服务使用了不同的表?那扩容呢?
解决这些问题本身就变成了一个全新的难题。在技术上,这可能不是一个无关紧要的小问题,因为数据库经常比软件寿命长。用数据复制来解决问题——不管是 Kafaka、AWS DMS 还是其他什么——都需要你的工程团队理解数据库的细节,以及如何处理重复时间等等。
在面向服务的架构中,API 网关是一种典型模式。它们帮助解耦后端与前端消费者。在实施端点聚合、速率限制或跨系统认证方面,它们也有用。近来,业界倾向于 backend-for-frontend(BFF,服务于前端的后端)的架构,将网关部署到前端的每个消费者群体(iOS、Android、Web 或桌面应用),从而解耦它们的进化。
和世界上的任何东西一样,人们开始有了新的创造性用例。有时这只是一个小技巧,使移动应用能够向后兼容。突然间,你的 API 网关变成了一个单点故障,因为人们发现在一个地方进行认证更加容易,其中还包含一些出乎意料的业务逻辑。现在,你不再有一个获得所有流量的单体应用,而是有一个自己开发的 Spring Boot 服务来或许所有的流量!会出什么问题呢?工程人员很快意识到这是个错误,但是由于存在大量的定制,有时候他们不能用它来取代无状态的、可扩展的定制。
当使用未分页的端点或返回大量响应时,就会导致 API 网关灾难。又或者,如果你在没有后备机制的情况下进行聚合,仅仅调用一次 API 就会“烧毁”你的网关。
分布式系统经常处于局部故障模式。如果服务 A 不能与服务 B 取得联系,会发生什么?咱们可以再试一次,对吗?但是这很快让我们陷入了困惑之中。我见过有些团队使用断路器,然后对下游服务进行 HTTP 调用时会超时。尽管这可能是一种正常的反应,为我们争取了一些时间来解决问题,但是它会产生二阶效应。所有这些请求都将被断路器取消,因为它们太长,在断路器上的时间太长。随着流量的增加,会有越来越多的请求进入队列,结果会比你希望修复的更糟。工程师们都在努力理解队列理论,理解为什么会出现超时现象。同样的事情发生在团队开始讨论 HTTP 客户端的线程池等问题时。尽管对这些东西进行配置本身就是一种艺术,但基于直觉来设置数值会使你陷入严重的停机状态。
在从失败中恢复的过程中,一个棘手的问题是并非所有的失败都一样。有些情况下,我们会希望我们的消费者是等幂的。但是这意味着我们应该积极的决定每一种失败情况下该怎么做。消费者是否等幂?能否重试这个调用?在认识到存在巨大的数据完整性问题之前,我已经看到许多工程师忽视了这些,因为它们只是“边缘情况”。
即使你设置了后备机制,重试也比所有这些更加复杂。假设你的移动应用有 500 万用户,而更新用户首选项的消息总线暂时无法运行。你创建了一个支持这种情况的后备机制,该机制通过 HTTP API 调用用户的首选项服务。你应该知道我在说什么吧。如今,该服务突然出现了巨大的流量尖峰,可能无法应付所有的流量。更糟糕的是:你的服务可能接收到所有这些新请求,但是如果重试机制不能实现指数退避和抖动,那么你就可能遇到来自移动应用的分布式拒绝服务。
要是我告诉你,我只是写下了我所看到的灾难中的一小部分呢?分布式系统很难掌握,而且大多数软件工程师只是在最近才持续接触到它们。
好消息是,对于我所说的很多灾难,我们都能找到解决方案,行业已经创造除了更好的工具,使得除了美国五大科技巨头(Facebook、苹果、亚马逊、Netflix、谷歌)之外的其他组织都能解决这些问题。
我还是喜欢分布式系统,而且我还是觉得微服务是一个解决组织问题的好方法。但是,当我们把失败看作“边缘案例”或者我们认为不可能发生的事时,问题就出现了。在一定范围内,这些边缘案例成为新常态,我们应该加以应对。
程序员过关斩将--错误的IOC和DI