优惠券进入用户券包后,最危险的并发发生在试算、下单、支付、关单和退款之间。本篇用一次“关单先释放、支付回调迟到、同一张券被两个订单消费”的脱敏事故,拆解租约令牌、状态版本、订单绑定、核销证据、退券策略和对账闭环。
Coupon LeaseState CASRefund ReturnRCA / Postmortem
结论先行:couponId 只能证明是哪张券,不能证明当前哪个订单拥有它。安全核销必须同时校验 couponId + orderId + lockToken + expectedState + stateVersion;支付、关单和退款只能发送事实或幂等命令,不能绕过统一状态机直接修改券状态。
案例边界与脱敏:本文中的用户券、订单、支付单、退款单、活动和模板均使用示例标识,例如 coupon_demo_1001、order_demo_A 和 lock_demo_L1。案例保留真实竞态机制、表关系和解决逻辑,不包含生产数据。
1. 背景与问题:券在支付完成前到底属于谁
第十一篇解决了优惠券如何安全发到用户券包,但一张 AVAILABLE 的券进入订单后,还要经历试算锁定、订单提交、支付成功核销、支付失败释放、关单、退款和可能的退券。多个服务会在不同时间看到不同事实,如果状态迁移只依赖 couponId,迟到事件就可能覆盖更晚的订单绑定。
一次压测中,订单 A 锁券后支付成功,但回调消息延迟;关单任务先把券释放为 AVAILABLE。订单 B 随后锁定同一张券。迟到的订单 A 核销请求只携带 couponId,最终把券改为 USED,导致两个订单都保留了同一优惠证据。

图 1:释放与核销竞态导致重复用券的事故链
2. 目标与约束:让每次状态迁移证明当前所有权
| 目标 | 可验证标准 | 关键约束 |
|---|---|---|
| 唯一锁定 | 同一时刻一张券最多绑定一个有效 orderId / lockToken | 试算重入必须返回原租约,不能生成并行锁 |
| 核销正确 | 只有当前租约持有者能从 LOCKED 迁移到 USED | 不能只按 couponId UPDATE |
| 释放安全 | 旧 Release 不覆盖已续租、已核销或新订单锁定 | 必须带 expectedVersion 和 lockToken |
| 退券可解释 | Return 决策引用原 Redemption、Quote 与策略快照 | 金额退款与券状态恢复分开判断 |
| 乱序可收敛 | 重复、延迟和乱序事件不产生非法迁移 | 消费者幂等,冲突交由协调器裁决 |
核心不变量:LOCKED ⇒ exactly one active(orderId, lockToken);USED ⇒ exactly one Redemption;任何命令仅在 currentState + stateVersion + ownership 同时匹配时生效。
3. 系统架构:订单事实、券状态、操作账本与对账分层
Order / Pricing 保存选择结果和 quoteId;Coupon Command API 统一处理 Lock、Redeem、Release、Return 和 Void;Coupon State Service 是券状态的唯一写入口;Payment / Refund 提供业务事实;Operation Ledger、Outbox 和 Reconciliation 负责审计、传播与长尾收敛。

图 2:优惠券锁定、核销与退券架构
4. 核心设计:租约令牌、状态版本与订单绑定
锁券不是把 status 改成 LOCKED 就结束。系统需要生成不可猜测的 lockToken,保存 orderId、lockedAt、expiresAt 和 acquiredStateVersion。后续 Redeem / Release 必须带回相同 token,并使用 expectedVersion 做 CAS。续租只能由当前订单完成,且必须记录新的版本和操作证据。

图 3:用户券锁定、核销与退券状态机
| 命令 | 前置条件 | 状态与证据 |
|---|---|---|
| Lock | AVAILABLE;未过期;活动允许 | LOCKED;创建 lockId / lockToken,绑定 orderId |
| Renew | LOCKED;同 orderId / token;租约未失效 | 保持 LOCKED;递增 stateVersion 和 expiresAt |
| Redeem | LOCKED;支付事实有效;所有权与版本匹配 | USED;创建唯一 Redemption |
| Release | LOCKED;取消/失败事实;所有权与版本匹配 | RELEASED,再按有效性决定 AVAILABLE / EXPIRED |
| Return | USED;退款和策略允许;累计次数合法 | RETURNED 或 AVAILABLE;保存 RefundCoupon |
5. 数据模型与 ERD:券实例、租约、核销与售后记录分离
user_coupon 保存当前状态和 stateVersion;coupon_lock 表示一次订单租约;coupon_redemption 是支付成功后的核销事实;order_coupon 固定订单与 Quote 的优惠证据;coupon_operation 记录不可变状态迁移;refund_coupon 保存售后金额与退券决策;coupon_outbox 传播领域事件。

