大促领券一秒抢空却有人没领到:优惠券库存扣减与核销状态机的并发一致性实践

简介: 大促抢券出现“扣了库存却没发券”:从条件更新原子扣减、核销状态机建模、幂等唯一约束到每日对账补偿,完整还原优惠券并发一致性的修复过程与踩坑。

大促领券一秒抢空却有人没领到:优惠券库存扣减与核销状态机的并发一致性实践

导读

大促开场 10 秒,5000 张满减券显示"已抢光",但后台对账发现实际只核销了 4800 张,还有 200 张既没发出去也没在库存里——用户投诉"明明点到了却提示已抢完"。优惠券这类"高并发扣减 + 多状态流转"的业务,最容易在库存扣减和状态流转之间出现缝隙:扣减了库存但没生成券、生成了券但状态没流转。本文记录一次完整修复:扣减与发券的原子化、核销状态机建模、以及对账补偿,让"抢券"和"用券"每个环节都有一致性兜底。

一、为什么会"扣了库存却没发券"

拆开抢券链路,一次领券要经过四步:校验资格 → 扣减库存 → 生成券 → 返回结果。问题大多出在扣减和生成之间:

  1. 先减库存后写券,中间失败:库存扣减成功、写券事务提交失败,库存少了、券没生成,用户看到"抢完了"。
  2. 先写券后减库存,库存超发:写券成功、扣减失败,券发多了,库存变负数。
  3. 并发重复扣减:同一个用户同一张券点击两次,两个请求都通过校验,各扣一次。
  4. 状态流转缺记录:券从"已领取"到"已核销"的每一步没有落库,出问题无法对账。

二、把"扣减+发券"放进同一个原子操作

修复的第一件事,是让库存扣减和券生成要么一起成功、要么一起失败。用数据库事务 + 条件更新保证原子性:

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 的券后台,两边各管各擅长的部分——这个边界划清楚后,大促再没出过一致性问题。

相关文章
|
存储 人工智能 运维
重磅发布!飞天智算平台+全球最大智算中心
阿里云宣布正式推出全栈智能计算解决方案“飞天智算平台”,并启动两座超大规模智算中心,为科研、公共服务和企业机构,提供强大的智能计算服务,可将计算资源利用率提高3倍以上,AI训练效率提升11倍,推理效率提升6倍。
9996 1
重磅发布!飞天智算平台+全球最大智算中心
|
数据采集 运维 数据管理
数据管理能力成熟度模型
为促进大数据产业持续深入发展,提高政府、企事业单位大数据资产管理意识,借鉴国内外成熟度相关理论思想,结合数据生命周期管理各个阶段的特征,对数据管理能力进行了分析、总结,提炼出组织数据管理的八大过程域,并对每项能力进行了二级过程域和发展等级的划分以及相关功能介绍和评定标准的制定。
2258 1
|
24天前
|
人工智能 前端开发 测试技术
AI越来越强了,为什么测试人反而越来越累!(为打工人发声)
本文揭示AI时代测试工程师“越忙越累”的真相:研发、产品等岗位因AI大幅提效,而测试却成瓶颈——AI擅长“迎合”,测试重在“挑刺”;产能翻倍导致被测物激增,验证环节却难以规模化。文章呼吁理性设定AI提效目标,尊重测试在质量兜底中的不可替代价值。
250 1
|
25天前
|
存储 缓存 供应链
制品库的工程化落地:从依赖治理到供应链安全的实践路径
从制品唯一性、依赖收敛、跨地域分发到安全准入与存储治理,系统梳理制品库工程化落地的核心命题与实践路径,并给出选型评估的四个技术锚点,帮助团队把供应链安全嵌入制品流转链路。
|
24天前
|
存储 弹性计算 人工智能
最新版阿里云服务器收费价格表(轻量/ECS/GPU):一年、1个月与小时费用清单,计费拆解与选型指南
阿里云三大主力服务器选型指南:轻量应用服务器(入门建站,年付300起)、ECS(企业级弹性计算,支持包年/按量/抢占式)、GPU云服务器(AI训练与推理,A10约¥8/小时)。价格受地域、活动及配置影响大,推荐用官网计算器实时比价。
220 0
|
1月前
|
前端开发 NoSQL 数据库
商城重复下单怎么彻底防住:前端防抖、接口幂等与唯一约束的三层防线
重复下单不只来自用户连点,还包括前端超时重试、刷新重放、消息重复投递和并发插入。本文搭建三层纵深防线:前端用提交状态锁改善体验,服务端用预签发幂等键加Redis去重保证同一请求只处理一次,数据库用唯一索引和状态机条件更新做永不失效的兜底,附可复用代码与5个踩坑点。
|
监控 Linux Shell
但凡我早点知道这个Linux批量ping的脚本,也不至于现在还单身!
但凡我早点知道这个Linux批量ping的脚本,也不至于现在还单身!
780 1
|
存储 NoSQL Redis
Redis 中bitMap使用及实现访问量
Redis 中bitMap使用及实现访问量
575 3
|
数据安全/隐私保护 网络架构
|
资源调度 算法 数据挖掘
变异系数法:一种强大的数据离散度度量工具
变异系数法:一种强大的数据离散度度量工具
1512 0
变异系数法:一种强大的数据离散度度量工具