微服务到底该多大?如何设计微服务的粒度?

本文最初发布于 Medium 博客,经原作者授权由 InfoQ 中文站翻译并分享。

微服务架构似乎终于成为了一种架构模式。上个月,距离 Martin Fowler 和 James Lewis 发表这篇 关于这一主题的开创性论文 已过了 6 年。感觉我们在与任何人讨论架构时,都会涉及这个主题,即使只是这个主题的一部分。

与任何新技术一样,微服务的发展也符合 Gartner 的技术曲线 。从在推特上收集到的信息来看,它处于低谷期,或者是复苏期的早期阶段。也就是说,在过去 6 年左右的时间里,我们在构建微服务方面积累了一些重要经验,其中之一就是要确保以恰当的方式考虑每个微服务的范围。

众所周知,微服务往往把人往沟里带,并没有解决他们的实际问题。当你想到“微服务”这个词时,首先看到的是前缀“微”(Micro)。据 An Introduction to Greek 一书的介绍,在柏拉图和亚里士多德那里, μικρός 只表示少或小。在日常英语中,“微”往往表示小得异乎寻常的东西——毕竟,“微米”是一米的百万分之一,而“显微镜”是用来观察那些用肉眼看不见的东西,因为它们的尺寸太小。

问题就出在这种不同的认知上。与之前的大型单体服务相比,微服务应该是“小”的。然而,它不应该太小—— 把微服务做得过小是很多团队实现微服务架构时遇到的最常见错误之一

正是这种“微服务必须非常小”的思想导致了这个问题。关于微服务,我们经常听到的另一个抱怨是,在银行等复杂的领域使用它们太困难了,因为所需的 REST 或消息传递接口没有提供跨多个微服务进行两阶段提交的方法。每当我们听到这种抱怨时,头脑中的警钟就会响起——通常,这种抱怨是一个症状,表明团队把他们的微服务看作是非常微小的东西。为了解决这个问题,让我们从一个简单的示例开始寻找解决方法,然后再回过头来,进一步考虑该解决方案对整个微服务架构的影响

示例从设计一个简单的 Account 服务开始,从这个没有包含微服务实现的设计开始,以便你可以看清它的演进过程。假设团队正在使用 领域驱动设计 (稍后会详细介绍),在他们的第一次尝试中,发现 Account 实体需要引用两个关联依赖实体——Entry 和 Owner。

最初的 Account 设计

很快,团队就发现 Account 上有一些定义良好的操作:借、贷、开、关。幸运的是,这些操作可以很好地映射到 REST 接口,因此,采用这种简单的基于实体的设计并将其映射到微服务实现起来比较容易。然而,很快他们就发现了问题——他们还没有弄清楚账户之间如何进行转账,如下图所示:

具有 Transfer 服务的 Account 设计

问题是,在账户之间转账需要一个全新的 REST 接口,这一点显而易见。问题是如何实现,这是否代表了一个全新的微服务?

最简单的假设是(也是许多团队都会采用的假设),微服务和 REST 接口之间应该存在 1 对 1 映射。然而,当把这个映射关系应用示例中时,我们很快就遇到了一个问题:新的微服务如何实现?最简单的方法似乎是让 Transfer 微服务调用 Account 微服务两次;一次从“from”账户借出,一次贷入“to”账户。所以,我们的实现可能是这样的:

调用其他服务的服务实现

但这将陷入前面讨论过的两阶段提交问题!如果借出成功,贷入失败,那么这个客户账户里的钱就少了,还没有任何追索权。这种情况显然是不可接受的;因此,许多团队尝试了以下解决方案:

在数据库引入耦合的 Account 设计

这解决了两阶段提交问题,但这是在数据库中引入耦合,违反了微服务设计的原则之一,即服务应该拥有自己的数据,而不是通过共享数据库“隐式”耦合。正确的做法是什么呢?在我看来,许多团队都走远了,并开始追求涉及 Saga 模式的解决方案,以便通过补偿事务来处理问题。虽然 Saga 模式可以解决问题,但它不是这种简单的情况的最优解决方案。

考虑下下面的解决方案,它打破了之前的那个假设:

在服务边界暴露多个服务

在这个解决方案中,我们重新考虑了之前假设的那个约束——一个微服务正好对应于一个 REST 接口。这个假设已经写入网络上的许多微服务教程中。然而,如果你仔细阅读 Fowler 的原始论文,就会发现其中从未指出过这个假设。在微服务边界上可以暴露多个服务。但这并不是这个小练习的真正目的。让我们回到主题,通过这个练习来阐明更常见的问题。

