上一篇把名额收进了一张预留表:预留有效期内名额归该用户,到期由释放任务归还。本篇在它上面加一层订单和一个假支付渠道,处理三件预留层回答不了的事:调用渠道超时,钱到底扣了没有;渠道的回调重复、乱序、迟到时,订单状态会不会被改坏;支付成功的消息到达时,预留已经过期并被别人买走,这笔钱怎么办。代码在 examples/flash-sale/src/lab/flash/order,库存层原样复用第 28 篇的 lab.flash.reserve.Inventory,没有改动。全部金额是模拟积分,订单固定 1990 分,SKU 为 sku-lab-01,用户为 user-0042 这类编号,渠道替身只监听本机端口。

需求与范围

需求写成可以逐条断言的形式:

  1. 订单状态只能沿状态机迁移;任何迁移都以“当前状态等于前置状态”为条件,不满足即拒绝,不覆盖。
  2. 渠道调用没有拿到确定应答时,订单不得判为失败,也不得归还库存;结果必须通过查询或关单收敛。
  3. 同一笔支付的成功通知无论到达几次、以什么顺序到达,最多造成一次状态迁移、一次库存确认和一条下游事件。
  4. 成功通知到达时若预留已被释放:能重新占到名额就确认,占不到就退款;退款按固定键幂等,重试不产生第二笔。
  5. 订单终态与下游事件在同一事务内写入;中继至少投递一次,消费者按事件编号去重。
  6. 恢复完成后,渠道账本、订单、预留三方可以逐单对上:PAID 订单数等于 CONFIRMED 预留数,渠道净额等于 PAID 订单金额之和,库存守恒式为 0。

排除项:用户主动退款与部分退款、清结算与对账文件、回调签名、多实例订单服务、真实消息中间件、吞吐与延迟。

教学规模假设:单 SKU 库存 1 到 40 件,单个用例同时竞争的线程 10 到 40 个;订单服务调用渠道的超时为 300ms,故障注入的延迟为 400 到 1500ms;预留有效期 60 秒,实验通过把 expires_at 改到过去来模拟到期。这些数字只用于构造实验输入。

接口与渠道替身契约

订单服务对外的入口:

1
2
3
4
5
6
POST /orders            request_key, user, sku, amount
201 outcome=CREATED order_id=ord-0001 | 200 REPLAY | 409 SOLD_OUT|USER_LIMIT | 422 KEY_REUSED
POST /orders/{id}/pay 200 status=PAID|FAILED|UNKNOWN|REFUNDING|REFUNDED | 409 outcome=REJECTED
GET /orders/{id} 200 status=... version=... reservation=...
POST /callbacks/payment notify_id, merchant_order, kind=CHARGED|DECLINED|REFUNDED, amount, txn
处理完成返回 200,渠道据此停止重试;数据库异常返回 500

下单在一个事务里插入订单并调用 Inventory.reserveInTx,预留不成功就回滚整笔事务,售罄的请求不留订单行。

假支付渠道 FakeChannel 是本章自定契约的 HTTP 替身:

1
2
3
4
5
6
POST /charge      merchant_order, amount, notify_url   按商户订单号幂等;同号不同额 422;已关单 409 result=CLOSED
GET /charges/{mo} status=CHARGED|DECLINED|CLOSED|REFUNDED|NOT_FOUND
POST /close merchant_order 无记录则登记 CLOSED,此后同号扣款一律拒绝
POST /refund merchant_order, refund_key, amount 按 refund_key 幂等,只支持全额
GET /statement 账本:CHARGE / REFUND 行
故障:先记账再拖延应答;请求滞留后才到达;首次退款应答拖延;回调重复、延迟或扣住由测试决定顺序

