覆盖从发券到核销的完整链路,分析核心问题与工程方案
系统分为五层:用户端(领券 / 卡包 / 选券)、网关层(限流 / 鉴权 / 风控)、优惠券核心服务(模板 / 发券 / 核销 / 过期)、存储层(Redis 缓存 + MySQL 持久化 + MQ 异步)、运营后台。
| 状态 | 含义 | 可转换到 | 触发条件 |
|---|---|---|---|
| 已创建 | 券已生成但未分配给用户 | 已发放 / 已过期 | 运营发放 / 模板过期 |
| 已发放 | 用户已领取,待使用 | 已核销 / 已过期 | 用户下单核销 / 超出有效期 |
| 已核销 | 已用于订单,但订单未完成 | 已完成 / 已发放 | 订单完成 / 订单取消退券 |
| 已完成 | 终态,不可再变化 | -- | -- |
| 已过期 | 终态,超有效期末使用 | -- | -- |
核心三张表:coupon_template(券模板)、user_coupon(用户券实例)、coupon_use_log(核销记录)。
| 表 | 关键字段 | 说明 |
|---|---|---|
coupon_template |
id, name, type, discount_value, min_amount, total_count, remain_count, start_time, end_time, per_user_limit, status | 券模板:减多少、满多少才能用、总库存、每人限领、有效期 |
user_coupon |
id, template_id, user_id, coupon_code, status, receive_time, use_time, order_id | 用户持有的券实例,每张券有唯一 code |
coupon_use_log |
id, coupon_id, user_id, order_id, use_amount, use_time, undo_flag | 核销流水,支持退券回滚 |
券码要求:全局唯一、不可猜测、支持索引。推荐方案:
// 方案:前缀 + 时间戳 + 机器ID + 序列号 + 校验位
coupon_code = "CP" + timestamp(36进制) + machine_id(2位)
+ sequence(4位) + checksum(2位)
// 示例: CP3K2N7X01A3B59F2
// 或者直接用 UUID v7(时间有序,便于数据库索引)
// 在应用层生成,避免数据库自增的扩展瓶颈
1000 张券,在秒杀场景下 10000 人同时抢。如果先在应用层查库存再减库存,就会出现「读到的都是 1,都去减,最终发出 200 张」的超发问题。
利用 Lua 脚本在 Redis 服务端单线程执行的特性,将「检查 + 扣减」打包为一个原子操作。
-- coupon_deduct.lua
local key = KEYS[1] -- coupon:stock:{template_id}
local user_key = KEYS[2] -- coupon:user_count:{template_id}:{user_id}
local limit = tonumber(ARGV[1]) -- 每人限领数量
-- 1. 检查库存
local stock = tonumber(redis.call('GET', key) or 0)
if stock <= 0 then
return {0, 'stock_empty'} -- 库存不足
end
-- 2. 检查用户领取次数
local count = tonumber(redis.call('GET', user_key) or 0)
if count >= limit then
return {0, 'user_limit'} -- 超出限领
end
-- 3. 原子扣减
redis.call('DECR', key)
redis.call('INCR', user_key)
return {1, stock - 1} -- 成功, 返回剩余库存
用法:EVAL coupon_deduct.lua 2 coupon:stock:123 coupon:user_count:123:user456 1
MySQL 行锁在高并发下会成为瓶颈 —— 所有请求串行化在同一行上,QPS 上限仅几百。Redis 单线程模型天然支持原子操作,Lua 脚本可将 QPS 推到数万级别。MySQL 只做异步持久化。
脚本、黄牛批量抢券,导致真实用户无法领取。需要在网关层、业务层、数据层多层设防。
| 层级 | 机制 | 工具 / 方式 |
|---|---|---|
| 网关层 | IP 级别限流 | 令牌桶 / 漏桶算法,每 IP 每秒 N 次 |
| 网关层 | 用户级别限流 | 同一用户 ID 每分钟最多 M 次请求 |
| 网关层 | 设备指纹 + 验证码 | 检测模拟器、同一设备多账号,触发滑块验证 |
| 业务层 | 领取频率限制 | 用户在 X 秒内只能领取一次,记录在 Redis 中 |
| 业务层 | 实名 + 手机绑定 | 大额券要求绑定手机号或实名认证 |
| 业务层 | 异常行为检测 | 同一 IP 多账号、短时高频率、异地登录 → 标记风险 |
| 数据层 | 事后分析 | 通过领取日志分析异常模式,批量冻结可疑账号 |
用户提交订单后,网络超时触发重试,导致同一张券被核销两次。或者支付回调重复触发,导致金额重复扣减。
coupon_use_log 表上建立 (coupon_id, order_id) 联合唯一索引,重复插入直接失败。UPDATE ... WHERE status = '已发放' 做乐观锁。SETNX coupon:lock:{coupon_id} 30 做互斥,核销完成后释放。// 核销伪代码
function useCoupon(couponId, orderId, userId) {
// 1. 分布式锁
lock := redis.setNX("coupon:lock:" + couponId, "1", 30s)
if !lock { return "操作太频繁,请稍后重试" }
// 2. 乐观锁更新状态
affected := db.Exec(
"UPDATE user_coupon SET status='已核销', order_id=?, use_time=NOW()
WHERE id=? AND status='已发放' AND user_id=?",
orderId, couponId, userId
)
if affected == 0 { return "券状态异常或已使用" }
// 3. 记录核销日志(唯一索引防重)
db.Exec("INSERT INTO coupon_use_log (coupon_id, order_id, user_id, ...) VALUES (?,?,?)",
couponId, orderId, userId) // 重复插入会报唯一键冲突
redis.del("coupon:lock:" + couponId)
return "核销成功"
}
先用 Redis 扣库存成功,但写 MySQL 失败,导致 Redis 和数据库数据不一致。或者 MySQL 写入成功但 Redis 未更新。
| 策略 | 做法 |
|---|---|
| 先写 Redis 做主控 | 库存扣减以 Redis 为准,MySQL 异步写入。Redis 扣减成功的才允许发券。 |
| MySQL 写入失败重试 | Redis 扣减成功后,将写入事件发到 MQ,消费者重试写入 MySQL。重试仍失败则记录到死信队列。 |
| 定时对账 | 每小时用 COUNT 对比 Redis 库存和 MySQL 中已发数量,不一致时以 Redis 为准修复 MySQL。 |
| 库存回滚 | 如果 MySQL 写入彻底失败(死信),回滚 Redis 库存 INCR,并记录异常日志。 |
| Redis 持久化 | 开启 AOF + RDB 双持久化,防止 Redis 宕机丢失库存数据。 |
不要实时扫描全表!有以下几种策略:
end_time < NOW() AND status = '已发放',直接返回「已过期」状态,不实际写库。end_time < NOW() - 1h AND status = '已发放' 的券,批量更新。使用 LIMIT 1000 分批避免长事务。(coupon_id, expire_timestamp) 加入 ZSET,定时任务按 score 范围取出到期的券批量处理。INCR 回 Redis。// 退券流程
function refundCoupon(couponId, orderId, userId) {
// 1. 乐观锁:已核销 → 已发放
affected := db.Exec(
"UPDATE user_coupon SET status='已发放', order_id=NULL
WHERE id=? AND status='已核销' AND user_id=?",
couponId, userId
)
if affected == 0 { return "券状态异常,无法退券" }
// 2. 标记核销日志为已回滚
db.Exec("UPDATE coupon_use_log SET undo_flag=1 WHERE coupon_id=? AND order_id=?",
couponId, orderId)
// 3. 如果模板允许回收库存
redis.incr("coupon:stock:" + templateId)
// 4. 延长有效期(可选)
// 补偿用户因订单占用而损失的使用时间
}
| 类型 | 规则示例 | 实现复杂度 |
|---|---|---|
| 满减券 | 满 100 减 20、满 200 减 50 | 低 |
| 折扣券 | 全场 8 折、最高减 30 元 | 低 |
| 指定商品券 | 仅限商品 ID 为 [A, B, C] 使用 | 中 |
| 品类券 | 仅限「数码」品类使用 | 中 |
| 新人专享券 | 注册 30 天内 + 首单可用 | 中 |
| 叠加券 | 可与其他券叠加使用,需算优先级 | 高 |
| 运费券 | 免运费,不计入商品折扣 | 低 |
当多张券可叠加时,需要计算最优组合让用户利益最大化(或平台补贴最小化):
| 评估维度 | 参考值 | 对应方案 |
|---|---|---|
| Redis 单机库存扣减 | ~10w QPS | Lua 脚本原子操作,单实例足够 |
| MySQL 写入 | ~5k TPS / 实例 | 异步 MQ 削峰,批量 INSERT |
| 网关限流 | 按业务阈值配置 | Sentinel / Nginx limit_req |
| 对账延迟 | ≤ 1 小时 | 定时任务分批扫描 + 修复 |
| 过期扫描 | 每小时一轮 | ZSET 范围查询 + 分批更新 |
| 数据保留 | 热数据 90 天 | 历史数据归档到冷存储 / Hive |
优惠券系统设计方案 · 覆盖发券 / 核销 / 退券 / 过期完整生命周期