优惠券系统设计方案

覆盖从发券到核销的完整链路,分析核心问题与工程方案

核心挑战
并发超发
关键机制
幂等 + 状态机
数据保障
Redis + DB 双写

一、系统整体架构

优惠券系统整体架构 用户端 领券中心 我的卡包 下单页选券 网关层 限流 鉴权 风控 优惠券服务 券模板管理 发券引擎 核销引擎 过期管理 存储 Redis MySQL MQ 运营后台 创建券模板 数据看板

系统分为五层:用户端(领券 / 卡包 / 选券)、网关层(限流 / 鉴权 / 风控)、优惠券核心服务(模板 / 发券 / 核销 / 过期)、存储层(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(时间有序,便于数据库索引)
// 在应用层生成,避免数据库自增的扩展瓶颈

四、发券流程详解

发券流程时序图 用户 网关 券服务 Redis MySQL 1. 点击领券 2. 限流 + 鉴权 + 风控 3. 转发请求 4. 检查领取次数 用户已领N次 5. Lua 脚本原子扣库存 DECR remain_count 剩余库存 > 0 ? OK : 已抢光 6. 生成券码 + 异步写DB 写入 user_coupon 表 7. 返回领取成功 用户卡包可见

五、核心问题与解决方案

5.1 库存超发 — 最致命的问题

问题描述

1000 张券,在秒杀场景下 10000 人同时抢。如果先在应用层查库存再减库存,就会出现「读到的都是 1,都去减,最终发出 200 张」的超发问题。

方案:Redis Lua 脚本原子扣减

利用 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 行锁?

MySQL 行锁在高并发下会成为瓶颈 —— 所有请求串行化在同一行上,QPS 上限仅几百。Redis 单线程模型天然支持原子操作,Lua 脚本可将 QPS 推到数万级别。MySQL 只做异步持久化。

5.2 防刷与风控

问题描述

脚本、黄牛批量抢券,导致真实用户无法领取。需要在网关层、业务层、数据层多层设防。

多层防刷体系
层级机制工具 / 方式
网关层IP 级别限流令牌桶 / 漏桶算法,每 IP 每秒 N 次
网关层用户级别限流同一用户 ID 每分钟最多 M 次请求
网关层设备指纹 + 验证码检测模拟器、同一设备多账号,触发滑块验证
业务层领取频率限制用户在 X 秒内只能领取一次,记录在 Redis 中
业务层实名 + 手机绑定大额券要求绑定手机号或实名认证
业务层异常行为检测同一 IP 多账号、短时高频率、异地登录 → 标记风险
数据层事后分析通过领取日志分析异常模式,批量冻结可疑账号

5.3 核销幂等性 — 防止重复扣减

问题描述

用户提交订单后,网络超时触发重试,导致同一张券被核销两次。或者支付回调重复触发,导致金额重复扣减。

方案:唯一键 + 状态机 + 乐观锁
  1. 唯一索引coupon_use_log 表上建立 (coupon_id, order_id) 联合唯一索引,重复插入直接失败。
  2. 状态机约束:核销前检查券状态必须为「已发放」,使用 UPDATE ... WHERE status = '已发放' 做乐观锁。
  3. 分布式锁兜底:用 Redis 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 "核销成功"
}

5.4 Redis 与 MySQL 数据一致性

问题描述

先用 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 宕机丢失库存数据。

5.5 优惠券过期处理

方案

不要实时扫描全表!有以下几种策略:

  1. 懒过期:用户查询券列表时,检查 end_time < NOW() AND status = '已发放',直接返回「已过期」状态,不实际写库。
  2. 定时任务分批扫描:每小时扫描一次,每次处理 end_time < NOW() - 1h AND status = '已发放' 的券,批量更新。使用 LIMIT 1000 分批避免长事务。
  3. Redis ZSET 延迟队列:发券时将 (coupon_id, expire_timestamp) 加入 ZSET,定时任务按 score 范围取出到期的券批量处理。
  4. 库存回补:过期券如果模板允许回收,将库存 INCR 回 Redis。

5.6 订单取消 / 退券

方案:状态回滚 + 库存回补
// 退券流程
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. 延长有效期(可选)
  // 补偿用户因订单占用而损失的使用时间
}

