3、服务拆分原则:领域驱动设计(DDD)、限界上下文、拆分粒度控制

服务拆分这件事,我做了快十年了。说实话,见过太多团队一上来就拍脑袋拆服务,结果拆完发现比没拆还痛苦。今天咱们聊聊怎么科学地拆,而不是瞎拆。

3.1 为什么需要领域驱动设计?

先问个问题:你拆服务的依据是什么?

很多人会说「按功能模块拆」。嗯,这听起来没错,但实际操作中你会发现——「用户管理」和「订单管理」到底算不算两个独立领域?用户下单后,用户信息变了,订单要不要跟着变?

这就是典型的边界模糊问题。我个人习惯用DDD来解决这个痛点。

核心思想:DDD不是技术框架,而是一种思维方式。它要求你从业务视角出发,把系统拆成一个个「有边界的业务领域」。

我在项目中遇到过最典型的反面案例:一个电商系统,把「商品」和「库存」拆成了两个服务。结果每次下单都要跨服务调用,延迟从2ms飙到50ms。为什么?因为从业务角度看,商品和库存本来就是同一个领域内的东西,硬拆开反而制造了问题。

3.2 限界上下文:服务的「护城河」

限界上下文,说白了就是给每个服务画个圈。圈内的事,服务自己说了算;圈外的事,通过接口沟通。

怎么画这个圈?我总结了三个原则:

  • 业务完整性:一个限界上下文应该覆盖一个完整的业务能力。比如「订单上下文」包含创建订单、修改订单、取消订单,但不包含支付——支付是另一个上下文。
  • 数据一致性:同一个上下文内的数据,尽量保持强一致性;跨上下文的数据,接受最终一致性。
  • 变化频率:经常一起变化的功能,放在同一个上下文里。你想想看,如果商品属性和商品价格经常一起改,你非把它们拆到两个服务里,每次改价还得发个消息通知商品服务,这不是自找麻烦吗?

我的小技巧:画限界上下文时,拿张白纸,把业务场景里的名词全部列出来。然后问自己:「这些名词之间,谁对谁负责?」比如「订单」对「商品」负责吗?不,订单只关心商品ID和价格,不关心商品描述。所以它们可以不在同一个上下文里。

3.3 拆分粒度控制:拆多细才算合适?

这是最让人头疼的问题。拆太粗,跟单体没区别;拆太细,分布式带来的复杂度会让你崩溃。

我见过一个团队,把用户服务拆成了「用户基本信息服务」「用户地址服务」「用户偏好服务」三个微服务。结果呢?每次查询用户详情,要调三个接口,网络开销翻了三倍。更惨的是,用户注册时,三个服务的事务根本没法保证一致性。

避坑指南:我曾经犯过一个错误——为了「微」而微,把一个小功能拆成独立服务。后来发现,这个服务每天只有几百次调用,却要维护一套完整的部署、监控、日志体系。得不偿失。

那么,到底拆多细?我一般用这个表格来判断:

判断维度 适合拆成独立服务 适合合并
业务独立性 可以独立演进,不依赖其他服务 频繁跨服务调用
数据变更频率 变化节奏差异大 经常一起变更
团队规模 有独立团队维护(2-5人) 团队人数少于10人
性能要求 需要独立扩缩容 调用链路过长导致延迟

举个例子。我之前做的一个支付系统,一开始把「支付」「退款」「对账」拆成了三个服务。后来发现,退款和支付其实共享同一个资金账户模型,每次退款都要查支付状态,跨服务调用频繁。最后我把它们合并成一个「资金服务」,内部用模块隔离,对外只暴露一个接口。性能提升了30%,代码量反而减少了。

3.4 实战:一个订单系统的拆分案例

咱们拿一个真实的订单系统来说说。假设业务包含:用户下单、支付、发货、售后。

按照DDD的思路,我首先识别出几个核心领域:

  • 订单领域:负责订单的创建、状态流转、查询
  • 支付领域:处理支付请求、回调、退款
  • 物流领域:发货、配送、签收
  • 售后领域:退货、换货、退款审核

然后画限界上下文。注意,这里有个关键点:退款这个功能,到底属于支付领域还是售后领域?

我的判断是:退款操作本身(扣钱、退钱)属于支付领域,但退款申请、审核流程属于售后领域。所以两个上下文之间通过事件驱动来协作——售后发起退款申请,支付领域监听事件后执行退款操作。

代码示例:事件驱动的边界通信

// 售后领域发布退款事件
public class AfterSaleService {
    @Autowired
    private EventPublisher eventPublisher;
    
    public void approveRefund(String orderId) {
        // 审核通过
        RefundApprovedEvent event = new RefundApprovedEvent(orderId);
        eventPublisher.publish(event);
    }
}

// 支付领域监听退款事件
public class PaymentService {
    @EventListener
    public void handleRefundApproved(RefundApprovedEvent event) {
        // 执行退款操作
        paymentGateway.refund(event.getOrderId());
    }
}

这样拆完,每个服务的内聚性很高,服务之间的耦合度很低。而且每个服务可以独立部署、独立扩缩容。比如大促期间订单量暴增,我只扩容订单服务就行,支付和物流服务不用动。

3.5 总结一下

服务拆分没有银弹。DDD和限界上下文给你提供了思考框架,但最终粒度控制还是要靠经验和对业务的理解。

我个人建议:先粗后细。刚开始拆的时候,宁可粗一点,也别拆太碎。运行一段时间后,根据实际的调用频率、变更频率、团队协作情况,再决定要不要进一步拆分。毕竟,合并服务比拆分服务难多了——你想想看,两个已经独立运行的服务,要合并成一个,数据迁移、接口兼容、历史数据一致性,哪个不是大坑?

最后一个小提醒:如果你发现一个服务里,有超过一半的接口都是被其他服务调用的,那说明这个服务的边界可能画错了。它应该被合并到调用方那边去。

嗯,今天就聊到这儿。下一章咱们聊聊服务间的通信方式——RPC和消息队列,到底该怎么选。