基于DDD构建微服务:从战略设计到落地实践

微服务架构的核心难题不是技术选型,而是如何找到正确的服务边界。拆分得太粗,和单体无异;拆分得太细,分布式的复杂性会吞噬所有收益。领域驱动设计(DDD)提供了一套从问题空间到解决方案空间的完整方法论——战略设计回答"边界在哪里",战术设计回答"边界内怎么建"。本文以电商领域为主线,从子域划分、事件风暴、上下文映射,一路推进到聚合设计、Entity、Value Object、Domain Service 等战术构件的代码落地,并覆盖 Outbox、Saga、CQRS 等分布式一致性方案。

微服务的本质:不是"小",而是"界限清晰"

微服务中的"微"容易让人把关注点放在服务的规模上,但规模从来不是核心标准。Adrian Cockcroft 对微服务有一个精炼的定义:

"面向服务的架构由具有界限上下文松散耦合的元素组成。"

一个真正的微服务架构应当具备以下特征:

特征 说明
业务边界清晰 服务以业务上下文为中心,而非技术抽象
实现细节隐藏 通过意图接口暴露功能,不泄露内部实现
数据独立 服务不共享数据库,每个服务拥有自己的数据存储
故障快速恢复 具备容错和弹性能力
独立部署 团队可以自主、频繁地发布变更
自动化文化 自动化测试、持续集成、持续交付

归纳起来:松散耦合的面向服务架构,每个服务封装在定义良好的界限上下文中,支持快速、频繁且可靠的交付。

微服务的强大之处在于:边界内建立高内聚,边界外建立低耦合——倾向于一起改变的事物应该放在一起。但说起来容易做起来难,业务在不断发展,设想也随之改变。因此,重构能力是设计系统时必须考虑的关键问题。

DDD 战略设计:从问题空间到解决方案空间

领域驱动设计(Domain-Driven Design)因 Eric Evans 的同名著作而闻名,它是一组思想、原则和模式,帮助我们基于业务领域的底层模型来设计软件系统。战略设计是 DDD 的宏观层面,关注的是如何划分系统边界、建立团队间的协作契约。

基本术语

概念 定义 示例
领域(Domain) 组织所从事的业务范围 零售、电子商务
子域(Subdomain) 领域下的业务单元,一个领域由多个子域组成 目录、购物车、履约、支付
统一语言(Ubiquitous Language) 开发人员与领域专家共同使用的、表达业务模型的语言 "商品"、"订单"、"履约"
界限上下文(Bounded Context) 模型的有效边界,同一术语在不同上下文中含义不同 见下文详述

子域分类:核心域、支撑域与通用域

并非所有子域对业务的价值相同。Eric Evans 将子域分为三类,这个分类直接决定了资源投入的优先级和技术决策:

类型 特征 投入策略 电商示例
核心域(Core Domain) 业务的差异化竞争力所在,是组织最独特的能力 投入最优秀的团队,自研,持续迭代 推荐算法、定价策略、供应链优化
支撑域(Supporting Subdomain) 核心域所依赖的业务能力,有一定定制需求但非核心竞争力 可以外包或由普通团队负责 订单管理、库存管理、客服系统
通用域(Generic Subdomain) 业界通用的能力,无差异化价值 优先购买成熟方案,避免自研 认证授权、邮件通知、文件存储

这个分类为什么重要?因为它决定了每个微服务应该获得多少投入。核心域值得精雕细琢的聚合设计和充分的领域建模,通用域则应尽量采用开源方案或 SaaS 服务。把核心域的精力花在通用域上,是最常见的资源错配。

界限上下文:同一个词,不同的含义

以电商系统中的 "Item"(商品) 为例,它在不同的上下文中有着截然不同的含义:

上下文 "Item" 的含义 关注的属性
Catalog(目录) 可出售的产品 名称、描述、价格、图片、分类
Cart(购物车) 客户添加到购物车的商品选项 SKU、数量、选中状态
Fulfillment(履约) 将要运送给客户的仓库物料 仓库位置、重量、物流单号

通过将这些模型分离并隔离在各自的边界内,我们可以自由地表达这些模型而不产生歧义。

子域 vs 界限上下文:子域属于问题空间(业务如何看待问题),界限上下文属于解决方案空间(如何实现问题的解决方案)。理论上一个子域可以有多个界限上下文,但我们努力做到每个子域只有一个。

事件风暴:协作式的边界发现

有了子域和界限上下文的概念,下一个问题是:具体的边界怎么找出来? 事件风暴(Event Storming)是 Alberto Brandolini 提出的一种轻量级的协作建模技术,它是将业务知识转化为聚合边界和上下文映射的实操工具。

什么是事件风暴?

