大促领券一秒抢空却有人没领到:优惠券库存扣减与核销状态机的并发一致性实践
导读
大促开场 10 秒,5000 张满减券显示"已抢光",但后台对账发现实际只核销了 4800 张,还有 200 张既没发出去也没在库存里——用户投诉"明明点到了却提示已抢完"。优惠券这类"高并发扣减 + 多状态流转"的业务,最容易在库存扣减和状态流转之间出现缝隙:扣减了库存但没生成券、生成了券但状态没流转。本文记录一次完整修复:扣减与发券的原子化、核销状态机建模、以及对账补偿,让"抢券"和"用券"每个环节都有一致性兜底。
一、为什么会"扣了库存却没发券"
拆开抢券链路,一次领券要经过四步:校验资格 → 扣减库存 → 生成券 → 返回结果。问题大多出在扣减和生成之间:
- 先减库存后写券,中间失败:库存扣减成功、写券事务提交失败,库存少了、券没生成,用户看到"抢完了"。
- 先写券后减库存,库存超发:写券成功、扣减失败,券发多了,库存变负数。
- 并发重复扣减:同一个用户同一张券点击两次,两个请求都通过校验,各扣一次。
- 状态流转缺记录:券从"已领取"到"已核销"的每一步没有落库,出问题无法对账。
二、把"扣减+发券"放进同一个原子操作
修复的第一件事,是让库存扣减和券生成要么一起成功、要么一起失败。用数据库事务 + 条件更新保证原子性:
BEGIN;
-- 条件扣减:库存>0 才扣,影响行数为0说明库存不足
UPDATE coupon_stock
SET stock = stock - 1, updated_at = NOW()
WHERE coupon_id = 1001 AND stock > 0;
-- 影响行数>0 才插入用户券
INSERT INTO user_coupon (coupon_id, user_id, order_no, status, created_at)
VALUES (1001, 888001, 'CP20260917', 'UNUSED', NOW());
COMMIT;
这里的关键是 WHERE coupon_id=1001 AND stock>0 条件更新——在高并发下天然串行化同一张券的扣减,不会出现"两个请求都读到 stock=1 然后都扣成功"的超发。应用层检查 UPDATE 影响行数,为 0 就直接返回"已抢光",不再执行后续插入。
压测数据:改造前单张券 1 万并发下超发 137 张;改造后同并发超发 0 张,接口 P99 从 380ms 降到 210ms。
三、核销状态机:每一张券都知道自己到哪一步
券不是"发出去就完了",它要经历:已领取 → 已使用 → 已过期/已作废。每个状态变化都落库,才能对账。我们用状态机约束流转,非法流转直接拒绝:
COUPON_STATES = {
'UNUSED': {
'to': ['USED', 'EXPIRED', 'VOID']},
'USED': {
'to': []}, # 终态,不可再变
'EXPIRED':{
'to': ['VOID']}, # 过期可作废,不可再用
'VOID': {
'to': []}, # 终态
}
def transit(coupon_id, from_state, to_state, tx_id):
if to_state not in COUPON_STATES[from_state]['to']:
raise StateError(f'{from_state} -> {to_state} 非法流转')
row = db.execute(
"UPDATE user_coupon SET status=%s WHERE id=%s AND status=%s",
(to_state, coupon_id, from_state),
)
if row == 0:
raise StateError(f'状态已被其他请求变更: {coupon_id}')
# 记录流转日志,用于对账
db.execute(
"INSERT INTO coupon_state_log(coupon_id, from_state, to_state, tx_id, created_at) "
"VALUES(%s,%s,%s,%s,NOW())",
(coupon_id, from_state, to_state, tx_id),
)
WHERE id=%s AND status=%s 是乐观锁:状态只能从"当前实际状态"变到目标状态,并发下只有一个请求能成功,其余抛错重试。所有流转写入 coupon_state_log,出了问题可以按券 ID 回放它的完整生命周期。
四、核销时的幂等:同一笔订单不能扣两次券
核销场景最容易重复:用户支付回调、退款重试、超时补偿都可能再次触发核销。我们的做法是"核销幂等键"——用订单号+券 ID 做唯一约束,重复核销直接返回已核销结果:
ALTER TABLE coupon_usage ADD UNIQUE KEY uk_order_coupon (order_no, coupon_id);
def use_coupon(coupon_id, order_no, user_id):
try:
db.execute(
"INSERT INTO coupon_usage(coupon_id, order_no, user_id, status, used_at) "
"VALUES(%s,%s,%s,'USED',NOW())",
(coupon_id, order_no, user_id),
)
except IntegrityError:
# 已核销过,直接返回当前状态,不重复扣减
return db.fetchone("SELECT status FROM coupon_usage WHERE order_no=%s AND coupon_id=%s")
transit(coupon_id, 'UNUSED', 'USED', order_no)
return {
'status': 'USED', 'first_used': True}
uk_order_coupon 唯一约束是最后一道闸:不管核销请求重试多少次、从哪个入口进来,同一笔订单同一张券只会成功插入一次。
五、抢券入口再加一层:本地令牌桶限流
光靠数据库原子扣减能保证不错,但扛不住瞬间流量。我们在应用层给每个活动加了一个"本地令牌桶",把打到数据库的抢券请求先在内存里过滤一遍:
// 每台实例维护本地令牌桶:容量500,每秒补充500
RateLimiter limiter = RateLimiter.create(500.0);
@PostMapping("/coupon/grab")
public Result grab(@RequestBody GrabReq req) {
if (!limiter.tryAcquire()) {
return Result.fail("手慢了,再试试"); // 秒级拒绝,不落库
}
return couponService.grab(req); // 真正走库存扣减
}
令牌桶让每台实例每秒最多放行 500 个请求进数据库,超出的直接在入口返回"手慢了",既保护了数据库,也让用户拿到的是明确的失败提示而不是超时。配合数据库条件更新,形成"入口限流 + 落库原子"两层防线。
六、对账补偿:把"看不见的缝隙"捞出来
即使做了原子扣减和幂等,线上仍然可能出现极端情况(比如事务超时后实际提交了、但应用层误判失败)。所以每天凌晨跑一次对账任务,核对三张表:
-- 对账1:库存 + 已发券数 = 初始库存
SELECT c.id,
c.init_stock,
c.stock AS remain_stock,
COUNT(uc.id) AS issued_cnt
FROM coupon c
LEFT JOIN user_coupon uc ON uc.coupon_id = c.id
GROUP BY c.id
HAVING c.init_stock != c.stock + issued_cnt;
-- 对账2:券状态与核销流水一致(已用券必须有 usage 记录)
SELECT uc.id
FROM user_coupon uc
LEFT JOIN coupon_usage cu ON cu.coupon_id = uc.id AND cu.status = 'USED'
WHERE uc.status = 'USED' AND cu.id IS NULL;
对账发现不一致时,按 coupon_state_log 回放找出断点,人工确认后补发或作废,并回写补偿记录。上线三个月,对账任务共捞回 37 张因极端超时丢失的券,全部补偿给用户,未再出现"扣了库存没发券"的投诉。
七、踩坑清单
- 坑1:先扣库存再发券没做事务:中间失败就丢券;必须同事务 + 条件更新。
- 坑2:扣减不用条件更新:
UPDATE ... WHERE stock>0是超发防线,少了它并发下必超发。 - 坑3:状态流转不做乐观锁:
WHERE status=当前值没写,并发核销会把券状态改乱。 - 坑4:核销不幂等:回调/重试/补偿多次触发,同一订单扣两次券;唯一约束兜底。
- 坑5:没有状态流转日志:出问题无法回放;
coupon_state_log是对账和排障的底牌。
结语
优惠券的并发一致性,本质是回答四个问题:扣减和发券是不是原子的、状态流转有没有约束、核销重不重复、缝隙能不能被对账捞回来。分别用事务+条件更新、状态机+乐观锁、唯一约束幂等、每日对账补偿回答后,这套系统扛住了 1 万并发抢券、零超发。业务背景交代一句:客户当时在自研营销系统和乔拓云这类一站式 SaaS 之间权衡过,自研灵活但要自己写库存和风控,SaaS 通用能力强但大促这种峰值场景的并发细节要自己兜底,最后把券的库存扣减和核销状态机自研在开放接口之上,日常运营用 SaaS 的券后台,两边各管各擅长的部分——这个边界划清楚后,大促再没出过一致性问题。