土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】单体架构:被严重低估的选择

文章导航

分类入口
architecture
标签入口
#monolith#modular-monolith#architecture-pattern#Shopify

目录

2023 年,Amazon Prime Video 团队发了一篇博客,标题直截了当:他们把一个微服务架构重构回了单体,成本降低了 90%。这篇文章在技术社区引发了轩然大波——不是因为结论多惊人,而是因为它说出了很多团队心里想但不敢讲的话:微服务不是银弹,单体也不是原罪。

过去十年,“单体架构”在技术讨论中几乎等同于”遗留系统”“技术债”“需要被拆解的对象”。面试里提到单体,潜台词往往是”落后”;架构评审里提到单体,似乎就意味着”不够先进”。但这种认知存在严重偏差。

Shopify 运行着世界上最大的 Ruby on Rails 单体应用之一,支撑着每年数千亿美元的交易额。Stack Overflow 在 2024 年仍然用一个 .NET 单体服务全球数亿用户。Basecamp 的创始人 DHH 更是直言:“我们从没需要过微服务。”

这篇文章不是要论证”单体一定比微服务好”。它要回答的问题是:单体架构到底是什么、什么时候它是正确选择、以及怎样把单体做好而不是做成一团泥。

适用范围说明 本文讨论的”单体”主要指后端应用服务。前端单页应用(SPA)、移动应用、数据管线等有各自不同的架构考量,不在本文讨论范围内。文中引用的案例和数据均标注来源,工程判断部分会明确标识。


一、单体架构到底是什么

定义:一个部署单元

单体架构(Monolithic Architecture)的核心定义只有一条:整个应用作为一个单独的部署单元(Single Deployable Unit)进行构建、测试和部署。

这个定义比很多人想象的要简单。它不规定代码如何组织,不规定内部结构是否清晰,不规定用什么语言或框架。它只说了一件事:最终交付物是一个整体。

具体来说,单体应用通常具备以下特征:

注意这里说的是”理论上”。后面会讲到,模块化单体可以在不拆分部署单元的前提下,实现相当程度的独立性。

还有一点需要澄清:单体不等于”单服务器”。一个单体应用可以在多台服务器上水平扩展运行——每台服务器跑的是同一个应用的副本,通过负载均衡器分发请求。Shopify 的 Rails 单体就是这样运行的。“单体”指的是部署单元的粒度,不是运行实例的数量。

单体不等于大泥球

这是最常见的误解,也是单体架构被污名化的根源。

“大泥球(Big Ball of Mud)”是 Brian Foote 和 Joseph Yoder 在 1997 年论文 Big Ball of Mud 中提出的概念,指的是一种没有可辨识结构的系统——代码之间随意依赖,任何改动都可能引发不可预测的连锁反应。他们的原话是:“A Big Ball of Mud is a haphazardly structured, sprawling, sloppy, duct-tape-and-baling-wire, spaghetti-code jungle.”

大泥球是一种反模式(Anti-pattern),它可以出现在任何架构风格里。一个微服务系统如果服务之间随意调用、数据随意共享、没有清晰的边界,同样是大泥球——只不过分布在了网络上,调试起来更痛苦。这种情况在业界有个专门的说法:分布式大泥球(Distributed Big Ball of Mud),Sam Newman 在《Building Microservices》第二版中对此有专门的讨论。

单体架构是一种部署策略,大泥球是一种结构缺陷。两者没有因果关系。

单体架构 ≠ 大泥球
微服务架构 ≠ 自动具备良好结构

架构风格决定的是部署边界
代码质量决定的是模块边界

把单体和大泥球划等号,本质上是把”部署方式”和”代码质量”混为一谈。一个精心设计的单体应用,内部模块清晰、接口稳定、职责分明,远比一堆边界模糊、数据混乱的微服务更容易维护。反过来,一个缺乏设计的单体当然会变成大泥球——但那不是单体的问题,是设计的问题。

单体的几种形态

并非所有单体都长一个样。按照内部结构的清晰程度,可以大致分为三类:

类型 内部结构 模块间依赖 数据访问 典型状态
无结构单体 没有明确模块划分 任意调用 全局共享数据库表 大泥球
分层单体 按技术层(Controller/Service/DAO)划分 层间单向,层内无约束 按层共享 多数传统应用
模块化单体 按业务能力划分独立模块 通过定义好的接口通信 每个模块拥有自己的表 当前最佳实践

下面这张图展示了三者的区别:

graph TB
    subgraph A["无结构单体(大泥球)"]
        direction TB
        A1["代码 A"] --- A2["代码 B"]
        A2 --- A3["代码 C"]
        A1 --- A3
        A1 --- A4["代码 D"]
        A3 --- A4
        A2 --- A4
        A5[("共享数据库")]
        A1 -.-> A5
        A2 -.-> A5
        A3 -.-> A5
        A4 -.-> A5
    end

    subgraph B["分层单体"]
        direction TB
        B1["Controller 层"]
        B2["Service 层"]
        B3["DAO 层"]
        B4[("共享数据库")]
        B1 --> B2
        B2 --> B3
        B3 --> B4
    end

    subgraph C["模块化单体"]
        direction TB
        C1["订单模块"]
        C2["库存模块"]
        C3["用户模块"]
        C1 -->|"公开接口"| C2
        C1 -->|"公开接口"| C3
        C4[("订单表")]
        C5[("库存表")]
        C6[("用户表")]
        C1 --> C4
        C2 --> C5
        C3 --> C6
    end

图中关键区别:无结构单体中所有代码互相纠缠、共享同一组数据库表;分层单体按技术职责隔离但同层内没有约束;模块化单体按业务能力划分,每个模块拥有自己的数据,模块间只通过公开接口通信。


