01 把边界钉死了。这篇顺着一张单走:从 Idempotency-Key 进门,到库存结果回来,再到扣余额或把货退回去。

一把钥匙:接口返回 202 只表示「店铺记下了」,不表示货已经留下。页面和 curl 都要再查,直到状态离开 PENDING 或 RELEASING。

订单状态只在店铺库里推进

一、下单:先定价,再进本地事务

OrderController.place 拒绝空的或超过 80 字符的幂等钥匙。然后 OrderService.place:

  1. 用行项目算出 fingerprint。同一用户同一把钥匙若已有订单,指纹对得上就原样返回(HTTP 200),对不上抛冲突。
  2. catalogService.get 按商品库现价做快照。店铺库里的 order_lines 存的是当时的名字和单价,后面改价不影响这张单。
  3. 本地事务里 Order.place(..., PENDING) + 写入 STOCK_DEDUCTION_REQUESTED 发件箱。新单返回 202。

真正的实现是:

// ink.workshop.order.OrderService.place
Optional<Order> existing = orderRepository.findByUserIdAndIdempotencyKey(userId, idempotencyKey);
if (existing.isPresent()) {
  return new PlaceResult(reuse(existing.get(), fingerprint), false);
}
List<LineSnapshot> snapshots = price(request.lines());
Order saved = transactions.execute(status -> savePending(...));
return new PlaceResult(response(saved, snapshots), true);

savePending 在同一笔事务里写订单和 outbox。崩溃发生在提交前,两边都没有;发生在提交后、消息发出前,发件箱还在,定时任务会再投。这就是不在接口里直接 rabbitTemplate.send 的原因。

唯一索引 (user_id, idempotency_key) 兜并发:两个请求同时插,一个成功,另一个吃 DataIntegrityViolationException,再读已有行当重放。

二、两份店铺抢同一张发件箱

8083 和 8084 共用 ink_order。如果两个进程都把同一行发到 RabbitMQ,库存会看到重复扣减请求——后面靠库存侧幂等也能挡住,但店铺不该先制造噪声。

OutboxClaims.claimNext 用原生 SQL:

select id from outbox
where published_at is null
  and (claimed_at is null or claimed_at < :staleBefore)
order by id
for update skip locked
limit 1

抢到的进程标记 claimed_at,发出去再标 published_at。claim 超过 30 秒视为过期,另一份店铺可以接手。这是两实例才出现的问题;单进程演示店写个 findTop20ByPublishedAtIsNull 就够了,墨坊偏要把这份痛留在代码里。

路由很窄:STOCK_DEDUCTION_REQUESTED 走 deduct,STOCK_RELEASE_REQUESTED 走 release。发件箱里出现别的类型直接抛错——不默默丢掉。

库存进程的发件箱更简单:只有一份库存,findTop20ByPublishedAtIsNullOrderByIdAsc 即可。同一套「先落库再投递」,实现按进程数减配。

三、状态机:PENDING 只接受两种结果

Order.applyStockResult 只允许 PENDING → RESERVED | REJECTED。已经离开 PENDING 的结果消息是空操作。StockResultListener 听到 RELEASED 则走 markCancelled,而且只从 RELEASING 进去。

状态怎么进来用户能做什么
PENDING下单事务提交等;再查
RESERVED库存 APPLIED付款
REJECTED库存不够结束
PAID余额扣成功结束
RELEASING余额不够,已写释放消息等退库存
CANCELLED库存已加回结束

付款同样要钥匙。pay 先 lockByIdAndUserId,同一把 payment_key 直接返回当前状态。不是 RESERVED 就 OrderNotPayableException。user.debit 成功则 PAID;失败则 markReleasing,发件箱多一行 STOCK_RELEASE_REQUESTED,接口再给 202。

店面 web/src/api.js 只认网关,轮询自己的订单列表。刷新之后,还在扣库存或退库存的单会继续等到结果——因为真相在库里,不在浏览器内存里。

四、这条生命故意不做什么

  • 不在下单时扣余额。 货没留下就扣钱,补偿更脏。
  • 不在下单时同步等库存。 接口时限绑死在库存进程上,店铺无法 202。
  • 不让第二份店铺再插一张「看起来一样」的单。 钥匙 + 指纹 + 唯一索引,三道。

下一篇 03 · 库存为什么能重试 把镜头转到 8081:条件更新、fingerprint、回补。