我们在上文中说过,示例中的团队使用领域驱动设计作为其微服务设计流程的一部分(要了解更多信息,请阅读 Eric Evans 的著作《领域驱动设计:软件核心复杂性应对之道》)。在这方面,团队是对的。我们看到,在微服务设计领域,其中一个最大的问题是,他们经常不是从领域驱动设计这样的技术开始,而是从其他地方开始,比如从现有系统的设计开始,并试图从那里得出自己的微服务。或者,从一个架构开始(通常以工具和框架的形式指定),然后尝试让微服务“有机地”发展。在这两种情况下,最终得到的都不是我所说的微服务——它们往往非常注重技术,与企业业务完全无关。而我们所说的微服务是用业务术语描述的“业务微服务”,业务人员可以识别它们,并可以从设计中找到它们。设计不足的症状之一是解决方案中很少或基本没有业务微服务,这是因为没有从业务词汇表开始设计。从业务词汇表开始设计是至关重要的一步,这就是为什么我们建议所有构建微服务的团队将领域驱动设计作为其设计过程的一部分。

如果不首先从业务词汇表入手,那么通常会搞成如下架构:

你们很多人看到这个可能会说“这到底有什么问题吗?”这看起来就是我们要的微服务架构!要回答这个问题,我们必须回顾下 Fowler 在他最初关于微服务的论文中提出的观点。他和 Lewis 提出的观点是,当你团队由技术专家组成时,所生产的软件也将按技术领域进行组织——这就是实践中的 康威定律 。Fowler 的解决方案是,按业务能力组织跨职能团队。

当你开发一个与上文类似的微服务架构时,就已经回到了微服务本来要解决的问题!你不仅重新创建了一个单体,而且还是一个分布式单体,情况变得更糟糕了。这违反了可能是 Martin 写过的最重要的一句话:

Fowler 的分布式对象第一定律:不要使用分布式对象。

那么,更好的架构应该是什么样的呢?首先在最上层,考虑下垂直画线,而不是水平画线。

从大处着眼,微服务架构就是这个样子。该架构由一组独立的服务组成,按业务领域组织,而不是面向复杂的端到端网络(取决于技术领域划分)。回顾完什么是微服务,让我们回到文章开头提出的问题——每个微服务应该有多大?我们从下界开始,然后逐步推出上界。

下界:微服务应该包含至少一个聚合(或至少一个独立实体)以及操作该聚合实体的相关服务。

为了理解这个定义,首先要了解其中的两个术语。 聚合(Aggregate ) 是领域驱动设计的概念,我们已经看了它的一个具体例子。聚合是一组相关的实体,它们的生命周期被捆绑在一起,可以把它们作为一个单独的单元来对待。典型的例子是 Order/LineItem,我们例子里的 Account/Entry 只是它的一个变体。

第二个术语我们也很熟悉,但可能不是你想的那样。我们在使用 DDD 定义服务时使用了这个术语。服务是功能的“具体化”——我们在前面的示例中提到的“Transfer”就是这一思想的典型示例。在 Evans 的书中,他建议我们将这些对象建模为独立接口,并称之为无状态服务,它们的接口通过领域模型的其他元素(实体和值对象)定义。关键是,这里的服务指的是一个领域概念,它并没有映射到任何特定的实体或值对象。

这里最重要的设计要点是,在考虑微服务要多小时,必须非常仔细地考虑事务边界。首先,你必须考虑微服务中涉及的实体的生命周期,即我们通常从持久化角度考虑的创建 / 读取 / 更新 / 删除周期。你必须考虑到所有可能发生在实体组上的更新——这些是服务将要识别的东西。你需要考虑的不仅仅是像转账这样的简单的一对一事务,而是需要更广泛地考虑领域内关于实体组的其他操作,特别是关于批量更新和复杂查询之类的事情。

关于这一点,一些纯粹主义者可能会大喊:“等等!这样的话,我的微服务将需要 10 个,也许 20 个独立的 REST 接口!”也许就是这样。如果领域的特定方面确实很复杂,需要在一组实体上进行许多操作,那么它就应该作为微服务发布的最小单元。它更像一个迷你型的单体,但这好过解决由于服务划分太小所带来的问题。

我们发现,一开始把微服务做得太大好过做得太小。采用较大的(粗粒度的)微服务并将其分成两个要比采用两个细粒度的微服务并将它们组合起来更容易。

找到合适的抽象级别

如果这是微服务设计的合适下界,那么在实际设计时该如何识别所有这些聚合,特别是与这些聚合相关的服务呢?领域驱动设计社区最近(过去几年)针对这个问题给出了一个非常好的答案——通过 事件风暴 开始设计过程。

事件风暴(参见 这里这里 )是 Alberto Brandolini 发明的一个研讨会和过程,团队可以使用便签和白板来快速识别业务领域内最重要的 事件(Event) ,将这些事件按时间排列,然后确定触发事件的 命令(Command) ,执行这些命令所需的 数据(Data) ,以及表示事件前后关系的 策略(Policy)