二、单体架构的真实优势

单体的优势不是”简单”这两个字能概括的。更准确地说,单体在一致性、可调试性和运维复杂度三个方面具备结构性优势。这些优势不是微服务”也能做到但更费劲”——而是单体架构的内在属性,微服务架构需要额外引入大量工具和流程才能弥补的差距。

部署简单,不是一句空话

单体应用的部署流程:构建一个制品(JAR、二进制、Docker 镜像),推到服务器,启动。结束。

微服务的部署流程:构建 N 个制品,处理 N 个服务的版本兼容性,协调部署顺序,确保服务发现(Service Discovery)正常,确保配置中心同步,确保网关路由更新。任何一步出错都可能导致部分服务不可用。

Google 的 SRE 团队在《Site Reliability Engineering》一书中指出,部署是线上事故最常见的触发因素之一。部署流程越复杂,出错的概率越高。单体应用的部署流程天然简单,这不是缺点,是优势。

补充一个容易被忽视的点:单体应用的回滚也是简单的。出了问题,回滚到上一个版本就行,因为只有一个部署单元。微服务架构中,如果问题出在服务 A 和服务 B 的新版本交互上,你可能需要同时回滚两个服务,而且还得确认回滚不会影响其他正在使用新 API 的服务 C。版本兼容性矩阵的复杂度随服务数量增长呈组合爆炸趋势。

调试和测试是直线而非迷宫

单体应用中,一个请求的完整调用链在一个进程内完成。你可以:

微服务架构中,同样的调试需要:分布式追踪系统(Distributed Tracing)、跨服务日志关联(Correlation ID)、服务间调用链路重建。这些工具本身也需要运维和调试。

测试方面的差异同样显著。单体应用的集成测试可以在一个进程内启动完整应用、连接测试数据库、执行端到端的请求验证。Spring Boot 的 @SpringBootTest、Rails 的集成测试、Django 的 TestCase 都能做到这一点,启动时间通常在秒级。

微服务的端到端测试则需要搭建完整的服务拓扑——要么用 Docker Compose 启动所有依赖服务(启动时间可能数分钟),要么对每个依赖服务编写 Mock/Stub(维护成本高且容易和真实行为不一致)。这是微服务架构中测试金字塔(Test Pyramid)比单体复杂得多的根本原因。

事务一致性是免费的

在单体应用中,跨模块的事务一致性(Transactional Consistency)是数据库的基础能力。一个数据库事务可以同时修改订单表和库存表,要么全部成功,要么全部回滚。ACID 保证由数据库引擎提供,应用层不需要额外处理。代码写起来非常直接:

@Transactional
public OrderResult checkout(Cart cart) {
    Order order = orderRepository.create(cart);
    inventoryRepository.deductStock(cart.getItems());
    paymentRepository.charge(order.getTotal());
    return new OrderResult(order);
    // 任何一步抛异常,整个事务自动回滚
}

在微服务架构中,订单服务和库存服务各有自己的数据库。跨服务的数据一致性需要引入 Saga 模式(Saga Pattern)或两阶段提交(Two-Phase Commit)。Saga 的补偿逻辑(Compensating Transaction)需要手动实现,两阶段提交的性能开销和故障处理复杂度极高。

Pat Helland 在经典论文 Life beyond Distributed Transactions: an Apostate’s Opinion(2007)中就指出:一旦放弃单机事务,应用层就不得不承担大量的一致性管理工作。单体架构让你免于面对这个问题。这不是单体的”限制”,而是单体的”福利”——只要你的数据规模还不需要分库分表,这个福利就一直有效。

没有网络调用,延迟确定性高

单体应用中,模块间通信是函数调用(In-Process Call),耗时通常在纳秒到微秒级别。这个延迟是确定的——不受网络抖动、DNS 解析、连接池竞争、序列化/反序列化开销的影响。

微服务间的远程过程调用(RPC)即使走同机房网络,延迟也在百微秒到毫秒级别。如果一个请求需要串行调用 5 个服务,网络延迟本身就可能累积到几十毫秒。更关键的是,网络延迟的方差(Variance)远大于函数调用。P50 延迟可能只有 0.5ms,但 P99 可能飙升到 50ms——尾延迟(Tail Latency)问题在微服务架构中尤为突出。

Google 的 Jeff Dean 在 2013 年的演讲 The Tail at Scale 中详细分析了尾延迟的放大效应:如果一个请求需要调用 100 个后端服务,即使每个服务的 P99 延迟只有 10ms,整个请求的 P99 延迟也会远高于 10ms,因为只要有一个服务的响应慢了,整个请求的延迟就被拉高。单体架构天然免疫这个问题。

Peter Deutsch 和 James Gosling 总结的”分布式计算的八大谬误(Fallacies of Distributed Computing)“中,第一条就是”网络是可靠的”,第二条是”延迟为零”。单体架构不需要对抗这两条谬误。

运维复杂度低

单体应用的运维栈(Operational Stack)相对简单:

组件 单体 微服务
服务发现 不需要 必需(Consul / Eureka / K8s Service)
API 网关 可选 通常必需
分布式追踪 不需要 必需(Jaeger / Zipkin / OpenTelemetry)
配置中心 简单文件即可 通常需要集中管理(Apollo / Consul KV)
服务网格 不需要 中大规模时常需要(Istio / Linkerd)
CI/CD 流水线 1 条 N 条
监控面板 1 个应用的指标 N 个服务的指标 + 服务间调用指标

