跳至正文
Starone

架构

微服务与模块化单体

微服务解决的是组织和扩展层面的问题,代价是引入一个分布式系统。模块化单体保留了大部分设计上的好处,却不必付出这个代价;在某个具体压力足以支撑拆分之前,它是我们默认的起点。

· 阅读约 4 分钟 · Starone 工程团队

这场争论常被描述成新旧之争。更准确的理解是:边界在哪里被强制执行。在模块化单体中,业务能力之间的边界由编译器和构建来强制执行。在微服务中,边界由网络来强制执行。网络是一堵更坚固的墙,也是一堵昂贵得多的墙。

对于新系统,我们的默认选择是模块化单体:一个可部署单元,内部模块边界严格,并且在构建时就考虑到日后可以把某个模块拆分出去。只有当某个说得出名目的具体压力要求如此时,我们才把模块迁移为独立服务,不会提前。

微服务究竟换来了什么

好处是真实而具体的。每个服务可以按自己的节奏部署,从而免去团队之间的发布协调。每个服务可以按自身的负载特征扩缩容。只要调用方在设计上能够容忍,故障就可以被隔离。在工作负载确有需要时,服务可以使用不同的运行时或数据存储。

值得注意的是,这些好处大多是组织层面的。只有当许多团队不得不排在同一列发布列车后面时,独立部署才真正重要。康威定律描述了其中的机制:系统最终会映射出构建它的组织的沟通结构。微服务在很大程度上是一种让众多团队互不阻塞地工作的工具。

它们的代价

每一次原本是函数调用的地方,都变成了一个网络请求:它可能变慢、可能失败,也可能成功了而调用方并不知情。仅此一项改变,就带来了一长串必须履行的义务。

  • 每一次服务间调用都需要超时、重试、熔断和幂等。
  • 没有跨服务事务,因此一致性要靠 saga、outbox 和补偿机制重新构建。
  • 每个接口都要有版本化的契约并保持向后兼容,还要在服务之间进行测试。
  • 仅仅为了回答某个请求到底发生了什么,就需要分布式追踪、集中式日志和每个服务各自的仪表盘。
  • 每个服务都要有部署流水线、运行时配置、密钥管理和值班负责人。

分布式单体

最糟糕的结果,是一个付出了这些代价却没有得到任何好处的系统。征兆并不陌生:必须一起部署的服务;多个服务读写同一批数据库表;一个共享库的升级迫使所有团队同时发布;以及深到某个端点的可用性要依赖其他十来个端点全部正常的请求链。

这通常发生在尚未理解领域就动手拆分的时候。过早划定的边界往往划错了地方,而在服务之间挪动边界远比在模块之间挪动困难,因为它涉及数据迁移和跨团队的契约变更。单体让边界可以在改动成本还很低的时候,通过一次重构得到纠正。

单体怎样才算模块化

模块化单体不是一个目录整齐的大型代码库。模块边界必须被强制执行,否则会在交付压力下逐渐被侵蚀。

  • 每个模块只暴露一个小的公开接口,其余一切都隐藏起来,手段可以是包可见性、独立的构建模块或语言自身的模块系统。
  • 依赖规则在构建中检查,例如使用 Java 生态中的 ArchUnit 或 Spring Modulith,让非法的 import 直接导致流水线失败。
  • 每个模块拥有自己的表,最好放在独立的 schema 中,任何模块都不直接查询其他模块的数据。
  • 模块之间通过显式接口或进程内事件通信;一旦模块被拆分出去,这些就变成网络调用或 broker 消息。

单一可部署单元的局限

单体确实有约束,把它们说清楚更好。所有模块共用一次发布,因此除非有功能开关和良好的测试覆盖,一个有问题的变更就可能让所有人的工作一起回滚。所有模块共用一个进程,因此某个模块的内存泄漏或失控查询会波及其余部分。构建和测试时间随代码库增长,需要主动治理。整个单元一起扩缩容,当某一个模块承担了大部分负载时就会浪费资源。

水平扩展依然可行。一个置于负载均衡器之后的无状态单体可以承载相当可观的流量,而且数据库通常早在应用层之前就成为瓶颈;对于共用该数据库的微服务来说,情况同样如此。

足以支撑拆分的压力

当以下某一条可以被证明成立,并且收益超过运维开销时,我们才会把一个模块拆分为服务。

  • 它的负载特征与其余部分差异显著,例如突发性的数据接入路径或计算密集的推理负载。
  • 它需要不同的运行时、硬件或数据存储。
  • 它有不同的可用性目标,或者出于合规原因必须隔离,例如处理银行卡数据的组件。
  • 参与这个系统的团队已经多到发布协调成为瓶颈。
  • 它的变更频率与其余部分相差悬殊,而且它的发布带来了不成比例的风险。

什么时候应当从服务起步

对于这样的组织,从微服务起步是正确的选择:已经拥有许多团队,拥有部署和可观测性都已铺好路的成熟平台,并且对领域了解透彻,往往是因为它正在重建一个自己运营多年的系统。在这种情况下,这些成本早已付过,边界也是已知的。

对其他所有人而言,模块化单体保留了选择权。沿着一条干净的模块边界进行拆分,遵循的是绞杀者(strangler)模式:把一项能力路由到新服务,迁移它的数据,然后下线旧代码。使这一切成为可能的纪律,即边界严格、数据自有的模块,无论最终是否拆分,都值得拥有。

正在做类似的事情

我们很乐意交流心得。欢迎与撰写本文的工程师聊聊。