图 4:核销、锁券与售后证据 ER Diagram
| 表 | 主键 / 唯一键 | 职责与关系 |
|---|---|---|
| user_coupon | user_coupon_id;state_version | 券聚合根与当前状态视图 |
| coupon_lock | lock_id;coupon + order + lease_no | 订单租约、token、到期时间和锁状态 |
| coupon_redemption | redemption_id;coupon + order 唯一 | 支付核销事实与 paymentId |
| order_coupon | order_coupon_id;order + coupon 唯一 | quoteId、优惠金额和订单行映射 |
| coupon_operation | operation_id;business_key + operation | from/to/version、reason 与关联操作 |
| refund_coupon | refund_coupon_id;refund + redemption | 退款金额、退券策略和结果 |
| coupon_outbox | event_id;aggregate + event_type | 状态事件与 payloadHash |
6. 试算锁券与支付核销流程
试算阶段选择券后创建短租约;订单提交保存 lockToken、quoteId 和 order_coupon 映射。支付成功事件到达后,Coupon Service 验证订单、token、预期状态和版本,写 Redemption、Operation、Outbox 并把券改为 USED。重复支付事件返回同一 redemptionId。

图 5:试算锁券与支付核销时序图
UPDATE user_coupon
SET status = 'USED',
state_version = state_version + 1,
updated_at = :now
WHERE user_coupon_id = :couponId
AND status = 'LOCKED'
AND locked_order_id = :orderId
AND lock_token_hash = :tokenHash
AND state_version = :expectedVersion;
7. 支付与关单竞态:状态服务拒绝猜测业务真相
支付成功和关单超时可能乱序到达。状态服务只按命令携带的 expectedVersion 执行 CAS:先成功的命令推进版本,后到命令得到 VERSION_CONFLICT。冲突并不等于简单失败;协调器读取订单和支付事实,决定补核销、订单退款或人工复核,但不能让旧命令覆盖新租约。

图 6:支付成功与关单释放竞态时序图
8. 锁券租约与过期扫描
Lease expiresAt 是兜底释放边界,不是业务支付截止时间的唯一来源。扫描器只处理仍处于 LOCKED、token 对应且 stateVersion 未变化的记录;释放前应检查订单/支付事实或投递协调事件。扫描任务使用稳定游标、分片和限速,重复执行不会重复改变券状态。
- 试算租约短于订单支付窗口;订单创建后通过显式 Renew 延长,而不是原地改时间。
- 扫描命令包含 lockId、lockTokenHash 和 expectedVersion,避免释放新租约。
- 券本身已过有效期时,释放结果进入 EXPIRED,不回到 AVAILABLE。
- 扫描器只生成 Release Command;不直接批量 UPDATE user_coupon。
9. 退款退券:金额退款与券状态恢复是两个决策
退款金额来自原 PriceQuote / Adjustment;是否退券来自原券模板和活动策略快照。整单退款也不一定退券,例如一次性兑换券、已过期券、活动关闭或风险复核中的券可能保持 USED / VOID。部分退款更不能简单恢复整张券。

图 7:退款后是否退券的决策模型
| 场景 | 金额处理 | 券状态建议 |
|---|---|---|
| 整单退款,券仍有效且策略允许 | 按原 Adjustment 退款 | RETURNED → AVAILABLE,保留新版本 |
| 整单退款,原券已过期 | 按原价格证据退款 | RETURNED / EXPIRED,不恢复可用 |
| 部分退款,优惠仍被剩余商品承接 | 按行分摊退款 | 保持 USED |
| 部分退款,全部券优惠已退回 | 累计退款达到原券优惠 | 按模板策略 RETURN 或 REVIEW |
| 高风险异常退款 | 资金链路照常审计 | VOID / REVIEW,禁止自动复用 |
10. 撤销与补偿:追加反向操作,而不是覆盖历史
错误核销、支付撤销或人工修复不应删除 Redemption 或 Operation。系统追加关联 originalOperationId 的 Reverse / Return 操作,递增 stateVersion,并保留原始事实。补偿命令自身也具有业务幂等键和 requestHash;同一补偿重复到达返回原结果。
11. 事务与分布式一致性
在 Coupon Service 内,UserCoupon 状态、Lock / Redemption / Refund 证据、Operation Ledger 和 Outbox 必须同事务提交。跨订单、支付和退款服务采用至少一次事件 + Inbox 去重 + 状态 CAS。消息队列顺序只能降低乱序概率,不能替代业务版本条件。