这些组件每一个都需要学习、部署、监控、升级和排障。微服务架构的”隐性成本”很大程度上来自这张运维清单。


三、模块化单体:把结构做对,不拆部署

核心思想

模块化单体(Modular Monolith)的核心思想是:在单一部署单元内部,按业务能力(Business Capability)划分出清晰的模块边界,并通过工具和规范强制执行这些边界。

换一个说法:模块化单体追求的是”微服务的组织优势 + 单体的运维简单性”。它承认微服务在模块隔离方面的思路是正确的(清晰的边界、独立的数据、明确的接口),但认为实现隔离不一定需要网络边界。进程内的隔离——通过语言机制、编译器检查或静态分析工具来执行——可以达到类似的效果,同时避免分布式系统的复杂度。

这个思路并不新。David Parnas 在 1972 年的经典论文 On the Criteria To Be Used in Decomposing Systems into Modules 中就提出了信息隐藏(Information Hiding)原则:模块应该封装可能变化的设计决策,对外只暴露稳定的接口。模块化单体是这个原则在部署边界选择上的现代应用。

设计原则

一个合格的模块化单体需要满足以下条件:

1. 模块按业务能力划分,而非技术层

错误的划分方式:
  controllers/
  services/
  repositories/
  models/

正确的划分方式:
  modules/
    orders/
      api/
      domain/
      infrastructure/
    inventory/
      api/
      domain/
      infrastructure/
    users/
      api/
      domain/
      infrastructure/

按技术层划分的问题在于:修改一个业务功能需要同时改动多个目录下的文件,而且同层内的代码没有隔离——订单的 Service 可以随意调用库存的 Repository。

按业务能力划分后,一个模块包含该业务领域所需的全部代码,修改时影响范围局限在模块内部。

2. 模块间只通过公开接口通信

每个模块对外暴露一组明确的接口(API),其他模块只能通过这些接口访问该模块的功能。禁止直接访问其他模块的内部类、内部方法或数据库表。

用 Java 举例:

// orders 模块的公开接口
// modules/orders/api/OrderService.java
public interface OrderService {
    OrderDTO createOrder(CreateOrderRequest request);
    OrderDTO getOrder(String orderId);
    void cancelOrder(String orderId);
}

// orders 模块的内部实现——其他模块不允许直接依赖
// modules/orders/domain/Order.java
class Order {
    // 包级私有,外部模块无法访问
    private String id;
    private List<LineItem> items;
    private OrderStatus status;
}

3. 每个模块拥有自己的数据

模块化单体中,虽然所有模块共用一个数据库实例,但每个模块只能读写自己的表。订单模块不能直接查询用户表,必须通过用户模块的公开接口获取用户信息。

数据库 my_app_db
├── orders_*       ← 只有订单模块可以读写
│   ├── orders_order
│   ├── orders_line_item
│   └── orders_payment
├── inventory_*    ← 只有库存模块可以读写
│   ├── inventory_product
│   └── inventory_stock
└── users_*        ← 只有用户模块可以读写
    ├── users_user
    └── users_address

数据所有权隔离有两个好处:第一,模块可以独立演进自己的数据模型而不影响其他模块——订单模块的表结构调整不需要通知库存模块,因为库存模块从来不直接读订单的表;第二,如果将来确实需要拆分成微服务,数据已经天然隔离,迁移成本低很多——每个模块的表可以直接搬到独立数据库,不需要做复杂的数据拆分。

实施数据隔离时有一个常见的困惑:跨模块的数据查询怎么办?比如管理后台需要展示”用户最近的订单”,同时显示用户信息和订单信息。在模块化单体中,做法是:通过用户模块接口获取用户信息,通过订单模块接口获取订单列表,在展示层组装。虽然这比直接写一条 JOIN 查询多了一步,但它维护了模块边界的完整性。而且在单体内,这些”接口调用”本质上是函数调用,性能开销可以忽略不计。

4. 模块间通信可以使用进程内事件

除了直接调用公开接口,模块间也可以通过进程内事件(In-Process Event)实现松耦合通信。订单创建成功后发布一个事件,库存模块和通知模块各自订阅处理。

// 订单模块发布事件
eventBus.publish(new OrderCreatedEvent(orderId, items));

// 库存模块订阅事件
@Subscribe
public void onOrderCreated(OrderCreatedEvent event) {
    inventoryService.reserveStock(event.getItems());
}

这里的 eventBus 是进程内的事件总线(例如 Guava EventBus 或 Spring ApplicationEvent),不涉及消息队列(Message Queue)。如果将来拆分服务,可以把进程内事件替换为消息队列,模块的业务逻辑不需要大改。

这种设计模式的价值在于降低模块间的编译期依赖。如果订单模块直接调用库存模块的接口,那么订单模块在编译时就依赖库存模块。通过事件机制,订单模块只需要定义事件类型,不需要知道谁会处理这个事件。这和微服务中”事件驱动架构(Event-Driven Architecture)“的思路一致,只是实现载体从网络消息队列变成了进程内事件总线。

需要注意的是,进程内事件默认是同步执行的(在同一个线程内依次调用所有订阅者),除非你显式配置为异步。事务边界也需要考虑:如果事件处理器和事件发布者在同一个数据库事务中,某个处理器失败会导致整个事务回滚。这通常是期望的行为,但如果某些处理器的失败不应该阻塞主流程,就需要把它们放到事务提交后异步执行。

边界执行:光有约定不够

模块边界如果只靠代码评审(Code Review)和文档来维护,迟早会被打破。一个赶工期的 PR 里”临时”跨模块调用一次,没人拦住,半年后就变成了几十处。这种现象有个名字:架构侵蚀(Architecture Erosion)。模块化单体需要工具级别的边界执行来对抗架构侵蚀。

