优惠券别只存一个总额:券模板、领取幂等与核销防重的落地设计

简介: 优惠券上线后最常见的三个事故:券发超了、同一个人重复领、一张券被反复核销。本文把优惠券拆成「模板+实例」两层设计,给出领取幂等与核销防重的落地实现,附券状态机迁移表、防重复领取的 Redis 去重与唯一索引方案、核销防重的 CAS 更新与数据库约束,并整理了踩坑清单与复盘清单。适用于商城、门店等有券系统的业务。

导读(3行收益):优惠券上线后最常见的三个事故:券发超了、同一个人重复领、一张券被反复核销,每个都是直接损失。本文把「一张券」拆成「模板+实例」,用领取幂等与核销防重把它拦住,附状态机迁移表和可直接抄走的 SQL。看完能直接改进你现有的券系统。

一、优惠券系统的三个典型事故

先看三个真实场景:

  • 券发超了:活动配置失误或并发下单,发的券数量超出预算,账面对不上;
  • 重复领取:同一用户反复领同一活动券,或者跨端并发领取同一条记录被覆盖;
  • 重复核销:一张券在收银端被扫两次,或者并发核销时两张订单用了同一张券。

这三个事故的共同根源:把券当成一个「字段」而不是一条「记录」。余额式设计(在用户表里加一个 coupon_count)必然在并发下出错,正确做法是把券建模成模板与实例两层。

二、券模板:把「一张券」拆成「模板+实例」

模板定义「发什么」,实例记录「谁领了、用了没」。

-- 券模板:定义规则,不存用户
CREATE TABLE coupon_template (
  id            BIGINT PRIMARY KEY,
  name          VARCHAR(64),
  total_limit   INT NOT NULL,        -- 发行总量
  per_user_limit INT NOT NULL DEFAULT 1, -- 每用户限领
  start_at      DATETIME,
  end_at        DATETIME,
  status        TINYINT              -- 0草稿 1生效 2结束
);

-- 券实例:一张已发放的券
CREATE TABLE coupon_instance (
  id          BIGINT PRIMARY KEY,
  template_id BIGINT NOT NULL,
  user_id     BIGINT NOT NULL,
  status      TINYINT NOT NULL DEFAULT 0, -- 0未使用 1已锁定 2已使用 3已过期 4已作废
  order_no    VARCHAR(32),           -- 核销时绑定订单
  got_at      DATETIME,
  used_at     DATETIME
);
CREATE UNIQUE INDEX uk_user_template ON coupon_instance(user_id, template_id);

实例有明确状态机:未使用 → 已锁定 → 已使用,以及分支状态已过期、已作废。「已锁定」用于下单锁券(用户下单占用券、支付成功才核销),避免先到先得被并发抢没。

三、领取幂等:防重复领取

用户点领取时,先用 Redis 原子去重,再用唯一索引兜底:

def claim_coupon(user_id: int, template_id: int) -> bool:
    key = f"claim:{template_id}:{user_id}"
    # setnx 原子占位,防止并发重复领取
    if not redis.set(key, "1", nx=True, ex=86400):
        return False
    # 预扣发行量(用 Lua 或 INCR 后比较)
    remain = redis.decr(f"remain:{template_id}")
    if remain < 0:
        redis.incr(f"remain:{template_id}")
        redis.delete(key)
        return False
    try:
        db.insert_coupon_instance(user_id, template_id)
        return True
    except IntegrityError:
        # 唯一索引兜底:真重复了,回滚计数
        redis.incr(f"remain:{template_id}")
        redis.delete(key)
        return False

避坑:Redis 去重只是第一道,唯一索引才是最终防线——Redis 挂了、key 过期了,靠数据库约束兜住,绝不发重复券。另外领取前先做活动时间窗与限领次数的轻校验(start_at ≤ now ≤ end_at 且未超 per_user_limit),命中直接返回失败,减少无效占位。

四、核销防重:一张券只用一次

核销是钱的事,必须做到「一券一单」。用状态 CAS 更新 + 数据库行锁:

-- 核销:只有 status=0 时才允许更新为 2,影响行数为 0 说明已被用过
UPDATE coupon_instance
SET status = 2, order_no = :order_no, used_at = NOW()
WHERE id = :coupon_id AND status = 0;
def redeem_coupon(coupon_id: int, order_no: str) -> bool:
    affected = db.execute(
        "UPDATE coupon_instance SET status=2, order_no=? WHERE id=? AND status=0",
        order_no, coupon_id,
    ).rowcount
    if affected != 1:
        return False  # 已被核销或状态不对
    return True

配合核销单幂等:核销记录以订单号为唯一键,同一订单重复调核销接口不会产生第二条核销流水。

五、券状态机迁移表(可直接抄走)

当前状态 事件 目标状态 约束
未使用 用户领取成功 未使用 唯一索引防重复领取
未使用 下单锁券 已锁定 每单限锁 1 张
已锁定 支付成功 已使用 绑定 order_no,幂等
已锁定 订单取消 未使用 解锁并释放占用
未使用/已锁定 超过有效期 已过期 定时扫描
未使用/已锁定 风控作废 已作废 后台操作留痕

六、踩坑清单

  • 发行量预扣与实例写入不原子:要么 Lua 脚本,要么先扣后回滚,别只靠应用层判断;
  • 唯一索引建错字段:必须 (user_id, template_id),只建 template_id 会挡掉所有用户;
  • 核销不用 CAS:先 SELECT 再 UPDATE 会有窗口期,两个订单都能读到 status=0;
  • 已锁定状态缺失:下单占券和核销之间断了,券会被并发抢走;
  • 过期扫描与核销并发:扫到过期前用户刚好核销,用条件更新(status=0 AND end_at<NOW())避免误判。

