一、死单:自动发卡平台最隐蔽的库存漏洞

转卡码系统的下单链路是:用户提交订单 → 支付 → 自动发放卡密。为了防超卖,下单时系统会预占库存并锁定卡密。可一旦用户"下单不付钱"——付款页关掉、余额不足、只是随手点点——订单就会永远卡在待支付状态,对应的库存和卡密被白白锁死。单看一笔没什么,日积月累就是个大窟窿:库存明明没卖出去,却显示已售罄;对账时账面和实际永远差一截

解决思路只有一个:给待支付订单一个"生命倒计时"(一般 15 分钟),超时自动关单、回补库存、回收卡密;如果用户恰好在关单前后脚完成了支付,则走自动退款。本文以商城在售的转卡码系统 V3 为蓝本,拆解这套"超时关单 + 卡密回收 + 自动退款"的完整实现。

二、先定规矩:订单与卡密的双状态机

所有关单、退款逻辑都建立在清晰的状态机上。订单侧和卡密侧各一张状态表,所有迁移必须带前置状态条件,这是防止重复关单、重复退款的根本。

订单状态:PENDING(待支付)→ PAID(已支付)→ REFUNDING(退款中)→ REFUNDED(已退款);PENDINGCLOSED(超时关闭)。

卡密状态:UNSOLD(可售)→ LOCKED(下单锁定)→ SOLD(已售出);LOCKEDRECYCLED(关单/退款后回收,可重新上架)。

# 卡密状态机(orders 表同理,前置条件写死在 WHERE 里)
UPDATE card_stock SET status='LOCKED', order_no=:no
WHERE id=:id AND status='UNSOLD'        # 下单锁卡

UPDATE card_stock SET status='RECYCLED'
WHERE order_no=:no AND status='LOCKED'    # 关单/退款回收

关键点:任何状态迁移都必须命中前置状态。比如回收卡密时限定 status='LOCKED',即使关单任务被并发执行两次,第二次也会因为影响行数为 0 而自然跳过——天然幂等。

三、Redis ZSET 延迟队列:关单任务的骨架

最朴素的做法是 crontab 每分钟扫一遍 created_at < NOW()-15min AND status='PENDING' 的订单。订单量小的时候没问题,量大了就是定时全表扫描,而且"每分钟最多延迟 60 秒"的精度也很难看。更优雅的方案是用 Redis ZSET 做延迟队列:score 就是关单时间戳,谁先到期谁排前面。

下单成功时入队:

# 下单成功:15 分钟后到期
import redis, time
r = redis.Redis.from_url("redis://127.0.0.1:6379/0")
close_ts = int(time.time()) + 15 * 60
r.zadd("delay:close:order", {order_no: close_ts})

关单 Worker 循环取出到期订单:

# 每 1 秒拉取一次已到期的订单
while True:
    now = int(time.time())
    due = r.zrangebyscore("delay:close:order", 0, now,
                          start=0, num=100)
    for order_no in due:
        # ZREM 成功才算"抢到",多 Worker 不会重复处理
        if r.zrem("delay:close:order", order_no):
            process_close(order_no)
    time.sleep(1)

这里 ZRANGEBYSCORE 取出 + ZREM 删除必须配对使用:ZREM 返回 1 才代表这个任务归你处理。多实例部署时,同一订单只会有一个 Worker 拿到处理权,不会重复关单。注意取出后还要在数据库里做条件更新二次确认,因为 Redis 只是调度器,数据库才是账本。

四、关单处理:状态流转 + 库存回补

Worker 拿到任务后,在数据库里完成"关单 → 回收卡密 → 回补库存"三步,全程用条件更新保证幂等:

def process_close(order_no):
    # 1. 关单:只有 PENDING 才能关闭(已支付/已关闭则跳过)
    n = db.execute(text(
        "UPDATE orders SET status='CLOSED', closed_at=NOW() "
        "WHERE order_no=:no AND status='PENDING'"), {"no": order_no}).rowcount
    if n == 0:
        return  # 已支付或已关闭,交给退款/其他流程

    # 2. 回收该订单锁定的卡密
    db.execute(text("UPDATE card_stock SET status='RECYCLED' "
                    "WHERE order_no=:no AND status='LOCKED'"), {"no": order_no})

    # 3. 回补 Redis 库存 + 写库存流水(change_type=3 超时回补)
    r.incr(f"stock:product:{pid}")
    insert_stock_flow(pid, order_no, 3, qty)
    db.session.commit()

第三步回补库存时,Redis 与数据库必须同步写,否则会出现"数据库有卡、Redis 显示 0 库存"的假售罄。建议把回补也做成 Lua 脚本(INCR + 流水计数),保证原子性。如果数据库里找不到该订单锁定的卡密(理论上不该发生),立即告警,说明数据已不一致。

五、临界竞态:关单前 1 秒支付成功怎么办

最麻烦的场景:用户卡在支付页,第 14 分 59 秒才付款成功,而 Worker 已经执行了关单。此时支付回调进来,发现订单状态是 CLOSED,绝不能发货,也不能直接丢弃——正确做法是发起自动退款,把用户的冤枉钱还回去。

退款必须解决幂等问题:支付宝/微信的退款接口都要求传一个商户侧唯一的 out_request_no(退款请求号),同一个号重复调用只会退一次。系统侧再配一张退款单表兜底:

# 退款单:refund_no 唯一,天然幂等
CREATE TABLE refund_order (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  refund_no VARCHAR(32) NOT NULL UNIQUE,   # 幂等键
  order_no VARCHAR(32) NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  status TINYINT NOT NULL DEFAULT 0,       # 0=处理中 1=成功 2=失败
  retry_count TINYINT NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
# 自动退款:先插退款单,再调支付宝退款接口
refund_no = f"R{order_no}{int(time.time())}"
db.execute(text("INSERT INTO refund_order (refund_no, order_no, amount) "
                "VALUES (:rn, :no, :amt)"), {"rn": refund_no, "no": order_no, "amt": amount})
alipay = AlipayTradeRefund(
    trade_no=trade_no, refund_amount=str(amount),
    out_request_no=refund_no)   # 同一个 refund_no 重复调用只退一次
resp = alipay.execute()

退款接口返回成功后,把退款单和订单状态一起推进(REFUNDING → REFUNDED),并再次把卡密回收。如果接口超时或返回失败,不要原地死磕,而是把退款单丢进另一个延迟队列,按 1/5/30 分钟退避重试,超过 5 次转人工并告警。退款结果最终以主动查询 + 异步回调双通道确认为准,避免"接口成功但状态没落库"的错账。

六、兜底与监控:别把命脉押在定时器上

延迟队列理论上可靠,但 Redis 宕机、Worker 崩溃、消息丢失都可能发生。所以必须加一道每日对账兜底:凌晨跑一次批量任务,把"超过 30 分钟仍是 PENDING"的订单补关单,把"处理中超过 24 小时"的退款单捞出来告警:

# 每日兜底:补关单 + 捞异常退款
UPDATE orders SET status='CLOSED', closed_at=NOW()
WHERE status='PENDING' AND created_at < NOW() - INTERVAL 30 MINUTE

SELECT * FROM refund_order
WHERE status=0 AND created_at < NOW() - INTERVAL 24 HOUR;  # 告警转人工

监控方面,至少盯三个指标:延迟队列长度(堆积说明 Worker 挂了)、关单成功率、退款失败率,任一异常立即告警。这套"延迟队列 + 状态机 + 每日兜底"的组合,单机就能扛住日订单十万级的自动发卡平台。

七、总结

订单超时关单不是"定时扫表"那么简单,它牵扯库存回补、卡密回收、支付竞态和退款幂等一整条链路。核心就三句话:状态迁移必须带前置条件、Redis 只做调度数据库做账本、所有外部调用都要幂等。我们商城在售的转卡码系统 V3 已内置延迟队列关单、卡密回收与自动退款模块,部署即可用;想自己从零写一遍的,也可以对照本文的代码逐段实现。