不同语言生态有不同的工具:

语言/框架 工具 机制
Ruby/Rails Packwerk(Shopify 开源) 静态分析,检测跨 package 的非法引用
Java ArchUnit 单元测试形式的架构规则检查
Java(Spring) Spring Modulith 模块定义 + 集成测试 + 事件支持
.NET 项目引用 + InternalsVisibleTo 编译器级别的访问控制
Go 包(package)+ internal 目录 语言级别的可见性规则
TypeScript ESLint 规则 + 路径别名 自定义 lint 规则禁止跨模块导入
Kotlin Module system + internal 关键字 语言级别的模块可见性

以 Java 的 ArchUnit 为例,可以在测试中编写架构规则:

// 架构规则:订单模块不允许直接访问库存模块的内部类
@Test
void orders_should_not_depend_on_inventory_internals() {
    noClasses()
        .that().resideInAPackage("..orders..")
        .should().dependOnClassesThat()
        .resideInAPackage("..inventory.domain..")
        .check(importedClasses);
}

这条规则在 CI 中自动执行。任何违反模块边界的代码提交都会导致构建失败,比代码评审靠谱得多。

关于工具选择的工程判断:如果你的语言本身有模块可见性机制(Go 的 internal 目录、Kotlin 的 internal 关键字),优先利用语言特性,因为它们的执行是编译器级别的,不存在被绕过的可能。如果语言没有足够强的可见性控制(Java 的 package-private 粒度有限、Ruby 几乎没有真正的私有性),就需要依赖外部工具。重点是:边界检查必须自动化,必须在 CI 中执行,必须阻止违规代码合并。 靠人力审查来维护模块边界是不可持续的。


四、Shopify 案例:世界级规模的单体实践

背景

Shopify 是全球最大的电商平台之一。截至 2024 年,Shopify 平台上的商家年交易额(GMV)超过 2350 亿美元(Shopify 2024 年报数据)。支撑这个规模的核心系统是一个 Ruby on Rails 单体应用——代码量超过数百万行,贡献者超过 2000 名工程师。

Shopify 在 2016 年前后也考虑过微服务拆分。但在深入评估后,他们选择了另一条路:不拆服务,改造单体内部的模块结构。

Shopify 首席架构师 Kirsten Westeinde 在 2019 年 RailsConf 演讲 Deconstructing the Monolith 中解释了这个决策背后的逻辑:拆分微服务的成本极高,而且大量工程问题的根源不是”服务太大”,而是”代码边界不清晰”。如果单体内部的模块边界足够清晰,拆分服务并不是必需的。

Packwerk:工具化的模块边界

Shopify 开源的 Packwerk 是他们内部用来执行模块边界的核心工具。它的工作原理是:

  1. 把单体代码划分为多个 Package,每个 Package 是一个业务域
  2. 每个 Package 有一个 package.yml 配置文件,声明该 Package 的依赖关系和可见性规则
  3. Packwerk 在 CI 中运行静态分析,检测所有违反规则的跨 Package 引用
# components/orders/package.yml
enforce_dependencies: true
enforce_privacy: true
dependencies:
  - components/users
  - components/inventory

这份配置的含义是:订单模块(components/orders)声明了它依赖用户模块和库存模块,并且开启了依赖强制执行和私有性强制执行。如果其他未声明的模块试图引用订单模块的内部类,Packwerk 会报错。

Shopify 的工程师在 2021 年的技术博客 Under the Hood of Shopify’s Application Remodeling 中披露了一组数据:

Packwerk 的一个设计亮点是它的”渐进执行”策略。在引入 Packwerk 时,不要求所有现有代码立刻符合规则——它允许你把现有的违规标记为”已知违规(known violations)“,然后只阻止新增违规。这意味着团队可以在不停下日常开发的前提下,逐步清理历史违规。这种务实的策略对于大型遗留代码库尤为重要,因为要求一次性清理所有问题既不现实也不必要。

Flash Sale 场景下的单体性能

Shopify 每年要应对多次大型促销场景(Flash Sale),例如黑色星期五/网络星期一(BFCM)。2023 年 BFCM 期间,Shopify 报告的峰值数据是每分钟处理超过 400 万美元的交易额(来源:Shopify BFCM 2023 数据页面)。

单体架构在这种场景下的优势是:所有业务逻辑在同一进程内执行,没有服务间网络调用的延迟累积。一个结账请求需要验证库存、计算价格、处理优惠券、创建订单、扣减库存——在单体中这些步骤是连续的函数调用,在微服务中则是一连串的网络请求。Flash Sale 场景下流量陡增,网络调用的排队效应和超时问题会被放大,单体在这方面天然更稳定。

Shopify 通过以下手段在单体架构下实现高并发:

关键点在于:水平扩展的单位是整个单体应用实例,而不是单个微服务。这让扩展策略变得简单——不需要为每个服务单独评估扩展策略、单独配置资源、单独处理服务间的流量比例。

有人会说:“这不是浪费资源吗?如果只有订单模块需要扩展,却要把整个应用都扩展。”这是事实,但需要量化来判断。如果订单模块占整个应用 CPU 开销的 60%,那么为了扩展订单模块而多部署的应用实例中,有 40% 的资源确实被其他模块”陪跑”了。但这 40% 的浪费,和维护一套完整微服务基础设施(Kubernetes 集群、服务网格、配置中心、分布式追踪)的成本相比,往往是更划算的。这是需要具体计算的取舍,不能一概而论。

