拆分之前先回答“为什么拆”
很多团队在单体还能跑的时候就开始拆微服务,动机往往是“别人都拆了”,或者招聘 JD 上写着熟悉微服务。拆分的收益是独立部署、独立扩缩容、故障隔离;代价是本地方法调用变成可能超时的网络调用,一个数据库事务变成若干次补偿,发布顺序、调用链、数据一致性全部变复杂。如果某个模块既没有独立的伸缩需求,也没有独立的发布节奏和负责团队,把它拆出去通常只是把一次函数调用换成一次 RPC。
判断一条边界值不值得付出进程间通信的成本,可以看三个信号:变更频率是否显著不同、峰值负载是否差一个数量级、故障是否必须隔离。三个都不满足,先留在单体里,用模块边界隔开。
边界应该来自业务语言
限界上下文经常被讲成“按业务分模块”,但它真正约束的是语言:同一个词在两个上下文里含义不同,就不能共用一套模型。订单上下文里的“商品”关心价格、优惠、下单时的快照;库存上下文里的“商品”关心可用量、锁定、批次。硬把两者合成一个 Product 实体,最后会得到一个几十字段的类,外加一堆按场景判断的 if。
数据所有权是更硬的指标。一个服务如果直接读写另一个服务的表,那它们其实还是一个系统,只是被进程拆开了。跨边界只允许两种交互:调用对方的接口,或者消费对方发布的事件。表结构、索引、状态机都属于内部实现,外部不需要知道。
先用模块化单体验证边界
最省钱的做法是先在一个进程里划模块:独立的包、独立的 Maven 模块、只允许通过对外接口通信,然后用 ArchUnit 把规则固化成测试,防止有人为了赶需求直接 import 另一个模块的内部类。
@AnalyzeClasses(packages = "com.example.shop")
class ModuleBoundaryTest {
@ArchTest
static final ArchRule orderMustNotTouchInventoryInternals =
noClasses()
.that().resideInAPackage("..order..")
.should().dependOnClassesThat()
.resideInAnyPackage("..inventory.internal..", "..payment.internal..");
@ArchTest
static final ArchRule domainMustNotDependOnFramework =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
}
这类测试跑在 CI 里,比架构评审文档有效得多:违规会直接变成红色构建。跑上一两个月,哪条边界是真的、哪条只画在图上,一目了然。
从模块到进程的顺序
模块化单体的边界稳定之后,把“最想独立”的那块先切出去,通常选读多写少、依赖少、失败影响小的。切换用绞杀者模式:新旧两套并存,网关按比例分流,盯住错误率和延迟,再逐步放大权重。
拆数据比拆代码难。比较稳的顺序是先用防腐层把读写收敛到一个 Repository,再考虑双写、对账、切读、切写。每一步都必须能回退,否则第一次发布就是最后一次发布。
常见坑
按人数而不是按业务拆。 人一多就想拆,拆完发现每个服务的边界都横跨两个业务,跨服务调用比原来的方法调用还乱,最后又加一层聚合服务把数据拼回去。
共享数据库当成共享内存。 两个服务连同一个库、直接 join 查询,短期很爽,长期任何一方改表都会打穿另一方,回滚也变成两个团队一起熬夜。
过度依赖分布式事务。 边界划对之后,大部分跨服务操作可以用事件加补偿解决;只有资金类操作才需要强一致,而它往往本来就应该收敛在同一个服务内。
没有防腐层就接第三方。 直接把外部系统的 DTO 传遍整个代码库,等对方升版或换供应商时,改动会扩散到几十个类。
评论