一、高并发下单:转卡码系统的生死线

转卡码系统的业务链路极短:用户提交订单 → 支付 → 系统自动发放卡密。链路短不代表简单——当流量上来,比如活动秒杀、代理批量进货时,下单接口会同时面对三个致命问题:超卖(库存 100 张却卖出 150 单)、重复(同一用户连点提交产生多笔订单)、丢单(支付成功但卡密没发出去)。任何一项发生,轻则退款纠纷,重则平台口碑崩塌。

本文以我们商城在售的转卡码系统 V3 的下单链路为蓝本,拆解一套经过生产验证的并发安全方案:Redis Lua 原子扣减 + 数据库条件更新 + 库存流水兜底。整套方案用 Python 实现,可以直接照搬。

二、库存模型:预生成卡密池 + Redis 缓存

先看库存怎么设计。卡密是预生成的:上架商品时批量生成卡密写入 card_stock 表,每一行就是一张待售卡密。数据库是最终真相,但下单时直接查库扣减扛不住高并发,所以要在 Redis 里维护一份可售数量

# 商品上架时:初始化 Redis 库存
SET stock:product:{pid} {total}   # 可售数量
SET sold:product:{pid} 0          # 已售计数(对账用)

这里有个关键点:Redis 库存只是闸门,数据库才是账本。Redis 扣减成功只代表抢到了"下单资格",真正的扣库存要等支付完成后落库。这样设计,Redis 只负责挡住超卖,数据库压力被均摊到支付回调阶段。

三、Redis Lua 原子扣减:防超卖的核心

为什么必须用 Lua 脚本?因为"检查库存 > 0 → 扣减"两步操作必须原子执行。用 GET + DECR 两条命令分开写,并发下必然超卖。Redis 的 Lua 脚本在单线程中执行,天然原子:

# decrement_stock.lua - 原子扣减,返回 1 成功 / 0 库存不足
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock < 1 then
    return 0
end
redis.call('DECR', KEYS[1])
redis.call('INCR', KEYS[2])
return 1
# Python 调用:redis-py 加载脚本后原子执行
import redis
r = redis.Redis.from_url("redis://127.0.0.1:6379/0")
script = r.register_script(open("decrement_stock.lua").read())

# KEYS[1]=可售库存  KEYS[2]=已售计数
ok = script(keys=[f"stock:product:{pid}", f"sold:product:{pid}"])
if not ok:
    raise OutOfStock("手慢了,库存不足")

注意 Lua 脚本里的 tonumber 容错:库存 key 不存在时按 0 处理,避免误放行。脚本注册后要固定返回结构,方便后续扩展为"扣减指定数量"(代理批量进货场景)。

四、下单幂等与防重提交

扣减成功只完成了一半。用户连点提交、前端重试、脚本并发下单,都会产生重复订单。业界标准做法是幂等键:下单前生成一个 idempotent_key(UUID),后端用 Redis SETNX 保证同一幂等键只创建一个订单:

# 幂等下单:SETNX 成功才允许建单
key = f"idem:order:{idempotent_key}"
if not r.set(key, "1", nx=True, ex=600):
    raise DuplicateSubmit("订单已提交,请勿重复操作")
order_no = gen_order_no()       # 日期+随机数+用户ID,全局唯一
insert_order(order_no, pid, qty, user_id)  # 状态 PENDING

配合前端按钮置灰(提交后禁用 3 秒),双重防护。另一个细节:下单成功但未支付的订单要占用库存吗?答案是占用,但必须有超时释放机制——订单超过 15 分钟未支付,由延迟队列触发关单并把库存回补(见第六节)。

五、支付回调:库存落库与卡密发放的最终一致性

用户支付成功后,回调里要做三件事:验签 → 落库扣库存 → 发卡密。此时 Redis 已经扣过,但数据库还没扣,所以要以数据库为准再做一次条件更新,防止"Redis 扣了但订单未支付成功"导致的账实不符:

# 支付回调:数据库条件更新扣库存(防重复发货)
updated = db.execute(text(
    "UPDATE orders SET status='PAID', paid_at=NOW() "
    "WHERE order_no=:no AND status='PENDING'"), {"no": order_no}).rowcount
if updated == 0:
    return "success"  # 已处理过,幂等返回

# 原子出库:FOR UPDATE 行锁取一张未售卡密
card = db.execute(text(
    "SELECT id FROM card_stock WHERE product_id=:pid AND status='UNSOLD' "
    "ORDER BY id LIMIT 1 FOR UPDATE"), {"pid": pid}).fetchone()
db.execute(text("UPDATE card_stock SET status='SOLD', order_no=:no WHERE id=:id"),
           {"no": order_no, "id": card.id})
db.session.commit()

订单状态机的条件更新(PENDING → PAID)保证了同一订单只发一次卡FOR UPDATE 行锁保证了同一张卡密只卖一次。如果出库时发现数据库库存不足(理论上不该发生,因为 Redis 已经挡过),说明数据不一致,立刻告警并冻结该商品。

六、库存流水与异常回滚

只靠 Redis + 订单状态还不够,必须给每一笔库存变动留下流水,否则线上出了账实不符,连查都没法查。设计一张 stock_flow 流水表:

# 库存流水表:每一笔变动可追溯
CREATE TABLE stock_flow (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  product_id INT NOT NULL,
  order_no VARCHAR(32) NOT NULL,
  change_type TINYINT NOT NULL,  # 1=下单预占 2=支付扣减 3=超时回补 4=退款回补
  qty INT NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_product (product_id, created_at)
) ENGINE=InnoDB;

三个回补场景必须写流水:超时关单回补(订单 15 分钟未支付)、支付失败回补退款回补(卡密置为 RECYCLED 回到可售池)。回补时用 Redis Lua 脚本 INCR 库存并同步写流水,保证 Redis 与数据库口径一致。

七、压测验证:用 wrk 证明不超卖

方案对不对,压测说了算。用 wrk 模拟 200 并发打下单接口,配合监控 Redis 库存与数据库已售数,验证两者最终一致:

# 200 并发 × 10 秒打下单接口
wrk -t8 -c200 -d10s --latency http://127.0.0.1:8000/api/order

# 验证:已售数 + 未售卡密 = 初始库存,即不超卖
redis-cli GET sold:product:1001
mysql -e "SELECT COUNT(*) FROM card_stock WHERE product_id=1001 AND status IN ('SOLD','UNSOLD');"

压测常见的三个坑:没有预热连接池(redis-py 的连接池要先 warmup)、回调里做了重活(发卡密、发通知必须丢消息队列异步处理,回调只做验签 + 落库)、事务范围过大FOR UPDATE 行锁持有时间越长并发越差,出库逻辑务必短平快)。

八、小结

转卡码系统的高并发下单,核心就三句话:Redis Lua 原子扣减挡超卖,幂等键挡重复,状态机 + 流水保证最终一致。这套方案在我们生产环境扛过单商品瞬时高并发流量,账实分毫不差。

如果你不想从零踩这些坑,商城里的转卡码系统 V3 已经内置 Redis 原子扣库存、幂等下单、超时关单与库存流水全套机制,部署即用;预算有限也可以选经过运营验证的V2 版。写这套并发代码时,搭配Codex Desktop 做 AI 结对编程,Lua 脚本和压测脚本都能快速生成,效率直接翻倍。