graph TB
    LB["负载均衡器"]
    LB --> App1["Rails 单体实例 1"]
    LB --> App2["Rails 单体实例 2"]
    LB --> App3["Rails 单体实例 3"]
    LB --> AppN["Rails 单体实例 N"]

    App1 --> Cache["Redis / Memcached"]
    App2 --> Cache
    App3 --> Cache
    AppN --> Cache

    App1 --> Pod1["Pod 1\n(DB 分片)"]
    App2 --> Pod2["Pod 2\n(DB 分片)"]
    App3 --> Pod1
    AppN --> PodN["Pod N\n(DB 分片)"]

    style LB fill:#388bfd,color:#fff
    style Cache fill:#f0883e,color:#fff

图中展示了 Shopify 的扩展模型:负载均衡器将请求分发到多个无状态的 Rails 单体实例,每个实例可以访问共享的缓存层,数据库按商家维度分片到不同的 Pod。这个架构在不拆分微服务的前提下实现了大规模水平扩展。

Shopify 的经验总结

从 Shopify 的实践中可以提炼出几条关键判断:

  1. 模块边界比服务边界更重要。如果代码内部没有清晰的边界,拆成微服务也只是把泥球分布到了网络上。Shopify 的 Packwerk 本质上是在单体内部实现了微服务之间的”接口契约”,但不需要承担网络调用的代价。

  2. 工具执行比制度约束更可靠。Packwerk 这样的工具能把”应该遵守的规范”变成”不遵守就无法合并的硬性约束”。Shopify 的工程师在博客中提到,仅靠代码评审和编码规范,模块边界平均每个季度会被打破上百次;引入 Packwerk 后,违规数量降到了接近零。

  3. 单体的可扩展性瓶颈通常在数据库,不在应用层。数据库分片解决了数据层面的扩展问题后,应用层的无状态单体可以简单地水平扩展。Shopify 的 Pods 架构把数据按商家维度分片,每个 Pod 可以独立扩展。

  4. 保留拆分的能力比立刻拆分更务实。模块化单体的数据隔离和接口隔离让将来的拆分变得可行,但不强迫你现在就付出拆分的代价。Shopify 确实也有少量独立服务(例如支付处理),但核心电商逻辑仍然在单体内。

  5. 渐进式改造比推倒重来更安全。Shopify 的模块化改造是在持续运行的系统上逐步进行的——先引入 Packwerk,再逐个模块清理边界违规,最后收紧规则。整个过程没有”停下来重写”的阶段。这种渐进式策略的风险远低于大规模的微服务拆分。


五、工程效率对比:单体 vs 微服务

关于单体和微服务的工程效率差异,业界有一些值得参考的数据和分析。以下数据均标注来源,结论部分包含综合判断。

开发速度

2020 年 Thoughtworks 在其 Technology Radar 中将”Modular Monolith”列为 Adopt(采纳)级别,原因之一是微服务架构在中小团队中造成的开发效率损耗。

Charity Majors(Honeycomb CTO)在 2018 年的文章 Microservices are for Tooling 中提出过一个观察:微服务架构的复杂度需要强大的工具链(可观测性平台、CI/CD 系统、服务网格)来支撑,而很多团队在没有这些工具的情况下就急着拆微服务,结果是把大量时间花在了运维基础设施上而不是业务功能上。

具体来说,微服务架构在开发阶段引入的额外工作包括:

这是工程判断,不是实验结论:在团队规模小于 30 人、产品处于快速迭代期时,微服务架构的开发效率损耗通常大于它带来的收益。因为小团队的沟通成本本身就低,不需要通过服务边界来降低协调开销。Fred Brooks 在《The Mythical Man-Month》中的观察仍然适用:增加人手会增加沟通路径,服务边界是应对沟通路径爆炸的手段——如果你的团队还没有这个问题,就不需要这个解法。

运维成本

Yan Cui 在 2023 年的分析文章 “Even Amazon can’t make sense of serverless or microservices” 中引用了 Amazon Prime Video 团队的案例数据:从微服务迁移到单体后,基础设施成本降低了 90% 以上。成本降低的主要原因是消除了大量的跨服务网络调用和中间件开销。

当然,这个案例有其特殊性——Prime Video 的原始微服务架构存在过度拆分的问题,每一帧视频的质量检测都触发了大量的跨服务调用和 S3 读写。但它说明了一个普遍问题:微服务架构的运维成本不是线性增长的,而是随着服务数量增加呈超线性增长。

为什么是超线性?因为运维复杂度不仅来自单个服务的管理,还来自服务间交互的管理。N 个服务之间的潜在交互关系是 O(N^2) 级别的。每多一个服务,不仅增加了一个需要管理的节点,还增加了它与所有现有服务之间的潜在依赖、故障传导路径和版本兼容性问题。

运维成本的来源可以拆解为:

成本项 单体(10 人团队) 微服务(10 人团队,15 个服务)
CI/CD 流水线维护 1 条 15 条
监控和告警配置 1 套应用指标 15 套服务指标 + 服务间调用指标
值班排障复杂度 单进程日志 + 单机调试 分布式日志 + 链路追踪 + 服务依赖分析
基础设施管理 若干台服务器 / 容器 Kubernetes 集群 + 服务网格 + 配置中心
安全更新 1 个部署单元 15 个部署单元各自的依赖版本
容量规划 整体评估 每个服务独立评估,需考虑服务间流量放大
故障演练 关注点集中 需覆盖各种服务组合故障场景

上表中的具体数字是示意性的,实际数据因团队和技术栈而异。但结构性的复杂度差异是客观存在的。

