DDD诊所——异步事件综合征
The following article is from BeeArt Author 钟敬 付施威
【按】“DDD诊所”是Thoughtworks DDD社区的一项活动,通过对同事们在实施DDD过程中遇到的问题进行分析和解答,共同提高开发水平。我们将其中一些典型案例整理成文供大家参考。之后也会考虑在适当的时候将这一形式对外部开放。
就诊日期:2021年11月1日
患者:某零部件管理系统
诊金:0元(免费义诊)
某制造企业为其经销商的售后部门开发了售后服务平台。本系统是该平台中的“零件”部分,服务于售后业务的“零件部门”。零件部门的业务包括售后单的零件准备、零件外销、零件采购以及零件管理等,目前采用微服务架构。患者的监护人从之前的团队接手该系统后,发现了一系列问题。
患者监护人梳理了当前系统的架构(主要是微服务间的关系),发现系统已经成了一个大泥球。架构图如下所示。
大量异步事件导致系统难以维护
系统存在数据不一致性以及莫名其妙的性能问题
在实现层面,同时采用了 Spring内置的事件总线和Message Queue两个机制。发布消息时,先发布一条Spring Event,Spring Event 的监听器再发消息到MQ。也就是说所有注册监听的服务都当做进程内的Spring Event处理,再由Spring和MQ打交道。如下图。
患者的病情还是比较复杂的,需要几个方面的综合治疗。但这次就诊时间有限,于是医生首先询问了患者的监护人,哪个问题是最紧迫的。监护人认为是异步事件机制。所以这次集中分析这个问题。(为了讨论方便,本文假定微服务和限界上下文是一一对应的,会将两者混合使用,不做区分。两者不一一对应的情况将在以后另文分析。)
“图1 当前系统架构”中有大量的MQ(消息队列),所以我们首先怀疑患者很可能过渡使用了异步事件。当然还需要通过进一步诊断来求证。
很多朋友知道,领域事件是DDD领域模型的重要组成部分,又看到很多大厂大量采用了异步机制来处理领域事件,而一些书上也强调了异步机制的使用,因此就认为采用异步机制处理领域事件是理所当然的。那么这种理解完全正确吗?让我们从头说起。
“领域事件是DDD领域模型的重要组成部分”这一点是完全正确的。事实上,在Evans 2003年的《领域驱动设计》一书中并没有提领域事件。但在DDD之后的发展过程中,很多专家指出了领域事件的重要性。在2015年,Evans又写了一本小册子“Domain-Driven Design Reference”简要总结了DDD中的各个模式,在其中增加了领域事件(Domain Events)。(这里有该书的一个正在翻译的中文版)
病理分析1:领域事件首先是一个业务概念
在DDD中,领域事件首先是一个业务概念而不是技术概念。例如要开发一个人寿保险系统,那么“保单已提交”、“保单已核保通过”等,都是领域专家可以理解的领域事件,并且也是开发人员和领域专家必须达成一致(并形成统一语言)的业务概念。至于同步异步、消息中间件等,都是技术概念,在领域建模时不需要重点考虑。不过,开发人员可以询问领域专家,如果核保通过后,用户是否必须立即看到这一结果呢?迟几秒钟可以吗?这些问题背后可能隐含了是否可以采用异步最终一致性这一技术问题。但与领域专家讨论时,用的仍然是业务语言,只不过反映的是非功能性需求。
领域事件可以通过事件风暴、用例分析等方式来识别。完成了领域模型后,在进一步的设计中,才需要考虑技术层面。例如,事件的显式实现还是隐式实现、是否采用事件驱动架构、同步集成还是异步集成、是否考虑最终一致性、采用轻量级事件总线还是消息中间件等等。下面逐一讨论。
病理分析2:显式实现 vs 隐式实现
显式实现
领域事件的显式实现指的是为每个领域事件创建单独的类,一个领域事件被触发时,要为这个事件创建相应类的对象。
定义“保单已核保通过”这一领域事件的伪代码如下(保险单的英文是policy,核保的英文是underwrite):
class PolicyUnderwirted extends DomainEvent {
……}
发布这一领域事件的伪代码:
eventBus.publish(new PoliycUnderwrited(underwritingResult))
隐式实现
领域事件的隐式实现指的是并不为领域事件创建单独的类。相反,领域事件的触发,隐含在某一段代码逻辑中,例如改变数据库中数据记录状态的代码,或者调用另一个上下文API的代码。
事实上,我们过去惯常的做法就是这种隐式实现,只不过没有从领域事件的角度去考虑。如果要追求模型和实现的对应关系,可以建立每一个领域事件和实现该事件的代码(例如某个Application Service)的对照表,以便管理。
两种方式的权衡
在实践中,一个系统往往同时采用隐式和显式两种方式。由于显式实现往往更加复杂,基于奥卡姆剃刀原理(如无必要勿增实体),应首选隐式方式,只在必要的情况下才采用显式方式。以下是常见的需要采用显式方式的场合:
希望采用事件溯源(Event Sourcing),将领域事件直接存储到数据库;
希望将领域事件存储为日志,以便审计;
希望采用事件驱动架构(见下文)。
在本病例中,由于早期开发者不了解领域事件有显式和隐式两种实现方式,而是误以为都应该采用显式实现,所以反而无谓的增加了系统的复杂性。
既然采用事件驱动架构是使用显式实现方式的一种场合,我们有必要对事件驱动架构做简要的分析。
病理分析3:是否采用事件驱动架构
什么是事件驱动架构
事件驱动架构的详细描述可以参考相关资料,这里用一个示意图简要说明。
在上图中,核保通过后,“核保上下文”通过消息总线发布一个“保单已核保通过”事件。“出单上下文”监听到这个事件后,会检查保单是否已经缴费,如果是,则将保单置为生效状态并打印保单(即“出单”)。“通知上下文”监听到该事件,则向特定的干系人发出短信或邮件通知。
事件驱动架构起到了在软件的上下游组件间解耦的作用。上游(例如核保上下文)只需要发布事件,不需要关心哪些下游(例如出单上下文)会处理这个事件。下游只需要关心特定的事件,而不需要知道这些事件是由哪个上游发布的。因此上下游就可以独立演化。例如,如果增加一个关心该事件的下游组件,上游组件不必做任何修改。
需要对事件驱动架构进行有效的管理
尽管理论上,上述架构起到了事件上下游的解耦作用,但带来了一个新的“陷阱”。由于上下游之间互相并不知情,那么当上游发布一个事件时,到底在整体上对系统会发生哪些影响就难以掌握,例如会不会导致性能问题或数据不一致问题,当系统出现缺陷的时候也难以快速定位。因此采用事件驱动架构时,必须以一定的方式将事件的发布和订阅关系管理起来。可以采用手动或自动两种方式进行管理。
手动方式,指的是在系统设计时,人工将每个事件的发布者和订阅者通过一个表格进行文档化。开发时需保持这一文档和代码实现相一致。这样,只需要查阅该表格,就可以知道每个事件的来龙去脉。
自动方式,指的是将事件的发布和订阅关系定义在一个配置文件中,程序运行时,读取该文件,根据文件的内容进行运行时的事件注册和订阅。另一方面,配置文件的内容又可以通过便于阅读的文档方式呈现出来,从而达到文档和实现一体化。
在本病例中,为了追求松耦合,大量使用了事件驱动架构,但是没有进行相应的管理,反而造成了维护的困难。
什么时候采用事件驱动架构
事件驱动架构在解耦的同时,也带来了实现和管理上的复杂性。同样根据奥卡姆剃刀原理,应该只在必要的时候采用。
通常,如果上下文间采用异步集成机制,那么使用事件驱动架构是比较合适的。如果采用同步集成,则只有在解耦的收益大于复杂性的代价时,才应采用。下面接着讨论什么时候采用同步集成,什么时候采用异步集成。
病理分析4:同步集成 vs 异步集成
两种方式各有利弊,要根据具体的场合进行取舍。如果采用同步方式,由于上游要等待下游的返回才能进行下一步操作,所以带来了两个问题:一是等待过程中CPU空转加上内存中要保持线程,造成资源的浪费;二是当并发请求较多时,可能会严重降低系统的可用性。异步方式的利弊与此相反。
根据患者监护人的说法,该系统并发量不大,即使采用同步集成,也未必会带来可用性的问题。当初之所以采用异步方式,仅仅是因为这种技术比较“先进”。
在本病例中,不分场合地大量采用异步集成,造成了不必要的复杂性和系统维护的困难。
在确实需要异步集成的地方,则可能需要处理事务的最终一致性。
病理分析5:是否考虑事务的最终一致性
这个问题可以分为两个子问题:第一,是否需要维护事务的一致性;第二,在需要维护事务一致性的前提下,采用强一致性还是最终一致性。我们先来讨论第一个问题。
在分布式环境下是否需要维护事务的一致性
这仍然取决于具体的应用场景,下面举例说明。
在前面提到的保险例子,就不需要维护事务的一致性。如下图。
“出单上下文”监听到“保单已核保通过”事件后,会检查保单是否已经缴费,如果没有,则不会出单(这时什么都不会做)。而即使不出单,也不会影响“保单已核保通过”这一事实,因此无所谓事务的回滚,所以不需要事务的一致性。
“通知上下文”的情况略有不同。假设由于电信服务提供商的故障,导致通知短信没有成功发出。在业务上,短信没有发出不是一个关键性错误,由于短信没有发出就回滚核保结果反而是不合理的。这时只需要重发短信就可以了。因此,也不需要事务的一致性。
而下面的典型电商场景则需要事务的一致性。
“库存上下文”监听到“订单上下文”发布的“订单已提交”事件后,要尝试扣减库存。如果由于商品数量不足导致失败,则订单提交也要回滚。因此需要维护事务的一致性。
在需要维护事务一致性的前提下,接着考虑以下问题。
采用强一致性还是最终一致性
在《实现领域驱动设计》一书中,作者建议,在聚合内部采用强一致性,跨聚合的操作则采用最终一致性。这种一刀切的说法值得商榷。
我们在实践中的体会是,在同一个微服务中,即使跨聚合,多数也采用强一致性。这是因为一个微服务往往对应唯一的数据库。只要事务粒度设计得当,避免长事务,那么利用数据库本身的事务机制(可能还要结合乐观锁或悲观锁)来实现强一致性就不会有问题。当然在少数情况下,业务确实要求粒度较大的事务,为了在技术上避免长事务,则在同一个微服务中也应采用最终一致性。
在跨微服务的分布式环境下,则要像上文讨论的那样,确定采用同步集成还是异步集成。如果既要维护事务的一致性,又要采用异步集成,那么就必须采用最终一致性了。
实现最终一致性的方法有多种,常见的是TCC和Saga模式,以及它们的各种变体。这方面的资料很多,就不赘述了。
在本病例中,微服务间采用了异步集成,但是在应该维护事务一致性的场合,没有采用最终一致性机制。这是导致数据不一致的重要原因之一。
病理分析6:采用轻量级事件总线还是消息中间件
这里说的轻量级事件总线,指的是由框架或程序库提供的,不依赖专门的消息中间件的机制。例如Spring自带的事件总线或Guava提供的事件总线。
采用轻量级总线的好处是不依赖第三方中间件,开发和部署比较简单。不足的地方是缺乏一些更强大的功能。比如说,专门的消息中间件往往提供消息的持久化功能,在意外断电或断网的时候,消息不会丢失,当故障恢复后,未消费的消息会自动重发。然而轻量级总线不具备这样的功能。
不论采用哪种机制,对于同一个事件的发布,一般没有必要同时采用两种。
在本病例中,同时采用了轻量级事件总线和消息中间件,从而给系统带来了额外的复杂性。由于增加了一个环节,无论在开发还是运行中,出错的可能性都提高了。
患者罹患晚期“异步事件综合征”,但经过适当的治疗,仍有望康复。
需要进行三项手术治疗。
第一项是“异步事件同步术”:
1.找到系统中最急需改进的部分,如缺陷最多、数据最不一致、最难维护、需求变化最频繁的部分。2.针对这些部分,分析是否需要异步集成。在分析过程中除了主观判断,还要以数据为依据。例如,先收集并发访问量和性能数据。如果并发访问量不大并且稳定,性能也没有问题,则考虑用事件的隐式实现以及同步集成替换异步集成。3.替换过程应该是渐进的,一个环节一个环节地替换。替换完一个环节后,要在生产环境监测性能和可用性。如果没有问题,再替换下一个环节。
第二项是“事务最终一致术”:
对于确实需要异步,并且同时需要保证事务一致性的部分,逐步引入实现最终一致性的技术。
第三项是“异步机制简化术”:
由于系统已经使用了消息中间件,因此可取消对Spring事件总线的使用,逐步改为只使用消息中间件一种机制。
术后保养:
对于异步事件,应采用手工或自动化的方式,将事件的发布和订阅机制管理起来,以便后续的维护和排错。
时间关系,本次治疗只针对异步事件相关的病征。要使患者彻底康复,还要进一步诊断微服务划分是否合理。这就留待下一次复诊时解决吧。
- 相关阅读 -
如何评估企业的数据质量
DDD 中的几个困难问题
点击【阅读原文】可至洞见网站查看原文&加粗字体部分的相关链接。本文版权属Thoughtworks公司所有,如需转载请在后台留言联系。