图 8:券状态、操作账本与事件的一致性边界
12. 核销对账:订单、支付、退款与券状态四方核验
- 支付成功订单使用优惠券,但券仍 LOCKED / AVAILABLE:生成补核销候选。
- 订单已取消且无支付事实,但券仍 LOCKED:生成 Release 候选。
- 券 USED 但不存在唯一 Redemption 或 order_coupon:P0 幽灵核销。
- 同一 userCouponId 关联多个已支付订单:P0 重复消费。
- RefundCoupon 决策为 RETURN,但状态未推进或 Outbox 未发布:补偿与重投。
- Operation 的 fromVersion / toVersion 不连续:状态历史被绕过或数据被异常修改。
13. Troubleshooting:同一张券被两个订单消费的完整排查
Symptoms
用户券 coupon_demo_1001 在订单 A 和订单 B 中都产生优惠。订单 A、B 均已支付,但券表只有一个最终 USED 状态;常规接口成功率和消息消费率都正常。
Investigation
- 从 userCouponId 展开 coupon_operation,重建 stateVersion 7 → 8 → 9 的迁移时间线。
- 发现订单 A 使用 token=L1 锁定;Release 后订单 B 使用 token=L2 再锁定。
- 迟到 Redeem 来自订单 A,但 SQL 只按 userCouponId + status 更新,没有 token 和 expectedVersion。
- coupon_redemption 唯一键是 couponId + paymentId,未阻止同一券跨订单核销。
- 关单与支付消费者分别拥有状态写权限,未经过统一 Command Handler。

图 9:重复用券事故的证据链
Root Cause
直接根因是订单 A 的迟到核销覆盖了订单 B 的新锁定;系统性根因是券状态迁移没有验证当前租约所有权。couponId 被误认为足够的业务身份,Release / Redeem 分散在不同消费者中直接写表,缺少 lockToken、orderId 与 stateVersion 的统一 CAS 契约。

