设计一个优惠券营销系统

本文系统设计一个高并发、防超发、防薅羊毛的优惠券营销平台:覆盖券模板与券种建模、发券渠道(主动发放/领券中心/兑换码/互动抽奖)、用户券包与核销链路、原子发券与超发防治、批量发券与秒杀领券的削峰、券码防刷与风控,并给出数据表结构、架构图与量级估算。

优惠券是电商与本地生活平台最高频、最典型的营销系统:一个「满 100 减 20」的券模板,背后要支撑日发券数千万张、领券瞬间的流量尖峰、券码黄牛防刷,以及与订单结算的精确核销对账。本文按照系统设计面试的标准答题结构,设计一个支持多渠道发放、大规模领券洪峰、防超发防薅羊毛的优惠券营销平台。

一句话:优惠券系统的核心不是「发券」而是「限量」——先锁量、再发券、后核销,用原子扣减与幂等把「每张券都有归属、每笔核销都唯一」这件事做对。

一、需求澄清与量级估算

1.1 需求澄清

面试官给出题目「设计一个优惠券营销系统」后,先通过提问明确边界:

  • 券种:满减券、折扣券、无门槛券、兑换码(卡密)券、随机金额券,是否都要支持?
  • 发放方式:主动发放(定向推送)、用户领券中心自领、兑换码兑换、签到/抽奖/秒杀互动,覆盖哪些?
  • 核销场景:下单抵扣、退款退回、叠加使用、转赠,规则如何?
  • 风控:是否需要防刷(黄牛囤券)、防超发(并发领券超过库存)、防重复核销?
  • 合规与对账:券使用后是否需要与财务/商家对账、平账?

明确假设(面向面试的合理假设):

需求项假设
券种满减券、折扣券、兑换码券、随机金额券四种
发放方式主动发放、领券中心、兑换码、互动抽奖
核销下单抵扣;退款按规则退回;不允许转赠(可退券)
使用约束券有效期、使用门槛(满减金额)、商品范围、渠道限制
风控设备/账号/收货地址多维度防刷;单账号领券限量
对账每日与订单结算中心对账,券核销产生营销费用凭证

1.2 量级估算

指标估算值推导
注册用户2 亿平台量级假设
日活跃用户5000 万—
日发券总量3000 万张领券中心 + 定向发放 + 兑换码,均值
领券峰值 QPS~20 万秒杀/签到场景瞬时尖峰
日核销券量800 万张发券量的 ~1/4 在有效期内被使用
券模板数数万运营可配置的券模板(含历史归档)
用户券包记录数十亿行2 亿用户 × 平均每人历史 30 张

一句话:3000 万日发券 + 20 万峰值 QPS 说明「发券」是读多写少的短事务、必须原子防超发;「核销」是资金级操作、必须幂等可对账。

二、高层架构设计

                    ┌────────────────────────────────────────────┐
                    │       客户端 (App / H5 / 小程序 / 运营后台)      │
                    └──────────┬───────────────┬──────────────────┘
                               │ 领券/查券/用券   │ 券模板配置/批量发券
                ┌──────────────▼──────┐   ┌────▼──────────────────┐
                │      接入网关层        │   │      运营管理端          │
                │ (鉴权/限流/风控埋点)    │   │ 模板配置/人群圈选/审批    │
                └──────────────┬──────┘   └────┬──────────────────┘
                               │               │
      ┌────────────────────────▼───────────────▼───────────────────┐
      │                       营销中台核心服务                          │
      │  ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
      │  │ 券模板服务  │ │ 发券服务    │ │ 用户券包服务 │ │ 核销服务    │ │
      │  │ (模板/库存) │ │ (锁量/原子) │ │ (查询/冻结) │ │ (幂等/标记) │ │
      │  └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
      │  ┌────────────┐ ┌────────────┐ ┌────────────┐               │
      │  │ 兑换码服务  │ │ 互动抽奖服务│ │ 对账服务    │               │
      │  │ (生成/校验) │ │ (概率/限量) │ │ (费用平账)  │               │
      │  └────────────┘ └────────────┘ └────────────┘               │
      └──────────┬───────────────┬───────────────┬─────────────────┘
                 │               │               │
      ┌──────────▼───┐  ┌───────▼────────┐  ┌───▼──────────────┐
      │ MySQL(分库)   │  │ Redis 集群     │  │ 消息队列(MQ)      │
      │ 券模板/用户券  │  │ 原子扣减/热点缓存│  │ 发券回执/对账事件  │
      └──────────────┘  └────────────────┘  └──────────────────┘
        ┌────────────────────────────────┐
        │ 风控服务:设备指纹/账号评分/限领 │
        │ 外部依赖:消息触达、订单中心     │
        └────────────────────────────────┘

