资讯详情

Axon框架实践:DDD+CQRS+事件溯源构建订单领域模型

📅 2026/10/9 13:01:45 | 华诺云谱 👁 阅读
Axon框架实践:DDD+CQRS+事件溯源构建订单领域模型
后端开发这些年我断断续续接触过不少号称能让领域模型落地的框架但真正让我愿意把业务代码完全交给它的Axon 算一个。它不是什么工作流引擎也不是简单的消息中间件而是一套把命令、事件、查询和领域对象串起来的 Java 框架。这篇指南不是文档翻译而是我基于一个订单域项目的实践复盘结合 DDD 和 CQRS/Event Sourcing 的思路适合想入门 Axon 或正在做技术选型的开发者。如果你还不太理解聚合、事件溯源这些词别急我会从最日常的场景一步步拆开讲。1. 一个创建订单的命令背后藏着 Axon 的什么设计意图1.1 当“状态修改”变成“意图与事实的分离”在没有 Axon 的传统服务里创建订单通常长这样Controller 接住一个请求Service 里调用订单表的 insert然后调库存服务的接口去扣减库存再调支付服务生成支付单。如果中间某个调用超时就得靠回滚或本地事务拼接。表面上每个服务都很独立但业务状态依赖的是“服务间同步调用是否成功”而不是领域本身。这是典型的“用远程调用伪装业务规则”。Axon 换了一种思路用命令表达用户到底想干什么用事件表达系统里已经发生了什么。比如“创建订单”是一个 CreateOrderCommand它描述意图订单聚合只关心自己是否已经被创建过如果没创建过它会产生一个 OrderCreatedEvent这是事实。至于之后库存扣减、支付单生成都是对这个事实的后续响应而不是同一段事务代码里的顺序调用。这个分离带来的第一个好处是业务规则不会散落在一个个 Controller 和 RPC 里而是集中在聚合内部。每个聚合依据自己的当前状态决定“接受这个命令”还是“拒绝这个命令”。换句话说业务知识的归属变得干净了。第二个好处是审计自然就有了。你不需要单独写操作日志表因为每次状态变化对应的 Event 都在事件存储里放着。想复盘任何一个用户的历史重放他的事件流即可。1.2 Axon 的模型分层聚合负责积累事实查询负责读数据这里需要先理清一个容易混淆的点在 Axon 里聚合不是数据库表映射出来的实体它是一个能够响应命令、产生事件的领域对象。它内部的状态也没有被直接暴露给外部而是通过处理命令改变再通过事件源落盘。你会经常看到类似apply(new OrderCreatedEvent(...))的调用这个apply()一方面把事件发布出去另一方面在聚合内部触发对应的EventSourcingHandler把订单 ID 等状态设置好。后续订单被重新加载时框架会把该聚合历史上所有事件从头回放每一条就播放到对应的事件处理器上最后得到当前状态。这种模式就是 Event Sourcing事件溯源。它和我们熟悉的先改表、再写日志的做法几乎是反过来的事实日志才是主数据数据库表只是最终状态的投影。用大白话说传统的做法是把“订单当前是什么样”记下来事件溯源是把“订单一路经历了什么”记下来你要知道当前什么样把经历重放一遍就行了。而查询数据也不建议直接查聚合——查询走的是另一套模型也就是 CQRS 里的查询侧。事件发生后投影组件在异步更新查询表业务报表、列表、详情页都从这些查询表读。这样聚合就不会因为高并发的查询需求而被迫暴露内部字段。我见过很多同学把 Axon 的 Event Handler 当成消息队列消费组这不能说错但真的低估了它的用法它是“读模型的建设者”不是“通知中心”。1.3 这个框架不是工作流它只是让业务领域自然生长如果上来就想用 Axon 写一个根据用户类型走不同审批路线的流程那会非常别扭。因为 Axon 不会替你维护流程图、任务表、状态机。它更擅长的是把复杂的业务规则拆成一组聚合和它们之间的事件协作每个聚合自治跨聚合的一致性交给 Saga 后期协调。我要强调的是“自治”。在同一个聚合里事务边界是明确的事件和应用状态天然一致。但跨聚合、跨服务时Axon 不会做分布式事务它只答应你“事件最终会被发布”做不到“所有事情同时成功”。这意味着你需要接受最终一致性并且在业务层面设计补偿。很多人一开始不习惯但一旦把这个问题想明白方案会比硬塞一个分布式事务成熟得多。2. 三大总线拆析CommandBus、EventBus、QueryBus 的边界感Axon 应用内部所有消息都通过总线流动。一个请求到达时框架把它投递给对应的处理器这个投递机制就是总线。理解三大总线是使用 Axon 的第一关。2.1 命令总线谁发的谁能接怎么保证只处理一次命令总线处理的是“用户希望发生什么”的命令消息。发送命令时调用方需要指定命令最终要到达哪个聚合这就是TargetAggregateIdentifier注解存在的意义。框架根据这个标识找到对应的聚合实例调用命令处理器方法。如果命令携带的是一个不存在的聚合 ID通常会被设计成由聚合的构造函数来响应从而创建一个新的聚合。命令总线的一个重要特性是“一个命令一个目的地”。它不会广播给所有处理器只会匹配一个与命令类型绑定的命令处理器。这保证了同一条业务意图不会被两个聚合分别处理。在分布式环境中Axon 还能利用 CommandBus 的路由能力让命令发送到正在管理该聚合的节点上尽量让聚合状态和命令位于同一台机器。这里有个容易踩的细节命令处理器里如果抛异常这个命令就不会产生事件。也就是说命令是“承诺性的”它要么成功并产生聚合状态变化要么失败并告诉调用方原因。调用方不能把命令发送出去就假装成功必须等待CommandGateway返回的CompletableFuture完成或者捕获 callback 里的异常。2.2 事件总线聚合讲出去的话再也不会收回当聚合调用apply()发布事件后事件就会进入事件总线。事件和命令最核心的区别在于命令面向未来事件面向过去。一件已经发生的事实不能回滚只能通过后续的补偿事件去“修正”。在 Axon 里事件总线默认支持多个订阅者同时消费每个事件处理器可以基于事件更新自己的读模型也可以触发后续的 Saga 操作。在 Axon 4 的模型里通常不会再直接对 EventBus 做太多定制而是把 EventStore 作为事件的存取仓库。事件发布时通过 EventBus 分发给本地订阅者同时持久化到 EventStore。这样既保证了事件不会丢失又能支持聚合的重建和查询侧的投影。如果你使用 Axon Server它还会负责跨节点的分布式事件流分发。设计事件时事件名要使用过去时态比如 OrderCreated、InventoryReserved。这看起来只是命名习惯其实在提醒你事件的语义边界已经确定之后不能偷偷改事件的时间线。2.3 查询总线把风光但复杂的读模型独立出来查询总线用来处理查询请求对应的是读模型。查询消息可以是简单的“查订单分页”也可以是复杂的汇总。Axon 支持同步返回和响应式查询。但和命令、事件不一样查询消息不和聚合的状态绑定它只走投影出来的查询表。这里的边界感是写模型在聚合侧读模型在投影侧两者通过事件同步。这也是 CQRS 的核心。有人会觉得这很麻烦查询栏的数据和写模型不同步怎么办所以要接受“最终一致”。比如订单创建后列表页可能稍稍延迟几毫秒才看到最新数据。对大多数商城类应用这些都是可以接受的。2.4 三个 Gateway 各自的视角与应用场景你在写业务代码时通常直接使用 CommandGateway、EventGateway、QueryGateway 这三个门面而不是直接操作底层的 Bus。CommandGateway.send() 返回一个 CompletableFuture可以同步等待结果EventGateway.publish() 用于主动发布事件QueryGateway.query() 能指定查询和返回类型。我通常只在应用外层或服务编排里使用 CommandGateway 和 QueryGateway而聚合内部的业务逻辑直接通过 apply() 发布事件不需要手动用 EventGateway。主动发布事件更多用于外部系统接入例如收到第三方支付回调后发布 PaymentReceivedEvent。把 Gateway 的职责分清楚代码会自然分层。3. 从零到一聚合、命令、事件与 Spring Boot 的首次对接3.1 依赖与最小配置Spring Boot 里如何拉起框架假设你已经建好了 Spring Boot 工程需要引入 Axon Starter。在 4.x 版本中最核心的依赖是dependency groupIdorg.axonframework/groupId artifactIdaxon-spring-boot-starter/artifactId version4.9.1/version /dependency这里我以 4.9 为例实际使用时建议查询最新小版本。如果不使用 Axon Server只依赖这个 Starter 也能本地跑起来框架会自动配置一个内存版的 EventStore 和 CommandBus。也就是说一个最小的万事俱备的 Axon 应用其实只需要这一个依赖再加上一个正常的 Spring Boot 启动类。需要注意官方文档推荐的搭配是 Spring Boot 3 或 Spring Boot 2.x 时要选择对应的 Axon 版本。以当前生态来看Axon 4.8 对 Spring Boot 3 支持较好。选错版本会直接影响自动配置是否生效。3.2 写第一个聚合命令处理与事件溯源的处理链下面是一个最简单的订单聚合。为了清晰我把命令和事件都定义成不可变类。Aggregate public class OrderAggregate { AggregateIdentifier private String orderId; private boolean created; protected OrderAggregate() { // 框架重建时使用的无参构造不能省 } CommandHandler public OrderAggregate(CreateOrderCommand command) { if (created) { throw new IllegalStateException(订单已存在); } apply(new OrderCreatedEvent(command.getOrderId())); } EventSourcingHandler public void on(OrderCreatedEvent event) { this.orderId event.getOrderId(); this.created true; } public String getOrderId() { return orderId; } }这个聚合的构造器就是一个命令处理器收到 CreateOrderCommand 时它检查当前状态然后调用 apply()。apply() 会把这个 OrderCreatedEvent 同时发布给外部订阅者并在聚合内部执行 on(OrderCreatedEvent)将 created 状态更新为 true。你可能注意到聚合必须有一个无参构造器。这是为了事件重放时框架先创建空对象再把事件一个一个地发送给对应的EventSourcingHandler。如果你忘记写重放时大概率会遇到异常。3.3 事件投影将存储的事件铺成一张可查询表聚合侧没有 findAll 方法查询必须走投影。最简单的方式是写一个事件处理器每当 OrderCreatedEvent 发出来时往一张 JPA 表里插入一条只读记录Component public class OrderViewProjector { EventHandler public void on(OrderCreatedEvent event) { OrderView view new OrderView(); view.setId(event.getOrderId()); view.setCreatedAt(LocalDateTime.now()); orderRepository.save(view); } }在这个投影里你可以把所有需要的查询字段都存下来。后续其他事件比如 OrderShippedEvent也会更新这张表的发货状态。要注意的是投影器通常托管在独立的查询服务节点上如果它属于命令所在节点也只会在事件发布后异步执行不要指望同一个事务里查询表立刻可见更新。如果使用 Axon 自带的 JPA Event Store事件的持久化由框架完成工程只需要关注 CommandGateway 的发送和目标聚合的处理投影逻辑则尽量保持“把事实转成视图”的单一职责。3.4 EventStore 与 Axon Server基础存储的选型逻辑我见到不少初次接触 Axon 的人误以为非得部署 Axon Server 才能用。实际不是。Spring Boot Starter 会自动配置一个基于 JPA 的 EventStore兼容普通数据库。只要配置好数据源事件就能持久化。Axon Server 更像是一个为 Axon 量身定制的后端你不需要自己处理消息路由、事件归档、多节点调试这些事。选型时我按规模来决定。单机开发或两三个内部服务完全可以用 JPA EventStore成本低、可控。如果计划把服务拆成多个物理节点并且需要跨节点转发命令和事件Axon Server 和它的分布式总线会更省心。当然你也能用外部的消息队列配合 Axon 的配置项实现总线扩展但那样要自己处理不少灰色地带最后维护成本不低。我个人在真实项目中偏向直接用 Axon Server理由很简单组件集成越完整踩到“别人没踩过”的坑概率就越小。JPA EventStore 也适合但涉及重新连不同数据库表的时候事件流的事务一致性解释起来会累一点。4. Saga 机制没有分布式事务如何协调订单与库存4.1 Saga 的角色定位一个会监听事件的中间协调者在 Axon 里Saga 不是聚合也不是一种事务模型而是一个长期运行的业务协调流程。它负责去倾听它关心的事件然后做出下一步动作。我们以创建订单并扣库存为例订单聚合创建订单并发出 OrderCreatedEvent库存聚合收到 ReserveInventoryCommand 并扣掉库存。这两个聚合彼此不知道对方存在Saga 把它们串起来。一个简单的订单 Saga 可能是这样的Saga public class OrderSaga { Autowired private transient CommandGateway commandGateway; StartSaga SagaEventHandler(assocKey orderId) public void on(OrderCreatedEvent event) { commandGateway.send(new ReserveInventoryCommand( event.getOrderId(), event.getSkuId(), event.getQuantity() )); } SagaEventHandler(assocKey orderId) public void on(InventoryReservedEvent event) { commandGateway.send(new PreparePaymentCommand( event.getOrderId(), event.getPaymentAmount() )); } EndSaga SagaEventHandler(assocKey orderId) public void on(OrderPaidEvent event) { commandGateway.send(new ShipOrderCommand(event.getOrderId())); } }这个 Saga 看起来就像事件的续篇它对感兴趣的事件做出反应并下发新的命令。注意Saga实例是长期存在的它可能在订单创建后存活几分钟到几天。4.2 关联 ID 是 Saga 的灵魂assocKey 决定事件将流向谁Saga 实例可能有成千上万个但当 OrderPaidEvent 发出来时Axon 如何知道该把它交给哪一单的 Saga答案就是关联 ID。每个SagaEventHandler方法上的 assocKey 声明了从这个事件里提取哪个字段作为关联标识与 Saga 内部保存的关联 ID 做匹配。比如 OrderPaidEvent 里有 orderId 字段assocKeyorderId 意味着框架会用该事件的 orderId 去查找关联了同样 orderId 的 Saga 实例。同一份 Saga 类型可能有很多实例分别监听不同订单它们互不干扰。一个容易忽视的坑是你在 Saga 里看到的命令参数里并不需要带关联 ID但命令携带的事件必须有能被后续 Saga 方法匹配的字段名。如果字段名不统一事件就找不到对应的 Saga 实例。维护关联 ID 时要像维护数据库主键一样小心。4.3 超时与补偿库存扣了但支付超时怎么办Saga 不可能无限等下去。Axon 提供了 DeadlineManager 来处理时间维度的问题。你可以在一段时间后发送一个 DeadlineMessage 给自己触发超时处理方法。比如当 OrderCreatedEvent 出现后同时启动一个“支付超时”倒计时。若在 30 分钟内没有收到支付成功事件超时方法会执行发送 ReleaseInventoryCommand 释放库存然后调用SagaLifecycle.end()终止该 Saga。这个补偿动作是业务自己定义的Axon 只是在正确的时间把你的回调叫醒。这个设计比写复杂的分布式事务要健壮得多库存扣减和支付不是原子操作但它们的状态可以通过事件流被 Saga 追踪最终是“成功、失败、补偿完成”里的一种稳定结局。你在业务文档里要能说清楚每个环节失败后做什么这比保证“不可能失败”更实际。4.4 Saga 不是万能药什么时候需要拆出子流程Saga 也是有边界的。如果一个订单后续还有复杂的审核链、多商家分账、退款争议我就不建议把所有步骤塞进一个 Saga。Saga 里塞进的命令越多补偿路径就越混乱。更好的做法是让 Saga 只负责“串联主干流程”比如从创建订单到支付完成而支付成功后再通过支付成功事件驱动其他独立的 Saga 或事件处理器。这样每个 Saga 的责任边界清楚出问题也好排查。毕竟 Saga 的本质是事件监听器不是流程引擎复杂的编排逻辑层次多了你会希望在代码里一眼能看到主流程而不是陷入成片的监听方法里。5. 翻车现场Axon 写起来爽但忽略这几点会非常难受这一章我想聊些只有实际动手才会明白的细节。5.1 聚合重建的顺序问题事件处理器不是随意排队的事件溯源下聚合的状态完全依赖历史事件回放。这个顺序是固定的不能因为后来想多增加一个字段就随意改历史事件。如果你在某个业务版本调整了 OrderCreatedEvent 的结构而旧事件里还没有这个字段那么老的EventSourcingHandler方法可能会因为拿到不完整的旧事件而出错。我的习惯是所有事件类一旦发布就当它是不可变的“公共接口”后续只新增字段、不删除字段。如果新旧事件结构差异太大考虑新增一个 OrderCreatedEventV2在历史重放时兼容两个版本。这个成本比你以为的高但比线上事故小。5.2 事件版本与升级策略改事件结构时比想象中麻烦和上一节相关事件升级是个绕不开的话题。事件表里一个订单可能存了 10 条事件每条都是一个可序列化对象。如果你在聚合定义里改了事件类字段名没有做迁移逻辑老事件反序列化就会失败。更稳妥的做法是给事件定义明确的 schema 版本并在聚合的事件处理器中兼容这个版本。也可以使用专门的序列化迁移工具在事件入库或读取时做转换。别等上了生产环境之后再改事件类结构那基本等于要停机修数据。5.3 死信与重试命令处理异常不代表流程结束命令处理失败时异常会返回到调用方。但如果是异步调用异常可能被吞掉。你需要建立一套监控机制观察命令和事件的分发情况。Axon Server 管理界面能查看各类消息的处理状态自建 EventStore 则需要自己从日志中捞。另一个实际问题是事件处理失败。投影器里如果遇到数据异常抛了异常框架默认可能会反复重试也可能停止处理。你需要按照自己的业务决定是不停重试等待系统恢复还是把这条事件放到死信里后续人工补偿。这两种策略在解释一致性和可用性时出发点不同但绝不能什么都不做就假装事件处理完成。5.4 并发与快照老是让所有事件从头回放会死人当订单事件数量增长到几千条时每次加载聚合都要从头把几千条事件回放一遍性能很难看。Axon 支持快照Snapshotting每隔 N 条事件创建一个聚合状态的快照加载时先读取最新快照再回放快照之后的事件。你可以按聚合 ID 的流量设置快照触发阈值我一般取 100 或 200。快照也是存储在事件存储里的所以不会有额外的数据库依赖。但要记住升级聚合代码之后旧快照可能不再兼容系统中需要保留快照重建或忽略失效快照的兜底逻辑。5.5 测试Axon 的 Fixture 才是业务单测的正确姿势Axon 为聚合测试提供了很好的 fixture。例如AggregateTestFixtureOrderAggregate fixture new AggregateTestFixture(OrderAggregate.class); fixture.given(new OrderCreatedEvent(orderId-1)) .when(new CreateOrderCommand(orderId-1)) .expectException(IllegalStateException.class);这个用例表达的是当聚合已经处于“已创建”状态时再次收到创建命令应该拒绝。给定事件、当命令、期望事件或异常这种三段式能覆盖聚合的大多数业务分支。Saga 也有对应的 SagaTestFixture可以模拟事件输入并断言 Saga 发出来哪些命令。我的经验是把核心业务规则用 Fixture 测试固定下来后代码重构会安心非常多。Axon 的注解方式下业务逻辑相对内聚测试其实比传统 Controller-Service 更直白。6. 我最终会在什么场景继续选它6.1 有足够理由记录历史与追查问题的业务如果只是用户管理、文章 CRUD你根本不需要事件溯源也不需要 CQRS。但当业务领域本身就是围绕长流程状态变化的比如订单、支付、供应链、账务流水历史可追溯性会直接决定信任度和排查问题的效率。事件流天然就是一个不可篡改的审计日志这一点对这类业务吸引力极大。6.2 团队已经有 DDD 概念储备Axon 的上手门槛不在注解而在“你是不是真的理解聚合和事件”。如果团队里没有一个人能解释清楚聚合的不变量是什么我强烈建议先拿内部培训案例做一两个星期的热身不要直接上生产。很多翻车案例不是因为 Axon 不行而是团队依然用传统的表模型思维写事件结果事件被写成日志聚合被写成数据表。6.3 我留下的判断清单在决定要不要选用 Axon 前我通常会问自己几个问题业务的写模型是否复杂且多变是否需要沉淀完整的历史事件团队能否承受最终一致性是否有足够精力维护事件版本的兼容如果有一半答案是否定的我会劝自己再用普通 CRUD 挡一阵子。但反过来如果这些问题都能答“是”Axon 带来的收益很可观。它让你不用从零去实现命令分发、事件存储、投影同步、Saga 调度这些基础件而是腾出精力专注于领域规则本身。这种“业务代码密集度”的提升是很多框架给不到的体验。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