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

目录

微服务的边界应当围绕业务规则和变化方式确定。一个下单请求同时经过订单、库存和支付,并不意味着它们必须放进一个服务;反过来,画出三个实体也不意味着应该立即部署三个服务。真正需要解释的是:哪些规则必须一起成立,哪些变化可以独立完成,跨边界失败后如何恢复。

DDD 提供两组互相配合的工具:战略设计明确模型的适用范围与团队协作关系,战术设计把业务规则落实到对象和操作中。本文以一个假设的电商下单流程为例,先确定边界,再说明订单内部建模,以及独立服务之间的可靠协作。

先区分业务问题、模型边界与部署边界

概念 回答的问题 电商示例
子域 业务中有哪些相对独立的问题 定价、库存、履约
界限上下文 一套语言和模型在什么范围内有效 目录中的商品、履约中的待发货物料
聚合 哪些对象共同维护一组业务不变量 订单与订单行
微服务 哪些能力需要独立部署、运行与运维 独立发布的支付服务

界限上下文用于保持模型内部语义一致,并明确不同模型之间的关系。目录里的“商品”关注名称与销售属性,库存里的同一 SKU 关注仓库和可售量,履约里的物料关注重量、打包与运输。共享标识不要求三者共享一个包含全部字段的类。

子域与上下文不必机械地一一对应。遗留系统、不同团队和不同业务语言可能形成多个上下文;部署则还要考虑调用、数据和运维成本。一个模块化单体可以包含多个上下文,一个上下文也可能由多个部署单元实现。

按差异化价值决定建模投入

核心域、支撑域和通用域的划分,是相对于具体组织的业务目标而言的。

分类 判断依据 可能的投入方式
核心域 直接支撑组织独特的竞争能力 深入建模、持续实验、保留关键知识
支撑域 为业务提供必要能力,存在定制但不是当前差异化重点 按复杂度建设,控制无效扩展
通用域 需求较成熟,市场已有可复用能力 评估购买、开源或平台服务

库存对一家轻资产内容平台可能只是支撑能力,对以履约效率竞争的零售商却可能是核心域。不能把“推荐必然核心、库存必然支撑”写成固定分类,也不能因为某个子域不核心就忽略其资金或可靠性要求。

用事件风暴发现问题,而不是自动切出服务

EventStorming是一种跨角色协作探索业务的工作坊形式。对下单场景,可以先还原客户、客服、仓库与支付团队看到的过程,再讨论命令、已发生的事件、规则与疑问。

常见的便利贴约定包括:橙色表示领域事件,蓝色表示命令,黄色表示聚合,紫色表示策略,红色表示热点问题。颜色是协作约定;探索全局流程时,不必一开始就给每张事件贴纸指定聚合。

例如,“订单已提交”之后可能出现库存不足、支付拒绝或支付结果暂时未知。这些分支迫使团队回答:

  • 提交订单是否就代表承诺有货,还是先进入待确认状态?
  • 库存预留有无期限,支付晚到时如何处理?
  • 支付超时能否直接认定失败,重试用什么业务标识?
  • 用户取消与支付成功同时发生时,由谁决定最终状态?

聚合边界要从这些不变量和并发冲突中推导,不能按事件出现的时间接近程度聚类。“订单提交”和“支付授权”前后相连,并不能证明它们天然松耦合;用消息连接后,状态与业务约束仍然可能紧密相关。

工作坊的产出是对流程、规则、术语冲突和候选边界的共同理解。后续仍需用具体用例与失败场景检验边界。

上下文映射说明双方如何合作

模型分开后,需要明确谁提供能力、谁适配谁,以及变更如何协调。常见的上下文映射模式如下;这些模式可以组合使用,不等同于某一种通信协议。

模式 关注点 示例
合作关系 Partnership 双方共同协调演进 订单与支付团队联合交付新的结算流程
共享内核 Shared Kernel 对一小部分共享模型共同负责 双方共同维护且协调发布的金额规则
客户—供应商 Customer–Supplier 下游需求进入上游计划 库存团队为订单团队提供约定能力
遵从者 Conformist 下游接受上游模型 使用外部系统的现成领域语义
防腐层 Anticorruption Layer 下游翻译并隔离外部模型 内部支付状态与渠道状态转换
开放主机服务 Open Host Service 提供稳定、可理解的集成协议 支付服务对多个业务提供授权与查询接口
发布语言 Published Language 使用明确记录的交换语言 说明字段、状态和演进规则的事件契约
各行其道 Separate Ways 不建立不值得维护的集成 两项无共享业务要求的能力独立演进