Stack Overflow 的工程师 Nick Craver 在多次技术分享中提到过他们的运维数据:用 9 台服务器(含冗余)支撑日均 5000 万次页面浏览。他们的 .NET 单体架构让运维团队的规模可以保持极小。这个效率在微服务架构下很难复现——光是 Kubernetes 集群的管理就可能需要一个专职的平台工程团队。

认知负荷

Team Topologies(Matthew Skelton 和 Manuel Pais,2019)一书中提出了”认知负荷(Cognitive Load)“作为衡量团队效率的关键指标。他们把认知负荷分为三类:

微服务架构大幅增加了外在认知负荷。一个新加入团队的工程师,在写第一行业务代码之前,可能需要先理解:Kubernetes 的基本概念、服务发现机制、配置管理方式、日志聚合方式、部署流程、服务间认证机制。在单体架构中,这些通常不是问题。

综合判断:微服务架构的核心价值在于组织扩展性——当团队规模达到数百人、多个团队需要独立开发和部署时,服务边界可以降低跨团队协调成本。但对于小到中等规模的团队(少于 50 人),微服务架构引入的外在认知负荷和运维复杂度往往大于它在组织扩展性上的收益。

用一个类比来总结这部分的核心判断:微服务架构解决的问题是”大组织里多个团队如何独立交付”,不是”如何写出更好的代码”。如果你的团队还没有遇到前一个问题,引入微服务架构大概率是在用复杂度换取你并不需要的灵活性。


六、什么时候单体是正确选择

不是所有场景都适合单体。但下面这些场景中,单体通常是更优的选择。在列出这些场景之前,需要先说明一个前提:这里讨论的不是”无结构单体”(大泥球),而是”模块化单体”。一个没有内部结构的单体在任何场景下都不是好选择。

团队规模小到中等(少于 50 名工程师)

Conway 定律(Conway’s Law)告诉我们,系统的结构会趋向于复制组织的沟通结构。一个 10 人团队拆出 15 个微服务,意味着每个人平均要维护 1.5 个服务的全栈(开发、测试、部署、监控)。这不是专业化分工,是碎片化分散。

更具体地说,微服务架构假设每个服务有一个专门的团队负责其完整生命周期(“You build it, you run it”)。但如果团队总共只有 10 个人,每个人需要同时”拥有”多个服务,那么:

Shopify 有超过 2000 名工程师仍在使用单体。50 人以下的团队拆微服务,在多数情况下是组织规模不足以支撑微服务运维开销的标志。

产品早期,领域边界不清晰

产品早期最大的挑战是找到正确的业务模型。领域边界会随着产品迭代频繁变化——今天的”订单”模块明天可能需要和”物流”合并,后天又要拆出”售后”。

在单体中调整模块边界的成本是重构代码——移动文件、修改导入路径、更新接口。在微服务中调整服务边界的成本要高得多:

前者可能是一个 PR,后者可能是一个季度的项目。

Martin Fowler 在 MonolithFirst 一文中的建议是明确的:几乎所有成功的微服务架构都是从单体演化而来的,而不是从第一天就设计成微服务的。原因很简单:只有在系统运行一段时间后,你才能真正了解哪些模块之间耦合度高、哪些模块需要独立扩展、哪些边界是稳定的。在这些信息不充分的时候就做服务拆分,结果往往是拆错了,然后要花更大的代价调整。

延迟敏感型系统

高频交易(HFT,High-Frequency Trading)系统、实时游戏服务器、嵌入式控制系统、音视频处理管线——这些场景中,每一次网络调用都是不可接受的延迟来源。单体架构中的函数调用延迟在纳秒级,而即使是同机房的 gRPC 调用也在数百微秒级,差了三个数量级。

对于高频交易场景,纳秒和微秒的差异直接影响交易策略的有效性。对于实时游戏,每增加一毫秒的延迟都会影响玩家体验。这些场景下,微服务架构带来的网络开销是不可接受的成本。

强一致性需求

金融核算、库存管理、票务系统——这些场景需要严格的事务一致性(Strong Consistency)。在单体中,一个数据库事务就能解决。一条 SQL 事务可以同时扣减库存和创建订单,数据库保证原子性(Atomicity)。

在微服务架构中,订单服务和库存服务各有自己的数据库。跨服务的数据一致性需要引入 Saga 模式(Saga Pattern)或两阶段提交(Two-Phase Commit,2PC)。Saga 的补偿逻辑(Compensating Transaction)需要手动实现——如果扣减库存成功但创建订单失败,你需要编写”回补库存”的补偿操作,并确保这个补偿操作本身是幂等的。两阶段提交的性能开销和故障处理复杂度极高,跨数据库的 2PC 在生产环境中很少使用。

这些复杂度在单体架构中完全不存在。如果你的业务核心流程需要强一致性保证,这个差异是决定性的。

快速验证阶段(MVP / PoC)

创业公司或新项目的 MVP(Minimum Viable Product,最小可行产品)阶段,速度是第一优先级。花三个月搭微服务基础设施,不如花一个月用单体把产品推向市场,再根据实际增长数据决定是否以及如何拆分。

这个阶段的核心矛盾是:你还不知道产品会不会成功,花大量精力在架构灵活性上是在为一个不确定的未来投资。如果产品失败了(多数创业产品确实会失败),微服务基础设施的投入就是纯粹的浪费。如果产品成功了,团队规模自然会增长,届时有人力和预算来做架构演进。

Netflix 和 Amazon 的微服务架构是在已经证明产品市场匹配(Product-Market Fit)之后,为了应对增长压力而逐步演化出来的。它们不是一开始就设计成微服务的。