简单来说,事件风暴是团队在一起进行的头脑风暴,目标是识别系统中发生的各种领域事件业务流程。所有相关团队在同一个房间(物理或虚拟),在白板上用不同颜色的便利贴标记事件、命令、聚合和策略,识别重叠概念、模糊的领域语言和冲突的业务流程,最后对相关模型进行分组,发现聚合边界。

便利贴颜色约定

颜色 含义 示例
橙色 领域事件(已发生的事实) "订单已创建"、"支付已完成"
蓝色 命令(触发事件的动作) "创建订单"、"取消订单"
黄色 聚合(命令作用的对象) "订单"、"支付"、"库存"
紫色 策略/规则(事件触发的后续逻辑) "支付完成后发送确认邮件"
红色 热点/问题(需要讨论的疑问) "退款流程和订单取消是否耦合?"

从事件风暴到聚合边界

事件风暴的关键产出不是事件清单本身,而是通过事件的聚类发现聚合边界。具体做法是:先把所有领域事件铺到时间线上,然后把频繁一起出现的事件归拢到同一个聚合,最后观察哪些聚合之间的交互是事件驱动的(松耦合),哪些需要同步调用(紧耦合)。紧耦合的聚合倾向于放在同一个服务内,松耦合的聚合适合拆分为不同服务。

以电商下单场景为例:把"商品已添加到购物车"、"购物车已更新"归入购物车聚合;把"订单已创建"、"订单行已添加"、"订单已提交"归入订单聚合;把"支付已授权"、"支付已完成"、"支付已退款"归入支付聚合。然后观察到"订单已提交"之后触发"支付已授权"是跨聚合的事件流——这两个聚合之间是松耦合的,适合拆分为不同服务。而"订单已创建"和"订单行已添加"总是在同一个事务中发生——它们属于同一个聚合。

一次成功的事件风暴通常会产出:重新定义的聚合列表(这些可能成为新的微服务)、聚合之间的事件流(上下文映射的输入)、热点问题(需要进一步讨论的边界争议)、以及团队对统一语言的共同理解。事件风暴的产出直接喂入下一步的上下文映射。

上下文映射:定义服务间的协作契约

事件风暴帮我们发现了聚合和它们之间的事件流,上下文映射(Context Mapping)则在此基础上进一步定义:这些上下文之间应该用什么模式协作? DDD 定义了一组标准化的映射模式,每种模式反映了不同的团队关系和技术集成方式。

八种上下文映射模式

模式 关系描述 适用场景 电商示例
合作关系(Partnership) 两个团队共同协调演进,成败与共 两个紧密配合的核心域团队 订单团队与支付团队联合迭代新的结算流程
共享内核(Shared Kernel) 两个上下文共享一小部分模型和代码 模型高度重叠且变更频率低 多个服务共享的 Money 值对象
客户-供应商(Customer-Supplier) 上游供应商提供能力,下游客户提出需求 上游有意愿响应下游的需求 库存服务(供应商)为订单服务(客户)提供库存查询 API
遵从者(Conformist) 下游无条件接受上游模型,无谈判空间 上游强势或是第三方不可控系统 对接政府税务系统,必须按照其数据格式提交
防腐层(Anticorruption Layer) 下游建立翻译层,隔离外部模型对内部模型的污染 集成遗留系统或第三方服务 支付服务与第三方支付网关之间的适配层
开放主机服务(Open Host Service) 上游提供标准化的公开协议供多个下游消费 上游有多个消费者且接口稳定 商品目录服务提供标准 REST API,搜索、推荐、购物车均消费
发布语言(Published Language) 与开放主机服务配合,用标准化格式交换数据 需要跨上下文的数据交换标准 使用 JSON Schema 定义领域事件的标准格式
各行其道(Separate Ways) 两个上下文完全解耦,不做集成 集成成本高于收益 内部 BI 系统与面向客户的推荐系统各自维护用户画像

其中最值得深入理解的是防腐层(ACL),因为它在微服务集成中使用频率最高。

防腐层:隔离外部模型污染

在实际项目中,我们不可避免地要对接第三方系统——支付网关、物流平台、ERP 系统。这些系统的数据模型和你的领域模型往往差异巨大。如果直接在领域代码中使用第三方的数据结构,外部的任何变更都会直接穿透到核心业务逻辑。

防腐层的职责是在两个世界之间做翻译:

// 防腐层:将第三方支付网关的响应转换为内部领域模型
public class PaymentGatewayACL {
    private final ExternalPaymentClient client;

