后端架构实战(二):为单体正名——模块化单体才是被低估的最佳实践

写在前面

上一篇我说"架构是权衡",不要跳级。这一篇就来论证:对绝大多数团队,单体 是起点,甚至是很长一段时间内的终点。

但这里的"单体"不是那个被嘲笑的"大泥球"(Big Ball of Mud)。我要替它正名的是它的升级版——模块化单体(Modular Monolith)。它可能是你被低估得最厉害的一个选项。


一、单体到底做错了什么

先搞清楚:单体为什么名声这么差?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
被骂的"单体",其实是"没设计的单体"——大泥球:

  - 所有代码堆在一个工程里
  - 模块间任意互相调用(没有边界)
  - 数据库一张大表谁都能读写
  - 改一处怕牵连全身
  - 发布必须整体一起发

  这些问题,根因不是"单体",是"没有边界"。
  把同样的代码拆成 20 个微服务,边界还是乱的,
  只是把"一个大泥球"变成了"20 个小泥球 + 网络"。

关键认知:单体 ≠ 混乱。单体也可以有清晰的模块边界。 混乱的是"没有边界",不是"部署在一起"。


二、单体被低估的优势

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
单体(即使不模块化)天然拥有的好东西:

  ✓ 一个进程内调用,没有网络开销
      → 性能天花板高,调试简单

  ✓ 强一致性"免费"
      → 一个数据库事务搞定,不用 Saga、不用最终一致

  ✓ 部署简单
      → 一个二进制 / 一个镜像,没有编排地狱

  ✓ 开发体验好
      → 一个 IDE 打开全栈,重构随便改,编译器帮你查

  ✓ 监控简单
      → 一个进程的日志、指标,不用分布式追踪

  ✓ 没有"分布式税"
      → 不用服务发现、配置中心、链路追踪、熔断……

这些优势,一旦你拆成微服务,全部要花钱、花人、花时间自己补回来。第四篇我会专门算这笔账。


三、什么时候单体真的不够用

不替单体无脑洗白。它确实有扛不住的时候:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
单体扛不住的信号(出现这些,才考虑拆):

  1. 团队规模超过 8~10 人,互相踩脚
     → 多人改同一个工程,合并冲突、发布互相阻塞

  2. 局部需要独立扩容
     → 只有"秒杀"模块要 100 倍算力,整体扩太浪费

  3. 局部需要独立的技术栈 / 发布节奏
     → AI 推理要用 Python,核心交易用 .NET,没法塞一起

  4. 故障爆炸半径太大
     → 一个小 bug 拖垮整个进程,影响所有功能

  注意:上面任何一条单独出现,都不一定非要上微服务。
       先看能不能用"模块化单体 + 进程内隔离"解决。

四、模块化单体:鱼和熊掌兼得

模块化单体的核心思想:部署还是一个,但内部按模块严格切分边界

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────┐
│              单体应用(一个进程)              │
│                                              │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐     │
│  │ 订单模块  │ │ 用户模块  │ │ 商品模块  │     │
│  │ Order    │ │ User     │ │ Product  │     │
│  └────┬─────┘ └────┬─────┘ └────┬─────┘     │
│       │  只通过模块的公开接口调用  │           │
│       └──────────┬──────────────┘           │
│              ┌───┴────┐                      │
│              │ 共享内核 │                      │
│              └────────┘                      │
└─────────────────────────────────────────────┘

  规则:
    - 模块之间不直接 new 对方的内部类
    - 只调用对方 module 暴露的 Service / 接口
    - 理想情况下,每个模块有自己独立的表(Schema 隔离)

它保留了单体的全部优势(一个进程、强一致、好调试),又解决了"边界混乱"的根因。

4.1 怎么落地边界

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
1. 按业务能力划分模块(不是按技术分层)
   订单、用户、商品、支付…… 而不是 Controller/Service/Dao

2. 模块对外只暴露少量接口(公开 API)
   内部实现细节对其他模块不可见

3. 数据归属清晰
   订单表只有订单模块写,别人要数据走订单模块的接口
   (这条最难,也最关键——下一节展开)

4. 工程结构上做硬隔离
   - .NET:每个模块一个 Project,依赖方向单向
   - 用 InternalsVisibleTo / 架构测试(如 NetArchTest)强制约束
   - 谁违规了编译/测试就挂

4.2 为什么不直接拆成微服务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
模块化单体 vs 微服务:

  模块化单体:先在进程内把边界画对
    - 边界画错了?重构成本 = 改代码(小时级)
    - 跑通了再决定要不要物理拆开

  直接上微服务:在网络上画边界
    - 边界画错了?重构成本 = 跨服务迁移数据和流量(周/月级)
    - 而且多了网络、一致性、运维一堆税

  结论:先用模块化单体验证边界,再考虑物理拆分。
       这是最稳的演进路径。

五、最容易踩的坑:共享数据库

模块化单体最大的诱惑,也是最大的坑——所有模块共用一个数据库,互相直接读写对方的表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
错误做法:
  订单模块为了"方便",直接 JOIN 用户表、商品表
  → 表结构耦合,用户表一改,订单模块崩
  → 边界形同虚设,又退回大泥球

正确做法:
  - 每个模块"拥有"自己的表(schema 或表前缀划分)
  - 别的模块要数据,调模块的接口,不直接读表
  - 共享库可以,但读写权限按模块隔离

  这一步做不好,模块化就是假的。
  数据边界,才是真正的边界。

这正是下一篇的主题:服务/模块到底该按什么切。答案是按"数据和领域"切,不是按技术层切。


六、真实世界的成功案例

别以为单体是"落后"的代名词:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- Stack Overflow:几个单体应用撑住全球 Top 50 的流量
    几百台服务器,没用微服务,靠的就是模块化 + 极致优化

- Shopify:早期巨型 Rails 单体,逐步模块化
    到很大规模才开始"模块化单体 → 组件化"演进

- Basecamp37signals):长期单体 + 模块化
    明确反对过早微服务化

  共同点:先榨干单体的价值,确认边界,再按需拆。
  没有一上来就微服务的。

七、小结

  • 被骂的不是单体,是"没有边界的大泥球"
  • 单体的天然优势:进程内调用、强一致免费、部署简单、好调试——拆了都要花钱补
  • 拆分信号:团队踩脚、局部扩容、技术栈差异、爆炸半径——出现且模块化解决不了,才拆
  • 模块化单体:部署还是一个,内部按业务模块严格切边界,鱼和熊掌兼得
  • 边界关键在数据:每个模块拥有自己的表,禁止跨模块直接读写
  • 演进路径:先用模块化单体验证边界,再考虑物理拆分——不要跳级

下一篇讲拆分的第一性原理:模块/服务到底按什么切——领域、数据,还是技术?