DDD诊所——聚合过大综合症
“DDD诊所”是Thoughtworks DDD社区的一项活动,通过对同事们在实施DDD过程中遇到的问题进行分析和解答,共同提高开发水平。我们将其中一些典型案例整理成文供大家参考。之后也会考虑在适当的时候将这一形式对外部开放。
就诊日期:2022年6月8日
患者:某DevOps平台持续集成模块
诊金:0元(免费义诊)
某DevOps平台需提供持续交付流水线设计功能,使用户可在界面上规划完整流程。因此,流水线设计页面功能繁杂,涵盖阶段规划、前置触发条件设置、质量门禁控制等。随着功能扩展,页面复杂度逐渐提升。此类功能旨在协助用户优化持续交付流水线管理,提升交付效能与质量。
功能介绍:
设计不同的阶段:用户可以根据需要设置不同的阶段,例如开发阶段、测试阶段等。对于每个阶段,用户可以设置不同的步骤,例如Checkout、编译、构建镜像和部署等。
设计前置触发条件:用户可以选择两种触发方式,一种是某个代码仓库提交触发,另一种是定时任务。用户可以设置多个前置触发条件。
设置质量门禁:用户可以选择给阶段设置质量门禁,例如单元测试覆盖率大于80%等。这些门禁指标可以在质量门禁管理功能中设置。在流水线执行时,如果不满足门禁指标,会阻止流水线进入下一个阶段。
项目架构师在接到需求后,根据需求设计了一个名为“持续交付流水线领域模型”。该模型的目的是方便用户在界面上完成一系列操作,如代码质量检查、编译代码、构建镜像和部署等。这些操作被看作是一个整体,并被组织在一个叫做“流水线”的聚合中。这个聚合包括质量门禁、不同的阶段和触发规则等。这种设计旨在保证流水线中各个组成部分的一致性。
请注意,图中的“《map from》”是一种自定义的衍型,意味着该对象映射自其他上下文。
团队按照这个模型落地了代码,随着交付的深入,这个模型的缺点也浮现出来。
1. 认知负载上升
这个聚合包含了7个实体(不包括抽象类),每个实体都有自己相关的业务,因此这个聚合的认知负载相对较大。此外,该部分业务需要集成不同的外部依赖系统,如Sonar(用于实现质量门禁)、定时任务组件(用于定时任务触发器)、GitLab或GitHub(用于代码提交触发器)等。尽管可以通过使用Repository模式和依赖倒置原则来分离功能和实现,但开发人员或维护人员仍需要掌握相关知识。如果某个人接手了这个功能(例如修改质量门禁相关功能),他/她基本上需要了解整个系统的工作方式。
2. 部分可用性难以实现
这个功能集成了多个第三方系统,并被设计为一个聚合。由于这些功能中的任何一部分不可用时,整个功能都将受到影响,因此难以实现部分可用性。例如,当Sonar服务暂时不可用时,代码触发和阶段维护的部分也无法为用户提供服务。如果后续用户提出了部分可用性需求,要求Sonar不可用的情况下,要提示质量门禁设置失败,但同时,其他部分仍需为用户提供服务,那么根据这个设计就很难实现了,只能推倒重建,成本非常高。
不幸的是,在设计过程中我们不知不觉地构建了一个分布式单体。分布式单体架构是对一种设计糟糕的微服务架构的戏称。一般指那种由于架构师没有充分考虑和掌握分布式的优势和代价,凭感觉设计出的微服务架构。这种架构导致设计出的系统既不能享受分布式的弹性优势,又丢失了单体服务易于实现ACID事务强一致性方面的便利。通常出现在未经良好设计的微服务风格的分布式软件中。
3. 牺牲了性能和并发性
当前的交互设计是用户在设计界面对流水线的各部分进行设计,设计完成后按提交按钮提交所有部分。实际上,用户在每次修改时,不一定需要同时修改质量门禁、阶段、触发规则等所有的部分。然而由于聚合模式的特点,我们每次都需要对整个聚合的所有实体进行整存整取。即使用户只想修改其中一部分(如“触发规则”),我们仍需要更新名为“流水线”的聚合的整个7个模型,这将导致巨大的性能浪费。此外,如果两个用户同时操作业务上互不影响的两部分如“触发规则”和“质量门禁”时,会相互冲突,有一个用户要被提示“设计已变更,修改失败”,降低了系统的吞吐量。
初步诊断,患者的病情主要是聚合过大综合症,即聚合设计不合理,导致聚合过大。在DDD实践中,合理划分聚合是个比较有挑战的问题。
在DDD的落地实践过程中,聚合的大小经常被描述为一个不可言说的知识。很多时候,凭经验和感觉会导致比较差的设计。然而,在实践中,识别大聚合仍有迹可循,一般来说,出现了这三种情况时,就需要警惕聚合过大综合症:
1. 宽聚合
一个聚合聚合了多个同级实体,一个“父亲”多个 “儿子”。如图所示:一旦超过三就有大聚合的风险。
2. 深聚合
聚合的深度过深,例如聚合根有儿子实体,也有孙子实体,也有重孙实体。当层级达到三层时,就存在大聚合的风险。
3. 胖聚合
尽管结构简单,但实体对象实例多。例如订单和订单行作为了一个聚合,而实际业务经常出现有几千个订单行的订单。这种也存在大聚合的风险。
正如该案例所表现的那样,这是一个具有宽聚合和深聚合的聚合过大综合症症状。聚合过大综合症会导致一系列问题,例如认知负荷增加、部分可用性下降以及性能问题。而在该案例中,这些问题正是由聚合过大综合症所导致的。
出现聚合过大综合症,大多数情况是由于聚合划分不合理所导致的。为了解决这个问题,我们可以回顾《领域驱动设计》一书中有关聚合模式的定义,以了解如何合理地划分聚合。
在《领域驱动设计:软件核心复杂性应对之道》一书中,聚合的定义如下:
”在具有复杂关联的模型中,要想保证对象更改的一致性是很困难的。需要维护适用于密切相关的对象组的Invariant,而不仅仅是离散的对象。然而,过于谨慎的锁定机制又会导致多个用户之间毫无意义地互相干扰,从而使系统不可用。 我们应该将 Entity和 Value Object分门别类地聚集到Aggregate中并定义每Aggregate的边界。在每Aggregate中,选择一个Entity作为根,并通过根来控制对边界内其他对象的所有访问。只允许外部对象保持对根的引用。对内部成员的临时引用可以被传递出去,但仅在一次操作中有效。由于根控制访问,因此不能绕过它来修改内部对象。这种设计有利于确保Aggregate中的对象满足所有固定规则,也可以确保在任何状态变化时Aggregate作为一个整体满足固定规则。
根据聚合模式的定义,我们可以得出判断两个实体(Entity)是否属于一个聚合的依据,需要把握两个条件:
1. 存在整体部分关系
当某个Entity是另一个Entity的部分时,称为整体部分关系。例如:
汽车轮胎是汽车的一部分
学生是班级的一部分
订单行是订单的一部分
2. 实体之间存在变更时需要遵守的固定规则(Invariants)
固定规则或称为不变量不变式,来自契约式设计。
”在计算机科学中,不变量是指在计算机程序执行的某一阶段始终为真的逻辑论断。例如,循环不变量是一个条件,在一个循环的每个迭代开始和结束时都是真的。--- wiki <https://en.wikipedia.org/wiki/Invariant_(mathematics)>
在划分聚合时,我们关注的是一些由业务原因所约束的固定规则。这些规则通常是通过与业务人员沟通,了解“A更新的时候,会不会引起B的某个属性的更新”,“如果两个实体暂时不一致,是否会导致难以承受的业务后果”等问题来得出的。
例如,在订单系统中,假设需求是整个订单的总价等于订单行的总价之和,且总价必须小于3000(左图)。在这种情况下,订单与订单行之间就存在固定规则。因此,可以把它们放在同一个聚合中,并通过整存整取来维护这个固定规则。然而,如果业务场景是订单仅作为订单行的分组,用户需要按照订单行逐一结账(右图)那么订单和订单行就无需划分成一个聚合。
需要注意的是,固定规则中的一部分是由技术实现所约束的固定规则,例如数据库ID不重复、订单编号唯一等。这些规则通常是全局性的固定规则,需要在整个系统范围内遵循。然而,由于这些全局性的固定规则通常与特定的业务逻辑无关,因此在划分聚合时,它们通常不是参考因素。
尽管利用业务固定规则通常能够确定较小的业务一致性边界,从而得出比较合适的聚合规模,但并不是所有业务都适用这一原则。在有些业务场景中,大聚合可能是不可避免的。在这种情况下,如果想维护固定规则,是否采用聚合模式,需要权衡使用大聚合带来的成本是否可以接受。聚合模式是一种设计模式,而非万能的解决方案,在不适用的场景下强行应用聚合可能会导致收益不成正比。
聚合模式是为了解决复杂业务中的一致性问题,将具备固定规则的一组对象整存整取的一种方案。采用聚合模式的优点在于比较简单地就能实现固定规则约束,代价就是整存整取带来的性能损失等问题。
在案例中,尽管流水线各部分之间存在整体部分关系,通过对业务进行分析,我们确定了以下固定规则:
阶段内的步骤之间存在严格的顺序依赖,因为下一个步骤通常依赖上一个步骤的产出物。因此,存在一个固定规则:某个步骤的执行顺序必须按照阶段的步骤列表中的顺序关系。
阶段质量门禁和门禁项之间存在固定规则,即当在门禁项发生变更时,门禁版本也必须随之更新门禁的版本有一定的业务含义(例如,门禁版本更新需要在界面上进行明确提示)。
因此,根据上述整改方案,原本的一个大聚合被拆分成了四个小聚合,每个聚合只包含少量实体。这种改动可以有效地降低聚合的规模,从而更好地平衡性能和业务一致性之间的关系。
在领域驱动设计实践中,聚合的划分确实是一个难以把握的问题。聚合本身是一种有代价的模式,不合理的聚合划分可能导致严重的问题,从而引发一系列疑问,例如“为什么使用DDD后仍然遇到各种问题?”聚合过大综合症是聚合划分中的一种常见问题,当模型中出现宽聚合、深聚合或胖聚合时,我们需要警惕聚合过大综合症及其带来的难以维护、牺牲可用性和性能损失等问题。
要解决这个问题,我们需要回顾聚合解决的核心问题:如何维护对象之间的固定规则。再次考虑聚合的划分时,需要满足两个条件:整体-部分关系和实体间需要遵循的固定规则。当使用聚合模式的代价过大时,可以考虑其他方法来实现,例如通过锁机制锁定需要维护一致性的对象方法。
总之,在实践DDD时,我们应该关注聚合的划分和优化,以确保在保持业务一致性和完整性的同时,避免因聚合过大导致的性能和可用性问题。在面临聚合模式代价过大的情况时,可以灵活选择其他方法来实现业务一致性和完整性。
- 相关阅读 -