编程 订单关单后的资源释放:库存回滚、优惠券退回与额度解冻怎么做到不重不漏

2026-10-18 00:04:35

订单关单后的资源释放:库存回滚、优惠券退回与额度解冻怎么做到不重不漏

前提

订单状态机是 PENDING → PAID / CLOSED。状态翻转和资源释放不是一件事:前者在订单表上一行更新就完事,后者要动库存、优惠券、冻结额度、活动名额、座位/时段占用这一堆外部资源。

关单由什么触发(扫表还是延迟消息)这里不展开,只看状态已经变成 CLOSED 之后,资源怎么退得干净且只退一次。

1. 别把释放塞进关单事务

事务里只做两件事:条件更新抢状态 + 写一条释放任务(outbox)。释放动作异步做,可重试。

原因很实际:释放要调库存服务、券服务,可能跨库跨服务。放进事务里事务变长、锁变长;而且跨服务本来就不在事务里,包着也只是假的原子性。

CREATE TABLE order_release_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
resource_type TINYINT NOT NULL,   -- 1库存 2优惠券 3冻结额度 4活动名额
biz_key VARCHAR(64) NOT NULL,     -- sku_id / coupon_id / account_id
qty INT NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0待释放 1成功 2失败待重试 3人工
retry INT NOT NULL DEFAULT 0,
next_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_order_res (order_no, resource_type, biz_key),
KEY idx_status_next (status, next_at)
);

唯一键 uk_order_res 是幂等的地基:同一单同一资源只允许一条任务。重复关单插入时直接 duplicate 冲突,抛给调用方忽略即可。

2. 库存:预占模型,别直接加减

库存用两个字段而不是一个:available(可售)、locked(预占)。下单 available-1、locked+1;支付成功 locked-1;关单 locked-1、available+1。

-- 下单
UPDATE inventory SET available = available - ?, locked = locked + ?
WHERE sku_id = ? AND available >= ?;
-- 关单释放
UPDATE inventory SET locked = locked - ?, available = available + ?
WHERE sku_id = ? AND locked >= ?;

locked >= ? 这个条件不能省:重复释放会让 locked 变负数,且 available 虚增,超卖直接出现。受影响行数为 0 说明数据已经不对,进人工,不要重试。

直接 UPDATE stock SET qty = qty + 1 的写法看起来简单,但没有「预占」语义:用户下单后 30 分钟内商品实际已被别人买走,回滚就是凭空多出一件。

3. 释放流水

只改库存数不留痕,事后无法判断「这 1 件是退回来的还是本来就没人买」。加流水表:

CREATE TABLE inventory_flow (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id BIGINT NOT NULL,
order_no VARCHAR(32) NOT NULL,
type TINYINT NOT NULL,  -- 1锁定 2释放 3出库
qty INT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_flow (order_no, sku_id, type)
);

先插流水(唯一键冲突即已处理),再改库存数。两步在同一本地事务里。

4. 优惠券退回的四个坑

  • 有效期:券已过期,不能退回成可用。要么按剩余有效期退(用户吃亏),要么统一延长 N 天(成本)。二选一,别「原样退回」——那等于送了张过期券。
  • 叠加用券:一单用 3 张,要按 order_coupon 明细逐张退,不能按订单金额反推。
  • 幂等:按 (coupon_id, order_no) 唯一,重复退回第二次直接被唯一键挡住。
  • 归属:平台券和商家券成本方不同,退回动作一样但流水要分开记,否则对账时算不出谁承担。
UPDATE user_coupon
SET status = 'UNUSED', order_no = NULL, expire_at = GREATEST(expire_at, NOW() + INTERVAL 3 DAY)
WHERE id = ? AND status = 'LOCKED' AND order_no = ?;

5. 冻结额度(余额/积分)

解冻写成「加回可用 + 减冻结」,两条都要带条件:

UPDATE account SET frozen = frozen - ?, available = available + ?
WHERE uid = ? AND frozen >= ?;

只写 available + ? 不写 frozen - ?,冻结额永久挂在那里;反过来只减 frozen 不加 available,用户的钱凭空消失。两条必须一起成功,用同一事务。

积分过期策略同理,退回的积分是否重置有效期要提前定,不要上线后被客诉倒逼改。

6. 关单后又被推到已支付

释放已经执行了,迟到回调把订单推到 PAID,就需要二次扣减,而此时 available 可能已为 0,扣不回来。这类竞态不展开,原则只有一条:以渠道真实状态为准,扣减失败转「缺货/退款」分支,不要盲目把订单状态改回 PENDING 等它再超时。

7. 对账:locked 汇总 vs 待支付明细

每天跑一次:库存侧 locked 应该等于所有 PENDING 订单明细数量之和。

SELECT i.sku_id,
i.locked AS locked_in_db,
COALESCE(SUM(oi.qty), 0) AS pending_qty
FROM inventory i
LEFT JOIN order_item oi ON oi.sku_id = i.sku_id
LEFT JOIN orders o ON o.id = oi.order_id AND o.status = 'PENDING'
GROUP BY i.sku_id
HAVING locked_in_db <> pending_qty;

差异为正 = 有单关了库存没退(订单侧漏释放);为负 = 库存退了单还挂着(重复释放或状态被回滚)。两种都进工单。

同样的比对做券和冻结额度:券 LOCKED 数量 vs 待支付订单关联券数量。

8. 重试与人工

next_at + 指数退避(1s, 5s, 30s, 5min, 30min),retry >= 8 落 status=3 人工。失败原因分类记录:库存服务超时 / locked 不足 / 券状态不符 / 数据库唯一冲突(这类应算成功)。

人工补偿入口必须能「按 order_no 重跑单个资源类型」,不要只提供「整单重跑」——整单重跑容易把已经成功的券再退一次,虽然有唯一键兜着,但流水会被污染。

9. 指标

  • 释放成功率、各 resource_type 失败数
  • 待释放任务积压量与最老任务年龄(最老 > 10 分钟就要看消费者是否挂了)
  • 对账差异 sku 数
  • 重复释放冲突次数(突增说明关单被重复触发)

参考

  • 阿里云开发者社区《订单超时自动取消怎么做才靠谱:延迟队列、定时扫描与幂等关单的取舍》

推荐文章

程序员茄子在线接单