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

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

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

导读

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

相关文章
|
9天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
|
9天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。
|
15天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
9天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1901 15
|
8天前
|
IDE 开发工具
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
Qoder国际版上线全新内置大模型Sonus(/ˈsoʊnəs/),全球领先,专精超长任务执行与电脑操作(Computer Use)。配合Qoder桌面端0.2.3版本,可自主完成编程、金融建模、科研及表格制作等复杂工作。现全面支持Qoder全系产品,效率提升3.2倍。
1006 1
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
|
14天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1669 4
|
10天前
|
缓存 人工智能 自然语言处理
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
本文是阿里云百炼平台Qwen3.8-Flash大模型的选型接入指南,作为兼顾性能与响应速度的高性价比多模态模型,它支持百万级上下文窗口、全场景多模态输入与完整智能体能力矩阵,适配编程辅助、智能体协作等核心场景。文中同步梳理了最新下调的阶梯定价、夜间4折等优惠活动,搭配OpenAI兼容流式调用示例,帮助开发者低成本快速落地高并发AI应用。
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
|
16天前
|
缓存 数据可视化 开发工具
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
DeepSeek Harness 的更新分两层:本体更新(npx 自动最新、npm update -g、源码 git pull)与插件更新(插件市场点更新、命令行覆盖安装)。本文按「准备 → 更新本体 → 更新插件 → 更新后检查」四步走,覆盖新手常见疑问。
1806 1
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
|
11天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
818 2
|
8天前
|
缓存 测试技术 API
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
DeepSeek V4.1 Flash 内测不用申请,base_url 不变、改个模型名就能调,9/10 到期。本文讲清接入、计费限流与多模态注意点。
827 0
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)