整体拆为五层:

  1. 接入层:网关负责鉴权、限流、风控埋点;运营管理端负责模板配置与批量发券。
  2. 核心服务层:券模板、发券、用户券包、核销、兑换码、互动抽奖、对账七个服务。
  3. 依赖设施:MySQL(券模板/用户券分库分表)、Redis(原子扣减、热点缓存、幂等)、MQ(异步发券回执、对账事件)。
  4. 风控:贯穿发券与核销,拦截黄牛与刷量。
  5. 外部:订单中心(核销触发)、消息触达(发券通知)。

2.1 为什么拆成七个服务

  • 券模板服务:只负责「券长什么样、有多少量」,不碰用户。
  • 发券服务:负责「把一张券从模板扣出并绑定给用户」,必须原子。
  • 用户券包服务:负责「用户有哪些券、状态如何」,高频读,独立缓存。
  • 核销服务:负责「券能不能用、用完标记」,必须幂等,对接订单中心。
  • 兑换码服务:负责卡密券的生成、预绑定、兑换,与用户体系解耦。
  • 互动抽奖服务:负责抽奖概率、限量、风控,削峰入口。
  • 对账服务:负责券核销后的营销费用平账与异常回溯。

一句话:把「券的存量」与「券的归属」分离,让高并发的扣量、高频的查询、资金级的核销各自独立扩缩容、独立保障。

三、核心组件设计

3.1 券模板服务与库存模型

券模板是「发行计划」,库存是模板的并发量单位。模板服务为每个模板维护一个库存字段,但在高并发下单字段扣减会成为热点:

CREATE TABLE coupon_template (
  template_id   BIGINT PRIMARY KEY,
  title         VARCHAR(128),
  type          TINYINT,        -- 1满减 2折扣 3兑换码 4随机金额
  face_value    DECIMAL(10,2),  -- 面值/满减门槛
  threshold     DECIMAL(10,2),  -- 使用门槛
  total_count   INT,            -- 总发行量
  issued_count  INT,            -- 已发出量(冗余,供快速判断)
  valid_start   DATETIME,
  valid_end     DATETIME,
  scope_json    JSON,           -- 商品/渠道/用户标签范围
  status        TINYINT         -- 0草稿 1发放中 2已发完 3下线
);

库存扣减必须在 Redis 原子完成,MySQL 只做终态:

Redis key: coupon:stock:{template_id}
  原子扣减: DECR / DECRBY(返回值 < 0 说明超发,回滚)

伪代码:
  if redis.DECR("coupon:stock:" + tid) >= 0:   # 原子锁量
      return 发券成功
  else:
      redis.INCR("coupon:stock:" + tid)        # 回滚
      return "库存不足"

要点:Redis DECR 的原子性天然防止并发超发;MySQL 的 issued_count 由异步回执更新,作为对账基线而非并发防线。

3.2 发券服务:原子发券与幂等

发券动作 = 「锁量 + 建用户券 + 发通知」,必须保证同一请求只能发成功一次(幂等),且不能超过模板库存(防超发):

CREATE TABLE user_coupon (
  coupon_id     BIGINT PRIMARY KEY,      -- 雪花ID
  user_id       BIGINT,
  template_id   BIGINT,
  status        TINYINT,                 -- 0未使用 1已使用 2已过期 3已冻结 4已退回
  source        TINYINT,                 -- 1主动发放 2领券中心 3兑换码 4抽奖
  obtain_time   DATETIME,
  expire_time   DATETIME,
  order_no      VARCHAR(64),             -- 核销订单号(幂等键)
  use_time      DATETIME
);
发券流程(幂等键 = 请求方业务ID):
1. 幂等检查:若 redis.SETNX(idem:issue:{bizId}, 1, 1min) 失败 → 返回已发
2. 锁量:redis.DECR("coupon:stock:"+tid) < 0 → 回滚并返回库存不足
3. 建券:insert user_coupon(coupon_id 雪花,状态 0)
4. 异步回执:发 MQ 更新模板 issued_count、发送消息通知
5. 返回券信息

批量定向发放(运营给 100 万用户发券):不能逐条 insert,要分片批量:

人群圈选(数仓标签) → 生成发放批次 → 按用户 ID 分片(如 100 片)
每片一个消费组 → 批量 insert user_coupon(每批 1000 行)
Redis 预扣整批库存 → 失败整批回滚 → 对账记录发放批次

要点:单个用户领券是「短事务」,批量发放是「长任务」——前者走同步锁量,后者走异步分片,避免大事务锁死数据库。

3.3 领券中心与秒杀场景的削峰

领券中心在「9 点开抢」等场景会形成瞬时尖峰。削峰三板斧:

① 预扣预热:活动开始前把库存预加载进 Redis(预热),避免打爆 MySQL
② 限流排队:网关按用户维度限流,超过阈值进 MQ 排队异步发券
③ 异步化:前台先返回「领取中」,MQ 消费者真正落库,失败短信补偿
;; 伪代码(幂等领券:每人限领 N 张,超限直接拒绝)
(defn claim-coupon [user-id template-id]
  (when-not (rate-limit-exceeded? user-id)
    (let [cnt (redis/incr (str "claim:count:" user-id ":" template-id))]
      (if (<= cnt 2)                       ; 每人限领 2 张
        (issue-coupon user-id template-id) ; 走 3.2 的原子发券
        :over-quota))))