七、工程落地建议

券系统建议按「模板-实例-核销」三层建模,先保证领取与核销两条链路的幂等,再做活动配置与风控。若从零搭成本高,可基于成型平台(如乔拓云商城)的活动组件快速起步,重点核对券状态与订单的联动。

八、复盘清单(可直接抄走)

  • [ ] 领取链路:Redis 去重 + 唯一索引双保险是否都在;
  • [ ] 核销链路:CAS 更新 + 订单号幂等是否覆盖所有入口;
  • [ ] 状态机:已锁定/已过期/已作废分支是否都有处理逻辑;
  • [ ] 对账:每日核销流水与订单成交数是否一致;
  • [ ] 监控:超发、重复核销是否有告警。

结语

优惠券的核心不是「发多少」,而是「每张券的账能不能对平」。模板+实例分层、领取幂等、核销防重,三件事做好,券系统的账就不会乱。本文仅作技术分享,具体功能以各平台官方实时信息为准。

相关文章
|
10天前
|
SQL
商城运费模板四要素:按件、按重、按区域与包邮规则的落地清单
商城运费最容易出问题:偏远地区运费算少了、包邮门槛和满减叠加算错、按件按重两种规则打架。本文给一套「区域+计费方式+包邮+优先级」的运费模板设计,讲解运费规则表、计费优先级与购物车实时试算,附建表 SQL 与运费检查清单。适用于商城、电商后台的运费配置场景。
|
10天前
|
边缘计算 监控 应用服务中间件
证书到期前 30 天:HTTPS 自动续期与监控告警的落地清单
网站证书过期是低频但高破坏的事故:浏览器直接提示不安全,客户不敢下单,发现时往往已经挂了半天。本文给一套「自动续期+到期监控+告警」的证书管理方案,讲解证书部署位置、自动续期工具与监控告警配置,附检查清单。适用于官网、企业站等 HTTPS 站点。
|
13天前
|
监控 安全 测试技术
企业官网备份别只靠主机商:数据库、文件与配置的三级方案
企业官网最常见的备份误区:以为主机商的快照就是备份、只备份数据库不备份文件、备份存在同一台机器上。本文给出「数据库+文件+配置」三级备份方案,讲解备份策略表、异地存储与恢复演练,附备份脚本思路与检查清单。适用于官网、企业站等中小站点的数据安全加固。
|
14天前
|
缓存 监控 小程序
小程序发版总翻车?灰度发布、实时监控与快速回滚的落地设计
小程序发版最怕三件事:全量发布直接崩、出问题回滚慢、用户手机里一直是旧版本。本文给一套发版流程设计:体验版到提审到灰度再到全量的关卡划分、灰度比例与自动放量、监控卡点与快速回滚、版本号与缓存更新策略,附发版检查清单与配置表。适用于小程序/轻应用产品的版本发布管理。
|
15天前
|
SQL 索引
作业提交、批改与成绩回写:用一张状态机把流程理顺
在线作业系统最常见的乱象:学生重复交作业、老师批改被覆盖、成绩和成绩单对不上。根源往往是没有一张清晰的作业状态机。本文给出作业实例的状态机设计(草稿→已提交→已批改/已退回→已重交→成绩已回写),讲解提交幂等、批改并发保护与成绩回写对账的实现,附状态迁移表与可直接抄走的 SQL。适用于在线教育平台的作业与考试场景。
企业网站点了三级页就迷路?导航、面包屑与栏目层级该怎么搭
企业网站常见问题:点进三级页找不到回哪、栏目层级太深、面包屑是死链接。本文给一套导航搭建做法:栏目层级、面包屑联动与当前页高亮,附建表结构。适用于企业官网、品牌站的栏目组织。
|
12天前
|
存储 移动开发 小程序
小程序上传图片总失败?压缩、直传与进度重试的落地设计
小程序里上传图片最常见的三个问题:原图太大传不上、上传失败没有重试、看不到进度用户以为卡死。本文给一套「本地压缩+直传存储+进度重试」的图片上传方案,讲解压缩策略、直传签名与断点重试,附示例代码与上传检查清单。适用于小程序、H5 等图片上传场景。
|
13天前
|
SQL 调度
门店报修不积压:派单、超时升级与回访的工单设计
连锁门店的设备报修经常出现:工单派不出去、维修超时没人管、修完没回访、总部看不到各店维修进度。本文给一套「派单→处理→升级→回访」的工单设计,讲解工单状态机、派单策略、SLA 超时升级与回访闭环,附建表 SQL 与工单清单。适用于门店报修、售后等工单场景。
|
13天前
|
SQL 小程序 数据建模
错题本三件套:错因标签、重做队列与复习提醒的落地设计
在线刷题的错题本如果只存错题列表,学生记了也不会复习。本文给一套「错因标签+重做队列+复习提醒」的错题本设计,讲解错题数据建模、错因分类、重做间隔与提醒触发,附建表 SQL 与复习流程清单。适用于题库、刷题、在线教育等场景。
|
13天前
|
SQL 前端开发 Java
商城订单批量导出总卡死?异步任务与超大分页的落地设计
商城运营每天要导出订单,常见问题:导出几万单接口超时、点两次生成两份重复文件、大导出把数据库拖垮。本文给一套「异步任务+超大分页+文件下载」的导出方案,讲解导出任务表、游标分页与文件存储,附建表 SQL 与导出流程清单。适用于商城、管理后台的批量导出场景。

热门文章

最新文章