Java企业级应用框架设计:DDD+CQRS实战与踩坑记录
2026/9/24 21:05:53 网站建设 项目流程

如果你的 Java 企业级应用已经开始因为十几个模块共用一套 Service 而头疼,那么 DDD 和 CQRS 这套组合多半已经在你的候选清单里。我最近在重构一个订单中台项目时,基于 DDD 与 CQRS 设计了一套适合 Java 的企业级应用框架,核心思路是先按业务能力拆域,再把读写链路彻底分离。整个过程踩了不少坑,也推翻了两次上一版的架构设计,所以这篇文章会尽量讲清楚“为什么这样做”,而不只是贴一张看起来很漂亮的架构图。

这套框架适合两类人:一类是想在团队里引入 DDD 但不知道怎么落地的同学,另一类是已经被 CQRS 的异步读模型和事件风暴绕晕、只想找一个务实方案的开发。我不打算堆砌抽象概念,而是直接给出一套能跑的代码骨架、设计取舍和真实踩坑记录。

1. 先把问题想明白:Java 单体应用为什么会走到 DDD + CQRS 这一步

1.1 传统 Service 三层结构失控的真实过程

大多数 Java 企业项目起步时都是 Controller-Service-DAO,开发速度确实快。可只要业务跑两年,订单、支付、库存、会员这些模块互相调用,Service 层就会迅速膨胀成一个“上帝类”。我见过一个订单 Service 有两千多行,里面既有下单校验、优惠计算,也有发送短信、修改库存的逻辑。更夸张的是,连订单列表的查询条件也塞在同一个方法里。

这种结构下,最明显的问题不是代码长,而是领域规则没有边界。任何想复用订单逻辑的地方都只能调用这个 Service,调着调着,原本属于促销领域的规则被塞进了订单领域,订单领域的状态流转又被营销活动牵制。改一处代码,需要把整个调用链都看一遍,最后谁都不太敢动。

DDD 的价值在这里就体现出来了:它强制你先画出业务边界,区分核心领域、支撑领域和通用领域,再把每个领域内部的对象分成聚合根、实体、值对象。这个动作本身就能暴露出很多“不该由订单 Service 管”的逻辑。

1.2 同一个业务对象,读写诉求完全不一样

当我把订单模块重构到时,最先意识到的是:同一个 Order,根本不应该只有一个实体模型。

在写操作这一侧,下订单、取消订单、确认收货这些动作需要严格的业务规则。比如“已发货的订单不能取消”“超时未支付要自动关闭”,这些规则和状态机强相关,要求模型完整、一致、可控。

在读操作这一侧,用户订单列表、管理后台订单筛选、运营报表统计,需要的字段往往来自多张表。比如列表页要显示“商品名称 + 店铺名 + 物流状态 + 实付金额”,这些数据分散在不同表里。如果查询也走领域模型,就要先加载整个 Order 聚合,再关联一堆对象,性能差不说,还把领域模型搞得臃肿不堪。

CQRS 的核心主张就是把“变更数据”的命令链路和“读取数据”的查询链路拆开。命令端保留完整的业务约束,查询端则可以针对视图需求单独建模型。拆开之后,两边可以独立优化,谁也不拖累谁。

1.3 不是所有 Java 项目都适合这套框架

我也要泼一盆冷水:DDD + CQRS 不是银弹。如果项目只是一个简单的后台管理系统,CRUD 占总逻辑的 80%,引入这套框架完全是给自己增加负担。领域模型、事件总线、读模型同步这些概念都会变成团队交流的成本。

我建议出现以下情况再考虑这个组合:

  • 存在多个微服务或模块共同依赖同一个核心领域,且该领域规则经常变化。
  • 同一份业务数据在写入和读取时,结构差异巨大。
  • 查询性能成为瓶颈,但又不想直接改业务写逻辑。
  • 团队中至少有两个人对 DDD 有实践经验,并且愿意投入时间做建模。

