产品复杂度与日俱增。想继续按以前的节奏去演进产品变得越来越困难了,是时候寻找一种更好的方法了。微服务架构承诺可以让团队快速前进... 但与此同时也带来了一系列全新的挑战。
在为Enchant搭建微服务架构时,我希望总结出一套适用于现代化Web和云技术的实战经验。为确保少犯错误,我还从这一领域的先行者(如Netflix、Soundcloud、谷歌、亚马逊、Spotify等)身上学到了很多经验。
长话短说
微服务架构主要是为了应对复杂度。相对于单一的复杂系统,该架构由多个简单的服务组成,这些服务之间存在复杂的交互,其目标在于确保复杂度能够得到控制。
确定关键需求
微服务架构自身也会导致复杂度增加。需要运维的系统数量不仅没有减少,反而变得更多。到处散布着日志文件,分布式环境中难以维持一致性,类似的问题还有很多。我们的目标在于实现一种简化复杂度的状态:知道复杂度问题无法避免,但可通过工具和过程加以控制。
明确了需求后,我打算从下列几方面着手:
- 为团队提供最大化自主权:通过创建一种环境让团队无须与其他团队协调即可完成更多工作。
- 优化开发速度:硬件很便宜,人工则很贵。我们需要为员工赋能,让他们更轻松快速地开发出强大的服务。
- 以自动化为重心:人都会犯错。需要运维的系统越多意味着出错概率越大,要尽量让一切实现自动化。
- 不危及一致性的前提下提供灵活性:让团队自由决定最适合的行事方法,同时通过一系列标准化的构建块确保长远范围内一切保持井然有序。
- 有弹性的构建:系统发生故障的原因有很多种,而分布式系统又引入了一系列可能出现故障的新场景。为了将影响降至最低,需要确保具备相应的衡量机制。
- 简化维护:不要只使用一套基准代码,而是可以使用多套。为确保一致性可提供必要的指导和工具。
平台
服务团队需要能自由构建必要的东西。与此同时为确保一致性并管理愈加复杂的运维工作,还要设立相应标准。这意味着需要让通信、日志、监控和部署等工作实现标准化。
平台本身就是一系列标准和工具组合的产物,借助平台将能更容易地创建并运维满足标准要求的服务。
要有一个控制面板
团队该如何与平台交互?通常可能需要大量Web接口进行持续集成、监控、日志,以及文档记录。团队需要用一个控制中心作为这一切的起点。列出所有服务并链接到各种内部工具的简单控制中心就行。理想情况下,控制中心还可以从内部工具中收集数据,并在快速浏览中提供额外的价值。
对于已经在使用团队交流解决方案的组织,一种较为常见的做法是使用自定义的机器人程序将常用任务直接显示在聊天界面中。这种做法在需要触发测试和部署,或需要快速了解某个运行中服务状态的时候较为有用。通过这种做法,聊天记录也可以变成一种针对以往操作进行审计的审计轨迹。
服务的本质
平台内部通常运行了很多服务。“很多”取决于具体规模,可能指十多个,数百个,甚至上千个。每个服务均通过相互独立的程序包封装了一定的业务能力。为确保服务能专注于某一具体用途,所构建的服务必须尽可能小;但为了将服务之间的交互减至最低,服务又要足够大。
独立开发和部署
每个服务都需要独立开发和部署。如果没有对API做出破坏性改动,则无需与其他服务团队进行协调。每个服务实际上都是相关团队自己的产品,有着自己的基准代码和生命周期。
如果发现需要配合其他服务一起部署,无疑是哪里做错了。
如果所有服务使用了同一套基准代码,无疑是哪里做错了。
如果每次部署一个服务前要先提醒他人注意,无疑是哪里做错了。
提防共享的库!如果对共享库进行更改需要同时更新所有服务,意味着所有服务间存在强耦合点。在引入共享库之前,一定要充分理解可能产生的后果。
私有数据的所有权
当多个服务直接读写数据库中同一张表时,对这些表做任何改动都需要协调这些相关服务的部署。这一点违背了服务相互独立这一原则。共享的数据存储很容易不经意间造成耦合。每个服务需要有自己的私有数据。
私有数据还能提供另一个优势:根据服务的具体用例选择最适合的数据库技术。
每个服务都要有自己的数据服务器吗?
不一定。每个服务需要自己的数据库,但这些数据库可共置在一台共享的数据服务器上。重点在于不应让服务知道其他服务底层数据库的存在。这样即可用一台共享数据服务器先开始开发,以后只要更改配置即可将不同服务的数据库隔离起来。
然而共享的数据服务器也可能造成一些问题。首先会形成单点故障,进而导致一大批服务同时故障,这一点绝不能掉以轻心。其次很可能因为一个服务占用太多资源而无意中对其他服务造成影响。
确定服务的边界
这个问题很复杂。每个服务应该是一种能提供某些业务能力的自治单位。
服务应当弱耦合在一起,对其他服务的依赖应尽可能低。一个服务与其他服务的任何通信都应通过公开暴露的接口(API、事件等)实现,这些接口需要妥善设计以隐藏内部细节。
服务应具备高内聚力。密切相关的多个功能应尽量包含在同一个服务中,这样可将服务之间的干扰降至最低。
服务应包含单一的界限上下文。界限上下文(Bounded context)可将某一领域的内部细节,包括该领域特定的模块封装在一起。
理想情况下,必须对自己的产品和业务有足够的了解才能确定最自然的服务边界。就算一开始确定的边界是错误的,服务之间的弱耦合也可以让你在未来轻松重构(例如合并、拆分、重组)。
等一下,那共享模型呢?
再深入看看界限上下文吧。应该尽量避免创建哑CRUD服务,这类服务只能导致紧密的耦合以及较差的内聚力。领域驱动设计所引入的界限上下文这一概念可以帮助我们确定最合理的服务边界。界限上下文可将某领域的相关内容封装在一起(例如将其封装为服务)。多个界限上下文可通过妥善定义的接口(例如API)进行通信。虽然一些模型可以完全封装在界限上下文内部,但有些模型可能有跨越不同界限上下文的不同用例(和相关属性)。这种情况下每个界限上下文须具备与模型有关的属性。
这里可以举一个更具体的例子。例如技术服务台解决方案Enchant,该系统的核心模型是工单(Ticket),每个工单代表客户的一个支持请求。工单服务负责管理工单的整个生命周期,包含主要属性。此外还有报表服务负责预先计算并存储每个特定工单的统计信息。每个工单的报表统计信息用两种方式存储:
- 将统计信息存储在工单服务中,因为最终工单模型和工单生命周期都由该服务所拥有。通过这种方法,报表服务用数据执行任何操作前需要首先与工单服务通信。这种服务之间的紧密耦合显得非常“啰嗦”。
将统计信息存储在报表服务中,因为与数据有关的报表工作都是该服务负责处理的。通过这种方法,两个服务都将具备一个工单模型,但存储不同属性。这样数据可放置在更接近实际使用这些数据的地方。这种方式还使得报表服务能够针对报表具体用例持续进行优化,但这种方式一旦新建工单或现有工单有改动,还需要通知报表服务。 - 将统计信息存储在报表中的做法可以更好地满足服务要求:弱耦合、高内聚,每个服务自行负责自己的界限上下文。然而这种方法会增加复杂度。工单的改动需要通知报表服务,为此可以让报表服务订阅由工单服务提供的事件流,这样也可以让服务之间的耦合程度降至最低。
但服务到底能大到什么程度?
微服务这个词中的微字与物理规模或代码行数没有任何关系,仅代表最小化的复杂度。服务应当足够小,只承担某个单一作用,与此同时服务也要足够大,以便将服务之间的通信量降至最低。
没什么硬性规定限制服务只能是一个进程、一个虚拟机、或一个容器。服务应包含能够以自治方式实现业务能力所需的全部功能,甚至可包含外部服务,例如用于实现持久存储的数据服务器,用于异步工作进程的作业队列,甚至确保一切快速运行所需的缓存。
无状态的服务实例
无状态(Stateless)服务实例不存储与上一个请求有关的任何信息,传入请求可发送至服务的任何实例。这种做法的主要收益在于可简化运维和伸缩工作。可以在后台服务之前增加一个简单的负载均衡器,随后即可根据请求数量的变化轻松地增加或删除实例,同时故障实例的替换过程也更为简单。
话虽如此,很多服务可能依然需要用某种方式存储数据。这些数据可推送至外部服务中,例如以磁盘作为边界的数据库服务器或以内存作为边界的缓存。
最终一致性
无论怎么看,分布式系统的一致性都是个老大难问题。与其对抗这种问题,更好的方法是让分布式系统能够实现最终一致性。在最终一致的系统中,虽然不同服务在特定时间点会对数据产生有分歧的视图,但最终会通过汇聚获得一致视图。
如果能对服务进行妥善建模(例如弱耦合和高内聚),你会发现对大部分用例来说,最终一致性是一种足够好的默认设置。创建最终一致的分布式系统,这种做法在方向上与创建弱耦合系统的目标是一致的。都趋向于进行异步通信,面对下游服务的故障可以获得固有的保护。
举个例子。在Enchant中有一个工单服务(用于管理客户支持请求)和一个报表服务(用于计算工单的统计信息)。报表服务可从工单服务获得一个异步的内容更新馈送源(Feed)。这意味着当任何时候工单服务中产生更新后,报表服务可在几秒后获得相关信息。在这几秒钟内,两个服务会对底层客户请求产生有差异的视图。对于报表这一具体用例来说,几秒钟的延迟是可接受的。此外这种方式还提供了一个附加的收益,可以保护工单服务不受报表服务故障的影响。
异步工作进程
在采用最终一致性的设置后会发现,当请求等待回应而受阻时,并不需要在这过程中将其他所有操作全部完成。任何可以等待(并且是资源或时间密集型)的操作都可以作为作业传递给异步工作进程。
这种方式可以:
- 为主要请求的处理路径提速,因为只需要完成请求处理过程中必须要完成的部分操作。
- 通过分散负载更轻松地对工作进程进行伸缩,是自动伸缩配置的最佳选择,可根据待完成工作的数量对工作进程的数量进行动态调整。
- 减少主要服务API中出现的错误,通过异步工作进程运行的作业失败后,可直接重试而无须迫使发起请求的服务等待。
理解幂等性
上文提到过作业运行失败后可重试。自动重试失败作业的做法会遇到一个挑战:可能会无法知道失败的作业在失败前是否完成了自己的工作。为确保相关操作足够简单,必须让作业具备幂等性(Idempotent)。在这个语境下,幂等性意味着多次运行同一个作业不会产生任何消极影响。无论作业运行一次或多次,最终结果必须相同。
文档
服务(及其API)和文档一样重要。对于每个服务,一定要提供清晰易用的使用文档。理想情况下,所有服务的使用文档应放在同一个位置,并确保需要使用时服务团队不需要花费大量时间考虑文档到底在哪里。
API有变化后会怎样?
需要将已记录端点的变动情况通知给其他所依赖服务的所有者。通知系统必须了解不同服务目前的所有者是谁,并了解有关团队或所有权的全部改动。这些信息可通过平台进行追踪并提供。
负载均衡器
无状态服务的优势之一在于可同时为服务运行多个实例。由于每个实例都是无状态的,可由任何实例处理任何请求。这一特性与负载均衡器非常相符,借此可对服务进行更简单的伸缩。同时几乎所有云平台都提供了负载均衡器功能。
传统负载均衡器位于接收端,此时客户端只知道一个目标的存在。该目标接收请求并将其分散至多个(对外隐藏的)内部服务实例。另一种做法是客户端负载均衡器,例如Netflix使用Ribbon构建了这样的实现。对于客户端负载均衡器,客户端可以知道多个可能的目标,并根据策略选择要使用的目标,例如可首选使用同一数据中心内的目标以降低请求延迟。此外还可配合使用这两种做法:首先从传统负载均衡器着手,随后在需要实现高级路由功能时添加客户端负载均衡器。
客户端负载均衡器可包含在客户端库中。
网络边缘的聚合服务
如果要让数据跨越私有网络边界与外部客户端通信,将有更多额外需求需要考虑。数据需要通过某种方式编码,将往返通信的次数降至最低,为确保这些客户端只能访问需要的数据,还要提高安全措施,诸如此类。
聚合服务将负责收集来自其他服务的数据,处理任何特殊的编码或压缩要求,同时由于客户端仅需要与这一个服务通信,这样做还有助于简化安全工作的实现。
在不同的需求下,构建不同的聚合服务会更符合实际,如一个用户场景(公开API、移动客户端、桌面客户端等)一个聚合服务。如果你的需求更简单,那么一个聚合服务也就足够了。
对一个聚合服务中的业务逻辑量进行限制
由于聚合服务需要处理来自多个服务的数据,很容易无意中泄露其中包含的业务逻辑并降低服务内聚力。这种情况需要当心!任何与某个服务有关的业务逻辑应该只属于该服务。聚合服务的本意是在外部客户端和内部服务之间充当一个稀薄的粘合层。
如果某个内部服务出现故障了怎么办?
答案主要取决于具体的上下文。因此首先需要考虑下列几个问题:
- 能否将相应功能优雅地移除,或者让端点抛出错误信息?
- 该服务的可用性是否关键到需要将整个聚合服务停用?
- 如果从端点中优雅地移除,客户端会向用户显示怎样的错误信息?
聚合服务的本质使得这种服务依赖(并紧密耦合于)一个或多个其他服务,因此这种服务会受到其他任何相关故障服务的影响,进而导致聚合服务故障。所以必须了解不同的故障场景并制定好应对措施。
安全性
需要结合数据所在位置或服务在整个大架构中的角色考虑服务安全需求。可能要对传输中或存储后的数据提供安全保护。可能要在服务边界或私有网络边界实施网络安全机制。很难确保万无一失,下文提供了一些值得考虑的原则:
- 分层式安全:也叫纵深防御。这种方法不会假设网络边界防火墙已足够好了,而是分别为最重要的部分持续添加多个安全防护层。借此可让安全措施实现冗余,当某一安全层被攻陷或存在弱点时,有助于减缓攻击者的入侵速度。
- 使用自动化的安全更新:很多情况下自动化安全更新机制所提供的收益远远超过由于此类机制的存在导致服务故障的可能性。自动更新可配合自动测试机制一起使用,这样也可以更自信地推出安全更新。
- 加固底层操作系统:服务通常需要对底层操作系统进行最小量访问。因此操作系统可以对服务能或不能做什么施加严格的限制。这种做法有助于控制服务中可能存在的弱点。
- 不要自行编写加密代码:这种代码很难写得足够好,并很容易产生已经写得足够好的错觉。建议始终选择知名的,并已得到广泛应用的实现。