    public PaymentResult authorize(PaymentCommand cmd) {
        // 将内部领域命令转为外部 API 请求
        GatewayRequest request = GatewayRequest.builder()
            .merchantId(config.getMerchantId())
            .amount(cmd.getAmount().toCents())
            .currency(cmd.getAmount().getCurrency().name())
            .cardToken(cmd.getTokenizedCard())
            .build();

        // 调用外部服务
        GatewayResponse response = client.charge(request);

        // 将外部响应转为内部领域模型
        return PaymentResult.builder()
            .transactionId(TransactionId.of(response.getTxnRef()))
            .status(mapStatus(response.getResultCode()))
            .authorizedAmount(Money.of(response.getSettledAmount(), cmd.getAmount().getCurrency()))
            .build();
    }

    private PaymentStatus mapStatus(String gatewayCode) {
        return switch (gatewayCode) {
            case "00" -> PaymentStatus.AUTHORIZED;
            case "05" -> PaymentStatus.DECLINED;
            default -> PaymentStatus.FAILED;
        };
    }
}

ACL 的价值在于:当第三方网关从 v2 升级到 v3、字段名和状态码全部变化时,你只需要修改 ACL 内部的映射逻辑,领域层完全不受影响。

上下文映射实战:支付场景的边界重新划定

以电商支付场景为例,假设有三个服务都需要处理支付:

服务 支付相关操作
购物车服务 在线支付授权
订单服务 订单履约后结算
联络中心服务 支付重试、变更支付方式

如果每个服务都内嵌支付聚合并直接对接支付网关,会产生严重问题:支付聚合分散在多个服务中,无法强制执行不变性;联络中心更改支付方式时,订单服务可能正在用旧方式结算;支付网关的任何变更都要改动多个服务、多个团队。

通过上下文映射分析,正确的做法是将支付聚合收拢到一个独立的支付服务中,它以 Open Host Service 的角色对外提供标准化的支付能力,内部通过 ACL 隔离第三方网关:

改造项 映射模式 说明
支付服务独立 Open Host Service 支付聚合有了专属的界限上下文,对外提供标准支付 API
支付网关对接 ACL 在支付服务和支付网关之间加入适配层
购物车→支付 Customer-Supplier(同步) 下单时需要即时的支付授权反馈
订单→支付 Published Language(异步事件) 订单服务发出域事件,支付服务监听并完成结算
联络中心→支付 Published Language(异步事件) 变更支付方式时发出事件,支付服务撤销旧卡、处理新卡

这个例子体现了上下文映射的核心价值:它不只是画图,而是在做架构决策——哪些服务是上游、哪些是下游、用什么模式集成、团队间的依赖关系是什么。

从界限上下文到微服务

战略设计工具(事件风暴 + 上下文映射)帮我们识别了聚合边界和上下文间的协作关系,接下来的问题是:一个界限上下文应该拆成几个微服务?

界限上下文 ≠ 微服务

以"定价"界限上下文为例,它可能包含三个不同的聚合:

聚合 职责
Price(价格) 管理目录商品的价格
Priced Items(定价项) 计算商品列表的总价
Discounts(折扣) 管理和应用各类折扣规则

如果把这三个聚合放在一个服务中,随着时间推移,界限可能变得模糊,职责开始重叠,最终退化为"大泥球"。

拆分策略:从保守到激进

拆分策略的选择取决于你对领域的理解深度:

策略 适用场景 优势 风险
一个界限上下文 = 一个微服务 领域模糊、业务初期 保守安全,避免过早拆分 服务可能过大
一个聚合 = 一个微服务 领域清晰、边界确定 粒度精细,独立演进 分布式复杂度高
一个界限上下文 = 多个微服务 上下文内聚合边界清晰 兼顾灵活与可控 需要精确的聚合划分

对于不完全了解的业务领域,建议从保守策略开始:将整个界限上下文及其聚合组成单个微服务。确保聚合之间通过接口充分隔离,后续再拆分的成本会低得多。将两个微服务合并为一个的成本远高于将一个微服务拆分为两个

何时应该合并而非拆分?

如果发现两个聚合之间需要强 ACID 事务,这是一个强烈的信号——它们可能应该属于同一个聚合,或者至少属于同一个微服务。在拆分之前,事件风暴和上下文映射可以帮助我们及早识别这些依赖关系。

战术设计:边界内的领域建模

战略设计确定了服务边界,接下来的问题是:边界内部怎么建? 这就是战术设计的领域——一组用于构建丰富领域模型的构件,其中聚合是最核心的概念。

聚合(Aggregate):数据一致性的原子边界

聚合是 DDD 中最重要也最容易被误用的概念。它是数据变更的原子边界——一次事务只能修改一个聚合。

聚合是关联对象的集群,被视为数据变更的单元。外部引用仅限于指定聚合的一个成员——聚合根(Aggregate Root)。在聚合的边界内需应用一组一致性规则。

