从单体架构到微服务架构

我在Martin Fowler网站上读到一篇名为How to break a Monolith into Microservices的微服务文章,作者为ThoughtWorks的咨询师Zhamak Dehghani,介绍了如何从单体架构演进到微服务架构。

微服务生态系统

在讲解如何拆分之前,Dehghani首先介绍了微服务生态系统(microservices ecosystem),她认为微服务生态系统是“封装了业务能力的服务平台”。Martin Fowler与James Lewis总结的微服务特征中,也提及“通过业务能力组织服务”。认识到这一点,对于微服务拆分而言非常重要。微服务首先是业务手段,然后才是技术手段。Dehghani定义了“业务能力”:

A business capability represents what a business does in a particular domain to fulfill its objectives and responsibilities. 业务能力体现为在特定领域为达成其目标与职责的相关业务。

微服务生态系统如下图所示:

该生态系统牵涉到微服务的特征,团队的职责和组织结构,开发实践等,这些因素横跨业务、组织与技术诸方面,说明系统的微服务架构迁移不仅仅需要在技术层面做好准备,还需要在整个企业或团队层面做好充分准备,否则就可能“出师未捷身先死”!

旅程的开始

从单体架构到微服务架构是一个漫长的旅程。在开始演进之前,Dehghani建议最好结合Martin Fowler给出的微服务前提条件对系统和团队进行评估。在开始拆分第一个服务之前,开发与运维团队应该事先准备好系统的基础设施、持续交付管道以及API的管理系统。这样可以尽可能地保证单体系统的客户端不会受到服务拆分的影响。

在开始拆分单体架构的服务时,需得小心行事。如果从一开始就选择拆分核心领域的微服务,风险就太大了。Dehghani建议从一些边界服务(edge services)开始着手,例如认证服务。拆分这样的服务难度低,对整个系统的影响也较小,方便团队快速上手,算是拆分服务的一次“热身”。一旦拆分出一个微服务,就开始了架构风格的转换,与此同时,就可以测试微服务的整体架构是否正确。这就好像在单元测试中运行第一个测试一般,哪怕测试变红了,它也是有价值的,因为通过它的成功运行可以确认测试环境是正确的。这个演进过程如下图所示:

降低对单体系统的依赖

在拆分微服务时,处理依赖是至关重要的一点。我们必须确保一个微服务具有快速而独立的发布周期。由于单体架构与微服务架构粒度的不同,必然会导致二者在相当长的一段时间内存在依赖关系。要注意彼此之间的依赖方向!Dehghani认为应该减少微服务对单体系统的依赖。换言之,对付依赖的原则是:

  • 首先尽可能去掉二者之间不必要的依赖关系
  • 如果需要依赖,应优先考虑单体系统依赖微服务,而不是微服务依赖单体系统

例如针对一个零售在线系统,“购买”和“促销”都是系统的核心业务能力,在顾客付款的过程中,“购买”会调用“促销”以获得最佳的促销优惠。如果我们需要将这两个核心能力作为微服务从单体系统中拆分出来,那么拆分的顺序应该怎样?Dehghani认为应该先拆分“促销”,此时“购买”服务仍然在单体系统中,且依赖于新分离出来的“促销”服务。这样的拆分顺序可以减少对单体系统的依赖。

为何要遵循这样的原则?正如文中所说:“微服务需要具有快速而独立的发布周期”,如果让微服务依赖单体系统,就会让微服务变得很笨重。这就好似一只本该灵活飞翔的蝴蝶,正摇摇欲坠地拉着一头笨重的大象。

倘若无法避免新的微服务对单体系统的依赖呢?这就需要引入领域驱动设计介绍的防腐层(anti-corruption layer),即在单体系统中定义一个新的API,然后让新的微服务通过这个新API访问单体系统。新的API应遵循微服务API的设计原则,体现该服务的领域概念和结构。之所以引入防腐层,目标有二:

  • 避免单体系统的代码实现直接泄漏给微服务
  • 便于在未来用新的微服务实现替换单体系统的实现

这两种依赖方向如下图所示:

优先分离黏合逻辑

单体架构为人诟病的一点在于它太容易随着时间推移而变成一个高度耦合的“毛线球”。各个功能纠缠在一起,没有体现出清晰的领域逻辑,这会给微服务拆分带来很大的障碍。因此,Dehghani建议优先识别出单体系统中的这些黏合逻辑(Sticky Capabilities),分解它们,并定义出良好的领域概念,然后再将这些领域概念分解到各自独立的微服务中。

如何来理解“黏合逻辑(Sticky Capabilities)”?我个人认为文中提到的“黏合逻辑”,并非系统的核心业务能力,而是一种近乎于横切关注点的功能,又或者说是一个全局的数据结构(变量)。文中以session为例,单体系统中的客户属性、期望列表、支付偏好等信息都放在session之中。这时,我们就应该从具有session的这段黏合逻辑中识别领域逻辑,然后依次将这些领域逻辑分离出来形成独立的微服务:

注意,这块黏合逻辑并不需要分解为专门的微服务,例如分解为会话服务。

我在为客户做的一次微服务迁移时,也遭遇了同等问题。在原有的单体架构中,定义了诸多全局变量,并被系统中的大多数业务功能使用。这些全局变量就像一个公共澡堂,谁都可以进来泡澡。微服务是独立而私有的,当然应该在自家宅里修建自己的浴室了。所以这一原则的确立,其实是从解耦的角度思考的。

解耦的一个前提是识别依赖,Dehghani推荐诸如Structure101这样的依赖分析工具来识别单体系统中耦合最为紧密的代码。

纵向解耦并尽早发布数据