RFC 9110 §9.2.2 规定,POST 这类非幂等方法,客户端除非知道语义上幂等或能确认原请求未生效,否则不应自动重试。渠道按商户订单号去重,提供了第一种依据;查询与关单提供了第二种。IETF 草案 draft-ietf-httpapi-idempotency-key-header-07 的 §2.6、§2.7 建议:重复请求返回先前结果,同键不同请求体返回 422,原请求仍在处理时返回 409。替身遵守前两条,第三条改为在一把锁下串行处理。该草案尚未成为 RFC。

替身与真实渠道的差别要先写清:账本在进程内存里,回调不签名,关单与扣款在同一把锁下互斥,查询总是读到最新状态。本章“关单之后迟到的扣款一定被拒”这一保证,完全依赖替身契约里的这一条;换成真实渠道时,必须先在其文档里找到对应承诺。

数据模型

订单表 orders 保存金额、状态、版本号和 reservation_key,后者指向第 28 篇 reservation 表的 request_key,初始为 ord-0001/r1,重新占用时换成 /r2。另有三张辅助表:order_transition 记录每次迁移的前后状态、版本与原因;payment_notification 记录每份回调及其效果;outbox 存放待投递的订单事件。下游消费者 Fulfillment 有自己的 ful_inbox(事件编号主键)与 fulfillment(每单最后版本与发货次数),另有 ful_received 记录每次到达及其效果(自动提交,用于统计投递次数)。为了让实验只依赖一个数据库实例,这些表放在同一个库里,访问路径彼此独立。

状态机

图 29-1:订单状态机(方括号内为前置条件,注释框为拒绝或不迁移的结果)

stateDiagram-v2
    [*] --> CREATED: create [预留 HELD,同事务]
    CREATED --> PAYING: pay [status=CREATED],先提交再调用渠道
    CREATED --> CANCELLED: cancel 或预留到期 [status=CREATED],同事务归还 HELD
    CREATED --> PAID: 成功通知先于 pay 应答到达 [预留仍为 HELD]
    CREATED --> REFUNDING: 成功通知到达但名额已失
    PAYING --> UNKNOWN: 超时 / IO 错误,不归还库存
    PAYING --> PAID: 成功 [确认原预留,或重新占用后确认]
    UNKNOWN --> PAID: 查询/回调成功 [同上]
    PAYING --> REFUNDING: 成功但名额已失
    UNKNOWN --> REFUNDING: 成功但名额已失
    PAYING --> FAILED: 拒付或关单 CLOSED,同事务归还
    UNKNOWN --> FAILED: 拒付或关单 CLOSED,同事务归还
    FAILED --> REFUNDING: 成功通知(异常补偿)
    CANCELLED --> REFUNDING: 成功通知(异常补偿)
    REFUNDING --> REFUNDED: 退款确认
    note right of PAID: 再次 pay / cancel 返回 REJECTED;成功通知记 DUPLICATE
    note right of REFUNDED: 迟到的 CHARGED 记 DUPLICATE,REFUNDED 通知记 IGNORED

所有迁移都经过同一个函数,条件写在 UPDATE 的 WHERE 里,终态同时插入 outbox 事件:

1
2
3
4
5
6
7
"UPDATE orders SET status = ?, version = version + 1, updated_at = now() "
+ "WHERE order_id = ? AND status = ? RETURNING version, user_id, sku, amount"
...
if (TERMINAL.contains(to)) {
Db.update(c, "INSERT INTO outbox (order_id, event_type, order_version, payload) VALUES (?,?,?,?)",
id, "ORDER_" + to, v, payload);
}

处理通知前先 SELECT ... FOR UPDATE 锁住订单行。PostgreSQL 16 文档 13.3.2 写明,被 FOR UPDATE 锁住的行,其他事务的 UPDATE 与 SELECT FOR UPDATE 要等到当前事务结束;13.2.1 又说明等待结束后,后来者拿到的是对方提交后的新版本。同一订单上的回调、查询结果和取消因此串行执行,每一个都基于最新状态判断。