三条铁律:一致性在单个聚合内保证(跨聚合只能最终一致);只能通过聚合根的已发布接口修改聚合(外部不能绕过聚合根直接操作内部对象);任何违反这些规则的行为都有让应用退化为大泥球的风险。

聚合设计原则

Vaughn Vernon 在《Implementing Domain-Driven Design》中总结了四条聚合设计原则,这四条原则比定义本身更有实操价值:

原则一:在聚合边界内保护业务不变量。 聚合存在的理由是保护不变量(Invariant)。例如"订单总金额必须等于所有订单行金额之和"这条规则,Order 和 OrderLine 必须在同一个聚合内,因为它们共同维护这个不变量。

原则二:设计小聚合。 大聚合意味着大事务、大锁、高冲突。一个包含几十个实体的聚合,每次修改都要加载和锁定整个对象图——并发性能会急剧下降。尽量让聚合只包含维护不变量所必需的最小实体集合。

原则三:通过唯一标识引用其他聚合,而非直接对象引用。 如果 Order 聚合需要关联 Customer,不要在 Order 内部持有 Customer 对象的引用,而是持有 CustomerId。这强制了聚合边界,避免了跨聚合的事务。

原则四:使用最终一致性更新其他聚合。 当一个聚合的变更需要触发另一个聚合的更新时,通过领域事件实现异步通知,而不是在同一个事务中同时修改两个聚合。

聚合设计实战:订单聚合

// 聚合根
public class Order {
    private OrderId id;
    private CustomerId customerId;   // 通过 ID 引用,而非持有 Customer 对象
    private OrderStatus status;
    private Money totalAmount;
    private List<OrderLine> lines;   // OrderLine 是聚合内部的实体
    private List<DomainEvent> events = new ArrayList<>();

    // 业务行为封装在聚合根上
    public void addLine(ProductId productId, int quantity, Money unitPrice) {
        if (status != OrderStatus.DRAFT) {
            throw new OrderAlreadySubmittedException(id);
        }
        OrderLine line = new OrderLine(productId, quantity, unitPrice);
        this.lines.add(line);
        recalculateTotal();   // 保护不变量:总金额 = Σ 行金额
    }

    public void submit() {
        if (lines.isEmpty()) {
            throw new EmptyOrderException(id);
        }
        this.status = OrderStatus.SUBMITTED;
        events.add(new OrderSubmitted(id, customerId, totalAmount, lines, Instant.now()));
    }

    public void cancel(String reason) {
        if (!status.isCancellable()) {
            throw new OrderNotCancellableException(id, status);
        }
        this.status = OrderStatus.CANCELLED;
        events.add(new OrderCancelled(
            UUID.randomUUID().toString(),
            id, customerId, totalAmount, reason,
            toCancelledItems(lines), Instant.now(), nextEventVersion()));
    }

    private void recalculateTotal() {
        this.totalAmount = lines.stream()
            .map(OrderLine::lineAmount)
            .reduce(Money.ZERO, Money::add);
    }
}

这个设计体现了几个关键点:Order 是聚合根,OrderLine 是内部实体,不对外暴露;Customer 通过 CustomerId 引用,不在 Order 聚合内;所有业务行为(addLine、submit、cancel)都封装在聚合根上,由聚合根保护不变量;状态变更产生领域事件,由聚合内部收集,持久化时一并发布。

如何判断一个聚合是不是太大了?有几个信号:事务冲突频繁(多个用户同时操作同一个聚合)、加载一个聚合需要查询多张表和大量数据、一个看似简单的修改要锁定大量对象。反过来,聚合太小也有问题:如果你发现两个聚合之间需要强 ACID 一致性,它们可能应该属于同一个聚合。

实体(Entity)与值对象(Value Object)

这是 DDD 战术设计中最基本的二分法:

维度 实体(Entity) 值对象(Value Object)
标识 有唯一标识,即使属性完全相同的两个实例也不相等 无标识,仅由属性值决定相等性
生命周期 有生命周期,状态会变化 不可变,创建后不再修改
存储 通常有独立的数据库记录 通常内嵌在实体中或作为字段
示例 Order、Customer、Product Money、Address、DateRange

值对象是被严重低估的战术构件。很多团队习惯性地把所有概念建模为实体,导致领域模型充斥着不必要的 ID 和可变状态。一个好的判断标准:如果你关心的是"它是哪一个",用实体;如果你关心的是"它是什么值",用值对象。

// 值对象:Money——不可变、无标识、通过值判断相等
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("Amount scale exceeds currency precision");
        }
    }

    public Money add(Money other) {
        assertSameCurrency(other);
        return new Money(amount.add(other.amount), currency);
    }

    public Money multiply(int quantity) {
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }

    public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.getInstance("CNY"));
}
// 值对象:Address——两个 Address 相等,当且仅当所有字段都相等,不需要 ID
public record Address(String province, String city, String district, String street, String zipCode) {}

