跳过导航

从单体迁移到微服务:什么时候该拆,边界又该怎么划?

约 14 分钟...次浏览
专栏微服务架构与工程治理第 7 篇

“系统越来越大,所以要拆微服务”是一个缺少因果关系的结论。代码行数多不一定需要分布式,部署单元少也不意味着架构落后。微服务真正购买的是独立变化、独立发布和独立扩缩容,支付的成本则是网络故障、数据一致性、可观测性、平台治理和组织协调。

正确的问题不是“单体还是微服务更先进”,而是:当前最昂贵的约束是什么,服务化能否以可接受的复杂度解除它?

一、先判断问题是不是架构边界造成的

以下信号说明拆分可能开始产生收益:

  • 多个团队频繁修改同一代码库,发布排队和合并冲突成为主要交付瓶颈;
  • 某个业务能力有独立的发布节奏、合规要求或可用性目标;
  • 局部热点需要独立扩容,但单体只能整体扩容;
  • 故障经常跨模块扩散,进程级隔离确实能缩小故障域;
  • 模块之间已经有相对稳定的业务契约和明确的数据责任;
  • 构建、测试和发布时长已无法通过工程优化控制。

相反,下面的问题通常不应靠拆服务解决:

  • 代码层次混乱、循环依赖、缺少测试;
  • 数据模型尚在快速探索,业务边界每天变化;
  • 团队只有少数开发者,却要同时维护网关、注册发现、监控、消息和部署平台;
  • 单体性能差,但瓶颈其实是一条无索引 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 模型会让所有团队互相等待。

识别边界可以依次问五个问题:

  1. 哪些规则必须在一次本地事务内同时成立?
  2. 哪些数据由同一个业务角色负责解释和修改?
  3. 哪些能力以相似节奏变化和发布?
  4. 哪些能力拥有不同的容量、可用性或合规要求?
  5. 团队能否端到端拥有其开发、发布、值班与数据质量?

以电商下单为例:

  • 订单域保证订单项、金额快照和订单状态合法;
  • 库存域保证可售库存不被超卖;
  • 支付域保证支付单、退款和渠道流水一致;
  • 商品域维护当前标题和价格,但不能反向修改历史订单快照。

这些边界比“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

典型步骤是:

  1. 在单体内先整理候选模块和测试;
  2. 为旧能力建立稳定 Facade,停止新增直连;
  3. 抽取读路径,使用旧库只读或事件投影验证结果;
  4. 建立新服务的数据所有权与写路径;
  5. 按租户、用户或请求哈希灰度流量;
  6. 对比新旧结果,扩大比例;
  7. 停止旧写入,越过观察期后清理旧模块和数据。

过渡期可使用防腐层把旧模型翻译为新模型:

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-serviceaddress-servicephone-service 可能让一次简单操作跨越多次网络调用。

按技术层拆服务

Controller、Business、DAO 各自成为服务,只会把进程内调用变成高延迟 RPC,仍然没有业务自治。

共享数据库作为永久集成方式

它回避了 API 设计,却让任何 schema 变更都可能破坏未知消费者,也无法建立数据权限边界。

建立万能公共服务

把日期转换、字典、用户信息等都做成同步公共服务,会形成高扇出单点。代码复用优先用库,数据能力才通过有所有权的服务提供。

服务切得过细

服务数量不是成熟度指标。过细边界会放大网络调用、部署对象和认知负担。可以独立部署,不代表必须独立部署。

只迁移,不删除

新服务上线后旧逻辑仍在运行,形成两个事实源。每项迁移都需要明确退役标准和清理预算。

十三、生产迁移检查清单

  • 是否用发布、资源、故障和组织数据证明了拆分收益?
  • 候选模块是否已在单体内建立明确接口和依赖约束?
  • 边界是否围绕业务能力、不变量和统一语言,而非技术层或表?
  • 每项核心数据是否只有一个权威写入者和明确负责人?
  • 跨边界事务是否被重新设计为状态机、幂等处理和可补偿流程?
  • 首个候选是否边界清楚、依赖较少、失败可控并能快速验证平台能力?
  • 是否有网关/Facade、防腐层、灰度路由和新旧结果对账?
  • 数据迁移是否定义主从切换点、追平、校验、回切与旧数据退役?
  • 每个同步调用是否设置超时、重试预算、熔断和降级?
  • 团队是否真正拥有服务的开发、部署、值班、成本和数据质量?
  • CI/CD、日志、指标、Trace、密钥和运行基线是否已平台化?
  • 是否定义了旧模块、兼容层、双写和临时开关的删除日期?

总结

从单体走向微服务不是一次重写,而是一系列用边界换取自治的投资决策。先用模块化单体验证高内聚边界,再以数据所有权和业务不变量切割;从低风险能力开始,通过绞杀者模式逐步迁移,并把一致性、可观测性和组织责任一起带过去。好的拆分让多数变更只落在一个边界内;如果每次需求仍要协调所有服务,问题不是服务还不够多,而是边界从未真正建立。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。