Skip to content

参考应用:商城 ddk-mall ​

用一个完整的业务场景把 DDK 的各个 starter 放在一起用:下单、预占库存、支付、扣减,超时未支付则关单。它是示例,也是 DDK 的验收场:下文列出的八个问题,都是 starter 组合使用之后才暴露的。

源码在仓库的 ddk-examples/ddk-mall。它从 0.6.0 起随仓库发布,0.5.0 里没有这个应用。

一、它是什么 ​

订单、库存、支付三个限界上下文,部署成一个应用(模块化单体)。每个上下文占一个顶层包,内部是四层结构;上下文之间不互相调用,只通过集成事件协作。

text
com.example.mall
├── platform     各上下文共用的技术代码:顾客身份、接口文档分组
├── order        订单:下单、取消、超时关单
├── inventory    库存:预占、释放、扣减;订单在这里只是一个编号
└── payment      支付:支付单、模拟渠道、退款

ArchitectureTest 除了 DDK 的分层规则,还检查三个上下文的包互不引用。支付上下文需要的应付金额和支付期限随消息带过去,它不回头查订单。

没有做的部分:前端页面、真实支付渠道、真正的身份提供方(默认 profile 的顾客身份放在请求头 X-Customer-Id 里;secure profile 用演示用户登录,见 3.5)、多租户。

二、怎么跑 ​

不依赖任何外部组件,用内存 H2 启动:

bash
mvn -pl ddk-examples/ddk-mall -am install -DskipTests
mvn -pl ddk-examples/ddk-mall spring-boot:run

连接真实的 MySQL、Redis 和 RocketMQ:

bash
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

两种方式跑的是同一份业务代码,差别只在基础设施:

默认 profilecompose profile
数据库内存 H2MySQL 8.4
库存锁进程内的锁,只适合单实例Redis,多个实例之间互斥
定时任务的锁进程内存里的锁,只适合单实例Redis
缓存只有进程内的一级商品两级,库存只用 Redis
上下文之间的消息DDK 的进程内转发,进程退出就丢事件发布记录加 RocketMQ,失败可重投

冒烟命令、接口文档地址和 MCP 的接入方式见应用的 README。

三、上下文怎么协作 ​

mermaid
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 互斥、预占和结果消息一起提交、同一条消息重复投递时只处理一次。三者的嵌套顺序是固定的:

text
锁住涉及的 SKU(按固定顺序,避免死锁)
  开启事务
    登记「这条消息处理过了」    ← 和业务修改一起提交、一起回滚
    预占库存,登记结果消息
  提交事务
  让这些 SKU 的缓存失效          ← 提交之后,释放锁之前
释放锁

锁在事务外面,是因为释放锁时数据必须已经提交;缓存在提交之后失效,是因为提交之前失效的话,并发的读请求会立刻把旧值放回缓存。

3.4 请求支付渠道不靠分布式事务 ​

扣款是外部副作用,不随本地事务回滚。0.8 的做法是「一个事务里扣款再保存」,只靠渠道按支付单标识去重,有两个漏洞:扣款成功后进程崩溃,支付单停在待支付、钱不退;扣款进行中订单被取消,支付单关了、钱也扣了。0.9 改成先登记、再扣款、事后对账:

text
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:

text
请求 → 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 选中,导出器在运行时报 NoSuchMethodErrorBOM 统一 okio 的版本
7下单的链路只有一个 HTTP span链路上下文没有被带进投递事件的线程池,也没有写进消息事件 starter 观测发布和消费并传递上下文;链路 starter 注册上下文传播
8错误消息里的订单号是 2,108,448,507,731,107,840MessageFormat 默认给数字加千分位数字按原样写出

第 6、7 两项修完之后,一次下单在追踪后端里是一条完整的链路,跨三个上下文和三条消息,应用里没有任何链路相关的代码:

text
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 维护:

text
下单 / 库存确认 / 支付 / 取消(订单的事务)
  保存订单
  标记「这个订单的搜索数据需要刷新」      ← 和订单在同一个事务里提交
提交
  └─ 后台:读取订单现在的状态 → 覆盖宽表里的那一行

四种事件做的是同一件事,所以事件乱序、重复都不会让读模型出错;读模型丢了可以调用一个接口整体重建。订单上下文只认识 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、PaymentApiTestH2,进程内转发接口、三个上下文的联动、超时关单、付款与取消交叉后的退款
SecureProfileTestH2,secure profile未认证、错误口令、冒充请求头、越权读取和取消、顾客调用管理操作、MCP 参数冒充
PaymentRecoveryTestH2,进程内转发故障注入:扣款后响应丢失、扣款期间取消订单、两者同时发生、渠道拒绝;对账后每笔扣款要么记账要么退回,只退一次
OrderSearchTestH2,进程内转发读模型跟随订单的整个生命周期、按商品名搜索、重建
ReadSideTest、McpToolsTest、ObservabilityTestH2缓存与失效、接口文档分组、MCP 工具、响应头里的 traceId 和消息指标
StockConcurrencyIntegrationTestMySQL、Redis 容器20 个订单同时抢 3 件库存,恰好 3 个成功
MallFlowIntegrationTestMySQL、Redis、RocketMQ 容器整条链路、重复投递只处理一次、乱序后的自我纠正、库存缓存在 Redis 里的读写
ArchitectureTest无分层、上下文互不引用、定时任务和 MCP 工具的位置

相关文档 ​

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权