单体到微服务:架构演进、服务拆分与数据边界
微服务不是把一个 Maven 工程拆成很多仓库,而是把业务能力、数据所有权、发布责任和故障边界一起拆开。拆得不合理,只会得到“分布式单体”:服务很多,但任何需求仍要所有团队一起改、一起发、一起失败。
一、学习目标
学完后应能判断系统是否需要微服务,按业务能力确定服务边界,解释数据所有权的意义,并设计可灰度、可回滚的渐进迁移方案。
二、架构通常怎样演进
flowchart TD
A["单体应用:统一进程和数据库"] --> B["模块化单体:代码边界清晰"]
B --> C["按业务能力抽取少量独立服务"]
C --> D["服务拥有数据与独立发布能力"]
D --> E["治理平台完善:观测、弹性、安全、交付"]2.1 单体并不是落后架构
单体具有本地调用快、事务简单、调试直接和部署单元少的优势。团队规模小、业务边界不稳定、流量不大时,结构良好的模块化单体通常比微服务更可靠。
值得评估拆分的信号包括:
- 不同业务模块需要完全不同的扩容节奏。
- 一个小改动必须发布整个大应用,发布窗口越来越长。
- 团队频繁修改同一代码和同一数据表,交付相互阻塞。
- 非核心模块耗尽线程或内存会拖垮全部业务。
- 构建、启动和全量回归已经显著影响交付。
“代码行数很多”不是拆微服务的充分理由。领域边界尚未稳定时先拆进程,会把原本可重构的本地依赖固化成远程契约。
2.2 模块化单体是重要过渡形态
模块化单体仍是一个部署单元,但代码、依赖和数据访问有明确边界。例如订单模块只能通过库存模块公开接口使用库存能力,不能直接调用库存 Mapper。
order-api 对外接口与DTO
order-domain 订单规则和状态机
order-application 用例编排
order-infrastructure 数据库、消息和外部适配先在进程内建立边界,再决定哪些边界值得跨进程,迁移成本更低。
三、什么是分布式单体
出现以下特征时,系统虽然有很多服务,却仍是分布式单体:
- 多个服务直接读写同一批业务表。
- 一次发布必须按固定顺序同时发布多个服务。
- 一个请求同步调用十几个服务,任何一个失败都让主流程失败。
- 服务之间存在 A 调 B、B 调 C、C 又调 A 的循环依赖。
- 契约没有兼容策略,字段修改要求所有消费者同步上线。
- 服务没有独立 SLO、容量、告警和负责人。
它承担了网络不确定性和数据一致性成本,却没有获得独立演进与故障隔离收益,因此往往比普通单体更难维护。
四、服务边界为什么要按业务能力划分
“用户表、订单表、库存表各拆一个服务”是按数据名词拆分,容易得到贫血 CRUD 服务。更好的问题是:哪个业务能力拥有规则、状态和数据的最终解释权?
| 服务 | 拥有的能力 | 拥有的数据 | 不应该承担 |
|---|---|---|---|
| 商品服务 | 商品信息、上下架、类目 | 商品主数据 | 库存扣减 |
| 定价服务 | 价格规则、促销试算 | 价格规则与计算快照 | 修改订单状态 |
| 订单服务 | 下单、取消、订单状态机 | 订单与订单明细 | 直接改库存表 |
| 库存服务 | 可售量、预占、释放、扣减 | 库存与预占流水 | 决定支付结果 |
| 支付服务 | 支付单、回调、退款 | 支付与退款流水 | 直接发货 |
| 履约服务 | 出库、配送、签收 | 履约单 | 修改支付流水 |
同一个词在不同上下文可能有不同含义。商品上下文中的价格是当前售卖价,订单上下文中的价格是下单时的交易快照;历史订单不能每次远程查询商品当前价格。
五、数据所有权是微服务边界的硬约束
一份业务数据只有一个服务拥有写入解释权,其他服务通过 API、事件或受控查询模型获得副本。
flowchart TD
A["订单服务提交本地事务"] --> B["订单库成为订单事实源"]
B --> C["发布OrderCreated事件"]
C --> D["搜索服务更新查询索引"]
C --> E["报表服务更新统计模型"]
C --> F["通知服务发送消息"]多个服务共同写一张表会产生以下问题:
- 表结构调整无法由单一团队决策。
- 服务可以绕过对方状态机和校验规则。
- 数据错误无法明确责任边界。
- 数据库锁和事务形成隐藏的运行时耦合。
- 服务无法独立迁移存储和回滚。
在线交易主链不应依赖跨服务数据库 Join。常用替代方案:
- API Composition:聚合服务并行调用多个服务,适合小结果集和实时查询。
- CQRS 查询模型:消费事件构建宽表或搜索索引,适合高频复杂查询。
- 数据快照:订单保存商品名、成交价等交易时刻信息。
- 离线数仓:报表通过 CDC/ETL 汇总,不反向耦合在线库。
六、服务拆分的证据化流程
flowchart TD
A["梳理业务事件和核心用例"] --> B["识别规则、状态和数据所有者"]
B --> C["形成候选限界上下文"]
C --> D["分析调用频率、事务和团队边界"]
D --> E["先在单体内强制模块边界"]
E --> F["选择收益最大的边界抽取"]
F --> G["建立契约、观测、回滚和SLO"]
G --> H["灰度迁移流量并验证独立性"]| 维度 | 适合独立的信号 | 暂缓独立的信号 |
|---|---|---|
| 业务边界 | 规则和术语稳定,有明确负责人 | 需求仍频繁跨模块重构 |
| 数据 | 有清晰事实源,可用事件同步 | 必须在同一事务中频繁改多表 |
| 容量 | 流量和资源模型明显不同 | 模块负载相近且规模很小 |
| 发布 | 需要独立发布和回滚 | 每次需求仍必须同时改全部模块 |
| 故障 | 值得建立独立隔离边界 | 拆出后仍必须同步强依赖 |
| 团队 | 团队能端到端负责 | 没有服务 Owner 和运维能力 |
七、绞杀者模式:不要一次性重写
以抽取订单查询服务为例:
- 网关按接口或租户把少量流量路由到新服务。
- 新服务先只读,通过 CDC 构建自己的查询模型。
- 使用影子流量比较新旧结果,影子请求不得产生副作用。
- 指标稳定后逐步扩大读流量。
- 再迁写链路,期间保持单一事实源和兼容事件。
- 旧路径无流量且完成回滚观察期后再删除。
flowchart TD
A["网关接收订单查询"] --> B{"迁移路由规则"}
B -- "旧路径" --> C["单体订单模块"]
B -- "灰度路径" --> D["新订单查询服务"]
C --> E["比较结果、延迟和错误率"]
D --> E
E --> F{"是否达到验收门槛"}
F -- "是" --> G["扩大灰度比例"]
F -- "否" --> H["回切旧路径并定位"]八、服务独立发布依赖契约兼容
使用 Expand–Migrate–Contract:生产者先增加新字段或新接口并保留旧能力;消费者逐个迁移并通过契约测试;确认旧消费者全部退出后再删除旧字段。
数据库也遵循相同思路。先加可空列或新表、让双版本代码兼容,再回填和切读,最后删除旧列。发布时直接重命名字段,会让仍在运行的旧实例失败,灰度和回滚都无法成立。
九、代码 Demo:用模块接口阻止跨边界写库
public interface InventoryReservationService {
ReservationResult reserve(String requestId, long skuId, int quantity);
void release(String requestId);
}public final class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final InventoryReservationService inventoryService;
public CreateOrderUseCase(OrderRepository orderRepository,
InventoryReservationService inventoryService) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
}
public OrderResult create(CreateOrderCommand command) {
ReservationResult reservation = inventoryService.reserve(
command.requestId(), command.skuId(), command.quantity());
if (!reservation.success()) {
return OrderResult.rejected(reservation.reason());
}
return orderRepository.save(Order.create(command, reservation.id()));
}
}模块化单体阶段,接口实现可以是本地 Bean;抽成远程服务后,替换为 RPC Adapter。业务用例不直接依赖 Feign、Dubbo 或 Mapper,协议变化就不会污染领域规则。
上述写法没有自动解决“库存预占成功、订单保存失败”。跨边界后仍需释放补偿、TCC、Saga 或事务消息完成收敛,不能把接口抽象误认为分布式事务。
十、常见反模式与后果
| 反模式 | 为什么危险 | 改进 |
|---|---|---|
| 按数据库表一表一服务 | 调用碎片化、缺少业务内聚 | 按业务能力和状态机聚合 |
| 所有服务共享数据库 | 绕过业务规则、无法独立发布 | 单一数据所有者,通过契约同步 |
| 同步调用链过长 | 延迟相加、可用率相乘 | 缩短主链,副作用异步化 |
| 每个方法都做远程接口 | Chatty RPC,失败点暴增 | 设计粗粒度业务用例接口 |
| 一次性重写 | 无法持续比较和快速回滚 | 绞杀者模式分阶段迁移 |
| 没有服务所有者 | 告警、容量和数据问题无人闭环 | 建立 Owner、SLO 和 Runbook |
十一、架构耦合排查 Runbook
- 从变更记录统计总是共同发布的服务集合。
- 从 Trace 统计同步调用边数量、深度和 P95/P99。
- 从数据库审计确认哪些服务账号写同一张表。
- 从依赖图查循环调用和公共 Jar 的反向依赖。
- 从契约仓库确认哪些接口没有兼容窗口。
- 选择一个高频耦合点,先建立单一数据所有者和兼容 API。
- 使用事件或查询模型替代跨库读取。
- 用独立发布和故障隔离演练证明边界真实存在。
十二、怎样识别限界上下文
限界上下文不是画几个框,而是找到“同一个词在什么范围内有稳定含义、由谁负责解释、状态怎样变化”。常用识别方法是从业务事件和状态机开始。
flowchart TD
A["收集核心业务事件"] --> B["按业务目标聚类"]
B --> C["找每组事件里的状态机"]
C --> D["识别谁拥有规则和事实源"]
D --> E["形成候选限界上下文"]
E --> F["用调用频率、事务范围和团队边界验证"]以电商或医疗资产平台为例:
| 事件 | 可能归属 | 事实源 |
|---|---|---|
OrderCreated | 订单上下文 | 订单库 |
StockReserved | 库存上下文 | 库存库和预占流水 |
PaymentConfirmed | 支付上下文 | 支付流水 |
AssetCollected | 采集上下文 | 采集批次和原始报文 |
AssetNormalized | 资产上下文 | 资产主数据 |
SearchIndexed | 搜索上下文 | ES索引视图,不是事实源 |
判断边界时重点看四件事:
- 术语是否稳定:同一个词在两个场景含义不同,通常是边界信号。比如“价格”在商品服务是当前售价,在订单服务是成交快照。
- 状态机是否独立:订单取消、支付退款、库存释放有各自合法状态流转,不能让别的服务直接改表绕过状态机。
- 规则由谁解释:库存服务解释可售、预占、释放;订单服务解释订单生命周期。
- 数据由谁写入:一个事实只能有一个写入所有者,否则出现冲突时无法确定谁对。
十三、服务粒度怎样判断
服务不是越小越好。粒度过粗会发布和容量耦合,粒度过细会产生 Chatty RPC、事务分散和排查困难。
| 现象 | 可能说明 |
|---|---|
| 两个模块总是一起改、一起发 | 可能不该拆成两个服务 |
| 两个模块事务强绑定且无法补偿 | 可能应保留同一事实源或本地事务 |
| 一个模块流量远高于其他模块 | 可能适合单独扩容 |
| 一个模块故障经常拖垮全局 | 可能需要独立故障域 |
| 一个服务只有CRUD没有规则 | 可能拆得太碎 |
| 一个服务拥有多个不相关状态机 | 可能拆得太粗 |
一个实用评分表:
| 维度 | 高分信号 | 低分信号 |
|---|---|---|
| 业务内聚 | 有独立规则、状态机和负责人 | 只是某几张表的薄封装 |
| 数据所有权 | 有明确事实源 | 多服务共同写同一表 |
| 发布价值 | 能独立发布并降低风险 | 每次仍要协调其他服务 |
| 容量差异 | 资源模型明显不同 | 负载与主应用一致 |
| 故障隔离 | 独立后能阻断故障扩散 | 失败仍让主链路不可用 |
| 契约稳定 | 接口语义稳定可版本化 | 需求每天重构字段含义 |
拆分前可以先问一句:如果这个服务今天独立宕机,其他服务是否知道怎样降级、补偿或停止相关功能? 如果答案是否定的,说明边界、契约和故障策略还没准备好。
十四、共享数据库怎样迁移
共享数据库是很多老系统迁微服务的最大阻碍。不能一刀切把表复制出去,否则会出现双写、数据漂移和回滚困难。
推荐迁移路径:
flowchart TD
A["识别表的写入服务账号和SQL"] --> B["指定唯一写入所有者"]
B --> C["其他服务改为只读或调用API"]
C --> D["新增事件或Outbox同步副本"]
D --> E["消费者构建查询模型"]
E --> F["灰度切读新查询模型"]
F --> G["禁止跨服务直连数据库"]迁移阶段:
| 阶段 | 目标 | 注意 |
|---|---|---|
| 盘点 | 查清谁读写哪些表 | 用SQL审计、账号、慢日志和代码扫描 |
| 收口写入 | 每张业务表指定唯一写Owner | 先拦截新写入,不急着拆库 |
| 暴露契约 | 其他服务通过API或事件拿数据 | 避免跨库Join变成跨服务雪崩 |
| 构建副本 | 查询方用事件、CDC或定时同步建读模型 | 副本不是事实源 |
| 切读 | 灰度把读流量切到新模型 | 比对结果、延迟和缺失率 |
| 物理拆分 | 最后再拆库、账号和权限 | 保留回滚窗口 |
错误做法:
订单服务、报表服务、搜索服务都直接 update order 表正确方向:
订单服务独占订单写入
报表服务消费订单事件
搜索服务消费订单事件或CDC
其他服务通过订单API查询必要事实十五、防腐层:不要让老系统模型污染新服务
迁移时新服务经常要调用老单体或第三方系统。不要把老系统字段、状态码和异常直接渗透到新领域模型里,应建立防腐层。
flowchart TD
A["新订单服务"] --> B["防腐层Adapter"]
B --> C["老单体接口或第三方接口"]
C --> D["老状态码、字段和异常"]
D --> B
B --> E["转换为新领域对象和稳定错误语义"]防腐层负责:
- 字段名转换。
- 枚举和状态映射。
- 错误码和异常分类。
- 超时、重试、幂等和限流。
- 老接口兼容逻辑。
- 日志脱敏和 Trace 关联。
Java 8 示例:
public final class LegacyInventoryAdapter implements InventoryReservationService {
private final LegacyInventoryClient legacyClient;
public LegacyInventoryAdapter(LegacyInventoryClient legacyClient) {
this.legacyClient = legacyClient;
}
@Override
public ReservationResult reserve(String requestId, long skuId, int quantity) {
LegacyReserveResponse response = legacyClient.freeze(requestId, skuId, quantity);
if ("00".equals(response.getCode())) {
return ReservationResult.success(response.getFreezeNo());
}
if ("STOCK_NOT_ENOUGH".equals(response.getCode())) {
return ReservationResult.rejected("STOCK_NOT_ENOUGH");
}
if ("TIMEOUT".equals(response.getCode())) {
return ReservationResult.unknown("LEGACY_TIMEOUT");
}
return ReservationResult.rejected("LEGACY_REJECTED");
}
}这样领域层只认识 success/rejected/unknown,不会被老系统 00、01、99 这类代码污染。
十六、循环依赖怎么治理
循环调用是分布式单体的重要信号。
flowchart TD
A["订单服务"] --> B["库存服务"]
B --> C["活动服务"]
C --> A循环依赖的风险:
- 发布顺序被锁死。
- 启动和注册互相等待。
- 超时和重试形成环形放大。
- 领域责任不清。
- 出问题时没有清晰 Owner。
治理方式:
| 方法 | 适用场景 |
|---|---|
| 上移编排 | 由应用服务或流程编排器统一协调 |
| 领域事件 | A完成事实后发布事件,B异步响应 |
| 查询模型 | C不回调A,而是订阅A事件构建本地读模型 |
| 合并边界 | 两个服务总是互相强依赖,可能应合并 |
| 引入防腐层 | 第三方或老系统不可控时隔离模型 |
循环依赖不是靠“把 Feign 改成 MQ”自动解决。MQ 只是改变通信方式,如果事件语义仍是互相命令、没有幂等和状态机,依然会得到异步版分布式单体。
十七、服务边界验收清单
一个服务边界至少要能通过这些问题:
| 问题 | 合格答案 |
|---|---|
| 这个服务拥有什么业务能力 | 能用业务语言描述,不是表名堆砌 |
| 它拥有哪份事实数据 | 有明确写入所有者 |
| 其他服务怎样获取它的数据 | API、事件、查询模型或快照 |
| 它能否独立发布 | 有契约兼容和回滚方案 |
| 它失败时影响谁 | 有SLO、降级和告警 |
| 它是否和其他服务共享库表 | 不共享写入,读副本受控 |
| 它的接口粒度是否合适 | 不Chatty,围绕业务用例 |
| 它是否存在循环依赖 | 没有同步强循环 |
| 它是否有Owner | 有团队负责功能、数据、容量和事故 |