C29-02 覆盖拒绝路径:8 个线程同时支付同一订单,本次结果 {PAID=1, REJECTED PAYING=7},渠道只收到 1 次扣款请求;PAID 再支付、PAID 取消、CANCELLED 再支付、重复取消都返回 REJECTED;金额不符的成功通知记为 AMOUNT_MISMATCH,订单仍为 CREATED;售罄请求不留订单行。C29-03 让支付与取消在 10 张订单上成对并发,本次 3 张 PAID、7 张 CANCELLED,扣款请求数恰好为 3,每张 CANCELLED 订单在渠道都查不到记录。

  • 成立条件:所有写订单状态的代码都经过带前置状态的条件 UPDATE,并在判断前锁住订单行。
  • 观察位置:C29-06 的“只迁移一次”断言(order_transition 中迁移到 PAID 的行数);C29-02、C29-03 的断言。
  • 失败边界:任何一处绕过前置条件直接写状态,C29-16 的对照会说明后果。

支付请求:先落 PAYING,再出门

pay 分三步,各自独立提交:

1
2
3
4
5
6
7
8
9
10
11
12
int amount = beginPay(c, id);                 // 事务 1:CREATED → PAYING,提交
if (amount < 0) return "REJECTED " + status(c, id);
try {
r = channel.charge(id, amount, notifyUrl, timeout);
} catch (HttpTimeoutException e) {
markUnknown(c, id, "charge timeout"); // 事务 2:PAYING → UNKNOWN
return status(c, id);
} catch (IOException e) {
markUnknown(c, id, "charge io error"); // IO 错误同样记 UNKNOWN
return status(c, id);
}
applyChannelResult(c, id, r.get("result"), amount, r.get("txn"), "charge reply", timeout); // 事务 3

把 PAYING 放在调用之前提交,是为了让“可能已经扣款”这个事实先落库。若进程在扣款之后、记账之前死掉,数据库里至少留着一张 PAYING 订单,恢复任务能找到它;若先调用再写状态,崩溃后连“发起过支付”都无从得知。Java 21 的 HttpRequest.Builder.timeout 只规定期限内收不到响应时抛出 HttpTimeoutException;这个异常无法区分请求是没送到、还是对端已经处理完而应答丢了,所以超时只能记为 UNKNOWN。

图 29-2:应答丢失时的收敛过程(C29-04 的调用顺序)

sequenceDiagram
    participant O as 订单服务
    participant DB as PostgreSQL
    participant P as 假支付渠道
    O->>DB: CREATED→PAYING 提交
    O->>P: POST /charge ord-0001
    P->>P: 记账 CHARGED,回调扣住
    Note over O: 300ms 未收到应答,HttpTimeoutException
    O->>DB: PAYING→UNKNOWN(预留仍为 HELD)
    O->>P: 恢复任务 GET /charges/ord-0001
    P-->>O: status=CHARGED
    O->>DB: 确认预留,UNKNOWN→PAID,写 outbox
    P->>O: 扣住的回调后到
    O->>DB: 记 DUPLICATE PAID,不迁移
    P-->>O: 1200ms 后原请求的应答(调用方早已放弃,无人接收)

C29-04 的迁移记录:

1
timeline ord-0001: [-->CREATED v1 create] [CREATED->PAYING v2 pay] [PAYING->UNKNOWN v3 charge timeout] [UNKNOWN->PAID v4 query]

收敛前对账给出 mismatches=0 pending=1:UNKNOWN 订单列为待收敛,不算差异,库存也没有被提前归还。

C29-01 用 HTTP 走完正常路径:首次下单 201,同键重放 200 并返回同一个 ord-0001;支付的同步应答为 CHARGED,订单直接 PAID;渠道随后发来的两份回调都记为 DUPLICATE PAID。另一张订单被渠道拒付,转 FAILED 并在同一事务里归还预留,库存从 3 回到 4。

NOT_FOUND 不等于没扣款

查询得到 NOT_FOUND 只说明渠道此刻没有记录。请求可能还在途中,稍后到达照样扣款。恢复任务因此先关单,以关单的结果为准:

1
2
3
4
5
6
7
8
Http.Resp q = ch.query(id, timeout);
String st = q.get("status");
if (st.equals("NOT_FOUND")) {
Http.Resp cl = ch.close(id, timeout);
st = cl.get("status"); // CLOSED,或在查询与关单之间刚刚到达的 CHARGED/DECLINED
svc.applyChannelResult(c, id, st, amount, cl.get("txn"), "close", timeout);
...
}

C29-05 让扣款请求在途中滞留 1500ms。订单 300ms 后进入 UNKNOWN,恢复任务查询得到 NOT_FOUND,关单得到 CLOSED,订单转 FAILED 并归还库存;1500ms 后迟到的请求到达渠道,遇到 CLOSED 被拒,账本中没有扣款行;用例随后再发一次同号扣款,渠道应答 result=CLOSED。这一保证完全依赖替身契约中“关单后同号扣款一律拒绝”这一条。

两个负向对照说明这两步都不能省。C29-14 把超时当失败,立即归还库存,另一个用户随即买走这个名额:

1
2
mismatch ord-0001 FAILED CHARGED_BUT_FAILED charged=1990 refunded=0 reservation=RELEASED
mismatch GLOBAL channel_net=3980 != paid_sum=1990

C29-15 查询到 NOT_FOUND 就判失败、不关单,迟到的请求随后扣款成功,对账出现同样的 CHARGED_BUT_FAILED。

  • 成立条件:渠道按商户订单号幂等,并承诺关单后拒绝同号扣款;关单与扣款在渠道侧互斥。
  • 观察位置:渠道 /charges/{mo} 的最终状态;Reconcile 的 CHARGED_BUT_FAILED 规则。
  • 失败边界:渠道没有关单能力,或关单异步生效、查询有延迟,NOT_FOUND 之后只能继续等待或人工处理,不能自动判失败。

重复、乱序与迟到的回调

成功通知不论来自同步应答、查询还是回调,都进入同一个函数。它先锁住订单,按当前状态决定效果:

1
2
3
4
5
6
7
8
9
10
11
Order o = lock(c, id);
if (o.amount() != amount) return "AMOUNT_MISMATCH";
switch (o.status()) {
case "PAID", "REFUNDING", "REFUNDED" -> { return "DUPLICATE " + o.status(); }
case "FAILED", "CANCELLED" -> { startRefund(c, o, txn, cause + " after " + o.status()); return "REFUNDING_ANOMALY"; }
default -> { } // CREATED / PAYING / UNKNOWN
}
if (Inventory.confirmHeldInTx(c, o.reservationKey())) {
transition(c, id, o.status(), "PAID", cause);
return "PAID";
}

C29-06 把同一份 CHARGED 回调用 16 个线程同时投给订单服务,结果是 effects {DUPLICATE PAID=15, PAID=1},outbox 中只有一条 ORDER_PAID,order_transition 中只有一次迁移到 PAID。第二部分构造乱序:订单在 UNKNOWN 期间预留到期、名额被别人占走,恢复任务查询后走退款,订单变为 REFUNDED;随后把扣住的两份通知倒序发送,先到的 REFUNDED 记为 IGNORED REFUNDED,后到的 CHARGED 记为 DUPLICATE REFUNDED,终态不变。

C29-16 去掉前置条件,让迟到的 CHARGED 直接把状态写成 PAID。已退款的订单被改回 PAID,对账给出三条差异:PAID_BUT_REFUNDED、confirmed=0 != paid=1、channel_net=0 != paid_sum=1990。

预留到期与补偿

第 28 篇的到期释放只看预留表,不知道订单是否正在支付。本章选择保留这一点:释放任务不等待慢支付,名额尽快回到可售状态,代价是成功消息可能晚于释放到达。处理规则写在 onChargedInTx 的后半段:原预留仍为 HELD 时直接确认;已被释放时用新键 /r2 重新占用一次,占到就确认;占不到就转 REFUNDING,refund_key 固定为 rf-<order_id>。

1
2
3
4
5
6
7
8
Inventory.Hold h = Inventory.reserveInTx(c, key, o.user(), o.sku(), ttlMillis);
if (h.outcome().equals("HELD") && Inventory.confirmHeldInTx(c, key)) {
Db.update(c, "UPDATE orders SET reservation_key = ?, reserve_attempt = ? WHERE order_id = ?", key, attempt, id);
transition(c, id, o.status(), "PAID", cause + " re-reserved " + key);
return "PAID_REHELD";
}
startRefund(c, o, txn, cause + " stock lost (" + h.outcome() + ")");
return "REFUNDING";

这套做法的概念出处是 Garcia-Molina 与 Salem 1987 年的论文《Sagas》:长事务拆成一串可以与其他事务交错的事务,每一步配一个补偿事务;补偿在语义上撤销该步的效果,不保证把数据库恢复到该步之前的状态,其他事务也可能已经看到部分执行的结果。放到本章:扣款是无法撤回的外部步骤,它的补偿是退款;预留被释放后,名额可能已经卖给别人,相当于其他事务看到了部分执行的结果,所以处理只能是再占一次或者退款,无法把原来那个名额“还原”给原订单。论文第 4 节把失败后的处理分为补偿已执行步骤的 backward recovery 与补完剩余步骤的 forward recovery,退款与重新占用可以分别类比这两个方向。本章只借用这一概念,补偿的正确性由下面的实验支撑。

C29-07 构造两个订单 A、B,都在 UNKNOWN 时到期,第三个用户随后占走 1 件,只剩 1 件:

1
2
timeline ord-0001: ... [PAYING->UNKNOWN v3 charge timeout] [UNKNOWN->PAID v4 callback ntf-0001 re-reserved ord-0001/r2]
timeline ord-0002: ... [PAYING->UNKNOWN v3 charge timeout] [UNKNOWN->REFUNDING v4 callback ntf-0002 stock lost (SOLD_OUT)] [REFUNDING->REFUNDED v5 refund reply]

B 的首次退款应答被拖延到超时,订单停在 REFUNDING,此时渠道其实已经退款。恢复任务按同一个 refund_key 重发,渠道识别为重放并立即应答,账本中 B 只有一笔退款。之后到达的 REFUNDED 通知与测试构造的一份迟到 CHARGED 都不改变 B 的状态。

C29-08 把竞争放大:20 张 UNKNOWN 订单的预留全部到期,20 份成功回调、2 个到期释放任务和 10 个新用户的下单支付同时开始。本次结果为原订单 PAID_REHELD=10, REFUNDING=10,新用户 PAID=10,最终 {PAID=20, REFUNDED=10},对账 channel_net=39800 paid_sum=39800 confirmed=20 paid=20 drift=0。各类数目随调度变化,稳定的是断言:PAID 不超过库存 20,三方对账无差异。

C29-17 是这一节的负向对照:成功回调调用 confirmHeldInTx 但不看返回值,直接写 PAID。10 件库存、10 张原订单到期后被 10 个新用户买走,原订单再收到成功回调,PAID 订单变成 20 张,confirmed=10 != paid=20,超卖由对账发现。

  • 成立条件:确认与重新占用都在订单行锁内、与状态迁移同事务;只有 confirmHeldInTx 返回 true 才写 PAID;退款键对每张订单固定。
  • 观察位置:orders.reservation_key 指向的预留为 CONFIRMED;渠道账本中每单至多一笔 REFUND。
  • 失败边界:渠道退款不按键幂等;或重新占用时绕过第 28 篇的条件扣减。

Outbox 与消费者去重

订单终态与 outbox 事件在 transition 中同事务写入。PostgreSQL 文档 3.4 把事务描述为全有或全无,中间状态对并发事务不可见,因此不会出现“订单 PAID 却没有事件”或反过来的情况。中继在一个事务里领取、投递、标记:

1
2
SELECT event_id, order_id, event_type, order_version FROM outbox
WHERE published_at IS NULL ORDER BY event_id LIMIT ? FOR UPDATE SKIP LOCKED

