从单体迁移到微服务:什么时候该拆,边界又该怎么划?
“系统越来越大,所以要拆微服务”是一个缺少因果关系的结论。代码行数多不一定需要分布式,部署单元少也不意味着架构落后。微服务真正购买的是独立变化、独立发布和独立扩缩容,支付的成本则是网络故障、数据一致性、可观测性、平台治理和组织协调。
正确的问题不是“单体还是微服务更先进”,而是:当前最昂贵的约束是什么,服务化能否以可接受的复杂度解除它?
一、先判断问题是不是架构边界造成的
以下信号说明拆分可能开始产生收益:
- 多个团队频繁修改同一代码库,发布排队和合并冲突成为主要交付瓶颈;
- 某个业务能力有独立的发布节奏、合规要求或可用性目标;
- 局部热点需要独立扩容,但单体只能整体扩容;
- 故障经常跨模块扩散,进程级隔离确实能缩小故障域;
- 模块之间已经有相对稳定的业务契约和明确的数据责任;
- 构建、测试和发布时长已无法通过工程优化控制。
相反,下面的问题通常不应靠拆服务解决:
- 代码层次混乱、循环依赖、缺少测试;
- 数据模型尚在快速探索,业务边界每天变化;
- 团队只有少数开发者,却要同时维护网关、注册发现、监控、消息和部署平台;
- 单体性能差,但瓶颈其实是一条无索引 SQL;
- 希望通过 RPC “强制解耦”,却没有改变共享数据库和组织责任。
把混乱单体直接切成进程,往往得到的是分布式单体:每次需求仍需同时修改多个服务,只是编译错误变成了运行时超时。
二、用可量化证据做迁移决策
拆分前收集至少一个发布周期的数据,而不是凭架构感觉:
| 维度 | 可观察指标 | 服务化可能带来的收益 |
|---|---|---|
| 交付耦合 | 同一发布涉及模块数、等待时间、回滚范围 | 独立发布与责任边界 |
| 变更冲突 | 跨团队 PR、热点文件、接口反复修改 | 降低代码所有权冲突 |
| 运行资源 | 各模块 CPU、内存、I/O、流量曲线 | 局部独立扩缩容 |
| 可靠性 | 故障传播路径、恢复时间、共享资源争用 | 进程与容量隔离 |
| 数据耦合 | 跨域 join、跨模块事务、共享表写入者 | 明确数据所有权,但会增加一致性成本 |
| 组织能力 | 值班、CI/CD、可观测性、平台自动化 | 决定团队能否承担分布式运维 |
如果最大的交付延迟来自需求决策而非技术发布,微服务不会缩短它。如果某模块占单体 80% 的 CPU,且边界稳定,独立扩容就有明确经济收益。
一个实用判断是:只有当边界内的变更频率显著高于跨边界变更频率时,拆分才可能降低协调成本。
三、模块化单体是最好的边界实验室
在引入网络之前,先在单体内部建立可执行边界:模块只能通过公开接口调用,禁止直接访问其他模块的内部类和表映射;每个模块拥有自己的领域模型和测试。
commerce-app
├── ordering
│ ├── api
│ ├── application
│ ├── domain
│ └── infrastructure
├── inventory
├── payment
└── identity
以 Java 为例,可用多模块构建、ArchUnit 或 JPMS 约束依赖:
@ArchTest
static final ArchRule orderingMustNotDependOnInventoryInternals =
noClasses().that().resideInAPackage("..ordering..")
.should().dependOnClassesThat()
.resideInAPackage("..inventory.internal..");
如果连进程内的公开接口都无法稳定,改成 HTTP 只会增加超时、重试和版本兼容问题。模块化单体还能低成本测量依赖方向和共同变更关系,是正式拆分前的重要阶段,而不是失败的过渡品。
四、服务边界从业务不变量开始
DDD 的限界上下文不是把名词圈起来,而是划定一套模型、语言和不变量成立的范围。同一个“用户”在身份域可能是账号和凭证,在营销域可能是受众标签,在订单域只是购买者快照;强行共享一个全局 User 模型会让所有团队互相等待。
识别边界可以依次问五个问题:
- 哪些规则必须在一次本地事务内同时成立?
- 哪些数据由同一个业务角色负责解释和修改?
- 哪些能力以相似节奏变化和发布?
- 哪些能力拥有不同的容量、可用性或合规要求?
- 团队能否端到端拥有其开发、发布、值班与数据质量?
以电商下单为例:
- 订单域保证订单项、金额快照和订单状态合法;
- 库存域保证可售库存不被超卖;
- 支付域保证支付单、退款和渠道流水一致;
- 商品域维护当前标题和价格,但不能反向修改历史订单快照。
这些边界比“Controller 服务”“数据库服务”更稳定,因为它们围绕业务能力,而不是技术层次。
五、用数据所有权检验边界
真正的服务自治要求一个事实只有一个权威写入者。其他服务通过 API、事件或只读投影获取数据,而不是直接更新它的表。
Order Service --owns--> order, order_item
Inventory Service --owns--> stock, reservation
Payment Service --owns--> payment, refund
如果拆分后所有服务仍共享一个数据库账户并自由 join、update,部署看似独立,实际上任何表变更仍需全体协调。过渡期可以共享物理数据库实例,但至少做到 schema、账号、迁移脚本和写权限隔离。
边界判断的强信号包括:
- 两组表总在同一事务里修改,可能仍属于同一边界;
- 一个表被多个团队写入,所有权尚未建立;
- 服务间大量同步查询只是为了拼装页面,可能需要 API Composition 或读模型;
- 跨服务事务频繁出现,可能边界切错,也可能需要重新设计业务流程。
六、跨边界后,不要复制单体事务模型
单体中一次下单可能是一个数据库事务:创建订单、扣库存、生成支付单。拆开后,试图用分布式事务恢复完全相同的同步语义,通常会提高耦合和可用性风险。
更可运营的方式是显式状态机和 Saga:
PENDING
-> inventory reserved
WAITING_PAYMENT
-> payment succeeded
CONFIRMED
任何阶段失败 -> CANCELING -> 释放已占用资源 -> CANCELED
订单服务在本地事务中保存订单和 Outbox 事件:
BEGIN;
INSERT INTO orders(id, status, total_amount)
VALUES (:id, 'PENDING', :amount);
INSERT INTO outbox_event(event_id, aggregate_id, event_type, payload)
VALUES (:eventId, :id, 'OrderCreated', :payload);
COMMIT;
后台发布器把事件发送到消息系统,消费者按 event_id 幂等处理。这里接受的是至少一次投递和最终一致,而不是声称端到端“恰好一次”。业务界面也要能表达处理中、失败可重试和已补偿等状态。
如果一个业务动作绝不能接受中间态,而且所有数据必须强一致,那么把它们留在同一服务和本地事务中,往往比强行拆分更合理。
七、如何选择第一块被抽取的能力
第一项迁移不应是最核心、最复杂、依赖最多的模块。理想候选同时满足:边界清楚、价值可验证、依赖较少、失败可降级、团队有所有权。
| 候选 | 优点 | 风险 | 首批建议 |
|---|---|---|---|
| 通知/邮件 | 边界清楚、可异步、失败可重试 | 模板与用户偏好可能耦合 | 很适合 |
| 文件处理 | 资源特征独立、易单独扩容 | 对象存储和安全治理 | 适合 |
| 搜索 | 可由事件构建索引、读模型独立 | 索引延迟与回建 | 适合 |
| 身份认证 | 复用价值高 | 高安全、高可用、全系统依赖 | 有成熟平台时再做 |
| 订单核心 | 业务价值高 | 事务、库存、支付耦合复杂 | 通常不作为第一项 |
| 公共工具服务 | 看似容易复用 | 容易形成高扇出同步依赖 | 通常不应服务化 |
先用一条真实但可控的路径验证组织的 CI/CD、告警、追踪、值班和故障恢复能力,再扩大迁移范围。
八、用绞杀者模式逐步替换
一次性重写会让新旧系统在数月内无法获得真实反馈。绞杀者模式在入口处逐步路由:
Client
|
Gateway / Facade
|-----------------> New Service
\-----------------> Legacy Monolith
典型步骤是:
- 在单体内先整理候选模块和测试;
- 为旧能力建立稳定 Facade,停止新增直连;
- 抽取读路径,使用旧库只读或事件投影验证结果;
- 建立新服务的数据所有权与写路径;
- 按租户、用户或请求哈希灰度流量;
- 对比新旧结果,扩大比例;
- 停止旧写入,越过观察期后清理旧模块和数据。
过渡期可使用防腐层把旧模型翻译为新模型:
final class LegacyCustomerAdapter implements CustomerProfilePort {
@Override
public CustomerProfile get(CustomerId id) {
LegacyUser user = legacyClient.findUser(id.value());
return new CustomerProfile(
id,
CustomerName.of(user.getDisplayName()),
mapLevel(user.getVipCode()));
}
}
防腐层的目的不是永久兼容所有历史细节,而是阻止旧语义污染新领域模型,并为替换提供一个明确切点。
九、数据库拆分需要独立迁移计划
服务抽取最难的往往不是代码,而是数据。常见过渡方案如下:
| 方案 | 适用阶段 | 主要问题 |
|---|---|---|
| 新服务暂时只读旧库 | 验证读路径 | 仍受旧表结构约束,不能长期自治 |
| CDC/事件构建新库 | 读模型迁移 | 延迟、顺序、删除和回放处理复杂 |
| 应用双写 | 短期切换窗口 | 部分失败、一致性校验和补偿困难 |
| Outbox 发布领域事件 | 建立长期集成 | 需要事件契约和消费者幂等 |
| 停机导出导入 | 小数据、可维护窗口 | 过程简单但有明确停机 |
不要让两个服务长期双向同步同一事实,那会失去权威源。迁移计划必须明确某个时刻之前谁是主、之后谁是主,并准备差异对账、增量追平和回切方案。
十、服务之间怎样通信
同步调用适合调用者立即需要结果、失败能直接反馈的查询或命令;异步事件适合事实传播、削峰和解耦生命周期。不能因为用了 Kafka 就把所有交互都改成事件,也不能让一个用户请求同步穿过十几个服务。
服务契约需要版本兼容:先让消费者兼容新旧字段,再升级生产者;新增可选字段通常比删除或改语义安全。事件表示已经发生的业务事实,例如 OrderPaid,不要把消息系统仅当作远程方法调用队列。
对每条跨服务边界,明确超时、重试、幂等、熔断、降级、背压和追踪。微服务数量增加时,接口数量增长得更快,平台能力必须先于大规模拆分。
十一、组织边界与系统边界应相互支持
一个服务被三个团队共同审批、没人独立值班,就不是真正的自治单元。反过来,为每个服务单独组建小团队也可能造成重复建设。通常应让一个稳定团队拥有一组高内聚服务,而不是机械追求“一队一服务”。
平台团队应提供 paved road:服务模板、流水线、运行时基线、鉴权、可观测性、密钥、流量治理和成本视图。否则每拆一个服务,都在新增一套风格不同的运维系统。
十二、常见反模式
按数据库表一表一服务
表是持久化细节,不是业务能力。user-service、address-service、phone-service 可能让一次简单操作跨越多次网络调用。
按技术层拆服务
Controller、Business、DAO 各自成为服务,只会把进程内调用变成高延迟 RPC,仍然没有业务自治。
共享数据库作为永久集成方式
它回避了 API 设计,却让任何 schema 变更都可能破坏未知消费者,也无法建立数据权限边界。
建立万能公共服务
把日期转换、字典、用户信息等都做成同步公共服务,会形成高扇出单点。代码复用优先用库,数据能力才通过有所有权的服务提供。
服务切得过细
服务数量不是成熟度指标。过细边界会放大网络调用、部署对象和认知负担。可以独立部署,不代表必须独立部署。
只迁移,不删除
新服务上线后旧逻辑仍在运行,形成两个事实源。每项迁移都需要明确退役标准和清理预算。
十三、生产迁移检查清单
- 是否用发布、资源、故障和组织数据证明了拆分收益?
- 候选模块是否已在单体内建立明确接口和依赖约束?
- 边界是否围绕业务能力、不变量和统一语言,而非技术层或表?
- 每项核心数据是否只有一个权威写入者和明确负责人?
- 跨边界事务是否被重新设计为状态机、幂等处理和可补偿流程?
- 首个候选是否边界清楚、依赖较少、失败可控并能快速验证平台能力?
- 是否有网关/Facade、防腐层、灰度路由和新旧结果对账?
- 数据迁移是否定义主从切换点、追平、校验、回切与旧数据退役?
- 每个同步调用是否设置超时、重试预算、熔断和降级?
- 团队是否真正拥有服务的开发、部署、值班、成本和数据质量?
- CI/CD、日志、指标、Trace、密钥和运行基线是否已平台化?
- 是否定义了旧模块、兼容层、双写和临时开关的删除日期?
总结
从单体走向微服务不是一次重写,而是一系列用边界换取自治的投资决策。先用模块化单体验证高内聚边界,再以数据所有权和业务不变量切割;从低风险能力开始,通过绞杀者模式逐步迁移,并把一致性、可观测性和组织责任一起带过去。好的拆分让多数变更只落在一个边界内;如果每次需求仍要协调所有服务,问题不是服务还不够多,而是边界从未真正建立。