在过去几年间,微服务架构成为业界主流,很多公司开始采用微服务,并迁移原有的单体应用迁移到微服务架构。从架构上,微服务和单体最大的变化在于微服务架构下应用的粒度被“拆小”:将所有业务逻辑都在一起的单体应用,按照领域模型拆分为多个内聚而自治的“更小”的应用。而Function则在拆分上更进一步,拆分粒度变成了“单个操作”,基于 Function 逐渐演进出现 FaaS 形态和 Serverless 架构。
在微服务和 serverless 的喧嚣中,也逐渐出现了很多质疑和反对的声音:越来越多的人发现,当他们兴冲冲的迁移单体应用到微服务和 serverless 架构之后,得到的收益并没有期望中的那么理想。最近,出现了对微服务的各种质疑、反思的声音,甚至放弃微服务回归单体。举例,我在 Infoq中国网站 简单搜索关键字“微服务”,前三页中就出现了如下的内容:
无论是支持还是反对微服务的声音,大多都是着眼于组织架构(康威定律,对应用和代码的ownership)、微服务拆分(粒度大小,如何识别领域模型和业务边界)、分布式事务(跨多个微服务调用时维持一致性),工具(自动化构建、部署,可测试性,监控,分布式链路跟踪,CI/CD),数据库分离(避免多个微服务尤其是领域模型外的微服务共享数据库)等方面进行合理性分析和观点阐述,相信大家都对这些问题都有了解。
而我今天的文章,将从另外一个角度来看待微服务(也包括Serverless)实践中存在的误区——辛辛苦苦从单体走到微服务,却最后沦为分布式单体。
“Distributed Monolith”,分布式单体,这真是一个悲伤的技术术语。而这偏偏是企业采用微服务后通常最容易踏进去的一个“陷阱”,事实上我看到的很多微服务落地最终都是以”分布式单体”收场,无法获得微服务的完整收益。
问题源于微服务实施的方式 —— 按照业务逻辑拆解单体,划分为多个微服务,定义API接口,然后通过REST或者RPC进行远程调用,最终把这些微服务组合起来提供各种业务功能。简单说,就是在业务拆分的基础上,用进程间的远程调用简单替代原来进程内的方法调用。期间,对于原来使用的各种分布式能力,继续原用之前的方式,简单说:方式不变,只是粒度变小。
从方法论说这样做无可厚非,这也是微服务采用过程中非常标准的做法。但问题在于,止步于此是不够的 —— 至少存在两个有待继续努力改进的地方。
分布式单体起因之一:通过共享库和网络客户端访问分布式能力
分布式能力的共享库和网络客户端是造成分布式单体问题的原因之一,关于这一点,来自 verizon 的 Mohamad Byan 在他的名为 Avoid the Distributed Monolith!! 的演讲中有详细的阐述,我这里援引他的图片和观点:
上图是微服务体系的逻辑架构,由两部分组成:
特别提示:这里说的“网络客户端”是各种分布式能力的客户端,如服务注册发现/MQ中间件/redis等key-value存储/数据库/监控日志追踪系统/安全体系等,不是服务间通讯如RPC的客户端。
而内层的微服务是通过 共享类库 和 网络客户端 来访问外层架构提供的分布式能力:
分布式能力的 共享类库 和 网络客户端 会迫使内层微服务和外层架构的各种分布式能力之间产生强耦合,增加运维的复杂性(如升级困难造成版本碎片化),多语言受限于类库和网络客户端支持的语言,各种组件(如消息中间件)往往使用自定义数据格式和通讯协议 —— 所有这些迫使内层微服务不得不实质性受限于外层架构的技术选型。
对于Function,这个问题就更加明显了:Function的粒度更小,更专注业务逻辑。某些简短的 Function 可能只有几百行代码,但是,为了让这几百行代码运转起来而需要引入的共享类库和网络客户端可能相比之下就规模惊人了。
在微服务架构改造过程中,熟悉单体系统和架构的开发人员,习惯性的会将这些单体时代的知识和经验重用到新的微服务架构之中。其中最典型的做法就是:在遵循领域模型将现有单体应用按照业务边界拆分为多个微服务时,往往选择用REST或者RPC等远程调用方式简单替代原有的进程内方法调用。
当两个逻辑上的业务模块存在协作需求时:
从单体到微服务,直接方法调用被替换为远程调用(REST或者RPC),即使采用Servicemesh也只是在链路中多增加了sidecar节点,并未改变远程调用的性质:
这导致了前面所说的 “分布式单体”:
抛开调用方式的差异来看采用微服务前后的系统架构,会发现:两者几乎是完全一样的!!
而微服务版本在某些情况下可能表现的更糟糕:因为调用方式更脆弱,因为网络远比内存不可靠。而我们将网络当成 “胶水” 来使用,试图把分散的业务逻辑模块(已经拆分为微服务)按照单体时代的同样方式简单粘在一起,这当然比单体在同一个进程内直接方法调用更加的不可靠。
关于这一点,在 “The Eight Fallacies of Distributed Computing/分布式计算的8个谬论” 一文中有详细阐述。
类似的,在采用 Function 时,如果依然沿用上面的方式,以单体或微服务架构的思维方式和设计模式来创建FaaS/Serverless架构:
其本质不会发生变化 —— 不过是将微服务变成粒度更小的函数,导致系统中的远程调用数量大为增加:
系统内的耦合并没有发生变化,Serverless并不能改变微服务中存在的这个内部耦合问题:调用在哪里,则耦合就在哪里!只是把将组件的粒度从 “微服务“换成了 “Function/函数”。
耦合的存在是源于系统不同组件之间的通讯模式,而不是实现通讯的技术。
如果让两个组件通过“调用”(后面再展开讲何为调用)进行远程通信,那么不管调用是如何实现的,这两个组件都是紧密耦合。因此,当系统从单体到微服务到serverless,如果止步于简单的用远程调用替代进程内方法调用,那么系统依然是高度耦合的,从这个角度来说:
单体应用 ≈ 分布式单体 ≈ Serverless单体
分布式单体起因小结
上面我们列出了微服务和serverless实践中容易形成“分布式单体”的两个主要原因:
下面我们针对这两个问题探讨解决的思路和对策。
引入非侵入式方案:物理隔离+逻辑抽象
前面谈到分布式单体产生的一个原因是“通过共享库和网络客户端访问分布式能力”,造成微服务和Lambda函数和分布式能力强耦合。以Servicemesh为典型代表的非侵入式方案是解决这一问题的有效手段,其他类似方案有 RSocket / Multiple Runtime Architecture,以及数据库和消息的Mesh化产品,其基本思路有两点:
以Servicemesh的sidecar为例,在植入 sidecar 之后,业务应用需要直接对接的分布式能力就大为减少(物理隔离):
最近出现的 Multiple Runtime / Mecha 架构,以及遵循这一架构思想的微软开源产品 Dapr ,则将这个做法推进到服务间通讯之外更多的分布式能力。
此外在委托之外,还提供对分布式能力的抽象。比如在Dapr中,业务应用只需要使用Dapr提供的标准API,就可以使用这些分布式能力而无法关注提供这些能力的具体产品(逻辑抽象):
以pub-sub模型中的发消息为例,这是 Dapr 提供的 Java客户端 SDK API:
public interface DaprClient {
Mono<Void> publishEvent(String topic, Object event);
Mono<Void> publishEvent(String topic, Object event, Map<String, String> metadata);
}
可见在发送事件时,Dapr完全屏蔽了底层消息机制的具体实现,通过客户端SDK为应用提供发送消息的高层抽象,在Dapr Runtime中对接底层MQ实现——完全解耦应用和MQ:
关于 Multiple Runtime / Mecha 架构的介绍不在这里深入展开,有兴趣的同学可以浏览我之前的博客文章 “Mecha:将Mesh进行到底” 。
稍后我会有一篇深度文章针对上面这个话题,详细介绍在消息通讯领域和EDA架构下如何实现消息通讯和事件驱动的抽象和标准化,以避免业务应用和底层消息产品绑定和强耦合,敬请关注。
引入Event:解除不必要的强耦合
在解决了微服务/serverless系统和外部分布式能力之间紧耦合的问题之后,我们继续看微服务/Serverless系统内部紧耦合的问题。前面讨论到,从单体到微服务到Function/Serverless,如果只是简单的将直接方法调用替换为远程调用(REST或者RPC),那么两个通讯的模块之间会因为这个紧密耦合的调用而形成依赖,而且依赖关系会伴随调用链继续传递,导致形成一个树形的依赖关系网络,表现为系统间的高度耦合:
要解决这个问题,基本思路在于审视两个组件之间通讯行为的业务语义,然后据此决定两者之间究竟是应该采用Command/命令模式还是Event/事件模式。
温故而知新:Event 和 Command
首先我们来温习一下 Event 和 Command 的概念和差别,借用一张图片,总结的非常到位:
什么是 Event?
Event: “A significant change in state” — K. Mani Chandy
Event 代表领域中已经发生的事情:通常意味着有行为(Action)已经发生,有状态(Status)已经改变。
因为是已经发生的事情,因此:
产生Event的目标是为了接下来的Event传播:
Event传播的方式:
什么是Command?
Command 用于传递一个要求执行某个动作(Action)的请求。
Command 代表将要发生的事情:
产生Command的目标是为了接下来的Command执行:
Command的传播方式:
Command 和 Event 总结
总结 —— Command 和 Event 的本质区别在于他们的意图:
从业务视角出发:关系模型决定通讯行为
在温习完 Command 和 Event 之后,我们再来看我们前面的问题:为什么简单的将直接方法调用替换为远程调用(REST或者RPC)会出问题?主要原因是在这个替换过程中,所谓简单是指不假思索直接选择远程调用,也就是选择全程Command方式:
真实业务场景下各个组件(微服务或者Function)的业务逻辑关系,通常不会像上图这么夸张,不应该全是 Command (后面会谈到也不应该全是 Event) ,而应该是类似下图描述的两者结合,以微服务为例(Function类推):
业务输入:图上微服务A接收到业务请求的输入(可能是 Command 方式,也可能是 Event 方式)
业务逻辑 “
实现
” 的执行过程:
业务状态变更
触发
的后续行为:
上面微服务A的业务逻辑执行处理过程中,需要以Command或者Event方式和其他微服务通讯,如图中的微服务B/C/D/E。而对于这些微服务B/C/D/E(视为微服务A的下游服务),他们在接受到业务请求后的处理流程和微服务A的处理流程是类似的。
因此我们可以简单推导一下,当业务处理逻辑从微服务A延展到微服务A的下游服务(图中的微服务B/C/D/E)时的场景:
将图中涉及的微服务A/B/C/D/E在处理业务逻辑的行为总结下来,通讯行为大体是一样的:
抽象起来,一个典型的微服务在业务处理流程中的通讯行为可以概括为以下四点:
在这个行为模式中,2和3是没有顺序的,而且可能交错执行,而4通常都是在流程的最后:只有当各种内部Action和外部Command都完成,业务逻辑实现结束,状态变更完成,“木已成舟”,才能以Event的方式对外发布:“操作已完成,状态已变更,望周知”。
这里我们回顾一下前面的总结 —— Event 和 Command 的本质区别在于他们的意图:
从业务逻辑处理的角度来看,外部访问的Command和内部操作的Action是业务逻辑的 “实现” 部分:这些操作组成了完整的业务逻辑——如果这些操作失败,则业务处理将会直接影响(失败或者部分失败)。而发布事件则是业务逻辑完成之后的后续 “通知” 部分:当业务逻辑处理完毕,状态变更完成后,以事件的方式驱动后续的进一步处理。注意是驱动,而不是直接操纵。
从时间线的角度来看整个业务处理流程如下图所示:
全程Command带来的问题:不必要的强耦合
全程Command的微服务系统,存在的问题就是在上述最后阶段的“状态变更通知”环节,没有采用Event和pub-sub模型,而是继续使用Command逐个调用下游相关的其他微服务:
Event可以解耦生产者和消费者,因此图中的微服务A和微服务C/D/E之间没有强烈的依赖关系,彼此无需锁定对方的存在。但是Command不同,在采用Command方式后微服务A和下游相关微服务C/D/E会形成强依赖,而且这种依赖关系会蔓延,最终导致形成一颗巨大而深层次的依赖树,而Function由于粒度更细,问题往往更严重:
而如果在“状态变更通知”环节引入Event,则可以解耦微服务和下游被通知的微服务,从而将依赖关系解除,避免无限制的蔓延。如下图所示,左边图形是使用Event代替Command来进行状态变更通知之后的依赖关系,考虑到Event对生产者和消费者的解耦作用,我们“斩断”绿色的Event箭头,这样就得到了右边这样一个被分解为多个小范围依赖树的系统依赖关系图:
Event和Command使用的建议:
在单体应用拆分为微服务时,不应该简单的将原有的方法调用替换为Command
应该审视每个调用在业务逻辑上的语义:是业务逻辑执行的组成部分?还是执行完成之后的状态通知?
然后据此决定采用Command还是Event
编排和协调
在Command和Event的使用上,还有两个概念:编排和协调。
这里强烈推荐一篇博客文章, Microservices Choreography vs Orchestration: The Benefits of Choreography,作者 Jonathan Schabowsky ,Solace 的CTO。他在这边博客中总结了让微服务协同工作的两种模式,并做了一个生动的比喻:
编排(Orchestration):需要主动控制所有的元素和交互,就像指挥家指挥乐团的乐手一样——对应Command。
协调(Choreography):需要建立一个模式,微服务会跟随音乐起舞,不需要监督和指令——对应Event。
全程Event带来的问题:开发困难和业务边界不清晰
在Command和Event的使用上,除了全程使用Command之外,还有一个极端是全程使用Event,这一点在Lambda(FaaS)中更常见一些:
这个方式首当其冲的问题就是在适用Command语义的地方采用了Event来替代,而由于Command和Event在使用语义上的差异,这个替代会显得别扭:
对于粒度较大的微服务系统,通常很难实现无状态,所以在微服务中全程采用Event通常会比较别扭的,事实上也很少有人这样做。而在粒度非常小的 Function/FaaS 系统中,全程采用Event方式比较常见。
关于全程使用Event,我个人持保留态度,我倾向于即使是在FaaS中,也适当保留Command的用法:如果某个操作是“业务逻辑”执行中不可或缺的一部分,那么Command方式的紧耦合反而更能体现出这个“业务逻辑”的存在:
如果完全采用Event方式,“彻底”解耦,则产生新的问题(且不论在编码方面额外带来的复杂度) —— 在海量细粒度的Event调用下,业务逻辑已经很难体现,领域模型(Domain Modeling)和 有界上下文(Bounded Context)则淹没在这些Event调用下,难于识别:
备注:这个问题被称为“Lambda Pinball”,这里不深入展开,后续计划会有一篇文章单独详细探讨“Lambda Pinball”的由来和解决的思路。
Command和Event的选择:实事求是不偏不倚
总结一下Command和Event的选择,我个人的建议是不要一刀切:全程Command方式的缺点容易理解,但简单替换为全程Event也未必合适。
我的个人观点是倾向于从实际“业务逻辑”处理的语义出发,判断:
警惕:不要沦为分布式单体
上面我们列出了微服务和serverless实践中容易形成 “分布式单体” 的两个主要原因和对策:
前者在技术上目前还不太成熟,典型如Istio/Dapr项目都还有待加强,暂时在落地上阻力比较大。但后者已经是业界多年的成熟实践,甚至在微服务和Serverless兴起之前就广泛使用,因此建议可以立即着手改进。
关于如何更方便的将Event和Event Driven Architecture引入到微服务和Serverless中,同时又不与提供Message Queue分布式能力的具体实现耦合,我将在稍后文章中详细展开,敬请期待。
反思:喧闹和谩骂之外的冷静思考
如果我们在微服务和Serverless实践中,始终停留在“用远程调用简单替代进程内方法调用”的程度,并固守单体时代的习惯引入各种SDK,那么 分布式单体 问题就必然不可避免。我们的微服务转型、Serverless实践最后得到的往往是:
把单体变成…… 更糟糕的分布式单体
当然,微服务可能成为分布式单体,但这并不意味着微服务架构是个谎言,也不意味着比单体架构更差。Serverless 可能同样遭遇分布式单体(还有后续要深入探讨的 Lambda Pinball),但这也不意味着 serverless 不可取 —— 微服务和serverless都是解决特定问题的工具,和所有的工具一样,在使用工具之前,我们需要先研究和了解它们,学习如何正确的使用它们: