微服务拆分之道
——Zhamak Dehghani
原文
解耦何物,何时解耦
当单体系统庞大到无法应付时,大多企业都会被迫把它们拆分成微服务。这是一次值得的旅行,但却非坦途。我们学到了一些如何把这件事情做好的东西。我们需要从简单的服务入手,然后把对业务重要的经常变化的垂直功能的服务挑出来。这些服务首先应该是大的,而且最好不依赖单体系统的其他部分。我们应该确保迁移的每一步都代表一个对整个架构的原子性改善。
迁移单体系统到一个微服务生态系统将会是一场史诗搬的旅行。开启这段旅行的人们都希望能够扩大运维的规模,加快变更的步伐以及避免变更的高昂代价。他们希望他们团队的数量获得增长,同时可以彼此独立并行的交付价值;他们希望快速试验核心业务功能,更快地交付价值;他们也希望避免因对已有单体系统的改变产生高成本。
把一个单体系统拆分进微服务生态系统,解耦什么功能,何时及如何增量迁移是架构挑战的一部分。在这篇文章中,我将分享一些技巧以指导交付团队——开发人员、架构师及技术经理——在这次旅途中如何拆分服务。
为了阐明这些技巧,我要借用一个多层的在线零售系统。该系统把用户接口,业务逻辑和数据层紧密结合在一起。我选择这个例子的原因是因为它的架构具有许多运行业务的单体应用的特征,而且它采用的技术栈也是很先进的,非常适合分解,而不是完全重写和替换。
目的地——微服务生态系统
在踏上旅途之前,拥有一个对微服务生态系统的一致的理解对我们来说是关键的。微服务生态系统就是一个服务平台,其上的每个服务封装了一个业务功能。业务功能代表在一个具体的域里面为了实现业务目标和职责而做的事情。每个微服务暴露一个API,开发者可以发现并以自服务方式使用它。微服务拥有独立的生命周期。开发者可以独立的构建、测试和发布每个微服务。微服务生态系统实施的组织结构中,团队是自治的且长期存在的,每个团队负责一个或多个服务。与人们普遍的认知不同,与微服务这个词中的‘微’字面意思不同,每个服务的大小并不重要,它会因组织运维成熟度不同而变化。正如Martin Fowler所说:“微服务是一个标签而不是一种描述”。
旅行指南
在开始深入了解本指南之前,认识到拆分已有系统到微服务总体成本会很高而且可能需要多次迭代很重要。开发者和架构师必须仔细评估是不是有必要分拆单体系统,微服务本身是不是其真正的归宿。讲完这些,让我们来看看本指南。
从简单,能合理解耦的功能开始热身
开始微服务之旅需要一个最低级别的运维就绪。它需要按需访问部署环境,创建新类型的CI管道,独立编译,测试和部署可执行的服务。同时它还需要为分布式系统提供安全,调试以及监控的能力。无论是构建全新(greenfield)服务还是分解已有系统都需要成熟的运维就绪环境。关于运维就绪的更多信息,可以阅读Martin的关于微服务前置条件的文章。不过,好消息是从Martin的那篇文章之后微服务架构运维技术得到快速的演进。其中包括诞生了Service Mesh——一个具体的运行快速、可靠及安全的微服务网格的基础架构层,提供更高层面的部署架构抽象的容器编排系统,像GoCD这样的以容器的方式编译,测试和部署微服务的CD系统。
我的建议是开发和运维团队构建底层基础架构,为第一个和第二个被分解出来或新构建的服务构建CI管道和API管理系统。我们从单体系统中能合理分解出来的功能开始。因为,他们不需要改变许多正在使用单体系统的面向客户的应用,可能也不需要数据存储。在这个时候交付团队需要优化他们交付方法,为团队成员提供技能培训,建立最小化的基础架构来交付安全的可独立部署的暴露自服务API的服务。比如,对于在线零售系统,第一个服务可能是这个单体系统调用验证用户的“终端用户验证”服务。第二个可能是“用户Profile”服务, 它是一个面板服务,为新的客户应用提供一个更好地消费者视图。
首先,我建议解耦简单的边界服务。其次我们采用不同的方法来分解深度嵌入在单体系统中的功能。之所以建议一开始分解边界服务是因为在这段旅行之初,交互团队最大的风险在于不能对微服务进行合适的运维。所以可以使用边界服务来实践他们所需的“运维前提”。一旦他们解决了运维先决条件相关的问题,他们就能够应对拆分单体系统的关键问题。
最小化对单体系统的向后依赖
作为一个最基本的原则,交付团队应该最小化新形成的微服务对原单体系统的依赖。微服务主要的一个优势在于具有一个快速且独立的发布周期。与原单体系统的数据、逻辑或者API的依赖会把服务与单体系统耦合在一起,妨碍微服务优势的发挥。通常我们从单体系统分解出服务的原因是与其绑定的功能变更的高成本和低效率。所以,我们希望通过消除对这个单体系统的依赖逐步解耦这些核心功能。假如团队遵守这个原则构建功能服务,他们会发现依赖都是反向的从单体系统到服务。这是所期望的依赖方向,因为这不会影响新的服务的变更速度。
考虑在线零售系统,“购买”和“促销”都是核心功能。“购买”的功能结账的时候会使用“促销”功能根据用户购买的产品为他们提供有效的最佳优惠。假如我们需要决定先解耦这两个功能的哪一个,我建议先解耦“促销”,再解耦“购买”。因为按照这个顺序我们就能够减少对这个单体系统的依赖。按照这个顺序,“购买”起初还会维持在老的单体系统内,但却是依赖新的“促销”微服务。
下一个指导原则是关于开发者决定解耦服务顺序的其他方式。这意味着他们不总是能够避免要反向依赖原单体系统。如果新的服务最终需要回调单体系统,我建议从单体中暴露一个新的API,在新的服务中通过一个“防腐”层来调用该API以确保单体系统的概念不会浸染微服务。要尽量确保这个API按照定义良好的域慨念和结构定义,即使单体系统内部实现可能不同(注:该新的API可以理解成是一个适配器,它按照新的微服务定义良好的业务域概念设计,但它需要实现单体系统中老的域概念到新的域概念的适配。通过这个API使得拆分出来的微服务对单体系统中旧的域概念透明,防止了旧的域概念“入侵”。该API起到了防腐层隔离的作用)。在这种不好的情况下,交付团队可能承受改变单体系统带来的成本和难度的问题,需要配合单体系统的发布一起测试发布的新的系统。
黏连性功能早拆分
假设现在交付团队可以很愉快的构建微服务了,并且准备好了解决棘手的问题。然他们发现接下来的功能拆分不依赖单体系统是不可能的。存在这个问题的根本原因常常是因为单体系统内的功能域概念设计部完整,没有良好定义,单体系统内许多其他的功能依赖它。为了能够继续,开发者需要识别这类黏连性功能,分解成定义良好的域概念,然后把这些域概念具体化到不同的服务中。
比如,在一个Web单体系统中“会话”的概念是最常用的耦合因子之一。 在那个在线零售的例子中,会话常常就是一个篮子,装有许多属性,从涉及不同域边界的用户偏好如发货和支付设置等,到用户的意图和交互,如当前访问页面,点击的产品和意向清单等。除非我们对当前“会话”概念解耦,分解及具体化,否则我们将为解耦未来的诸多功能而疲于奔命,因为他们会因为这个不严谨的会话概念和单体系统“纠缠”在一起。我也不鼓励在单体系统之外创建“会话”服务,因为它也只会导致和目前在单体进程中存在的相似的紧密耦合。而且,情况会更糟糕,因为这种耦合是进程外跨网络的。
开发人员可以从逐步从黏连性功能中提取出微服务,一次一个。比如, 重构“消费者意向清单”,并提取出来创建一个新的服务,然后重构“消费者支付偏好”,产生一个新的微服务。如此重复。
使用依赖和结构化代码分析工具,比如Structure101,来识别单体系统中耦合度最高,约束最大的功能因子。
垂直拆分并尽早切分数据
从单体中把功能拆分出来主要是为了能够单独发布这些功能。第一个原则就是指导开发人员进行如何拆分。单体系统经常是由紧密集成的多层,甚至是彼此依赖脆弱而且需要同时发布的多个(子)系统组成。比如,在线零售系统是由一个或多个面向客户的在线购物应用,一个实现许多业务功能的后端系统以及用于保存状态的中心化集成的数据存储组成。
许多拆分都是企图从提取出面向用户的组件入手,同时拆分出几个面板服务为开发者提供友好的新式用户界面的API。然而,数据依然维持在一个schema和一个存储系统中。这种方法可以快速获胜,比如可以更频繁的改变用户界面。但是当设计核心功能时,交互团队只能以单体系统和其数据存储变更最慢的部分效率进行。简单来说,数据没有拆分,就不是微服务架构。把所有数据保存在同一个数据存储里也是不符合去中心化数据管理微服务特征。
为此,我们的策略就是把功能垂直移出,将核心功能和数据解耦,并且把所有前端应用都重定向到新的服务API。
多个应用共享中心化数据是切分拆分后服务所使用的数据的主要障碍。交付团队需要采用适合他们环境的数据迁移策略,这取决于他们是否能够同时重定向和迁移所有的数据读写者。Stripe的四阶段数据迁移策略是其中之一,它被应用到要求逐步迁移与数据库集成的应用,同时所有系统都可以持续运行的环境中。
避免仅仅拆分前端用户面板和后端服务而不拆分数据。
拆分重要且频繁变化的业务
把功能从单体系统中拆分出来是件困难的事情。我听到过Neal Ford用周密的器官手术对此做过类比。在在线零售应用中,拆分出一个功能需要把该功能涉及的数据、逻辑和用户接口组件仔细地剥离出来,然后重定向到新的服务中。这需要花费不少的工作量,开发者需要基于他们获得的好处,比如提高效率,扩大规模等持续评估拆分的成本。举例来说,如果交互团队目标是加速对已有的被困于单体系统中功能的修改,那么他们必须识别出一直被修改的最多的功能然后把它拿出来。把那些不断变化的代码分离出来。这些代码正在获得开发人员大量“关爱”,但又在束缚他们快速发布价值。交互团队可以分析代码提交模式找出以往变化最多的代码, 然后结合产品路线图及其组合来了解最期望的在不久的将来亦最受关注的功能。开发人员需要与业务和产品经理来交谈以了解对他们来说真正重要的差异化的功能。
拿在线零售系统的例子来说,“客户个性化”功能需要反复进行试验以便为客户提供最好的体验。所以它是一个好的拆分候选项。它是一项对业务,对用户体验很重要而且会频繁变化的功能。
使用社交代码分析工具如CodeScene发现最活跃的组件。如果编译系统在每次代码提交的时候恰好会触及或者自动产生代码,请确保过滤掉了这些“杂音信号”。把这些频繁变化的代码和产品路线图即将发生的变更相对应然后找出需要拆分的交叉点。
拆分功能,而不是代码
不管什么时候,开发人员想从已有系统里头剥离服务有两种方式:代码萃取和功能重写。
通常缺省的情况下,服务提取或者单体分解被假定为重用原来已有的实现并把它摘取出来放入单独的服务中。这样做的部分原因是我们对自己设计和编写的代码存在认知偏差。不管过程如何痛苦结果如何不完美,付出的劳动会使我们对代码产生感情。事实上,这被称作为宜家效应。不幸的是,这种认知偏差将会妨碍单体系统分解的工作。它会导致开发人员,甚至包括技术经理即使萃取成本高但价值低也要重用代码。
作为一种选择,交互团队可以重写功能,而废除老的代码。重写为他们提供一个机会来重新审视业务功能,启动与业务会话,简化其往业务流程,挑战随着时间推移在老的系统形成的陈旧的假设和约束。这也为技术更新,为使用对具体服务最适合的技术栈及编程语言实现服务的机会。
就在线零售系统来说,“定价”和“促销”功能是一块复杂的智能化代码。它能够动态配置应用定价促销规则,基于诸如消费者行为、忠诚度及产品包等各种因素提供折扣和优惠。
上述功能比较适合代码萃取重用。然而,“用户Profile”是一个简单的CRUD功能,其主要由系样板式的系列化,处理存储和配置的代码组成。所以它比较适合弃用老代码而重写。
根据我的经验,在大部分分解场景中,团队最好重新实现新的功能而废弃老的代码。但因为下面的原因,可以考虑高成本低价值的重用:
存在大量的处理环境依赖的样板式代码,比如在运行时访问应用配置,访问数据,缓存,或者是使用老的框架构建的。这些样板式代码大部分需要重写。而新的运行微服务的框架与十几年前的老框架差别巨大,需要使用几乎不同的样板式代码;
很有可能已有的功能不是围绕清晰的域慨念构建的。这会导致没有反映新的域模型而需要进行大重构的数据结构传输和存储;
长期遗留的代码,经历了许多次变更迭代,可能具有很高级别的代码毒性和较低的重用价值。
除非是关联的,和清晰的域概念保持一致的,具有高知识产权的功能,我强烈建议对其重写,弃用老的代码。
使用代码毒性分析工具如CheckStyle来决定重用还是重写
先大后小
从遗留的单体系统中寻找域边界是一门艺术也是一门科学。作为一般规则,应用域驱动技术寻找定义微服务边界的“有界上下文”是一个很好的起点。我承认,我经常看到从大的单体到真正小的服务的“矫枉过正”。设计这些小服务是受已有的规范化的数据视图启发和驱动的。这种识别服务边界的方法几乎总会围绕创建、读取、修改和删除资源产生大量的弱服务,从而形成“寒武纪生命大爆炸”。对于微服务架构的新手来说,这会产生一个高摩擦的环境,最终导致无法使那些服务进行独立发布和运行。这会创建一个难于调试的分布式系统,一个跨事务因而难于保持一致性的分布式系统,一个对于相对组织运维成熟度来说过于复杂的系统。尽管有一些关于微服务应该多“微”的探讨,如团队的大小,重写服务的时间以及什么样的行为必须封装等,我的建议是微服务的大小取决于交互运维团队能够独立发布,监控和运维多少服务。开始可以是围绕逻辑域概念的大服务,当团队运维就绪的话,再分拆成多个服务。
就分解在线零售系统来说,开发人员开始可以使“购买”服务既包含包含购物袋上下文功能也包含购买的功能,比如“结账”。随着他们能够成立更小的团队,能够发布大量的服务,他们可以把“购物袋”分离出一个单独的服务。
使用Richardson Maturity Model L3(REST成熟度模型)和超链接来确保未来的服务拆分不会影响调用者。比如,调用者能够发现如何结账而不需要提前知道。
以原子性演进方式迁移
传统的单体应用被分解成设计优美的微服务,然后让单体应用消失的无影踪的想法有点荒唐,可以说是不可取的。任何经验丰富的工程师都可以分享一些关于尝试遗留系统迁移和改进的故事。这些尝试都是在抱着对最终完成过分乐观的状态下计划和开始的,但大部分都是在足够好的时间点被放弃了。这些长期努力的计划之所以取消是因为情况发生了一些大的变化,比如项目费用用完了;组织目标转移到其他方向上了;支持的领导层离职了。所以现实的做法是如何让单体应用踏上微服务之旅。我们称之为“架构原子演进式迁移”,这种方式的迁移每一步都是完整的,也是可以回撤。这一点,在我们谈到针对整个架构的改善和服务解耦的增量迭代的方法时尤为重要。每个迁移增量必须使我们更好地朝架构目标前进一步。借用“演进式架构”适应度函数说法,每一次原子性迁移之后,架构适应度函数应该产生一个更接近于架构目标的值。
让我来使用一个例子描述下这点。 想象一下,微服务架构目标是提高开发人员修改整体系统,交付价值的速度。团队决定基于OAuth2.0协议把终端用户验证功能解耦到一个独立服务中。这个服务用来替换已存在的(老的架构中的)客户端应用用户验证功能和作为新的微服务架构中的用户验证。让我们把这个演进式的增量称之为“验证服务引入”。引入该服务的一个方式就是先完成这些步骤:
(1) 构建Auth服务, 实现OAuth 2.0协议。
(2)增加一条新的验证路径到单体系统的后端调用新构建的Auth服务,处理终端用户请求。
假如团队到此为止,转而去构建其他的服务和功能,这会使得整个架构处于熵增状态。在这种情况下,有两种方法验证用户,一种是通过新的OAuth 2.0,一种是通过老的用户密码/会话。这个时候,实际上团队离快速变化的总的目标更远了。任何新的单体代码开发人员都需要处理两条代码路径,增加了他们理解代码的认知负担,降低了修改测试代码的速度。
然而,作为我们的一个原子性演进单元,团队可以增加下面的步骤:
(3)替换老的用户验证调用
(4)去除单体系统中老的用户验证代码
至此,我们可以说团队已经接近架构目标了。
单体拆分的一个原子单元包括:
拆分新的服务
重定向所有消费者(服务消费者)到新的用户
移除单体系统中老的代码
反模式:解耦新的服务,新的消费者使用新的服务,但老的代码永远不退休。
我经常发现团队只构建新的功能但不让老的代码退休就结束一个从单体系统功能的迁移,然后宣布全部完成。这就是上面描述的反模式。之所以存在这种情况的原因是,a)只关注引入一个新功能的短期好处;b)退休老的代码所需要总的工作量和构建新服务的优先级有竞争。为了做正确的事情,我们要尽可能的努力按原子性步骤去做。
使用这种迁移方法,我们可以把迁移之旅分成一段段更短的旅途,能够安稳的停下,然后重新开始,确保整个旅行的顺利,最终“消灭”单体。