七、什么时候该考虑离开单体

单体不是永远正确的。以下信号出现时,说明单体可能已经到达了它的适用边界:

构建和部署时间过长

当单体应用的构建时间超过 30 分钟、部署时间超过 1 小时时,开发效率会严重下降。工程师修改一行代码后要等半小时才能看到结果,快速迭代无从谈起。构建时间过长还会导致一个连锁问题:工程师倾向于把多个改动合并成一次大提交来减少等待次数,而大提交会增加代码评审难度和合并冲突风险。

应对策略:先尝试优化构建工具(增量构建、构建缓存、并行编译),如果优化后仍然过长,考虑拆分。很多时候构建时间长的原因不是代码量太大,而是构建配置不合理——比如每次都全量编译、没有利用构建缓存、测试没有分层执行。

团队之间频繁因代码冲突阻塞

当多个团队在同一个代码库中频繁出现合并冲突、互相阻塞的情况时,说明代码库的协作模型已经成为瓶颈。模块化单体可以缓解这个问题(模块间代码隔离后冲突概率降低),但如果团队规模继续增长、部署频率要求继续提高,独立部署可能是必要的。

一个具体的信号是:团队需要”排队”部署。如果三个团队的改动都准备好了,但因为共享同一个部署单元,需要协调”谁先部署”,这种协调开销会随着团队数量增加而显著恶化。

不同模块有截然不同的扩展需求

如果系统中某个模块需要 100 个实例来处理计算密集型任务,而其他模块只需要 2 个实例,把它们打包在一起会浪费大量资源。这时候把高负载模块拆分为独立服务是合理的。

但这不意味着要把所有模块都拆成微服务。更务实的做法是:只拆出那些扩展需求明显不同的模块,其余部分保持单体。例如,一个电商系统中搜索模块需要大量计算资源,可以把搜索拆成独立服务,而订单、库存、用户管理仍然在单体内。

不同模块需要不同的技术栈

某些场景下,特定模块用特定技术栈实现会有巨大优势——比如用 Python 做机器学习推理、用 Go 做高并发网关、用 Rust 做性能关键路径。单体架构限制了技术选型的灵活性。

不过需要注意的是,多技术栈本身也有代价:团队需要掌握多种语言和工具链,招聘范围受限,代码共享变得困难。技术栈的多样性只在它带来的收益明显大于管理成本时才值得引入。

组织结构要求独立交付

当公司规模增长到多个产品线、多个独立团队、甚至跨地域协作时,组织上的独立性需求会驱动架构走向服务拆分。这本质上是 Conway 定律的正面应用——如果你希望团队独立交付,系统结构需要支持独立部署。

一个在北京和旧金山各有一个团队的公司,如果两个团队需要在同一个代码库中频繁协作,时区差异会导致代码评审和合并冲突的周转时间极长。把各自负责的模块拆成独立服务,可以大幅减少跨团队的协调需求。

综合判断:以上信号中,最关键的是团队规模和组织结构。技术层面的问题(构建时间、扩展需求、技术栈差异)通常有局部解法,但组织层面的协调瓶颈往往需要架构层面的变化。

另外值得注意的是,离开单体不等于一步跳到微服务。中间有很多过渡状态:先从单体中拆出一两个高负载或高变化频率的模块作为独立服务,其余部分保持单体。这种”单体 + 少数卫星服务”的模式在实际工程中非常常见,也通常是最务实的迁移路径。关于从单体到微服务的具体迁移策略,可以参考本系列的单体到微服务迁移一文。


八、架构对比:单体 vs 模块化单体 vs 微服务

下面这张表从多个维度对比三种架构风格。每个维度都不是简单的”好/坏”,而是不同条件下的取舍。

维度 单体(无结构) 模块化单体 微服务
部署单元 1 个 1 个 N 个
模块边界 无或模糊 清晰,工具强制执行 由服务边界物理隔离
数据隔离 共享所有表 逻辑隔离(模块拥有自己的表) 物理隔离(独立数据库)
模块间通信 任意函数调用 通过公开接口的函数调用 网络调用(HTTP/gRPC/消息队列)
通信延迟 纳秒级 纳秒级 百微秒到毫秒级
事务一致性 数据库事务 数据库事务 需要 Saga / 最终一致性
独立部署 不支持 不支持 支持
独立扩展 不支持 不支持 支持
技术栈灵活性 单一技术栈 单一技术栈 每个服务可以不同
团队自治 中(模块级自治) 高(服务级自治)
本地开发复杂度 高(需要启动多个服务)
调试难度 低(单进程) 低(单进程) 高(分布式追踪)
运维复杂度
CI/CD 复杂度 低到中
适合团队规模 < 20 人 < 100 人 > 50 人
向微服务演进难度 高(边界不清晰) 低(边界已存在) 不适用

表中”适合团队规模”是经验判断,实际数字因产品复杂度、团队能力和组织文化而异。核心逻辑是:团队越小,微服务的协调开销越难被分摊;团队越大,单体的协作瓶颈越明显。模块化单体在这个频谱上处于中间位置——它用工具化的模块边界来降低协作冲突,但不引入分布式系统的复杂度。

对这张表的一个补充说明:表中”模块化单体不支持独立部署和独立扩展”是在严格定义下的判断。实际上,有些团队会采用”模块化单体 + 特定模块独立部署”的混合模式——大部分模块在单体内运行,但个别高负载或高变化频率的模块被拆出来独立部署。这种混合模式在实践中很常见,也是一种务实的选择。

下面这张图把三种架构的结构差异放在一起对比:

graph LR
    subgraph M1["传统单体"]
        direction TB
        M1A["所有代码混在一起"]
        M1B[("单一数据库\n所有表共享")]
        M1A --> M1B
    end

    subgraph M2["模块化单体"]
        direction TB
        M2A["模块 A"]
        M2B["模块 B"]
        M2C["模块 C"]
        M2A -->|"公开接口"| M2B
        M2B -->|"公开接口"| M2C
        M2D[("DB:A 的表")]
        M2E[("DB:B 的表")]
        M2F[("DB:C 的表")]
        M2A --> M2D
        M2B --> M2E
        M2C --> M2F
    end

    subgraph M3["微服务"]
        direction TB
        M3A["服务 A"]
        M3B["服务 B"]
        M3C["服务 C"]
        M3A -->|"HTTP/gRPC"| M3B
        M3B -->|"消息队列"| M3C
        M3D[("DB A")]
        M3E[("DB B")]
        M3F[("DB C")]
        M3A --> M3D
        M3B --> M3E
        M3C --> M3F
    end

三种架构的核心区别在于:传统单体没有内部边界,一切共享;模块化单体有清晰的逻辑边界但共享部署单元和进程空间;微服务通过网络实现物理隔离,代价是网络调用和分布式一致性问题。选择哪种架构,本质上是在”隔离程度”和”隔离成本”之间做取舍。


九、实施指南:如何构建一个好的模块化单体

如果你决定采用模块化单体,以下是实施层面的关键步骤。这些步骤既适用于新项目从零开始,也适用于对现有无结构单体的渐进式改造。

第一步:识别模块边界

模块划分的依据是业务能力(Business Capability),而不是技术层。可以用以下方法识别模块边界:

初期不要追求完美划分。先从明显的业务边界开始(用户、订单、支付、库存),在迭代中逐步调整。模块边界划错了不可怕——在单体内调整模块边界的成本远低于在微服务架构中调整服务边界。

第二步:定义模块接口

每个模块对外暴露的接口要满足以下标准:

接口设计的好坏直接决定模块间的耦合程度。一个设计良好的接口,让模块内部可以自由重构而不影响其他模块。

第三步:实施数据隔离

第四步:引入边界检查工具

根据技术栈选择合适的工具(Packwerk、ArchUnit、Spring Modulith 等),在 CI 中配置为必须通过。边界违规应该像测试失败一样阻止合并。

工具引入时建议采用 Shopify 的”渐进执行”策略:先把现有违规标记为”已知违规”,只阻止新增违规。然后每个迭代安排一部分时间清理历史违规,直到全部清理完毕。不要试图一次性清理所有问题——那会产生一个巨大的 PR,审查困难且风险高。

第五步:建立模块文档

每个模块需要一份简单的文档,说明:

这份文档不需要很长,但必须和代码保持同步。建议把文档放在模块目录下(例如 modules/orders/README.md),让它和代码一起接受版本控制和代码评审。

常见陷阱

实施模块化单体的过程中,有几个容易踩的坑:

1. 模块粒度太细

把模块拆得太细(例如每个数据库表一个模块)会导致模块间调用过于频繁,接口维护成本高,本质上重新发明了微服务的问题。模块的粒度应该和团队规模匹配——一个 5 人团队管理 3-5 个模块是合理的,30 个模块就太多了。

2. 循环依赖

模块 A 依赖模块 B,模块 B 又依赖模块 A。这说明模块划分有问题——要么两个模块应该合并,要么应该把共同依赖的部分提取成第三个模块。用工具检测循环依赖(Packwerk 和 ArchUnit 都支持),在 CI 中禁止引入新的循环依赖。

3. 共享模型泄漏

模块接口的参数和返回值如果直接暴露内部数据模型(比如直接返回 ORM 实体),其他模块就会对内部模型产生依赖。应该使用 DTO(Data Transfer Object)或专门的接口类型来隔离内部模型。

4. 只做了代码隔离,没做数据隔离

代码层面划分了模块,但数据库表仍然被所有模块共享读写。这种情况下模块边界是虚假的——任何模块都可以绕过接口直接查询其他模块的表。数据隔离是模块化单体的底线要求。


十、结论

单体架构不是一个需要被”克服”的阶段,它是一个在特定条件下完全正当的架构选择。

条件很清楚:团队规模可控(通常少于 50 人)、产品处于快速演进期、领域边界尚未稳定、一致性要求强于独立部署需求。满足这些条件时,模块化单体在开发效率、运维成本和系统可靠性上都优于微服务。

关键不在于选择单体还是微服务,而在于是否在代码内部建立了清晰的模块边界。一个有良好模块结构的单体,比一堆边界模糊的微服务更容易维护、更容易演进、也更容易在必要时拆分。

Shopify 的实践已经证明:单体架构可以支撑数千名工程师和数千亿美元的交易规模。前提是你认真对待模块边界,用工具而不是制度来执行它。

最后一个提醒:架构决策不应该基于”行业趋势”或”面试时怎么回答好看”。它应该基于你的团队规模、产品阶段、业务需求和技术能力。如果单体架构在你的场景下是正确答案,选择它不需要道歉。

做架构选择时问自己三个问题:我们的团队规模是否大到需要通过服务边界来降低协调成本?我们的产品是否成熟到领域边界足够稳定?我们是否有能力承担分布式系统的运维复杂度?如果三个答案中有两个是”否”,模块化单体大概率是更好的选择。


导航

上一篇:架构文档

下一篇:分层架构


参考资料

论文与书籍

技术博客与演讲

工具

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。

2026-04-13 · architecture

【系统架构设计百科】复杂性管理:架构的核心战场

系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略


By .