参考应用:商城 ddk-mall
用一个完整的业务场景把 DDK 的各个 starter 放在一起用:下单、预占库存、支付、扣减,超时未支付则关单。它是示例,也是 DDK 的验收场:下文列出的八个问题,都是 starter 组合使用之后才暴露的。
源码在仓库的 ddk-examples/ddk-mall。它从 0.6.0 起随仓库发布,0.5.0 里没有这个应用。
一、它是什么
订单、库存、支付三个限界上下文,部署成一个应用(模块化单体)。每个上下文占一个顶层包,内部是四层结构;上下文之间不互相调用,只通过集成事件协作。
com.example.mall
├── platform 各上下文共用的技术代码:顾客身份、接口文档分组
├── order 订单:下单、取消、超时关单
├── inventory 库存:预占、释放、扣减;订单在这里只是一个编号
└── payment 支付:支付单、模拟渠道、退款ArchitectureTest 除了 DDK 的分层规则,还检查三个上下文的包互不引用。支付上下文需要的应付金额和支付期限随消息带过去,它不回头查订单。
没有做的部分:前端页面、真实支付渠道、真正的身份提供方(默认 profile 的顾客身份放在请求头 X-Customer-Id 里;secure profile 用演示用户登录,见 3.5)、多租户。
二、怎么跑
不依赖任何外部组件,用内存 H2 启动:
mvn -pl ddk-examples/ddk-mall -am install -DskipTests
mvn -pl ddk-examples/ddk-mall spring-boot:run连接真实的 MySQL、Redis 和 RocketMQ:
docker compose -f ddk-examples/ddk-mall/docker-compose.yml up -d
mvn -pl ddk-examples/ddk-mall spring-boot:run -Dspring-boot.run.profiles=compose两种方式跑的是同一份业务代码,差别只在基础设施:
| 默认 profile | compose profile | |
|---|---|---|
| 数据库 | 内存 H2 | MySQL 8.4 |
| 库存锁 | 进程内的锁,只适合单实例 | Redis,多个实例之间互斥 |
| 定时任务的锁 | 进程内存里的锁,只适合单实例 | Redis |
| 缓存 | 只有进程内的一级 | 商品两级,库存只用 Redis |
| 上下文之间的消息 | DDK 的进程内转发,进程退出就丢 | 事件发布记录加 RocketMQ,失败可重投 |
冒烟命令、接口文档地址和 MCP 的接入方式见应用的 README。
三、上下文怎么协作
sequenceDiagram
participant C as 顾客
participant O as 订单
participant I as 库存
participant P as 支付
C->>O: 下单
O-->>I: order.placed
I-->>O: inventory.stock-reserved / rejected
O-->>P: order.awaiting-payment(金额、支付期限)
C->>P: 支付
P-->>O: payment.completed
O-->>I: order.paid:扣减预占的库存
Note over O: 顾客取消,或关单任务发现超过支付期限
O-->>I: order.cancelled:释放预占
O-->>P: order.cancelled:关闭支付单,已收的钱退回虚线都是消息。每条消息先和业务数据在同一个事务里登记,提交之后才投递。这条链路里有四个设计决定,值得单独说。
3.1 流程不依赖消息的先后
消息以订单号为 key,同一个订单的消息进同一个队列。但提交之后的投递是并发的,相邻两次提交的消息可能乱序发出:「已取消」有可能比「已下单」先到库存上下文。那时还没有预占,什么都没释放,随后「已下单」又把库存占上了,而订单早已取消。
应用没有去追求严格顺序,而是让流程自己纠正:订单上下文收到「库存已预占」时,如果订单已经取消,就再发一次「已取消」,把这次晚到的预占释放掉。顺序的保证范围和需要严格顺序时的做法,见 Event Starter 的「顺序」一节。
3.2 付款和取消交叉时,以订单为准
顾客付款的同时,订单可能刚好被取消或超时关闭。订单的状态由乐观锁保证只走一条路,另外两个上下文跟随它:
| 订单的最终状态 | 库存 | 支付 |
|---|---|---|
| 已支付 | 扣减 | 保持已支付 |
| 已取消 | 释放 | 没付的关闭,已付的退款 |
订单上下文收到「支付已完成」时如果订单已经取消,同样再发一次「已取消」,支付上下文据此退款。这和 3.1 是同一个办法。
3.3 锁包住事务,去重登记写在事务里
预占库存要同时满足三件事:涉及的 SKU 互斥、预占和结果消息一起提交、同一条消息重复投递时只处理一次。三者的嵌套顺序是固定的:
锁住涉及的 SKU(按固定顺序,避免死锁)
开启事务
登记「这条消息处理过了」 ← 和业务修改一起提交、一起回滚
预占库存,登记结果消息
提交事务
让这些 SKU 的缓存失效 ← 提交之后,释放锁之前
释放锁锁在事务外面,是因为释放锁时数据必须已经提交;缓存在提交之后失效,是因为提交之前失效的话,并发的读请求会立刻把旧值放回缓存。
3.4 请求支付渠道不靠分布式事务
扣款是外部副作用,不随本地事务回滚。0.8 的做法是「一个事务里扣款再保存」,只靠渠道按支付单标识去重,有两个漏洞:扣款成功后进程崩溃,支付单停在待支付、钱不退;扣款进行中订单被取消,支付单关了、钱也扣了。0.9 改成先登记、再扣款、事后对账:
pay
tx1: 校验可支付,记 charge_requested_at = now 先提交
channel.charge(payment) 不在事务里
tx2: 重新读取
PENDING → 记成已支付
CLOSED → 退款,REFUNDED 扣款期间订单被取消
PAID / REFUNDED → 原样返回
ReconcilePaymentsJob(定时,@SchedulerLock)
请求过扣款、状态仍是 PENDING 或 CLOSED 的支付单
channel.query → 扣过款:走同一段结算;没扣:清掉 charge_requested_at;查询失败:下一轮期限按「请求扣款的时间」判断:顾客在期限内付了钱,对账晚一点也要记账。渠道按支付单标识去重,重复扣款、重复退款、重复对账都安全。渠道是领域层的一个端口,目前的实现是模拟的,账本在内存里;换成真实渠道时,扣款、退款、按商户单号查询三个操作都要对上渠道的接口。
3.5 认证与授权:开发模式和 secure profile
默认 profile 是开发模式:放行全部请求,顾客身份取自请求头 X-Customer-Id。这个头谁都能伪造,MCP 工具的 customerId 参数也是模型随便填的,所以 0.9 加了 secure profile:
请求 → Spring Security 过滤器链 → DDK 的 OperatorContextFilter → 控制器 / MCP 工具 → 应用服务
secure:HTTP Basic,按 URL 授权 OperatorResolver:
默认:全部放行 secure → 认证结果里的用户名
默认 → 请求头| 谁 | 能做什么 |
|---|---|
| 未登录 | 健康检查、接口文档;其他请求 401 |
| 顾客(用户名是顾客 ID) | 自己的订单和支付、查库存;MCP 工具只能代自己 |
客服(support) | 读模型重建、库存变更和预占查询、actuator;MCP 工具里可以代任何顾客 |
MCP 工具在调用应用服务之前经 CustomerAccess 检查参数里的顾客,冒充得到 ACTING_FOR_OTHER_CUSTOMER。应用服务没有改。用户目录只是演示(口令都是 example_password),换成 OAuth2 资源服务器或企业身份提供方时,只要用户名是顾客 ID、角色对得上,其余代码不用改。DDK 不提供认证,只通过 OperatorResolver 消费认证结果。
四、搭建过程中发现的 DDK 问题
这些问题在各个 starter 自己的测试里都不会出现。每一个都已经在 DDK 里修复或补齐,包含在 0.6.0 里。
| # | 现象 | 原因 | 处理 |
|---|---|---|---|
| 1 | 订单带订单行,通用仓储只管一张表 | 仓储实现没有留给子表的扩展点 | GenericRepositoryImpl 新增 afterInsert、afterUpdate、afterLoad、beforeRemove |
| 2 | 一个订单要锁多个 SKU,逐个加锁可能死锁 | 聚合锁一次只能锁一个 | AggregateLocks.executeAll 按固定顺序加锁 |
| 3 | 消费消息要自己写订阅、解析、去重 | 事件 starter 只有发布端 | 新增 IntegrationEventConsumer,订阅和分发由 DDK 完成 |
| 4 | 同一个订单的消息到达时顺序是反的 | 文档写了「同 key 保持顺序」,实际提交后的投递是并发的 | 更正文档,给出单线程投递的配置,应用改成不依赖顺序 |
| 5 | 排除 Redis 自动配置后应用起不来 | Redis starter 创建模板时强制要求连接工厂 | 模板改为延迟创建,缓存 starter 的「只用本地缓存」得以生效 |
| 6 | 链路一条也导不出去,应用却显示健康 | rocketmq-client 带进来的旧版 okio 被 Maven 选中,导出器在运行时报 NoSuchMethodError | BOM 统一 okio 的版本 |
| 7 | 下单的链路只有一个 HTTP span | 链路上下文没有被带进投递事件的线程池,也没有写进消息 | 事件 starter 观测发布和消费并传递上下文;链路 starter 注册上下文传播 |
| 8 | 错误消息里的订单号是 2,108,448,507,731,107,840 | MessageFormat 默认给数字加千分位 | 数字按原样写出 |
第 6、7 两项修完之后,一次下单在追踪后端里是一条完整的链路,跨三个上下文和三条消息,应用里没有任何链路相关的代码:
http post /orders
└─ mall-order-events publish 「已下单」
└─ mall-order-events process 库存:预占
└─ mall-inventory-events publish 「库存已预占」
└─ mall-inventory-events process 订单:等待支付
└─ mall-order-events publish 「等待支付」
└─ mall-order-events process 支付:建立支付单上面的 span 名称取自一次实际运行(MySQL 8.4、Redis 7、RocketMQ 5.5.0、grafana/otel-lgtm 0.35.0)在 Tempo 里的查询结果,这次运行没有归档。
还有一个和 DDK 无关、但照着示例搭环境时容易踩到的问题:RocketMQ 依赖自动创建主题时,消费者在主题出现之前订阅,要等下一次路由刷新才能发现它。本机测得第一笔订单将近一分钟才走到等待支付,在 broker 启动时建好主题之后约一秒。
五、订单搜索:一个读模型
0.8.0 起。
顾客可以按商品名称搜自己的订单。这件事不适合放在订单表上做,所以另有一张为搜索准备的宽表,每个订单一行。它由 Projection Starter 维护:
下单 / 库存确认 / 支付 / 取消(订单的事务)
保存订单
标记「这个订单的搜索数据需要刷新」 ← 和订单在同一个事务里提交
提交
└─ 后台:读取订单现在的状态 → 覆盖宽表里的那一行四种事件做的是同一件事,所以事件乱序、重复都不会让读模型出错;读模型丢了可以调用一个接口整体重建。订单上下文只认识 OrderSearchIndex 这个端口,默认实现是关系库里的 LIKE。
「换成搜索引擎只需替换一个类」这句话后来在 main 分支上(0.8.0 之后)验证过:端口多了一个 Elasticsearch 的实现,用 search profile 切换,投影、接口、MCP 工具都没有改动,重建接口顺带充当了把已有订单灌进新存储的迁移步骤。调用方能感觉到的差别有两点:Elasticsearch 是近实时的,写入后约一秒才搜得到;重建不会删除已经不存在的订单对应的文档。
这是投影 starter 的第一个真实使用者。用下来有两点记录:
- starter 本身不需要改动,投影里没有任何和具体事件相关的逻辑。
- 重建要遍历全部订单号,通用仓储没有提供这个能力,应用里自己加了一个按主键翻页的方法。再有一个读模型需要同样的东西时,它应该进通用仓储。
六、还没有解决的
0.6.0 发布时还有一项「定时任务 starter 的任务锁只有 Redis 实现」,应用里用一个总是放行的替身绕过。0.7.0 已经补上:Job Starter 可以把锁放在进程内存里,应用里的替身已删除。
- 重新投递的事件会开启新链路。 链路上下文没有随事件发布记录保存。
- 在事务之外发布集成事件不会被投递。 0.6.0 里没有任何提示;0.7.0 起在发布处直接报错,见 Event Starter 的「必须在事务里发布」。
- 加锁的消费方不能用声明式的去重。 消费方一旦要加锁,就要关掉
idempotent(),自己调用IdempotentConsumer。3.3 的顺序本身可以用一条语句写出来,而且 0.7.0 起顺序写反会直接报错,见 Concurrency Starter。 - 导出到追踪后端没有自动化测试。 链路跨消息的接续有(事件 starter 里用真实的 RocketMQ 验证),「真的导出到了后端」只手工验证过。
七、验证方式
应用有 54 个测试(0.8.0),随仓库的 CI 在 JDK 21 和 25 上运行。
| 测试 | 环境 | 验证什么 |
|---|---|---|
OrderTest、StockTest、PaymentTest | 无 | 聚合的规则和状态流转,用 ddk-test 的领域断言 |
OrderApiTest、InventoryApiTest、PaymentApiTest | H2,进程内转发 | 接口、三个上下文的联动、超时关单、付款与取消交叉后的退款 |
SecureProfileTest | H2,secure profile | 未认证、错误口令、冒充请求头、越权读取和取消、顾客调用管理操作、MCP 参数冒充 |
PaymentRecoveryTest | H2,进程内转发 | 故障注入:扣款后响应丢失、扣款期间取消订单、两者同时发生、渠道拒绝;对账后每笔扣款要么记账要么退回,只退一次 |
OrderSearchTest | H2,进程内转发 | 读模型跟随订单的整个生命周期、按商品名搜索、重建 |
ReadSideTest、McpToolsTest、ObservabilityTest | H2 | 缓存与失效、接口文档分组、MCP 工具、响应头里的 traceId 和消息指标 |
StockConcurrencyIntegrationTest | MySQL、Redis 容器 | 20 个订单同时抢 3 件库存,恰好 3 个成功 |
MallFlowIntegrationTest | MySQL、Redis、RocketMQ 容器 | 整条链路、重复投递只处理一次、乱序后的自我纠正、库存缓存在 Redis 里的读写 |
ArchitectureTest | 无 | 分层、上下文互不引用、定时任务和 MCP 工具的位置 |
相关文档
- Event Starter:集成事件的发布、消费、顺序、幂等和链路
- Concurrency Starter:聚合锁、防重、限流
- Cache Starter、Job Starter、MCP Starter、Tracer Starter
- 测试支持 ddk-test