要点:秒杀领券的关键不是「更快发完」,而是「不超发、不漏发、人人公平」——限流排队让请求先进来慢慢落,Redis 原子扣减保证库存只减不超。

3.4 核销服务:幂等核销与对账

核销发生在用户下单时:订单中心携带券 ID 请求核销,同一订单重复核销必须返回同一结果:

核销流程:
1. 幂等:redis.SETNX("redeem:once:{orderNo}", 1) → 重复请求直接返回已核销结果
2. 校验:券状态=0(未使用)、在有效期内、满足门槛与范围
3. 扣减:UPDATE user_coupon SET status=1, order_no=?, use_time=now
          WHERE coupon_id=? AND status=0        -- 乐观锁:只更新未使用
   -- 影响行数 = 0 → 已被并发核销/过期,返回失败
4. 落账:发 MQ 生成营销费用流水(给财务对账)
5. 返回抵扣金额给订单中心

退款退回:订单退款时,按原抵扣金额退回券(重置 status=0、清空 order_no),并幂等(退款单为幂等键),避免重复退券造成超发。

-- 退回也要乐观锁:只退回「已使用且绑定该订单」的券
UPDATE user_coupon SET status=0, order_no=NULL, use_time=NULL
WHERE coupon_id=? AND status=1 AND order_no=?;

3.5 兑换码与随机金额券

兑换码(卡密):线下渠道批量售卖的券,兑换码需预生成、预绑定、防枚举:

生成:随机 12-16 位(大写字母+数字,避开易混淆字符)
存储:code_hash(只存哈希,防止数据库泄露后批量盗刷)
兑换:校验哈希 → 判状态(未使用)→ 绑定用户 → 标记已兑换
安全:限制兑换频率、同 IP/设备次数风控、码段绑定渠道

随机金额券:面值不固定(1~88 元区间随机)。金额随机要预生成面值池,避免每次随机导致的期望值失控,且需风控「刷脸值」——通过多账号领高额券是常见薅法:

面值池:按概率分布预生成 N 张面值并打乱 → 发券时从池中取一张
防刷:同设备/同 IP/同收货地址累计领取面值超阈值 → 触发风控拦截

四、深入权衡

4.1 用户券包的大 Key 与缓存

热门券模板发券后,「用户查自己券包」高频访问。若每用户一张券一个 key,会形成哈希大 Key(一个 key 里塞几千个字段):

方案优点缺点
每券一个 key简单、可过期券包查询需 mget 几千次
用户券包 hash(user:coupon:{uid})一次 hgetall 拿全部大 Key 阻塞、扩容难
券包 hash + 冷热分离热券 hgetall、历史走 DB实现复杂、冷热判断有误差

权衡结论:默认采用「用户券包 hash + 最近 N 张热券」两级缓存;全部券列表走分页查询 DB(带索引 (user_id, status, expire_time)),并给每个用户券包 hash 设过期与增量写,控制单 key 规模。

4.2 分布式锁 vs 原子扣减

防超发有两条路线,面试时要做对比:

方案机制适用
分布式锁(Redisson 可重入锁)串行化扣减,实现直观低频、强校验场景
Redis 原子 DECR无锁化,吞吐极高高频秒杀场景

结论:发券这类「高并发 + 只需保证不超发」的场景,用 DECR 原子扣减(O(1)、无锁等待);需要「扣减 + 读回校验 + 条件更新」的复合原子操作时,用 Lua 脚本合并,避免分布式锁带来的性能瓶颈。

4.3 对账与营销费用平账

核销后的费用要进财务系统,对账粒度要按日 + 按模板:

数据源:核销流水(MQ 落库)vs 订单中心实付明细
核对项:每张核销券是否都产生订单、抵扣金额是否一致
异常处理:券已核销但订单不存在(补发/挂账)、订单存在但券未核销(回查)

一句话:优惠券是「负债」不是「资产」——每张发出的券在核销后都变成真金白银的费用,对账是营销系统上线前必须设计好的兜底。

五、总结

优惠券营销系统的核心矛盾是限量与并发:模板库存要在 20 万 QPS 下只减不超(Redis 原子 DECR + 预热 + 限流排队),用户领券要幂等(业务 ID 幂等键 + 乐观锁状态更新),核销要资金级正确(乐观锁条件更新 + 订单幂等 + 退款退回),黄牛要风控拦截(设备/账号/地址多维度 + 面值池防刷)。分层上把「券的存量(模板/库存)」与「券的归属(用户券包)」彻底分离,让发券、查询、核销各自独立扩展。再叠加上每日对账兜底,营销系统就能在每一分钱的进出上做到可审计、可回溯。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个弹幕系统
  2. 设计一个权限系统(RBAC + ABAC)
  3. 设计一个分布式任务调度系统