这里所谓的“纵向(Vertically)”解耦,就是从客户端发起调用的服务API到数据库进行“一刀切”。这一原则颇让我出乎意料,因为我个人认为:数据库共享架构可以作为从单体架构到微服务架构的一个过渡;但是,Dehghani认为从微服务的“去中心化数据管理(Decentralized Data Management)”特征看,应尽早解耦数据库。

在解耦数据库时,若单体系统中的多个功能都需要访问一些共享数据,这部分功能就会制造障碍。在拆分之前,与这些功能相关的所有团队需要讨论确定数据迁移的策略,然后再对服务加数据进行拆分:

注意,在拆分服务和数据时,需要确定数据访问的边界。一个原则是数据库中的数据只能被一个微服务调用,如果其他微服务也需要访问这些数据,则应该通过拥有这些数据的微服务进行访问。因此,我们需要在拆分时弄清楚数据的归属权。

解耦核心领域和变化频繁的领域

从单体系统演进到微服务时,需要叩问自身:为什么要演进到微服务?不能为了拆分而拆分,而应该在拆分微服务时,随时权衡拆分的成本与收益。这就充分地解答了本原则的缘由。为何要重点解耦核心领域以及变化频繁的领域?因为核心领域的价值要远超其他子领域,因为变化频繁的领域在单体架构中的维护成本太高。前者是因为拆分后的收益高,后者是因为不拆分带来的成本高。

要应用这一原则,可以引入领域驱动设计的战略设计,通过识别系统的核心领域与子领域,由此确定提取微服务的边界和目标。领域是否为核心领域,与痛点和价值有关,这需要结合客户的愿景、目标和经营模式等因素综合考量。例如在文中,Dehghani认为“客户个性化”是核心领域,因为它能够为客户提供更好的用户体验,有助于提高客户的黏度。

依据能力而非代码去解耦

当开发人员从已有系统中提取服务时,无非两种形式:提取代码或重写能力。多数情况下,技术人员往往采用提取方式来重用现有实现。这其实是一种感知偏见(cognitive bias),对于自己设计和编写的代码抱有偏执的热爱。这种感知偏见在心理学中被称之为“宜家效应”。

我们应该抛开这种偏见,重新审视这二者带来的成本和价值。重写未必代价高昂,毕竟我们可以抛开过去的技术债务轻装前行,正所谓“无债一身轻”嘛。所以Dehghani给出了一个原则:重用和提取高价值低毒性的代码,重写和废弃低价值高毒性的代码。什么是代码毒性(code toxicity)?我想,应该就是Martin Fowler在《重构》一书中提及的代码坏味道。若需评估代码毒性的高低,可以使用CheckStyle等工具。

先宏观再微观

识别微服务边界的常见方法是运用领域驱动设计的限界上下文(Bounded Context)。虽然名为微服务,但服务的粒度不能没有原则的追求“微小”。过于微小的服务既会带来服务的“大爆炸”,还会出现大量只有CRUD操作的贫血服务(anemic service)。

微服务到底有多“微”,这是一个问题。衡量的指标包括团队的规模、重写一个服务花费的时间、服务封装了多少行为。但这些指标并无客观的量化值,同时,还得取决于这个系统自身的规模与业务复杂性。例如,我们自己开发一个学习型电商系统,它所拆分的微服务粒度与数量显然不能与天猫、京东这样的大型电商系统相提并论。在没有量化指标的指导下,若需要凭经验设计,就应该遵循Dehghani给出的建议:先从更大的服务开始,直到微服务的基础条件都满足了,再寻求对大的服务进行拆分。这些基本条件包括团队对业务的理解、对单体系统的理解、持续交付环境的准备等。

例如,在解耦零售系统时,团队最初提取的“购买”服务包含了购物车与结账功能。随着团队的微服务成熟度越来越高,也能够轻易地分解团队,形成小快灵的微服务团队,这时就可以考虑将购物车与结账功能分解出来,形成专门的微服务:

架构演化的原子步伐迁移

重构教导我们要“小步前行”,但真正的意图是要保持重构步伐的原子性(atomic),这样就能保证进可攻退可守的态势。单体架构向微服务架构的演进也需要遵循这一原则。若前进的步伐是正确的,且演进的内容是完整的,则意味着我们向着演进目标又靠近了一步;若前进的步伐出现了偏差,我们也能够轻易回滚。

Dehghani在文中给出了一个案例来阐释这一理念。假定微服务架构的目标是提高开发人员修改整个系统的速度,快速交付价值。团队决定将用户认证分解为一个服务,并基于OAuth 2.0协议来实现。该服务的目的是替换单体系统中的认证功能,那么演进的步骤就分为:

  • 构建一个Auth服务,采用OAuth 2.0来实现
  • 在单体系统中添加一个新的认证路径,然后调用新实现的认证服务

如果演进到这一步,团队的工作暂时停止,那么这个演进步伐就不是原子的,因为它使得系统处于一种不稳定的“中间状态”,单体系统的开发人员需要同时维护两条认证路径,增加了开发、测试和维护的成本。因此,我们不应该停下演进的步伐,而应继续前行:

  • 用新服务替换单体系统中旧有的基于用户名/密码的认证
  • 从单体系统中去掉旧有的认证实现代码

文中总结了单体系统分解的原子单元:

  • 解耦新服务
  • 将所有消费者指向新服务
  • 废弃单体系统中的旧代码

2013年,我在Scrum Gathering大会上的演讲《引入敏捷实践完成技术栈迁移》讲述了类似步骤,遵循的原则其实就是“抽象分支(Branch of Abstraction)”,在针对遗留系统进行解耦或旧代码替换时,往往会采用这种手法。故而步骤不是关键的,关键的是如何认识“原子性”。我认为,原子性就应该保证演进的功能是最小粒度的完整,且不允许新旧代码同时存在。

说明:本文中的插图全部来自于Zhamak Dehghani的原文。