SELECT 参考页说明 SKIP LOCKED 跳过不能立即加锁的行,给出的视图不一致,不适合一般用途,可用于多个消费者访问队列式表。C29-09 的两个中继各发送 20 条,合计 40 条,消费者恰好收到 40 次。中继在消费者返回 200 之后、提交标记之前崩溃时,标记随事务回滚,事件会再投一次,投递语义是至少一次。消费者用两道检查吸收重复与乱序:

1
2
3
4
INSERT INTO ful_inbox (event_id) VALUES (?) ON CONFLICT DO NOTHING   -- 影响 0 行即 DUPLICATE
INSERT INTO fulfillment (...) VALUES (...)
ON CONFLICT (order_id) DO UPDATE SET ...
WHERE fulfillment.last_version < EXCLUDED.last_version -- 不满足即 STALE

INSERT 参考页规定 DO UPDATE 的 WHERE 只更新条件为真的行。C29-09 中把已投递的事件再发一次得到 DUPLICATE,发一个更旧版本的事件得到 STALE,发货次数不变。

C29-12 用独立 JVM 运行中继,消费者已确认 3 条后对其 kill -9,退出码 137。等它的数据库会话消失后,outbox 中 3 条仍未发布;重启中继重投 3 条,消费者效果为 {APPLIED=3, DUPLICATE=3},每单只发货一次。C29-18 关掉消费者去重重复同样的过程,3 单发货 6 次。

  • 成立条件:终态迁移与事件插入在同一事务;中继只在收到 200 后标记;消费者先登记 event_id 再执行效果,且只接受更大的版本。
  • 观察位置:outbox.published_at、ful_received.effect、fulfillment.ship_count;C29-09、C29-12 的断言。
  • 失败边界:消费者的效果与 inbox 登记不在同一事务;事件没有单调版本;或中继在投递前就标记已发布(那样会变成至多一次)。

C29-19 演示没有 outbox 的双写:子进程先提交 PAID,再准备直接通知消费者,两步之间被杀。渠道、订单、预留三方对账完全一致,消费者却永远收不到事件,中继也没有可重发的记录。三方对账发现不了这种缺失,补救要靠事件完整性检查:用例中 PAID orders without ORDER_PAID outbox event: 1。

崩溃恢复

C29-10 在子进程里完成 CREATED→PAYING 与渠道扣款,打印 STAGE 后被 kill -9。订单停在 PAYING,渠道已是 CHARGED。恢复任务只处理超过陈旧阈值的 PAYING:阈值 60 秒时什么也不做,因为这样的订单可能仍在正常进行;阈值取 0 时查询得到 CHARGED,订单转 PAID,崩溃前发出的回调后到,记为重复。

C29-11 让子进程在成功通知的事务里完成预留确认、PAID 迁移和 outbox 插入,提交前停住。其他会话此时看到的仍是 UNKNOWN。kill -9 之后,按协议文档 55.2.9,异常断开的连接上未结束的事务一律回滚:订单仍为 UNKNOWN,预留仍为 HELD,outbox 中没有孤立事件;恢复任务重做一次,outbox 恰好一条。

恢复流程可以反复执行,与回调并发也安全:

  1. 选出 UNKNOWN、REFUNDING 以及超过陈旧阈值的 PAYING 订单。
  2. UNKNOWN 与 PAYING 先查询;CHARGED 或 DECLINED 直接落到状态机,NOT_FOUND 时关单,以关单结果为准。
  3. REFUNDING 用固定 refund_key 重发退款。
  4. CREATED 且预留已到期或被释放的订单取消,同事务归还仍为 HELD 的预留。
  5. 排空 outbox,再运行对账;待收敛数为 0 时才比较渠道净额。

对账

表 29-1:Reconcile 的逐单规则(渠道账本 × 订单 × 当前预留)

