模块化单体架构:结构、策略与可扩展性
选择合适的架构风格对于构建健壮系统至关重要。这一决策必须平衡短期、中期和长期需求——其中涉及的权衡值得深思熟虑。
在软件工程中,我们面临众多选择。但随着流行词满天飞,以及闪亮的新范式不断诱惑我们(有人说过FOMO吗?),很容易感到不知所措。某些架构风格仅仅因为对少数组织效果不佳就被视为“遗留”——即使它们仍然具有价值。
经典的辩论通常归结为单体架构和微服务。两者各有优缺点,但这些权衡可能不会随时间推移而保持优势,或无法满足不断演变的系统需求。后期重新架构并不总是可行或成本效益高,由此产生的技术债务可能成为业务增长的瓶颈。
但是否存在中间路线?一种结合单体架构简单性和微服务灵活性的架构风格——并允许您在系统成熟时进行调整。
介绍模块化单体架构——或称modulith。
模块化单体架构
定义
术语“模块化单体”由两部分组成:模块和单体。
通常,模块可以定义为独立的逻辑应用程序。这个逻辑应用程序可能包含若干功能、领域模型、API(供外部和内部使用)、数据库表和微前端。
正如我们所知,单体架构是作为一个单一单元构建的应用程序,所有功能、数据库表、前端相互交织且紧密耦合。
因此,结合这两者,模块化单体可以定义为具有多个独立但内聚的模块,作为一个软件单元耦合在一起的应用程序。
模块结构或边界
模块化单体的一个关键特征是明确定义模块及其各自的边界——即每个模块负责的业务能力。这些边界可以类似于领域驱动设计(DDD)中的有界上下文来推导。
最初,边界不需要严格。这种灵活性允许团队随时间评估和改进模块结构,使得在需要时更容易调整。例如,如果发现模块过于轻量(贫血)或过于复杂(臃肿),可以将其与另一个模块合并或拆分为更小的模块。
虽然模块可能出于组织目的驻留在单独的代码仓库中,但它们作为单个单元一起部署。
策略
让我们讨论关键设计考虑的可用策略。这些策略对于从模块化单体架构中获得最大收益至关重要。
根据领域复杂性和一致性要求,可以为相应的考虑因素采用一种或多种模式或混合模式。
模块间通信
尽管模块原则上是自包含的独立处理单元,但它仍然需要与其他模块通信以作为系统运行。为了实现松耦合和高内聚,通信模式的选择至关重要。
以下是有效模块间通信的几种可用选择:
- 内存事件:通过事件,模块解耦并异步通信。模块发布事件供其他模块对领域变化作出反应。使用这种模式,将模块提取为单独可部署单元(微服务)在很大程度上得到简化。然而,这种模式仅当需要最终一致性或较弱形式的一致性时才适用。
- 依赖倒置(DI):模块通过允许其他模块定义核心逻辑来倒转它们之间的依赖关系。依赖模块定义合同(即接口),由提供者模块实现。例如,对于支付模块(依赖)发送通知,通过允许通知模块实现和确定发送哪种通知以及如何发送(电子邮件、短信等)来倒置依赖关系。
- API:参与模块定义API并仅通过它们进行通信。这些API可以简单地是直接函数调用或REST、gRPC等。在直接函数调用的情况下,模块代码之间存在循环依赖的风险,因此应谨慎实现。
- 编排器:一个额外的模块,即编排器,可以通过在模块之间通信并将各个核心处理委托给它们来充当聚合器。编排器与模块之间的通信可以是基于事件或API调用。粗粒度的外部API通过此编排器可用。这种模式适用于需要强一致性的场景。例如,订单处理编排器将与库存、支付、通知、运输等模块通信以处理订单。此编排器还负责处理故障。
注意:模块间通信选择与外部API通信模式无关。例如,外部API可能仍然是同步的,而模块内部通过事件或DI或两者异步通信。两者都不应影响或强制对方。
数据隔离
数据隔离策略影响系统的健壮性、容错能力、吞吐量和可扩展性。尽管选择受到系统一致性要求的强烈影响,但也可以按功能/模块进行调整。例如,虽然支付需要强一致性,但通知可以适用于最终一致性或较弱一致性。因此,支付模块将具有与通知模块不同的数据隔离。
以下是数据隔离的几种可用选择:
- 无隔离:允许每个模块写入或读取所有数据库表。这简化了系统的事务管理并确保强一致性。然而,任何流氓模块导致数据损坏的风险很高。此外,任何模式更改几乎对每个模块都会产生相应的连锁效应。因此,除非绝对必要,否则应仅将此策略作为最后选择。
- 写入隔离:只有拥有模块写入表,但所有模块都可以直接从表读取。这确保了数据完整性和简化的事务管理。然而,任何模式更新都会破坏读取器模块。
- 命令查询责任分离(CQRS):类似于写入隔离,只有拥有模块写入表。对于其他模块,提供只读视图或影子表(具有只读访问权限)。此策略是最优选的方法,因为它确保数据完整性、简化的事务管理、灵活的模式更改而不破坏读取器模块,以及高读写吞吐量。然而,读取器可能会在数据新鲜度方面遇到一些延迟。
- 每个模块的模式:每个模块拥有一个数据库模式/表,可以自由读写。其他模块的数据访问仅限于通过各自的API。虽然确保了数据完整性和灵活的模式更改,但此策略存在提供最终一致性和复杂事务管理的问题。因此,在采用之前应仔细评估此选择。
开发速度
最初,如果需要高开发速度,从单体架构开始。典型的单体架构不会随时间推移而保持良好状态,它们最终会变成一个大泥球,开发速度低下。因此,在开发旅程的某个阶段,应首先将单体架构分解为模块化单体架构中的模块,并在各种参数上评估。但是,如果要分解现有的单体架构,则首先评估模块化单体架构。
因此,典型的开发旅程可能如下所示:
注意:Spring Modulith承诺协助开发人员并强制执行模块化单体原则,从而提高开发速度。
优点和缺点
以下是我们应该了解的模块化单体的主要优点和缺点。其中大多数与单体架构相似。
优点
- 简化部署:整个应用程序作为单个单元部署,减少了编排复杂性。
- 增强的模块化:代码库组织成明确定义的模块,提高了可维护性和清晰度。
- 无网络开销:模块间的进程内通信避免了服务间调用的延迟和复杂性。
- 更轻松的调试:集中式日志记录和堆栈跟踪使得故障排除比在分布式系统中更直接。
- 支持领域驱动设计(DDD):模块可以与有界上下文对齐,促进清晰的领域分离。
- 较低的运营成本:较少的基础设施组件意味着比微服务更低的DevOps开销。
- 平滑的重构路径:模块化单体有助于早期测试模块边界(例如,如果领域拆分不正确,可以快速轻松地合并回来)。从微服务到模块化单体的反向迁移可以轻松完成。
缺点
- 有限的可扩展性:无法独立扩展模块,这可能影响整体系统吞吐量。
- 紧耦合风险:不良边界或共享状态可能导致模块间的隐藏依赖关系。
- 单点故障:一个流氓模块可能潜在地崩溃整个系统。
- 更难强制执行隔离:与微服务不同,模块边界依赖于纪律。
- 部署瓶颈:任何更改,即使是在单个模块中,也需要重新部署整个应用程序。
案例研究
以下是模块化单体被使用或评估其好处的几个真实案例研究:
- Shopify成功从现有的单体架构迁移到模块化单体架构(参考)。
- GitLab正在评估从遗留单体架构迁移到模块化单体架构(参考)。
结论
模块化单体架构在传统单体架构和微服务之间取得了周到的平衡。通过将代码组织成内聚、松耦合的模块,团队获得了清晰度、可维护性以及未来架构演进的更平滑路径。这种方法减少了运营开销,支持领域驱动设计,并支持更快的开发周期——尤其是在早期阶段。虽然它不像微服务那样提供独立扩展,但它避免了微服务的复杂性和碎片化。
对于许多团队来说,从模块化单体开始提供了一个坚实的基础,可以随着系统增长而适应,使其成为构建健壮、灵活和可维护软件系统的战略选择。
参考文献
- Atlassian — 微服务 vs 单体架构
- Martin Fowler — 单体优先
单体架构 vs. 模块化单体架构 vs. 微服务
关于单体架构、模块化单体架构和微服务比较的快速入门指南。