六、缓存架构与性能优化

缓存架构 业务请求 L1: Caffeine 本地缓存 券模板信息 · TTL=10min L2: Redis 集群 券库存 · 用户已领次数 分布式锁 · 限流计数器 L3: MySQL 持久化 券实例 · 核销日志 · 对账 缓存更新策略 • 券模板:Cache-Aside,写时删缓存 • 库存:Redis 主控,MySQL 异步同步 • 用户券列表:分页查询 MySQL 热点用户加 Redis 缓存 • 永不缓存:库存 必须实时从 Redis 读取

七、优惠券类型与规则引擎

类型规则示例实现复杂度
满减券 满 100 减 20、满 200 减 50
折扣券 全场 8 折、最高减 30 元
指定商品券 仅限商品 ID 为 [A, B, C] 使用
品类券 仅限「数码」品类使用
新人专享券 注册 30 天内 + 首单可用
叠加券 可与其他券叠加使用,需算优先级
运费券 免运费,不计入商品折扣
券叠加优先级算法

当多张券可叠加时,需要计算最优组合让用户利益最大化(或平台补贴最小化):

  1. 按券类型分组:平台券 > 品类券 > 店铺券 > 商品券
  2. 同级券取折扣最大的
  3. 跨级券取组合优惠最大的(背包问题变种,券数量少时可暴力枚举)
  4. 最终价格 = 原价 - Σ(各级最优券面额),且结果 ≥ 0

八、系统容量评估

日均发券量
100万+
秒杀峰值 QPS
50,000
单次扣库存耗时
< 5ms
评估维度参考值对应方案
Redis 单机库存扣减~10w QPSLua 脚本原子操作,单实例足够
MySQL 写入~5k TPS / 实例异步 MQ 削峰,批量 INSERT
网关限流按业务阈值配置Sentinel / Nginx limit_req
对账延迟≤ 1 小时定时任务分批扫描 + 修复
过期扫描每小时一轮ZSET 范围查询 + 分批更新
数据保留热数据 90 天历史数据归档到冷存储 / Hive

九、完整架构总览(一图流)

优惠券系统完整架构总览 用户请求 领券 · 查券列表 · 核销 · 退券 API 网关 鉴权 · 限流 · 风控 · 日志 券模板服务 创建 · 修改 · 查询 库存管理 发券引擎 库存扣减 · 券码生成 MQ 异步写库 核销引擎 状态机 · 乐观锁 幂等校验 Redis 库存 · 限流 · 锁 RocketMQ 异步写库 · 削峰 Caffeine 本地缓存 XXL-Job 定时任务 MySQL 主库 券模板 · 用户券 · 核销日志 MySQL 从库 / TiDB 读操作 · 报表查询 · 对账 定时对账任务 Redis vs MySQL 过期处理任务 ZSET 扫描 + 批量更新 数据归档任务 90天热数据 → Hive/ES 监控与告警:Prometheus + Grafana + 业务大盘

十、总结:设计要点清单

  1. 库存扣减必须原子化:Redis Lua 脚本是经过验证的最优方案,不要用「先查后减」。
  2. 核销必须幂等:唯一索引 + 乐观锁 + 分布式锁,三重保障。
  3. Redis 做主控,MySQL 做持久化:允许短暂不一致,通过定时对账修复。
  4. 多层防刷:网关限流 → 业务频率限制 → 风控检测,逐层拦截。
  5. 异步削峰:高并发写入通过 MQ 异步化,避免 MySQL 成为瓶颈。
  6. 状态机驱动:券的状态流转(已发放→已核销→已完成/已过期)必须严格约束。
  7. 券码不可猜测:使用 UUID v7 或雪花算法,避免被遍历攻击。
  8. 过期处理用懒 + 定时:查询时懒检查 + 定时任务批量处理,避免全表扫描。
  9. 监控全覆盖:库存水位、发券QPS、核销成功率、延迟、异常率全部接入大盘。
  10. 灰度发布 + 降级:秒杀活动前逐步放量,异常时一键关闭领券入口。

优惠券系统设计方案 · 覆盖发券 / 核销 / 退券 / 过期完整生命周期