共享一个工具包未必形成共享内核;采用 JSON 也不自动形成发布语言。前者需要共同维护领域语义,后者需要描述数据含义与兼容规则。Customer–Supplier 可以使用同步 API,也可以使用消息,不能把组织关系写成同步调用的同义词。

防腐层保护语义,不只改字段名

假设外部渠道区分“授权成功”“扣款成功”和“结果待查询”,内部支付模型也必须保留相应差异。不能把 charge 的结果映射成 authorized,把已结算金额当成已授权金额,更不能把未知状态码一律映射为最终失败。

一个支付适配器需要明确以下契约:

输入或观察 内部处理
经过验证的授权成功响应 记录渠道交易标识与授权金额
明确的业务拒绝 记录拒绝原因,进入可解释的终态
超时、连接断开、未知结果码 保留“结果未知”,使用原业务标识查询或重试
重复响应或回调 以请求与渠道事件标识幂等处理
渠道金额单位或精度不同 明确换算与舍入规则,不假设所有币种都以分计量

防腐层可以吸收字段和协议变化。若渠道改变了授权有效期、退款能力或业务状态语义,内部模型与流程仍可能需要调整。“接入层隔离”不等于领域永远不受外部变化影响。

支付能力为什么适合集中管理

如果购物车、订单和客服系统各自维护一套支付状态,它们可能同时重试、撤销或退款,难以判断哪个记录代表真实支付结果。可以让支付上下文统一管理支付尝试、渠道标识和状态转换,再向其他上下文提供命令与结果契约。

订单发出“请求授权”表达意图;支付发出“授权成功”表达事实。两者不能仅通过改成异步消息就互换。同步返回或异步通知属于交互选择,支付状态的唯一写入责任和请求幂等性才是边界的关键。

聚合用来保护不变量,不用来规定服务数量

聚合把共同维护一致性规则的对象作为一个变更单元,聚合根对外暴露操作入口。订单与订单行可以形成一个聚合:新增行时,数量、币种、金额和订单状态必须同时合法。

常用的设计建议是保持聚合较小、通过根访问内部对象、用 ID 引用其他聚合,并尽量让一次事务围绕一个聚合完成。这是约束模型复杂度的设计方向,不是数据库无法跨聚合提交的技术定律。

若业务持续要求多个对象原子更新,应重新检查不变量和边界;也可以有明确理由使用跨聚合本地事务。需要跨服务保持某项强约束时,不能仅因为“DDD 建议最终一致”就取消业务要求。

三个有用的检查

  1. 不变量由谁维护? 订单总额可以由行金额与明确折扣规则推导,不能任意设置一个不相关的总额
  2. 并发由谁协调? Java 方法内的检查只能约束当前对象,还需要版本号、条件更新或合适的锁处理并发写入
  3. 对象是否过度膨胀? 一个客户的全部历史订单不应仅因“属于同一客户”就装进同一聚合

使用 CustomerId 而不是持有可变 Customer 对象,有助于减少无意的对象遍历与级联保存,但 ID 引用本身不能禁止跨聚合事务。大聚合也不必在所有实现中锁住整张对象图;其实际代价取决于加载和持久化方式,应通过访问行为验证。

战术构件如何共同实现一个用例

构件 责任 下单例子
Entity 以稳定标识区分对象,承载行为 Order
Value Object 用值表达概念,通常设计为不可变 Money、地址快照
Aggregate Root 对外控制聚合修改并维护不变量 Order.addLine、Order.submit
Domain Service 表达不自然属于单个实体的领域操作 组合定价规则
Repository 按聚合语义加载与保存对象 OrderRepository
Application Service 协调用例、权限、事务与基础设施 提交订单用例

同一实体的两个内存实例可以代表同一个业务标识;不能把“两个实例不是同一对象”当作实体身份不同。值对象的相等性则由业务值决定,不要求它必须存成独立表。

一个可编译的金额值对象