2. 框架整体分层:命令端和查询端各自的调用链

2.1 四层职责边界:接口层、应用层、领域层、基础层

这套框架沿用经典 DDD 分层,但把查询链路单独拆出来,形成这样一个结构:

  • 接口层(interfaces):负责接收 HTTP 请求、MQ 消息或 RPC 调用。这层只做参数转换,不能写业务逻辑。
  • 应用层(application):负责编排命令和查询,但不持有业务规则。应用服务只做“把事情串起来”的动作,比如开启事务、调用领域方法、发布事件。
  • 领域层(domain):承载核心业务逻辑,包含聚合根、实体、值对象、领域服务和领域事件。
  • 基础层(infrastructure):提供数据库、消息队列、缓存等基础设施实现。在命令端,这一层主要实现 Repository 接口;在查询端,这一层提供读模型的查询实现。

2.2 命令端的一次完整调用链

当用户发起“创建订单”请求时,请求会按下面这条链路流动,每一步的职责都固定在特定层:

Controller -> Command -> CommandHandler -> 聚合根业务方法 -> Repository.save -> 发布领域事件

CommandHandler 处于应用层,它拿到 Command 对象后,会先做通用校验(比如字段非空),然后加载聚合根,调用聚合根的业务方法,业务方法内部会执行真正的规则判断并修改状态。最后 Repository 负责持久化,持久化完成后事件总线再发布领域事件。

这样做的好处是,领域层完全不感知 Spring、MyBatis、Spring Cloud 这些技术细节。聚合根只是一个纯 Java 对象,可以方便地做单元测试。

2.3 查询端的一次完整调用链

查询端不加载聚合根,不触发业务规则。它的调用链简单得多:

Controller -> Query -> QueryHandler -> ReadModel -> DataSource / 缓存

QueryHandler 直接从读模型查询数据。读模型可以是关系库的视图、一张专门的订单列表表,也可以是 Redis 中的缓存结构。它跟领域模型是两套数据,互不影响。

这种分离带来一个好处:我可以针对“订单列表”单独建一张宽表,冗余商品名、店铺名这些字段,查询时只查一次,不用关联十几张表。虽然数据存在冗余,但对查询性能来说这是非常划算的。

2.4 模块划分与依赖规则

我在工程里把框架分成了四个 Maven Module,依赖方向从上层指向下层:

ddd-adapter(接口层) ↓ ddd-application(应用层,含命令和查询处理) ↓ ddd-domain(领域层) ↓ ddd-infrastructure(基础层)

查询模型虽然没有领域逻辑,但它的接口定义可以放在 application 层或独立的 query-model 包里。具体包结构如下:

com.example.ddd ├── interfaces │ ├── controller │ ├── command │ └── query ├── application │ ├── commandhandler │ ├── queryhandler │ └── dto ├── domain │ ├── model │ ├── repository │ ├── service │ └── event ├── infrastructure │ ├── repositoryimpl │ ├── readmodel │ └── message └── common

依赖规则只有一个核心约束:领域层不依赖任何外部框架,Repository 接口必须定义在领域层,实现放在基础设施层。这个约束保证了领域层是整个项目里最稳定、最不容易被污染的部分。

3. 领域层设计:聚合根、值对象和 Repository 的正确姿势

3.1 聚合根边界:到底哪些对象应该归一个聚合管

引入 DDD 后,团队最先争论的往往是“订单这个聚合根到底该包含哪些子实体”。拿订单来说,订单里面通常有订单行(OrderLine)、收货地址、优惠信息。如果把这几样都塞进订单聚合,每次加载订单都要把全部子对象查出来。

我的建议是:聚合根边界尽量“小”而不是“大”。聚合根解决的是一致性边界问题,只有在同一事务里必须保证一致的数据才应该放进一个聚合。