领域服务(Domain Service)

当一个业务操作不自然地属于任何一个实体或值对象时,它应该放在领域服务中。领域服务的特点是:无状态、操作涉及多个聚合或外部信息、名称来自统一语言。

// 领域服务:定价策略
public class PricingService {
    private final DiscountPolicyRepository discountRepo;

    /**
     * 计算订单的最终价格,涉及多条折扣规则的组合应用。
     * 这个逻辑不属于 Order(Order 不应该知道折扣规则的细节),
     * 也不属于 DiscountPolicy(单条规则不知道其他规则的存在),
     * 因此放在领域服务中。
     */
    public Money calculateFinalPrice(Order order, CustomerId customerId) {
        List<DiscountPolicy> policies = discountRepo.findApplicable(customerId, order.getLines());
        Money baseTotal = order.getTotalAmount();

        for (DiscountPolicy policy : policies) {
            baseTotal = policy.apply(baseTotal, order);
        }
        return baseTotal;
    }
}

一个常见误区是把所有业务逻辑都放在领域服务中,让实体退化为只有 getter/setter 的数据容器——这就是**贫血领域模型(Anemic Domain Model)**反模式,后面会专门讨论。

仓储(Repository)与应用服务(Application Service)

Repository 为聚合提供类集合的持久化接口,屏蔽底层存储细节。设计原则是一个聚合根对应一个 Repository,内部实体不需要独立的 Repository。

public interface OrderRepository {
    Optional<Order> findById(OrderId id);
    void save(Order order);
    List<Order> findByCustomerId(CustomerId customerId);
}

应用服务是领域模型的使用者,负责编排用例流程:接收外部请求、加载聚合、调用领域行为、持久化结果、发布事件。它本身不包含业务逻辑

@Service
@Transactional
public class OrderApplicationService {
    private final OrderRepository orderRepo;
    private final PricingService pricingService;
    private final DomainEventPublisher eventPublisher;

    public void submitOrder(SubmitOrderCommand cmd) {
        // 1. 加载聚合
        Order order = orderRepo.findById(cmd.getOrderId())
            .orElseThrow(() -> new OrderNotFoundException(cmd.getOrderId()));

        // 2. 调用领域服务(跨聚合逻辑)
        Money finalPrice = pricingService.calculateFinalPrice(order, cmd.getCustomerId());
        order.applyFinalPrice(finalPrice);

        // 3. 调用聚合的业务行为
        order.submit();

        // 4. 持久化
        orderRepo.save(order);

        // 5. 发布领域事件
        order.getDomainEvents().forEach(eventPublisher::publish);
    }
}

应用服务与领域服务的区别在于:应用服务编排"做什么",领域服务封装"怎么做"。 应用服务知道用例的流程步骤,但不知道业务规则的细节;领域服务知道业务规则,但不知道自己在哪个用例流程中被调用。

分层架构与六边形架构

战术设计的构件需要一个合理的代码组织方式。传统的 DDD 分层架构将代码分为四层:

层次 职责 包含的构件
Interface 层 处理 HTTP/gRPC 请求,参数校验,DTO 转换 Controller、DTO、Assembler
Application 层 编排用例流程,事务管理 Application Service、Command/Query
Domain 层 核心业务逻辑,不依赖任何外部框架 Entity、Value Object、Domain Service、Repository 接口、Domain Event
Infrastructure 层 技术实现细节 Repository 实现、消息队列、外部 API 客户端、ACL

这里的关键约束是依赖方向:上层可以依赖下层,Domain 层不依赖任何外层。这意味着 Repository 的接口定义在 Domain 层,实现放在 Infrastructure 层——依赖倒置原则的典型应用。

更进一步的做法是六边形架构(Hexagonal Architecture),也叫端口-适配器架构。它的核心思想是:领域模型在最内层,所有外部交互通过端口(Port,即接口)和适配器(Adapter,即实现)进行,无论是 HTTP 请求、数据库访问还是消息队列,对领域层来说都是可替换的适配器。六边形架构的实际收益是:你可以在不启动 Spring、不连接数据库的情况下,对领域模型进行全面的单元测试。领域逻辑的正确性不依赖于任何基础设施。

贫血领域模型:最常见的战术设计反模式

所谓贫血领域模型,是指实体只有数据属性和 getter/setter,所有业务逻辑都写在 Service 层:

// 贫血模型:Order 退化为数据容器
public class Order {
    private Long id;
    private String status;
    private BigDecimal totalAmount;
    private List<OrderLine> lines;
    // 只有 getter 和 setter,没有业务行为
}

