跳转到正文
zeno's blog
返回

微服务(一):拆分-按运维特征和组织边界画线

专题: 微服务

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 拆晚的风险不对称

拆晚了:团队速度逐步下降,部署频率降低,故障级联。痛苦是渐进的、可感知的。

拆早了(风险更大):

结论:默认不拆。拆分是对已验证痛点的回应,不是预防措施。


拆分的五个维度

维度 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 的案例是最有说服力的:

务实的演进路径

单体 → 模块化单体 → 提取第一个服务 → 按需提取更多
      ↑                              ↑
    大多数团队应该在这里停下      只在信号持续存在时继续

时间线参考(中等规模代码库):


拆分模式

Strangler Fig(绞杀者模式)

最常用的增量迁移模式。来源:Martin Fowler,灵感来自热带绞杀榕。

               客户端

          ┌──────▼──────┐
          │   Proxy /   │  ← 路由层决定走新还是走旧
          │   Facade    │
          └──┬──────┬───┘
             │      │
     ┌───────▼──┐ ┌─▼────────┐
     │ 新服务   │ │ 旧单体    │
     │ /orders  │ │ /users   │
     │          │ │ /products│
     └──────────┘ └──────────┘

执行要点:每次迁移必须是原子性的——构建新服务 + 重定向流量 + 下线旧代码。最常见的失败模式是”新服务上线了,旧代码永远没删”。

Branch by Abstraction

当需要替换的组件在单体内部深处(不在边缘)时使用。

步骤:

  1. 为要替换的功能创建抽象接口
  2. 让所有调用方通过抽象接口调用
  3. 创建新实现(可能调用外部微服务)
  4. 切换抽象到新实现
  5. 清理旧代码

vs Strangler Fig:Strangler Fig 包在外面,适合边缘功能;Branch by Abstraction 在内部替换,适合核心组件。

Parallel Run(并行运行)

同时调用新旧实现,对比结果。只有一个是真正的数据源。用于验证新实现的正确性和性能。

GitHub 从 MySQL 迁移存储层时用了这个模式——并行运行数周,对比查询结果。

选型

场景推荐模式
迁移边缘功能(认证、通知、图片处理)Strangler Fig
替换核心内部组件(ORM、存储引擎)Branch by Abstraction
高风险迁移,需要验证正确性Parallel Run
初期探索,不确定是否值得拆先模块化,再决定

拆分的代价——你付出什么

分布式事务

2PC(两阶段提交):跨微服务基本不可用——阻塞、协调者单点、不耐受网络分区。

Saga 模式:将事务拆成一系列本地事务 + 补偿操作。

创建订单 → 扣库存 → 扣款 → 发通知
                      ↓ 扣款失败
              补偿:恢复库存 → 取消订单

两种实现:

关键限制:Saga 没有隔离性(ACID 中的 I)。并发 saga 可能产生数据异常,必须自己实现对策(语义锁、可交换更新、悲观视图)。

数据一致性

最终一致性变成常态。双写问题(Dual Write):先写数据库再发事件,两步不是原子的——中间崩溃会导致不一致。

解决方案:

运维复杂度

具体代价:

网络延迟

函数调用(纳秒级)变成网络调用(毫秒级)。一个请求经过 10 个服务 = 10 倍延迟预算。

认知开销

Uber 发现工程师调查一个问题需要”追踪约 50 个服务,涉及 12 个团队”。这直接催生了 DOMA——把 2200 个微服务分组到 70 个领域。


反模式

分布式单体(Distributed Monolith)

两边最坏的结果:微服务的运维复杂度 + 零独立部署能力。

症状自检

命中任何一条,你的”微服务”其实是分布式单体。

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 CompositionAPI Gateway 调多个服务,内存中拼接数据量小、查询简单不适合大数据集或复杂查询
CQRS + 物化视图服务发领域事件,查询服务订阅维护反规范化视图高性能读取引入最终一致性和额外基础设施

实战决策流程

Step 1: 需要拆吗?
  ├── 部署争抢是实测到的问题(不是"感觉慢")?
  ├── 团队之间被彼此的变更阻塞?
  ├── 不同组件有真正不同的扩缩容需求?
  └── 以上都不是 → 留在单体,投资模块化设计。停。

Step 2: 模块化单体能解决吗?
  ├── 在代码中强制模块边界
  ├── 每个模块独占 schema
  ├── 模块间通过公共 API 通信
  └── 痛点消失了 → 停在这里。大多数组织应该停在这里。

Step 3: 提取哪个服务?
  ├── 从耦合度低、变更频率高的边缘能力开始
  │   (认证、通知、图片处理——不是核心域)
  ├── 用提交历史分析验证耦合度
  ├── 同时建设运维能力:独立 CI/CD、监控、分布式追踪
  └── 避免第一个就拆核心域(风险最高、数据最难分)

Step 4: 迭代评估
  ├── 拆完后:部署频率提升了吗?故障减少了吗?
  ├── 没有 → 重新评估边界,可能需要合回去
  └── 有 → 按需继续提取下一个

真实案例

公司做法关键教训
Shopify选择模块化单体,不走微服务。Black Friday 30TB/分钟极端规模不一定需要微服务,需要好的边界
Netflix2008-2012 从单体迁移到微服务按数据访问模式为每个服务选不同的存储;混沌工程成为必需
Uber2200 个微服务 → DOMA(70 个领域分组)微服务半衰期 1.5 年(频繁重写);Gateway 作为稳定接口层。平台支持成本降低一个数量级
InVision把微服务合回单体团队结构变了但服务边界没跟着变。合并只花了 3 周

InVision 的教训特别重要:当组织结构变化后,原来的服务边界可能不再合理。合并服务不是失败,是架构随组织演进的正常行为。


Pitfalls

  1. 把 “功能不同” 当拆分依据 — 用户管理和订单管理”功能不同”不是拆分的理由。如果它们由同一个团队维护、共享数据、一起变更,拆开只是增加网络跳转
  2. 第一个就拆核心域 — 核心域的数据最难分离、业务逻辑最复杂。应该从边缘、低耦合的能力开始练手
  3. 拆了服务但共享数据库 — 这只是把函数调用变成了网络调用 + 一个共享数据库。恭喜你发明了分布式单体
  4. 多个服务 import 同一个 domain 包 — 看起来是”复用”,实际是分布式单体。每个服务应有自己的领域模型,即使字段重复
  5. “重构留到以后” 但从不执行 — 技术债积累到无法模块化的程度。模块化窗口期是有限的
  6. 拆分后不测量效果 — 拆了但不测量部署频率、故障半径、交付速度是否改善 = 无法知道拆分是否成功
  7. 忽视合并的可能性 — 拆错了就合回去。这比维护一个错误的服务边界代价低得多

分享这篇文章:

上一篇
tokio(四):陷阱与生产最佳实践
下一篇
tokio(三):I/O、定时器、同步原语与 select!