订单和订单行属于强一致关系,放进同一个聚合。但订单和支付记录就不一定了,如果订单必须在“创建”时就校验支付信息,那可以放;如果支付是异步流程,订单和支付就应该分成两个聚合,用领域事件或 Saga 协调。

实际操作中我给自己定了一个简单的判断标准:如果删除订单行时订单总金额需要立即重算,并且两者不能出现短暂不一致,那就放进同一个聚合。如果只是“展示给用户看”的关系,就拆开放。

3.2 值对象的建模:别把所有字段都当成实体

很多从 MyBatis 转过来的同学会觉得数据库表字段就应该一一映射到实体字段,但 DDD 里“值对象”才是用来描述度量、金额、地址这些属性的。

比如Money,如果我在订单实体里定义BigDecimal amount,一旦多个地方都需要对金额做单位换算、精度校验,代码会特别分散。更合理的做法是定义一个Money值对象:

public class Money { private final BigDecimal amount; private final String currency; public Money(BigDecimal amount, String currency) { if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("金额不合法"); } this.amount = amount; this.currency = currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("币种不一致"); } return new Money(this.amount.add(other.amount), this.currency); } public Money multiply(int quantity) { return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency); } // getter }

把金额包装成值对象后,所有跟金额相关的校验和运算都收拢到一个类里,业务代码反而更干净。值对象是不可变的,这也符合并发场景下对安全性的要求。

3.3 Repository 接口不是 DAO:约束来自领域而不是表结构

很多开发一写 Repository,就下意识把它当成 MyBatis 的 Mapper,方法名都是insertupdateByIdselectByCondition。但 DDD 里的 Repository 更应该表达领域概念。

我习惯在领域层先定义这样的接口:

public interface OrderRepository { Optional<Order> findById(OrderId id); void save(Order order); void delete(Order order); }

接口里没有查询列表、分页排序这些方法,因为一个聚合根的全生命周期只应该通过它自己的状态变化来管理。查找聚合根,传进去的是OrderId而不是“userId + status + startTime”这种组合条件。读模型的事交给查询端去处理。

领域层命令 Order 执行完业务动作后,调用save方法;基础设施层负责把聚合根的状态同步到数据库表里。因为 Order 里可能包含多个子实体,save 实现里需要自己控制事务和主从表的写入顺序。

我分享一个订单聚合根返回的方法示例,注意看业务规则是如何写在“方法内部”而不是写在 Application Service 里的:

public class Order extends AggregateRoot<OrderId> { private OrderId id; private CustomerId customerId; private Money totalAmount; private OrderStatus status; private List<OrderLine> orderLines; public static Order create(OrderId id, CustomerId customerId, List<OrderLine> orderLines) { if (orderLines == null || orderLines.isEmpty()) { throw new IllegalArgumentException("订单必须包含商品行"); } Order order = new Order(); order.id = id; order.customerId = customerId; order.status = OrderStatus.CREATED; order.orderLines = orderLines; order.totalAmount = orderLines.stream() .map(line -> line.getSubTotal()) .reduce(Money.ZERO, Money::add); order.addEvent(new OrderCreatedEvent(id, customerId, order.totalAmount)); return order; } public void cancel(String reason) { if (status != OrderStatus.CREATED && status != OrderStatus.PAYMENT_PENDING) { throw new IllegalStateException("当前状态不能取消"); } this.status = OrderStatus.CANCELLED; this.addEvent(new OrderCancelledEvent(id, reason)); } public void markPaid() { if (status != OrderStatus.CREATED && status != OrderStatus.PAYMENT_PENDING) { throw new IllegalStateException("只有待支付订单可以标记为已支付"); } this.status = OrderStatus.PAID; } // 省略 getter }

看到没有,订单能否取消、能否标记已支付,这些规则都写在聚合根内部。外面任何调用方都无法绕过cancel()方法直接改变状态,因为status字段没有 public setter。这正是 DDD 和传统 JavaBean 式实体的最大区别——数据的安全和完整由领域对象自己负责

3.4 框架层如何防住“贫血模型回潮”

贫血模型指实体只有 getter / setter,所有逻辑都在 Service 里,这正是很多 DDD 项目失败的原因。要防止回潮,单纯靠代码审查是不够的,我在框架层做了两件事:

  • 聚合根需要继承AggregateRoot基类,这个基类内部维护领域事件列表,并通过模板方法约束事件只能在聚合根内部添加。
  • 框架扫描到Entity类型时,会检查所有属性是否都有对应的业务方法来修改。如果发现某个实体只能 set,而没有任何业务方法使用它,会在构建时输出警告。

这样做不能完全杜绝乱写,但至少让团队在提交代码时不得不考虑“这个逻辑是不是放错了层”。

4. 领域事件、读模型与最终一致性:从业务事件到查询视图

4.1 领域事件什么时候触发

领域事件是 CQRS 里命令端和查询端之间的桥梁。在命令端,聚合根的状态发生变化后,会发布事件。比如OrderCreatedEvent,这个事件本身是一次“已经发生过的事实”,所以它的命名应该是过去式,而不是一个指令。

一个容易忽略的细节是:领域事件必须在聚合根状态变更且事务提交后再发布。如果在事务还没提交时就发事件,读模型去查数据库会查到旧数据,产生奇怪的不一致问题。我见过不少项目在 Service 里调用完方法立刻发事件,结果消息消费者读不到最新数据。

更稳妥的做法是利用 Spring 的TransactionSynchronizationManager,在事务成功提交后执行事件发布逻辑:

@Transactional public OrderId handle(CreateOrderCommand command) { Order order = Order.create(...); orderRepository.save(order); // 事务提交后发布事件 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { order.publishEvents(eventBus); } }); return order.getId(); }

我之前在这个地方踩过一次大坑:事件先发布了,结果订单表还没提交成功,消息消费者那边收到事件后去查订单详情查不到,直接报错。改成 afterCommit 之后问题就消失了。

4.2 读模型投影:从领域事件到列表宽表

查询端的数据不一定来自原始表,它可以通过监听事件来构建自己的“投影”。我管这套机制叫 ReadModel Projection。

最直接的实现方式是:监听OrderCreatedEvent后,把订单表、商品表、会员表的字段组合成一条订单列表记录,写入order_list表或更新到 Redis。这样查询订单列表时,就不用再去 join 商品表和会员表了。

投影的实现要满足两个基本要求:幂等性和顺序性。同一个事件可能因为重复投递被执行两次,所以投影逻辑必须支持重入。我通常在宽表里加一个event_id唯一建,消费前先查一下是否已处理过,避免重复写入。

订单超时状态是我遇到的另一个顺序性问题。用户支付成功后,支付服务可能先发出PaymentSucceededEvent,同时支付立即失效的OrderClosedEvent又触发了,两个事件并发到达。如果顺序反了,订单状态会从“已支付”被改成“已关闭”。处理方案是给事件加版本号或时间戳,更新宽表时判断当前记录版本是否比事件版本旧,只有旧版本才允许被更新。

4.3 最终一致的延迟怎么向业务方交代

CQRS 分离后,读写模型之间存在短暂的延迟。一般来说本地数据库读模型同步可以做到毫秒级,基本无感。但如果通过 MQ 同步,用户提交订单后立刻查列表,可能发现新订单还没出现。

我的处理经验是,把“必须实时读到的数据”留在命令端返回给前端,把“可以稍后展示的数据”交给读模型。比如创建订单成功后,接口直接返回订单 ID 和总价,前端根据返回结果跳转详情页。详情页第一次加载时如果读模型还没更新,再走一次兜底查询,从订单表实时取数。等到用户第二次进入时,读模型大概率已经同步好了。

这样既保留了查询端的性能优势,又避免用户感知到数据延迟,是成本最低的妥协方案。

5. Saga 编排与异常恢复:订单、库存、支付在分布式环境下的最终一致性

5.1 2PC 为什么不适合企业级应用

分布式事务常见的方案有 2PC(两阶段提交)和 Saga。框架里我一开始也考虑过 2PC,但很快放弃了。2PC 要求所有参与方在事务期间持有数据库锁,如果某个节点宕机,整个事务会长时间阻塞。在订单、支付、库存这种跨服务场景里,2PC 的吞吐量太低,而且要引入独立的协调者组件,成本和复杂度都很高。

CQRS 框架里更自然的方案是 Saga。Saga 把一个长事务拆成多个本地事务,每个本地事务执行完成后发布事件,触发下一个步骤。如果中间某一步失败,执行反向补偿操作。

5.2 编排式 Saga 和协同式 Saga 怎么选

Saga 有两种实现方式。编排式 Saga 有一个独立的协调器(Orchestrator),它知道整个流程步骤,负责调用每个服务。协同式 Saga 没有中心协调器,每个服务消费事件后自行决定下一步动作。

我推荐订单类流程优先使用编排式 SAGA,因为订单流程环节多、状态明确,用中心化协调器更好排查问题。比如创建订单后,Saga Orchestrator 负责依次调用库存服务、支付服务,任何一个环节失败了,它都知道应该补偿哪些步骤。

伪代码示例:

public class OrderCreateSaga { private final InventoryClient inventoryClient; private final PaymentClient paymentClient; private final OrderCommandService orderCommandService; public void onOrderCreated(OrderCreatedEvent event) { // 1. 锁定库存,如果失败直接取消订单 boolean locked = inventoryClient.tryLock(event.getOrderItems()); if (!locked) { orderCommandService.cancelOrder(event.getOrderId(), "库存不足"); return; } // 2. 请求支付 PaymentResult paymentResult = paymentClient.pay(event.getOrderId(), event.getTotalAmount()); if (paymentResult.isSuccess()) { orderCommandService.markPaid(event.getOrderId()); } else { inventoryClient.unlock(event.getOrderItems()); orderCommandService.cancelOrder(event.getOrderId(), "支付失败"); } } }

如果项目里只有少量跨服务事件,协同式 Saga 更轻。它的缺点是流程隐含在各个事件处理器里,出了问题要串联事件记录才能看清全貌。我一般建议代码里额外画一张“事件流转图”存到文档里,否则三个月后连写代码的人都看不懂。

5.3 超时、幂等与补偿的落地细节

分布式环境下,超时按“结果未知”而非“失败”处理,这是一个基本认知。支付超时后,订单可能实际已经支付成功了,只是响应丢了。这时代理如果立刻补偿取消订单,就会造成“先取消后支付成功”的严重问题。

我使用的策略是:把 Saga 流程建模成一张状态机,步骤状态包括:

状态说明下一步动作
PENDING等待处理触发调用
PROCESSING已发起调用设置超时定时器
SUCCESS业务成功进入下一节点
FAILED明确失败执行补偿
COMPENSATING补偿中调用反向接口
COMPENSATED补偿完成流程结束

所有状态变化都持久化到 Saga 日志表。收到超时消息后,先查询当前状态,如果状态是 PENDING 或 PROCESSING,才允许发起重试查询,调用查询接口确认最终结果,而不是立刻走补偿。这样的设计既解决了超时的模糊性,也让运维可以在页面上直观地看到 Saga 走到哪一步了。

5.4 一个取消订单的 Saga 操作对照

我把常见操作和它的反向补偿操作列了一个表,做 Saga 的时候可以直接参考:

正向操作反向补偿操作补偿触发条件
锁定库存释放库存订单取消或支付超时
创建支付单关闭支付单支付流程异常
扣除优惠券返还优惠券业务失败
生成物流单作废物流单支付未完成或订单取消

注意补偿操作本身也需要幂等。比如释放库存请求被重复调用时,不能出现“库存被多释放一次”的情况。我在库存服务里用“原始锁定单号 + 操作类型”作为唯一键,重复补偿请求直接返回成功。

6. 手写一个最小可运行的框架核心:注解、命令总线和处理器注册

6.1 框架核心注解与启动注册机制

为了让这套框架有“框架感”而不是散落一堆代码,我实现了几个基础注解和一套处理器注册机制。命令处理器通过@CommandHandler注解被框架识别:

@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface CommandHandler { Class<?> commandType(); }

在 Spring Boot 启动时,打上这个注解的类会被扫描,并注册到CommandBus里。

考虑到可能要把这套框架用在多个项目中,我把它做成了ddd-cqrs-starter,内部封装了CommandBusQueryBusEventBus,对业务项目只暴露相关接口。

6.2 CommandBus 的核心实现

CommandBus 不需要多复杂,本质上是命令类型到处理器的映射关系。下面这个实现省去了很多功能,但保留了最核心的注册和分发逻辑:

@Component public class DefaultCommandBus implements CommandBus { private final Map<Class<?>, CommandHandler> handlerMap = new ConcurrentHashMap<>(); @Override public void register(Class<?> commandType, CommandHandler handler) { handlerMap.put(commandType, handler); } @Override @SuppressWarnings("unchecked") public <R> R dispatch(Object command) { CommandHandler handler = handlerMap.get(command.getClass()); if (handler == null) { throw new IllegalStateException("no handler for command: " + command.getClass()); } return (R) handler.handle(command); } }

真实的框架里还要支持拦截器,比如日志、权限、幂等校验。我建议把幂等校验做成 CommandBus 的拦截器,统一在命令进入处理器之前检查。如果某个命令已经执行过,直接返回之前的结果。

6.3 事件总线的同步与异步策略

事件总线我做了两种模式:同步和异步。

同步模式用于“同一个事务内必须立即生效”的事件,比如订单创建后要更新某个统计表。异步模式用于跨服务和耗时操作,比如发短信、推送消息。

这里有一个很实际的问题:如果异步发布事件使用的是线程池,那应用重启时线程池里排队的事件会丢。解决思路是把“待发布事件”先作为一张本地事件表存进数据库,然后由发件器扫描表并投递到 MQ。这个方案保证事件不丢,就是多了两步代码,但企业级应用里很值得。

6.4 一个订单创建全链路的最小代码

下面我给出一个完整的订单创建命令端代码,读者可以看到框架怎么串起来:

public class CreateOrderCommand { private final String orderId; private final String customerId; private final List<OrderItemDto> items; // 构造器、getter } @Component @CommandHandler(commandType = CreateOrderCommand.class) public class CreateOrderCommandHandler implements CommandHandlerInterface<CreateOrderCommand, String> { private final OrderRepository orderRepository; @Override @Transactional public String handle(CreateOrderCommand command) { List<OrderLine> orderLines = command.getItems().stream() .map(dto -> new OrderLine(dto.getProductId(), dto.getQuantity(), new Money(dto.getUnitPrice()))) .toList(); Order order = Order.create(new OrderId(command.getOrderId()), new CustomerId(command.getCustomerId()), orderLines); orderRepository.save(order); return order.getId().getValue(); } }

在接口层,Controller 接收到请求后只做数据转换:

@RestController @RequestMapping("/api/orders") public class OrderController { private final CommandBus commandBus; @PostMapping public ResponseEntity<String> create(@RequestBody CreateOrderRequest request) { String orderId = commandBus.dispatch(request.toCommand()); return ResponseEntity.ok(orderId); } }

查询端也大同小异,命令端使用 CommandBus,查询端使用 QueryBus。查询处理器直接返回查询结果,不需要经过领域层。

7. 落地过程中绕不开的坑:分布式 ID、幂等、大事务和调试

7.1 分布式 ID 生成与分库分表键的选择

CQRS 架构下,写模型和读模型可能需要不同的主键策略。我最早用了自增 ID,结果分库分表后订单表和订单列表宽表的主键经常冲突,排查问题很痛苦。

后来换成雪花算法生成Long型 ID,并在OrderId这个值对象里保存了 ID 的生成时间戳和机器标识。这样在排查问题时,从 ID 就能看出大致生成的机器和时间段。

分库分表键我建议用 customerId 而不是 orderId。因为订单维度的查询基本都是按用户来查的,把同一个用户的订单分到同一分片,可以避免跨分片查询。如果要用 orderId 查订单,就需要额外维护“订单和用户”的映射关系,增加复杂度。

7.2 幂等性:同一个命令重复执行两次会怎样

幂等是命令端最容易踩的坑。比如用户点击“创建订单”按钮,前端因为网络重试发送了两次请求。如果命令处理器不做幂等处理,就会生成两笔订单。

我在框架层面的 CommandBus 里做了一层幂等拦截,用法很简单:

@Idempotent(key = "#request.requestId") public String handle(CreateOrderCommand command) { // ... }

实现原理是,根据请求方传入的requestId,查数据库里的幂等表。如果已经存在相同 requestId 的记录,说明命令已执行过,直接返回原结果。如果不存在,先插入一条状态为 PROCESSING 的记录,再执行真正的业务逻辑,最后把状态改成 SUCCESS。

注意幂等表的插入和业务操作必须在同一个本地事务里。否则先插入记录、后执行业务,如果业务失败事务回滚,幂等记录也会回滚,重试还能重新执行,这正是我们想要的。

7.3 大事务拆分:聚合根 save 不要太“贪”

DDD 的聚合根保存天然容易把多个子对象写进同一事务,尤其当一个订单包含大量订单行时,整个 Order 聚合的保存会涉及几十条 SQL,如果全部放在一个大事务里,锁竞争和回滚代价都很高。

我的经验是,聚合根保存时只保存变化的部分。框架给聚合根增加了“变更追踪”能力,保存前对比快照,只把发生变化的实体更新到数据库。创建订单时,如果一次性插入几十条订单行没问题,但取消订单时,就不需要把整个订单行列表重新更新一遍。

实测下来,订单从原来的全量更新改成效验更新后,数据库压力至少降了一半。

7.4 调试读模型和写模型的技巧

CQRS 架构最让团队头疼的是:线上出问题后,你分不清到底是命令端没写对,还是读模型没同步对。

我常用的调试链路是:

  1. 先通过订单 ID,查命令端的订单事件表,确认事件是否发布。
  2. 查 Saga 日志表,看事件流转到哪一步,是否触发补偿。
  3. 查看读模型的宽表,看对应的event_id是否已经落库,如果没落库,翻消费者日志。

这个方法把一条复杂的链路拆成三段,每一段都能独立定位。我也在框架里加了一个运维接口,可以直接输入订单 ID 返回事件流、状态流转和补偿记录,排查时间从小时级降到了分钟级。

最后聊点个人体会

重构完这个框架之后,我最大的体会是:DDD 和 CQRS 真正降低的是长期维护成本,而不是短期开发成本。刚开始,每个命令都要写 Command、Handler、聚合方法、Repository 实现,代码量确实比传统 Controller-Service 要大一圈。但只要业务规则进入复杂阶段,这种结构的优势就非常明显——领域规则收拢在聚合根内部,查询性能瓶颈可以单独优化,再也不会出现改一个 Service 方法被十几个调用方连带影响的情况。

给你一个最直接的配置建议:第一次做 DDD + CQRS,不要把事件溯源一起引入。事件溯源会带来不小的事件存储和版本迁移复杂度,先只做模型分离 + 命令查询总线 + 事件投递读模型,就足够解决 90% 的问题了。把架子搭稳,以后再决定要不要往事件溯源方向演进。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询