// 所有逻辑堆在 Service 中
public class OrderService {
    public void addLine(Long orderId, ProductId productId, int qty, Money price) {
        Order order = orderRepo.findById(orderId);
        if (!"DRAFT".equals(order.getStatus())) {
            throw new RuntimeException("...");
        }
        OrderLine line = new OrderLine(productId, qty, price);
        order.getLines().add(line);  // 直接操作内部集合
        BigDecimal total = order.getLines().stream()...
        order.setTotalAmount(total);
        orderRepo.save(order);
    }
}

这种写法的问题在于:不变量(总金额 = Σ 行金额)没有被聚合保护,任何地方都可以直接 setTotalAmount 而不更新行项,或者直接操作 lines 集合而不重算总额。当 OrderService 之外的另一个 Service 也需要修改 Order 时,不变量的维护就变成了"记得调用"而非"编译器保证"。

对比之下,前面"聚合设计实战"中的 Order 实现,addLine 方法内部自动 recalculateTotal,外部根本没有 setTotalAmount 方法可调——不变量由聚合自身保护。

微服务间的通信与一致性

从单体到微服务的一致性挑战

在单体应用中,多个聚合在同一个进程边界内,可以在一个事务中完成:客户下单 → 扣减库存 → 发送邮件。所有操作要么都成功,要么都失败。

但微服务化后,这些聚合分散到了不同的分布式系统中。根据 CAP 定理,一个分布式系统只能同时满足一致性(C)、可用性(A)、分区容错(P)中的两个。在现实系统中,分区容错是不可协商的——网络不可靠、虚拟机可以宕机、区域延迟可能恶化。因此我们只能在可用性和一致性之间选择。而在现代互联网应用中,牺牲可用性通常也不可接受。结论:基于最终一致性设计应用程序。

领域事件设计

领域事件(Domain Event)是实现最终一致性的核心载体。一个设计良好的领域事件应该包含足够的信息让消费者独立处理,而不需要回调生产者查询更多数据:

public record OrderCancelled(
    String eventId,                           // 全局唯一事件 ID,用于消费端幂等去重
    OrderId orderId,
    CustomerId customerId,
    Money orderAmount,
    String reason,
    List<CancelledLineItem> cancelledItems,  // 包含足够的信息让库存服务恢复库存
    Instant occurredAt,
    long eventVersion                         // 用于乱序处理
) implements DomainEvent {

    public record CancelledLineItem(ProductId productId, int quantity) {}
}

事件驱动架构避免了两种耦合:行为耦合(一个领域无需规定其他领域应该做什么)和时间耦合(一个流程的完成不依赖于所有系统同时可用)。

Outbox 模式:保证事件发布的可靠性

事件驱动架构面临一个经典难题:聚合状态的持久化和事件的发布是两个操作——如果数据库写入成功但消息队列发送失败,就会出现数据和事件不一致。

Outbox 模式的解决思路是利用数据库事务的原子性:在同一个事务中,既写入聚合状态,又将事件写入同一数据库的 Outbox 表。然后由一个独立的进程(Relay/Poller 或基于 CDC 的 Debezium)从 Outbox 表读取事件并发布到消息队列。

-- 在同一个事务中
BEGIN;
  UPDATE orders SET status = 'CANCELLED', updated_at = NOW() WHERE id = ?;
  INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload, created_at)
    VALUES ('Order', ?, 'OrderCancelled', ?::jsonb, NOW());
COMMIT;

这样即使消息队列暂时不可用,事件也不会丢失——它安全地保存在数据库中,等待重试发布。

事件消费端的可靠性:幂等与乱序

Outbox 解决了生产端的可靠性,但消费端同样面临挑战。Outbox Relay 可能重复发送同一条事件(at-least-once 语义),网络或队列分区可能导致事件乱序到达。消费端必须做好两件事:

幂等消费:同一事件重复到达不产生副作用。常见做法是用事件 ID 做去重表:

@Transactional
public void handle(OrderCancelled event) {
    // 幂等检查:如果这条事件已经处理过,直接返回
    if (processedEventRepo.existsById(event.eventId())) {
        return;
    }

    // 执行业务逻辑
    for (var item : event.cancelledItems()) {
        inventory.restoreStock(item.productId(), item.quantity());
    }

    // 记录已处理,与业务操作在同一事务中
    processedEventRepo.save(new ProcessedEvent(event.eventId(), Instant.now()));
}

乱序处理:事件可能不按发生顺序到达。消费者可以利用事件中的 eventVersion 或时间戳判断:如果收到的事件版本比本地记录的版本更旧,说明是迟到的旧事件,可以安全忽略。

角色 保障措施
生产者 Outbox 模式确保事件至少发出一次
消费者 基于事件 ID 去重,以幂等方式消费
乱序处理 基于 eventVersion 或时间戳,忽略迟到的旧事件

