Table of contents
Open Table of contents
TL;DR
拆分的本质是让不同团队能独立部署和运维各自的代码。拆分依据的优先级:领域边界 > 团队所有权 > 数据所有权 > 运维特征差异 > 变更频率。大多数团队应该止步于模块化单体,只在出现可度量的痛点时才提取服务。拆错的代价远大于不拆的代价。
为什么要拆——真正的动机
组织层面(通常是主要驱动力)
Conway’s Law:“设计系统的架构,受制于产出这些设计的组织的沟通结构。” 这不是建议,是观察到的规律。
| 痛点 | 表现 | 本质 |
|---|---|---|
| 部署争抢 | 多个团队在同一仓库频繁冲突,发布互相等待 | 组织扩张但架构没跟上 |
| 变更协调 | 改一个功能要跨 3 个团队对齐 | 代码边界与团队边界不匹配 |
| 爆炸半径 | 一个模块的 bug 导致整个系统不可用 | 故障没有隔离 |
Amazon 的 “两个披萨团队” + “you build it, you run it” 就是把组织结构作为拆分的第一驱动力。
技术层面
| 痛点 | 表现 | 本质 |
|---|---|---|
| 异构扩缩容 | 推荐引擎要 GPU + 水平扩展,支付要强一致性 + 垂直扩展 | 资源模型不同的组件绑在一起 |
| 构建/测试耗时 | CI 跑完要 30+ 分钟,开发反馈循环太慢 | 单体太大导致工具链效率下降 |
| 技术栈锁定 | 某个模块需要不同语言/运行时,但单体无法容纳 | 技术多样性需求 |
拆早 vs 拆晚的风险不对称
拆晚了:团队速度逐步下降,部署频率降低,故障级联。痛苦是渐进的、可感知的。
拆早了(风险更大):
- 领域边界还没搞清楚,拆出来的服务边界是错的
- 跨服务重构的成本是跨模块重构的 10 倍(要改 API、数据存储、部署流程)
- Martin Fowler:“几乎所有成功的微服务案例都是从一个太大的单体拆出来的;几乎所有一开始就用微服务构建的系统都遇到了严重问题。”
结论:默认不拆。拆分是对已验证痛点的回应,不是预防措施。
拆分的五个维度
维度 1:领域边界(DDD Bounded Context)— 主轴
业务能力是拆分的第一依据。用 Event Storming 识别领域事件、聚合、限界上下文。每个限界上下文是一个候选服务边界。
关键工具:代码提交历史分析。哪些文件总是一起改?用 CodeScene 等工具分析 change coupling——耦合度高的模块应该在同一个服务里,耦合度低的才适合拆分。
维度 2:团队所有权(Conway’s Law)
如果拆出一个服务但两边都是同一个团队维护,你只得到了一个网络跳转,没有任何收益。反过来,Inverse Conway Maneuver:先调整团队结构,再让架构跟着演进。
维度 3:数据所有权
谁拥有哪些数据库表?如果两个限界上下文共享同一张表,要么它们其实是一个上下文,要么数据模型没有正确分解。数据拆分是最难也最贵的部分(后面专节讲)。
维度 4:运维特征
资源模型根本不同的组件是天然的拆分点:
| 特征差异 | 示例 | 为什么必须拆 |
|---|---|---|
| 有状态 vs 无状态 | IM 连接服务 vs CRUD API 服务 | 扩缩容策略、部署策略完全不同 |
| CPU 密集 vs 内存密集 | 视频转码 vs WebSocket 长连接 | 资源需求和硬件选型不同 |
| 高可用要求不同 | 支付(99.99%)vs 报表(99.9%) | SLA 不同,运维投入不同 |
IM 后端的典型案例:Connection Service(有状态、内存密集、不能随便重启)必须从第一天独立于 API Service(无状态、CPU/IO 密集、可随意滚动更新)。这不是”功能不同”,而是”运维特征不兼容”。
维度 5:变更频率
一个模块每天变更,另一个每季度变更一次——部署节奏的不匹配是拆分信号。但这通常是次要因素,不应单独驱动拆分决策。
优先级
领域边界 > 团队所有权 > 数据所有权 > 运维特征 > 变更频率
一次好的拆分应该在多个维度上对齐。 如果领域边界建议拆分,但数据无法干净分离或没有独立团队来维护——这是一个警告信号,说明现在还不该拆。
什么时候该拆——可度量的信号
必须是可观测的、持续的痛点
| 信号 | 怎么量化 | 阈值参考 |
|---|---|---|
| 部署频率下降 | 每周部署次数(per team) | 工程师增加但部署频率没涨 |
| 变更耦合 | 提交历史中跨模块联动比例 | > 30% 的 commit 需要改多个模块 |
| 构建时间 | CI 完成时间 | > 15-30 分钟 |
| 合并冲突率 | 每 sprint 冲突次数 | 逐季度增长 |
| 故障爆炸半径 | 单故障影响的功能数 | 不相关功能被连带影响 |
| 新人上手时间 | 新工程师到首次独立交付的时间 | > 数周 |
模块化单体:被低估的中间态
Shopify 的案例是最有说服力的:
- 6000 个 Ruby 类,从 Rails 式技术分层(models/views/controllers)重组为业务域组件(orders、shipping、billing)
- 每个组件变成独立的 “mini Rails app”,有自己的公共 API 和独占数据
- 开发了内部工具 Wedge,通过 Ruby tracepoints 在 CI 中捕获完整调用图,自动检测跨组件违规
- Black Friday 处理 30TB/分钟——证明极端规模不一定需要微服务,需要的是好的边界
务实的演进路径:
单体 → 模块化单体 → 提取第一个服务 → 按需提取更多
↑ ↑
大多数团队应该在这里停下 只在信号持续存在时继续
时间线参考(中等规模代码库):
- 0-2 月:领域发现、边界映射
- 2-6 月:模块化重构、定义模块接口和模块级测试
- 6-8 月:提取第一个服务(Strangler Fig + 灰度)
- 9-12 月:按需继续提取
- 12 月+:评估——大多数模块通常留在单体里
拆分模式
Strangler Fig(绞杀者模式)
最常用的增量迁移模式。来源:Martin Fowler,灵感来自热带绞杀榕。
客户端
│
┌──────▼──────┐
│ Proxy / │ ← 路由层决定走新还是走旧
│ Facade │
└──┬──────┬───┘
│ │
┌───────▼──┐ ┌─▼────────┐
│ 新服务 │ │ 旧单体 │
│ /orders │ │ /users │
│ │ │ /products│
└──────────┘ └──────────┘
执行要点:每次迁移必须是原子性的——构建新服务 + 重定向流量 + 下线旧代码。最常见的失败模式是”新服务上线了,旧代码永远没删”。
Branch by Abstraction
当需要替换的组件在单体内部深处(不在边缘)时使用。
步骤:
- 为要替换的功能创建抽象接口
- 让所有调用方通过抽象接口调用
- 创建新实现(可能调用外部微服务)
- 切换抽象到新实现
- 清理旧代码
vs Strangler Fig:Strangler Fig 包在外面,适合边缘功能;Branch by Abstraction 在内部替换,适合核心组件。
Parallel Run(并行运行)
同时调用新旧实现,对比结果。只有一个是真正的数据源。用于验证新实现的正确性和性能。
GitHub 从 MySQL 迁移存储层时用了这个模式——并行运行数周,对比查询结果。
选型
| 场景 | 推荐模式 |
|---|---|
| 迁移边缘功能(认证、通知、图片处理) | Strangler Fig |
| 替换核心内部组件(ORM、存储引擎) | Branch by Abstraction |
| 高风险迁移,需要验证正确性 | Parallel Run |
| 初期探索,不确定是否值得拆 | 先模块化,再决定 |
拆分的代价——你付出什么
分布式事务
2PC(两阶段提交):跨微服务基本不可用——阻塞、协调者单点、不耐受网络分区。
Saga 模式:将事务拆成一系列本地事务 + 补偿操作。
创建订单 → 扣库存 → 扣款 → 发通知
↓ 扣款失败
补偿:恢复库存 → 取消订单
两种实现:
- 编排式(Choreography):服务发事件,其他服务响应。去中心化,但流程难追踪
- 指挥式(Orchestration):中央协调者指挥流程。好理解,但协调者是重点
关键限制:Saga 没有隔离性(ACID 中的 I)。并发 saga 可能产生数据异常,必须自己实现对策(语义锁、可交换更新、悲观视图)。
数据一致性
最终一致性变成常态。双写问题(Dual Write):先写数据库再发事件,两步不是原子的——中间崩溃会导致不一致。
解决方案:
- Outbox Pattern:在同一个数据库事务中写业务数据 + outbox 表,再由独立进程发布事件
- CDC(Change Data Capture):监听数据库 binlog,Debezium 是标准工具
运维复杂度
具体代价:
- 分布式系统调试时间比单体多 35%
- 50 个服务级别的可观测性基础设施年费:$50K-$500K
- 人员配比:成熟工具链 1 SRE / 10-15 服务,建设期 1 SRE / 5-10 服务
- 必须有:分布式追踪(Jaeger/Tempo)、集中日志(Loki/ELK)、指标聚合(Prometheus)
网络延迟
函数调用(纳秒级)变成网络调用(毫秒级)。一个请求经过 10 个服务 = 10 倍延迟预算。
认知开销
Uber 发现工程师调查一个问题需要”追踪约 50 个服务,涉及 12 个团队”。这直接催生了 DOMA——把 2200 个微服务分组到 70 个领域。
反模式
分布式单体(Distributed Monolith)
两边最坏的结果:微服务的运维复杂度 + 零独立部署能力。
症状自检:
- 多个服务读写同一张数据库表
- 服务 A 调 B 调 C 调 D 形成同步链——一个挂全挂
- 发布 A 必须同时发布 B(lock-step deployment)
- 多个服务共享含业务逻辑的公共库,必须一起升级
命中任何一条,你的”微服务”其实是分布式单体。
Nano-service(过细拆分)
每个服务只包装一张数据库表 + CRUD。结果:每个服务都需要独立的 CI/CD、监控、告警、on-call 轮值,运维开销远超模块化收益。
判断标准:如果两个服务总是一起变更,且只有一个团队维护,合回去。
Entity Service(实体服务)
围绕数据库实体建服务(UserService、OrderService、ProductService),只暴露 CRUD 操作,不封装业务行为。业务逻辑散落在调用方——这是贫血领域模型的分布式版本,耦合度比单体更高。
大爆炸重写
Martin Fowler:“如果你做大爆炸重写,唯一保证的事情就是大爆炸。” 重写通常耗时 2-3 倍于预估,期间旧系统仍需维护,工作量翻倍。
数据拆分——最难的部分
共享数据库为什么是反模式
多个服务直接读写同一张表 = 实现耦合。任何 schema 变更要协调所有消费方。你失去了独立部署能力——微服务的核心定义特征。
迁移策略(渐进式)
阶段 1:拆服务,共享数据库(过渡态,可接受)
└── 逻辑分区:明确每张表的所有者
阶段 2:数据库分离(目标态)
├── Database View:只读投影,给过渡期的读取需求
├── Database Wrapping Service:用服务 API 封装数据库访问
├── Change Data Ownership:新服务成为 source of truth
└── CDC(Debezium):监听 binlog 同步数据变更
跨服务 Join 怎么办
| 方案 | 做法 | 适用 | 限制 |
|---|---|---|---|
| API Composition | API Gateway 调多个服务,内存中拼接 | 数据量小、查询简单 | 不适合大数据集或复杂查询 |
| CQRS + 物化视图 | 服务发领域事件,查询服务订阅维护反规范化视图 | 高性能读取 | 引入最终一致性和额外基础设施 |
实战决策流程
Step 1: 需要拆吗?
├── 部署争抢是实测到的问题(不是"感觉慢")?
├── 团队之间被彼此的变更阻塞?
├── 不同组件有真正不同的扩缩容需求?
└── 以上都不是 → 留在单体,投资模块化设计。停。
Step 2: 模块化单体能解决吗?
├── 在代码中强制模块边界
├── 每个模块独占 schema
├── 模块间通过公共 API 通信
└── 痛点消失了 → 停在这里。大多数组织应该停在这里。
Step 3: 提取哪个服务?
├── 从耦合度低、变更频率高的边缘能力开始
│ (认证、通知、图片处理——不是核心域)
├── 用提交历史分析验证耦合度
├── 同时建设运维能力:独立 CI/CD、监控、分布式追踪
└── 避免第一个就拆核心域(风险最高、数据最难分)
Step 4: 迭代评估
├── 拆完后:部署频率提升了吗?故障减少了吗?
├── 没有 → 重新评估边界,可能需要合回去
└── 有 → 按需继续提取下一个
真实案例
| 公司 | 做法 | 关键教训 |
|---|---|---|
| Shopify | 选择模块化单体,不走微服务。Black Friday 30TB/分钟 | 极端规模不一定需要微服务,需要好的边界 |
| Netflix | 2008-2012 从单体迁移到微服务 | 按数据访问模式为每个服务选不同的存储;混沌工程成为必需 |
| Uber | 2200 个微服务 → DOMA(70 个领域分组) | 微服务半衰期 1.5 年(频繁重写);Gateway 作为稳定接口层。平台支持成本降低一个数量级 |
| InVision | 把微服务合回单体 | 团队结构变了但服务边界没跟着变。合并只花了 3 周 |
InVision 的教训特别重要:当组织结构变化后,原来的服务边界可能不再合理。合并服务不是失败,是架构随组织演进的正常行为。
Pitfalls
- 把 “功能不同” 当拆分依据 — 用户管理和订单管理”功能不同”不是拆分的理由。如果它们由同一个团队维护、共享数据、一起变更,拆开只是增加网络跳转
- 第一个就拆核心域 — 核心域的数据最难分离、业务逻辑最复杂。应该从边缘、低耦合的能力开始练手
- 拆了服务但共享数据库 — 这只是把函数调用变成了网络调用 + 一个共享数据库。恭喜你发明了分布式单体
- 多个服务 import 同一个 domain 包 — 看起来是”复用”,实际是分布式单体。每个服务应有自己的领域模型,即使字段重复
- “重构留到以后” 但从不执行 — 技术债积累到无法模块化的程度。模块化窗口期是有限的
- 拆分后不测量效果 — 拆了但不测量部署频率、故障半径、交付速度是否改善 = 无法知道拆分是否成功
- 忽视合并的可能性 — 拆错了就合回去。这比维护一个错误的服务边界代价低得多