下面使用 Java 17 record,并限定为采用 Java Currency 默认小数位的货币金额。它不覆盖加密资产、利率计算或中间高精度价格。构造时统一 scale,使 1.0 与 1.00 在同一币种下具有相同的 equals/hashCode 语义;不允许通过隐式舍入吞掉精度。

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        int scale = currency.getDefaultFractionDigits();
        if (scale < 0) {
            throw new IllegalArgumentException("Unsupported currency precision");
        }
        amount = amount.setScale(scale, RoundingMode.UNNECESSARY);
    }

    public static Money zero(Currency currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    public Money add(Money other) {
        Objects.requireNonNull(other, "other");
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(amount.add(other.amount), currency);
    }

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

Money 可以表示正负金额,订单行的“数量必须大于零、单价不得为负”由订单规则另外校验。金额应由十进制字符串或精确数值构造,避免先经过 double 丢失精度。求和从 Money.zero(orderCurrency) 开始,不能用固定人民币零值汇总美元订单。

订单聚合与应用服务的职责边界

订单的 addLine 应校验草稿状态、数量、单价和币种,再更新行项与总额。submit 应再次确认仍是草稿且内容有效,避免重复提交产生重复业务事件。向外返回的行项及事件使用不可变快照,不能把内部可变 List 直接交给消费者。

如果有折扣,应明确区分原价、优惠与应付金额,例如“应付金额=行金额合计-优惠”。不能前面声明“总额永远等于行金额”,后面又任意 applyFinalPrice 破坏它。

提交订单的应用服务可以按以下步骤组织:

  1. 认证调用者并校验其对订单的操作权限
  2. 在事务中加载订单及需要的规则版本,检查乐观锁版本
  3. 调用定价服务计算结果,由订单验证并接受结果
  4. 调用 submit,产生带有业务标识和序号的领域事件
  5. 同一事务保存订单及 Outbox 记录,然后提交
  6. 由独立投递器发送集成消息,并跟踪投递结果

应用服务负责用例策略、权限和事务编排,领域对象负责业务不变量;不能把“应用服务不应塞满核心领域规则”解释成它完全没有任何业务判断。

Repository 面向聚合语义设计,不必为每张表设置一个。复杂列表查询可以通过查询接口读取专门视图,而不必先加载大量聚合。

分层与端口隔离

可以让应用层依赖领域接口,基础设施层实现仓储、消息与外部服务端口。领域规则因此能在不启动 Spring 或数据库的情况下单独测试。这里采用的是依赖倒置后的组织方式,不能一边要求 Domain 不依赖基础设施,一边又规定它必须按“上层依赖下层”调用数据库实现。

端口适配器隔离能减少领域测试的依赖,但集成行为仍需验证:事务是否真正覆盖 Outbox、SQL 条件更新是否生效、消息是否保持约定语义,都不是纯对象测试能证明的。

对于简单 CRUD,事务脚本可能更直接。问题不是所有 setter 都必须消失,而是复杂不变量分散在多个入口,任何调用方都能绕过规则。领域封装提高约束集中度,运行时校验仍需测试,不能宣称编译器自动证明全部业务正确性。

跨服务一致性要按业务结果设计

在同一数据库中,订单和库存记录可以共享本地事务;普通邮件发送或远程支付调用不会因为位于同一个 Java 进程或带有 @Transactional 就随数据库回滚。

拆成服务后,应逐项说明需要原子成立的事实、允许的中间状态和失败补救。CAP 讨论网络分区条件下的线性一致性与可用性,不能推出“互联网系统只能最终一致”。库存扣减、资金账务等约束仍可能要求拒绝或延迟请求,以保证正确性。

Outbox 补上提交与投递之间的缺口

订单状态更新与待发布事件写入同一个数据库事务,使“业务提交但没有留下待投递记录”的窗口消失。投递器随后读取记录,将消息发送到消息系统,收到确认后更新状态;也可通过 CDC 转发事务日志。Transactional Outbox 模式讨论了这种原子写入与独立投递的分工。

需要同时处理:

  • 订单更新未命中预期版本时,不能继续插入虚假的成功事件
  • 消息已送达但确认丢失时,重试可能重复投递
  • 并行投递器需要竞争协调;同一聚合的有序要求还要靠分区键、序号与投递策略落实
  • 积压、毒消息和长期失败需要监控、重试及修复入口

Outbox 持久化提高可恢复性,并不保证下游此刻已经处理,也不意味着不需要数据库备份或故障恢复。

幂等消费不能只做“先查再写”

两个并发消费者都可能先查到“未处理”。可靠的本地消费流程应在同一事务中:

  1. 利用唯一约束尝试插入 (consumer_id, event_id) 处理记录;已存在则按该数据库的冲突处理方式退出
  2. 对目标业务执行状态受约束的变更
  3. 一并提交;业务失败时,去重记录也回滚

如果副作用是远程退款或外部邮件,它不受这个本地事务保护,需要独立的幂等业务键、发送状态或 Outbox 机制。恢复库存也应根据实际预留记录释放,不能仅凭订单取消事件中的数量给可售库存直接做加法。

乱序处理取决于事件的语义

事件含义 对迟到消息的处理
完整状态快照 在确认版本口径一致时,可以拒绝覆盖较新的快照
必须逐条应用的增量 不能只因版本旧就丢弃,需要补缺、按序应用或重建
可交换且可幂等的独立事实 按各事实标识去重,不必强加不存在的全局顺序

事件 schemaVersion 表示格式版本,aggregateVersion 表示某个聚合的变化序号,两者应分开。物理时间戳可能重复或受时钟偏差影响,不适合作为所有场景的唯一排序依据。

领域事件也不必一律携带全部关联数据。使用快照还是标识引用,需要权衡事件体积、数据敏感性和消费方依赖,并明确字段表示“发生时的值”还是“需要另行查询的当前值”。

Saga 把业务补救做成可恢复流程

Saga将跨服务流程拆成多个本地事务,并在需要时执行业务补偿。它不提供与单个 ACID 事务相同的隔离和回滚语义,也不是唯一的跨服务一致性方案。

方式 谁推进流程 需要处理的问题
事件协作 Choreography 参与者根据事件推进 依赖分散,必须能追踪整体结果
集中编排 Orchestration 编排器保存流程状态并下达命令 编排状态持久化、并发推进与恢复

以“预留库存—支付授权—确认订单”为例:

观察结果 下一步
库存预留成功 持久化结果,发出带幂等键的支付授权请求
支付明确拒绝 释放已确认的库存预留
支付请求超时 先保留未知状态并查询;不能直接当作未执行
支付成功但订单确认失败 重试确认,或按业务规则撤销授权并释放库存
补偿暂时失败 持久化待补偿状态,重试或转人工处置
流程执行器重启 从持久化状态恢复,不能重新无条件执行全部步骤

一个内存中的 for 循环加 catch 逆序调用,缺少这些状态与恢复能力,不能作为完整 Saga 实现。补偿通常按业务依赖逆序安排,但并非每一步都有可逆操作;已经发送的通知或发生的现实交付无法通过数据库回滚抹除。

CQRS 分离读写职责,不要求两套数据库

CQRS允许为命令和查询使用不同模型。订单写模型维护规则,列表读模型可以直接提供客户名称、状态和物流摘要。两者可以共享数据库,也可以通过事件异步维护独立存储;CQRS 本身不要求事件溯源或最终一致。

若选择异步读模型,需要处理重复、乱序、重建和延迟。列表上需要历史客户名称时,应使用约定的快照;在重放每条旧事件时查询客户服务当前名称,会得到不同的结果,不能默认具有可重放的一致性。

提交后立即可见可以通过返回已提交的结果、读取权威状态、等待投影达到指定版本等方式实现。前端乐观展示只改善体验,不证明读模型已更新。写完主库再单独同步写另一个读库,如果没有额外协议,仍然存在部分成功问题。

再决定是否独立部署与增加 BFF

订单、库存和支付可以先在一个应用内保持清楚的模块边界。只有当独立发布、扩缩容、团队协作或故障隔离收益足以覆盖分布式成本时,再迁移为服务。

几个判断信号比类和表的数量更有用:

观察 应检查什么
每次发布都要同步改动多个服务 契约与模型边界是否稳定,兼容策略是否不足
两个服务经常跨库协调同一不变量 是否需要调整归属,或保留同一事务边界
一个服务由多个团队频繁冲突地修改 团队责任、业务语言与发布边界是否匹配
服务共享一个数据库实例 继续检查表所有权和访问权限,不能仅凭实例共享判断耦合
一个服务直接改另一个服务的表 数据所有权已被穿透,需要收敛写入入口

独立数据所有权不必等于每个服务都独占一台数据库主机。网关作为技术能力也不是天然反模式;问题是将一次业务变更按 Controller、Service、DAO 分散成远程服务,导致端到端变化都要跨团队协调。

BFF 可以为不同终端组合数据和定制响应,但不必在业务初期无条件增加一层。若订单详情仅有简单查询,现有应用接口可能足够;多个终端的数据组合和演进节奏明显不同后,再评估 BFF。业务决定仍由相应领域维护,BFF 负责面向终端的组合与交互。

用变化与失败场景验证边界

一次渐进式拆分可以从边界明确的模块开始:确定唯一写入责任,建立兼容接口,迁移数据和流量,验证失败恢复,再收缩旧入口。拆分或合并哪个更贵,取决于数据、契约和团队,不能给出固定结论。

DDD 的结果不是一张服务清单,而是一组能被检验的解释:订单为什么拥有这些行项,支付为什么拥有渠道状态,库存为什么按预留记录释放,以及每个跨边界流程如何结束或恢复。先让这些解释与代码、数据和实际行为一致,再决定部署多少个服务。

评论