Saga 模式:跨服务的业务流程编排

当一个业务流程需要跨多个微服务协调(如下单涉及订单、库存、支付三个服务),单个事务无法覆盖,就需要 Saga。Saga 将长事务拆分为一系列本地事务,每个本地事务对应一个补偿操作(Compensating Action),如果某个步骤失败,按反序执行补偿操作来回滚之前的步骤。

Saga 有两种实现方式:

方式 机制 适用场景 风险
编排式(Choreography) 每个服务监听上一步的事件,决定自己是执行还是补偿 步骤少(2-3 步)、参与者少 步骤多时流程难以追踪
协调式(Orchestration) 由一个 Saga 协调器集中管理流程状态和步骤推进 步骤多、需要可视化流程 协调器本身成为关键节点

以协调式 Saga 为例,下单流程的每个步骤都定义正向操作和补偿操作:

public class CreateOrderSaga {
    private final List<SagaStep> steps = List.of(
        new SagaStep("reserveInventory", this::reserveInventory, this::releaseInventory),
        new SagaStep("processPayment",   this::processPayment,   this::refundPayment),
        new SagaStep("confirmOrder",     this::confirmOrder,     this::rejectOrder)
    );

    public void execute(CreateOrderCommand cmd) {
        SagaState state = SagaState.start(cmd);

        for (SagaStep step : steps) {
            try {
                step.execute(state);
                state.markCompleted(step.name());
            } catch (Exception e) {
                // 失败:按反序执行已完成步骤的补偿操作
                state.completedSteps().reversed().forEach(
                    completedName -> findStep(completedName).compensate(state)
                );
                state.markFailed(step.name(), e);
                return;
            }
        }
        state.markSucceeded();
    }
}

关键在于:每个步骤的正向操作和补偿操作都是本地事务,通过 Outbox 模式保证可靠发布。补偿操作不是"撤销",而是一个语义上的逆操作——比如"释放库存"而不是"删除库存预留记录"。

CQRS:读写模型分离

Command Query Responsibility Segregation(CQRS)将系统的读模型和写模型分离:写操作通过聚合根维护业务不变量,读操作通过专门的查询模型提供高性能查询。

CQRS 不是所有微服务都需要的——它适用于读写差异大的场景。例如:订单服务的写模型是 Order 聚合(强一致性、完整的业务规则),但运营后台的订单列表页需要关联商品名称、客户信息、物流状态等多个聚合的数据。如果每次查询都通过聚合根加载再拼装,性能和复杂度都不可接受。

CQRS 的做法是维护一个为列表查询优化的只读视图,通过领域事件异步更新:

// 读模型:为查询优化的扁平化视图
public class OrderSummaryView {
    private String orderId;
    private String customerName;    // 冗余存储,避免查询时 join Customer
    private String statusDisplay;
    private BigDecimal totalAmount;
    private LocalDateTime createdAt;
    private String latestTrackingNo; // 冗余存储,避免查询时 join Fulfillment
}

// 事件处理器:监听领域事件,更新读模型
@Component
public class OrderSummaryProjection {

    @EventHandler
    public void on(OrderSubmitted event) {
        Customer customer = customerQueryService.findById(event.customerId());
        OrderSummaryView view = new OrderSummaryView();
        view.setOrderId(event.orderId().value());
        view.setCustomerName(customer.getName());
        view.setStatusDisplay("已提交");
        view.setTotalAmount(event.totalAmount().amount());
        view.setCreatedAt(event.occurredAt());
        summaryViewRepo.save(view);
    }

    @EventHandler
    public void on(OrderShipped event) {
        summaryViewRepo.updateTracking(event.orderId().value(), event.trackingNo());
        summaryViewRepo.updateStatus(event.orderId().value(), "已发货");
    }
}

读模型和写模型之间存在短暂的数据延迟(最终一致性),这在大多数查询场景下是可以接受的。但如果业务要求提交后立刻在列表中看到最新状态,可以在写操作返回后同步更新读模型,或者在前端做乐观更新。

何时仍需同步调用?

并非所有场景都适合事件驱动。当需要即时反馈时(如购物车→支付授权),仍需同步 API 调用。但要注意:同步调用引入了行为耦合和时间耦合,被调用服务不可用时调用方也会受影响。

缓解策略:同步调用作为主路径,辅以基于事件或批处理的异步重试作为降级方案。在用户体验、系统弹性和运营成本之间做好权衡。

BFF 模式:解耦前端与领域服务

问题:服务为了迎合调用者而变形

微服务架构中一个常见的反模式是:域服务为了满足前端的特定数据需求而编排其他服务

