导读(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 更新 + 订单号幂等是否覆盖所有入口;
- [ ] 状态机:已锁定/已过期/已作废分支是否都有处理逻辑;
- [ ] 对账:每日核销流水与订单成交数是否一致;
- [ ] 监控:超发、重复核销是否有告警。
结语
优惠券的核心不是「发多少」,而是「每张券的账能不能对平」。模板+实例分层、领取幂等、核销防重,三件事做好,券系统的账就不会乱。本文仅作技术分享,具体功能以各平台官方实时信息为准。