订单状态 渠道账本 当前预留 违反时的标记
PAID 扣款 = 金额,无退款 CONFIRMED PAID_WITHOUT_CHARGE / PAID_BUT_REFUNDED / PAID_WITHOUT_CONFIRMED_RESERVATION
REFUNDED 扣款 = 退款 = 金额 非 HELD、非 CONFIRMED REFUNDED_LEDGER_MISMATCH / REFUNDED_BUT_RESERVATION_…
FAILED、CANCELLED 净额为 0 非 HELD、非 CONFIRMED CHARGED_BUT_… / …BUT_RESERVATION…
CREATED 无扣款 HELD CHARGED_BUT_CREATED / CREATED_WITHOUT_HELD
PAYING、UNKNOWN、REFUNDING 不判定 不判定 列为待收敛

全局再查五项:同一订单多于一条扣款;渠道有记录而订单不存在;CONFIRMED 预留数等于 PAID 订单数;库存守恒式为 0;无待收敛时渠道净额等于 PAID 金额之和。

C29-13 用固定种子 29 给 24 张订单分配故障:3 张拒付、4 张应答丢失、3 张请求滞留、3 张不付款、6 张在 UNKNOWN 期间到期(其中 2 张首次退款超时),其余正常;回调随机重复 1 到 3 份、延迟 0 到 300ms。之前售罄的 12 个用户换新键补抢,与恢复任务并发,本次有 12 人下单成功。本次运行的输出:

1
2
3
note late buyers 12 ; final {CANCELLED=3, FAILED=5, PAID=24, REFUNDED=4}
note notification effects {DUPLICATE=30, IGNORED=10, PAID=5}
note reconcile classes={CANCELLED=3, FAILED=5, PAID=24, REFUNDED=4} mismatches=0 pending=0 channel_net=47760 paid_sum=47760 confirmed=24 paid=24 drift=0

请求滞留的订单中,有的在恢复任务关单之前请求已经落地,于是收敛为 PAID 而非 FAILED,FAILED 的数目因此随调度变化;补抢成功人数和随之产生的退款数也随调度变化。稳定的结论是最后一行:没有差异、没有待收敛订单,24 件库存对应 24 张 PAID 订单,渠道净额与 PAID 金额相等,每单最多发货一次。

证据与限制

运行入口 examples/flash-sale/run-29.sh 启动端口 55429 的集群 b3-29,重建库 flash29,编译 lab.common、lab.flash.reserve 与 lab.flash.order,构建目录与第 28 篇隔离,运行 C29-01 至 C29-19,输出写入 evidence/29/output.txt。本次汇总为 SUMMARY passed=133 failed=0,退出码 0。C29-10、C29-11、C29-12、C29-18、C29-19 是独立 JVM 加 SIGKILL 的多进程集成,其余是真实 PostgreSQL 事务与本地 HTTP 替身的组件实验;到期一律用可控时钟推进。

evidence/29/LIMITS.md 逐条列出证据不覆盖的部分:渠道替身的查询、关单、退款都是同步且强一致的,真实渠道可能并非如此;回调不签名;只有全额补偿退款,没有售后退款;中继与消费者共用一个数据库实例,中继在持锁事务内调用消费者的代价未测;陈旧阈值的取值未做系统验证;数据库 immediate 崩溃未执行,标为 NOT_RUN。C29-02、C29-03、C29-08、C29-13 中各类结果的数目随调度变化,引用时只能当作本次运行的记录。

本批导航

  • 上一篇:28 稀缺名额的准入与库存预留
  • 下一篇:30 关注关系与可撤销订阅
  • 本批索引:仓库 batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md
  • 运行入口:batch-3/examples/flash-sale/run-29.sh
  • 证据目录:batch-3/evidence/29/
  • 本篇工程与证据快照:backend-systems-29.tar.gz(SHA-256 见同目录 backend-systems-29.tar.gz.sha256)。正文中的 examples/、evidence/、SOURCES.md 路径都相对仓库的 batch-3/ 目录,与快照包内布局一致。