以"订单详情页"为例,页面需要同时展示订单信息和退款信息。如果让订单服务调用退款服务来组装复合响应:订单服务的自治性降低、增加故障点、变更成本高。

解决方案:Backend for Frontends(BFF)

BFF 是由消费者团队(前端团队)创建和维护的后端服务,负责对多个域服务进行集成和编排、为前端提供定制化的数据契约、根据不同终端优化响应格式和体积。

对比 无 BFF 有 BFF
数据编排 域服务互相调用,或前端直接调多个服务 BFF 统一编排,域服务保持纯粹
变更自主性 前端需求变化要改多个域服务 前端团队自主改 BFF
性能优化 移动端可能获取过多冗余数据 可按终端定制负载大小
技术选型 受域服务 API 限制 BFF 可采用 GraphQL 等灵活方案

尽早构建 BFF 服务,可以避免两种不良后果:域服务被迫支持跨域编排,或前端不得不直接调用多个后端服务。

常见反模式与陷阱

DDD 微服务实践中有几个高频踩坑点,值得单独拎出来。

分布式单体

表面上拆成了多个服务,但所有服务共享同一个数据库,或者每次变更必须同时部署多个服务——这不是微服务,而是分布式单体。它继承了单体的所有缺点(无法独立部署、无法独立扩展),同时还增加了分布式系统的复杂性(网络延迟、分布式事务、运维成本)。

症状:部署一个服务需要同时部署另外三个;两个服务的数据库表之间有外键约束;改一个服务的 API 需要另一个服务同步跟改。

按技术层拆分而非按业务域拆分

把用户相关的表放在一个"用户服务"、把所有 API 网关逻辑放在"网关服务"、把所有消息处理放在"消息服务"——这是按技术关注点拆分,不是按业务域拆分。结果是每个业务变更都需要改多个服务。

正确的做法是:每个服务对应一个界限上下文或聚合,包含从 API 到数据库的完整垂直切面。

数据所有权不清

两个服务同时写同一张表,或者一个服务直接读另一个服务的数据库——这是数据所有权不清的典型表现。解决方案是:每个聚合的数据只由其所属服务管理,其他服务需要数据时通过 API 或事件获取。

过早拆分

在业务模型还没有稳定的时候就急于拆分为微服务,结果频繁的需求变更导致服务边界不断调整,重构成本远超单体。如果你还不确定边界在哪里,就不要拆分。 先在单体内通过模块边界和接口隔离做好"可拆分"的准备,等业务稳定后再拆分。

渐进式拆分路线图

将以上所有工具整合,从单体拆分到微服务的推荐路径:

第一步:战略设计

与领域专家一起梳理业务,划分子域并识别核心域、支撑域和通用域。为每个子域确定界限上下文的边界,在每个上下文内建立一致的统一语言。

第二步:协作建模

通过事件风暴跨团队协作,识别领域事件、命令、聚合和热点问题。在此基础上绘制上下文映射,明确上下文之间的依赖关系和协作模式。

第三步:服务划分

根据聚合和上下文映射,确定每个微服务的边界。区分同步调用和异步事件驱动的通信方式,为不同终端规划 BFF 层。

第四步:渐进式拆分

从边缘开始,先拆分耦合最少、边界最清晰的服务。采用绞杀者模式(Strangler Fig Pattern):新功能用微服务实现,老功能逐步迁移。每拆分一个服务,验证边界是否正确,必要时调整。

第五步:战术深化

对核心域服务实施完整的战术设计——聚合、Entity、Value Object、Domain Service、Repository、Application Service 分层组织。对支撑域和通用域,根据复杂度决定是否需要完整的战术设计,还是简单的 CRUD 即可。

总结

基于 DDD 构建微服务,战略设计和战术设计缺一不可。战略设计决定边界在哪里——子域分类告诉你资源优先级,事件风暴发现聚合和事件流,上下文映射定义服务间的协作契约。战术设计决定边界内怎么建——聚合保护业务不变量,Entity 和 Value Object 构建领域模型,Repository 和 Application Service 将领域逻辑与基础设施解耦。跨服务的一致性则由 Outbox 保证事件可靠发布、Saga 编排跨服务流程、CQRS 分离读写模型。

几条核心原则值得反复校验:微服务的本质是界限清晰,不是规模小;聚合是数据一致性的原子边界,设计小聚合,通过事件实现跨聚合的最终一致性;上下文映射不只是画图,而是在做架构决策;先在单体内做好模块隔离,再渐进式拆分——合并两个服务的成本远高于拆分一个。

DDD 不是银弹,它是一种思考方式。它引导我们从业务本质出发,用结构化的方法找到正确的服务边界。在微服务架构中,找到正确的边界比选择正确的技术栈重要十倍

加载导航中...

评论