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