图 10:同一张券被两个订单消费的 5 Whys
Resolution
- 所有状态修改收敛到 Coupon Command Handler,外部服务只发送事实或幂等命令。
- Lock 返回 lockToken 和 stateVersion;Redeem / Release / Renew 必须携带并校验。
- Redemption 增加 userCouponId 唯一成功约束,并保存 orderId / paymentId。
- 冲突命令返回 VERSION_CONFLICT,由协调器读取订单/支付事实后决定补偿。
- 把事故序列加入乱序事件 Golden Case,并增加跨订单重复核销对账 Gate。
Verification
修复后对 PaymentSucceeded、OrderClosed、LeaseExpired 三类事件执行全排列和重复投递测试,共 18,000 组命令序列;每张券最多产生一个成功 Redemption,旧 token 命令全部返回 TOKEN_MISMATCH 或 VERSION_CONFLICT。历史 126,000 张已用券对账未发现新增跨订单消费。
Lessons Learned
数据库里只有一个最终 status,并不证明过程正确。状态机安全依赖的是谁有权迁移、基于哪个版本迁移,以及每次迁移是否留下不可变证据;缺少任何一项,迟到事件都可能把系统带回一个看似合法、实际错误的状态。
14. 为什么原方案会失败,最终方案为什么有效
| 原方案 | 失败机制 | 最终方案为何有效 |
|---|---|---|
| 只校验 couponId + status | 无法证明当前订单拥有券,旧命令可命中新状态 | orderId + lockToken + version 验证租约所有权 |
| 多个消费者直接 UPDATE | 状态规则分散,竞态和补偿语义不一致 | 统一 Command Handler 封闭迁移规则 |
| 依赖消息顺序 | 重试、分区和回放仍会产生乱序 | 状态 CAS 让乱序成为可观测冲突 |
| 退款即自动退券 | 金额和券有效性、风险、活动规则并不等价 | Return Policy 独立读取原证据决策 |
| 覆盖历史修数据 | 失去事故证据,无法重算和审计 | 追加 Reverse / Return 操作并关联原记录 |
15. 关键实现:带所有权验证的核销命令
RedeemResult redeem(RedeemCommand cmd) {
return tx.execute(() -> {
UserCoupon c = coupons.lock(cmd.couponId());
idem.verify(cmd.businessKey(), cmd.requestHash());
if (c.isUsedBy(cmd.orderId())) return existingRedemption(c);
require(c.status() == LOCKED);
require(c.lockedOrderId().equals(cmd.orderId()));
require(c.lockTokenHash().equals(hash(cmd.lockToken())));
require(c.stateVersion() == cmd.expectedVersion());
Redemption r = redemptions.insertUnique(cmd);
coupons.casToUsed(c.id(), cmd.expectedVersion(), r.id());
operations.append(c, USED, cmd.businessKey());
outbox.append("CouponRedeemed", c.id(), r.id());
return RedeemResult.of(r.id(), USED);
});
}
16. 验证方案:状态属性、并发、乱序与回放
- State Property:随机生成 Lock / Renew / Redeem / Release / Return 序列,验证非法迁移永不成功。
- Concurrency:同券多订单并发锁定、支付与关单同时到达、扫描器与续租竞争。
- Event Permutation:支付、关单、过期、退款事件全排列、重复和延迟投递。
- Replay / Reconcile:由 Operation Ledger 重放每张券状态,并与订单、支付和退款事实核验。
Invariant 1: one coupon has at most one active lock Invariant 2: one coupon has at most one successful redemption Invariant 3: old lockToken can never mutate a newer lease Invariant 4: every state change increments stateVersion exactly once Invariant 5: every successful command has one operation ledger entry Invariant 6: return decision references original redemption and policy snapshot Invariant 7: replay(operations) equals current coupon state
17. 可观测性与发布 Gate
| 指标 / Gate | 观察内容 | 触发动作 |
|---|---|---|
| Cross-order Redemption | 同券关联多个已支付订单 | P0:冻结券服务写入并启动核验 |
| Token Mismatch | 旧租约命令比例与来源 | 定位迟到消费者或错误重试 |
| Version Conflict | Redeem / Release / Renew 冲突分布 | 检查订单支付竞态和处理延迟 |
| Orphan Lock | 租约过期但长期 LOCKED | 触发协调检查和安全释放 |
| Return Drift | 退款策略结果与券状态不一致 | 生成补偿任务并阻断批量自动退券 |
| Ledger Gap | Operation version 不连续 | P0:禁止自动修复,进入审计 |
18. 方案取舍
| 决策 | 收益 | 代价与控制 |
|---|---|---|
| 短租约 + 显式续租 | 避免试算长期占券,所有权清晰 | 续租调用增加;订单提交统一管理 |
| 状态 CAS | 低成本阻止旧命令覆盖新状态 | 冲突需要协调器裁决,不能盲目重试 |
| Operation Ledger | 过程可回放、异常可定位 | 数据增长;冷热分层但保留审计主线 |
| 金额退款与退券分离 | 适配部分退款、过期和风险场景 | 策略更复杂;保存模板快照与 reasonCode |
| 至少一次事件 + Inbox | 容忍消息重复和服务恢复 | 消费者必须幂等并验证状态版本 |
19. Lessons Learned
- 第一,couponId 是资源标识,lockToken 才证明当前订单的临时所有权。
- 第二,消息有序不能替代状态 CAS;任何历史事件都可能被重放。
- 第三,释放必须验证租约代次,否则扫描器会释放新订单的锁。
- 第四,退款金额与退券状态属于两个业务决策,必须分别留证。
- 第五,补偿应追加反向操作,不能删除 Redemption 或覆盖 Operation。
- 第六,最终状态正确不代表过程正确,操作账本和跨域对账不可省略。
20. 后续优化与总结
- 建设可视化券状态时间线,按 couponId 一键展开 Lock、Order、Payment、Refund 与 Operation。
- 对热点券包引入命令串行键,但保留数据库 CAS 作为最终防线。
- 将支付、关单、过期和退款事件排列组合纳入持续故障注入。
- 为 Return Policy 建立版本化规则和影响面回放,避免策略更新改变历史售后结果。
- 建立跨订单重复用券、孤儿锁和 Ledger Gap 的实时 Gate。
优惠券核销系统的核心不是一个 USED 状态,而是从试算到售后的所有权协议。LockToken 表达当前订单租约,stateVersion 阻止迟到命令,Redemption 和 Operation 保存不可变事实,Return Policy 解释售后决策,对账持续证明订单、支付、退款和券状态没有悄悄分叉。
最终结论:任何会修改券状态的命令,都必须回答四个问题:谁在操作、基于哪个租约、期望哪个版本、失败后如何留下证据。回答不完整,就不应执行状态迁移。
21. 下一篇
第十三篇进入优惠券数据建模与生命周期治理:《券模板、发放批次与用户券数据模型:不可变快照、版本演进、状态归档与冷热分层》。重点讨论模板版本、实例快照、有效期模型、ID 映射、数据迁移、分库分表和历史审计。
本文深入剖析餐饮SaaS优惠券系统中核销与退券的状态机演进,聚焦锁券、核销、释放、撤销及售后一致性问题。通过租约令牌(lockToken)、状态版本(stateVersion)、订单绑定与CAS校验机制,解决支付与关单竞态导致的重复用券问题。系统采用操作账本、四方对账(订单/支付/退款/券状态)和不可变事件保障分布式一致性,并强调所有权验证而非仅依赖couponId。
优惠券核销与退券状态机:锁券、核销、释放、撤销与售后一致性&spm=1001.2101.3001.5002&articleId=168519388&d=1&t=3&u=d2618d5475f247968cae28a0b9c7cae5)
394

被折叠的 条评论
为什么被折叠?