在我们与客户一起使用事件风暴的过程中,我们发现它提供了一种可重复的、易于理解的方法,可以用于在与领域专家讨论领域词汇表时识别实体和聚合。但最重要的是,它不只是通过实体和聚合标明数据结构,它还会展示用户操作这些实体创建事件的命令,以及通常“隐藏”的策略,即将系统的各个部分连接在一起的业务逻辑。我们不会像上面的链接那样带你完成整个过程( 这里 有一个完整过程的示例),但是我们将向你展示一个研讨会的结果示例。

事件风暴研讨会结果

在事件风暴中,黄色的便签是操作系统的用户(表示为角色)。绿色的便签是用户通过命令(蓝色的便签)与之交互的数据元素(聚合或偶尔是单独的实体)。橙色的便签是事件,它们有的是执行命令时产生的结果,有的是从另一个外部实体接收某种信息时产生的结果,还有的是以策略形式执行业务逻辑而产生的结果。

但重要的是最后的处理。如你所见,操作特定数据集、生成特定事件集的命令都分别分组。这在适当的粒度级别上完成了微服务初步设计。这是因为,这个流程本身在早期就倾向于将不同的参与者以及他们与系统交互的事件分开。最终得到一种由外向内的设计,避免了由于只关注数据结构或微服务之间交互的纯技术考量而导致的许多过早优化。但这只是初步设计。在初次尝试设计时就控制好微服务粒度是非常困难的。你需要对设计进行几次迭代,以达到最恰当的粒度。

因此,如果聚合及其关联的服务对象是微服务大小的合适下界,那么合适的上界是什么?在这里,我们从本文一直在讨论的领域驱动设计转到一个完全不同的问题:

上界:微服务的大小应该能让“双披萨团队”在一天内向生产环境发布一个完整的、适当大小的用户故事。

现在,一定有部分读者非常生气。其中一些人已经在喊,“但是这取决于你拥有什么样的 CI/CD 工具,你的测试过程,你的 QA 过程,你的用户故事大小,甚至你的团队的开发速度!”对此,我们的回答是 确实如此

这个行业采用微服务的全部原因就是让团队可以更快地发布软件,而且问题更少。到目前为止,大多数人已经阅读了 Nicole Forsgren 等人的著作《加速:精益软件和 DevOps 的科学:如何构建和扩展高性能的技术组织》。在这本书中,其团队研究得出的最重要的结论是,软件交付的速度(团队的发布频率)与高绩效团队的各种优秀因素相关。表现最好的团队也是发布频率最高的团队。这就触及了上述问题的核心。

微服务比单体服务更有吸引力的原因是可以减少在使用单体服务时遇到的问题。通常,单体服务的测试周期可以延续数天或数周,它们非常复杂,修改或扩展都非常有挑战性。如果你的团队能够一天添加一个新特性,那么你就不会受到测试周期、修改或复杂变更的阻碍。

但我们也听到了一些不同的声音,当我们向团队建议这种节奏时,他们就开始寻找为什么不能达到这种快速发布原因。他们开始讨论“敏捷”过程开销,比如持续数小时的站立会议,或者上层管理者命令他们使用 Jira 之类的工具减慢了他们的速度,或者比这些更糟,在将任何东西发布到生产环境之前,都需要经过变更委员会。如果你也有类似问题,可以参考下面这个略有争议的建议:

除非你已经理解并成功地实现了敏捷方法和 DevOps 原则(如自动化测试和持续集成),否则不要大规模采用微服务。

另一个适用于上限的常用试验是,如果微服务失败,检查一下业务功能的退化程度。如果失去的业务功能不只一个,要么说明你的微服务粒度太粗,应该重构,要么说明太多的业务流程依赖于这一特定微服务(它被放在几种不同的流的关键路径上)。

总结一下。微服务是一种出色的架构创新,它有可能彻底改变我们多年遇到的单体架构问题。当在适当的粒度级别上实现微服务架构时,系统会敏捷,并减少决策的影响范围,而且效果相当明显。

但微服务并不是万能药。在成功地应用微服务之前,团队在 DevOps 和敏捷实践方面必须已经相当成熟。微服务设计应该是一个迭代过程。如果你的团队不能采用该迭代过程,那么要么微服务不适合你的团队,要么你的团队需要改变。我们发现,在修改系统之前,我们通常并不真正知道一组微服务的粒度是否合适。如果你发现实现新业务功能需要修改多个业务微服务,那这种粒度可能不合适(粒度太细)。另一方面,如果发布变更 / 新特性所需的测试量与编码工作量相比过于冗长,则表示粒度可能过粗。无论哪种情况,解决方案都是进行适当的更改,然后总结经验并更改流程,以避免将来其他微服务出现问题。

原文链接:

What’s the right size for a Microservice?