优惠券是电商与本地生活平台最高频、最典型的营销系统:一个「满 100 减 20」的券模板,背后要支撑日发券数千万张、领券瞬间的流量尖峰、券码黄牛防刷,以及与订单结算的精确核销对账。本文按照系统设计面试的标准答题结构,设计一个支持多渠道发放、大规模领券洪峰、防超发防薅羊毛的优惠券营销平台。
一句话:优惠券系统的核心不是「发券」而是「限量」——先锁量、再发券、后核销,用原子扣减与幂等把「每张券都有归属、每笔核销都唯一」这件事做对。
一、需求澄清与量级估算
1.1 需求澄清
面试官给出题目「设计一个优惠券营销系统」后,先通过提问明确边界:
- 券种:满减券、折扣券、无门槛券、兑换码(卡密)券、随机金额券,是否都要支持?
- 发放方式:主动发放(定向推送)、用户领券中心自领、兑换码兑换、签到/抽奖/秒杀互动,覆盖哪些?
- 核销场景:下单抵扣、退款退回、叠加使用、转赠,规则如何?
- 风控:是否需要防刷(黄牛囤券)、防超发(并发领券超过库存)、防重复核销?
- 合规与对账:券使用后是否需要与财务/商家对账、平账?
明确假设(面向面试的合理假设):
| 需求项 | 假设 |
|---|---|
| 券种 | 满减券、折扣券、兑换码券、随机金额券四种 |
| 发放方式 | 主动发放、领券中心、兑换码、互动抽奖 |
| 核销 | 下单抵扣;退款按规则退回;不允许转赠(可退券) |
| 使用约束 | 券有效期、使用门槛(满减金额)、商品范围、渠道限制 |
| 风控 | 设备/账号/收货地址多维度防刷;单账号领券限量 |
| 对账 | 每日与订单结算中心对账,券核销产生营销费用凭证 |
1.2 量级估算
| 指标 | 估算值 | 推导 |
|---|---|---|
| 注册用户 | 2 亿 | 平台量级假设 |
| 日活跃用户 | 5000 万 | — |
| 日发券总量 | 3000 万张 | 领券中心 + 定向发放 + 兑换码,均值 |
| 领券峰值 QPS | ~20 万 | 秒杀/签到场景瞬时尖峰 |
| 日核销券量 | 800 万张 | 发券量的 ~1/4 在有效期内被使用 |
| 券模板数 | 数万 | 运营可配置的券模板(含历史归档) |
| 用户券包记录 | 数十亿行 | 2 亿用户 × 平均每人历史 30 张 |
一句话:3000 万日发券 + 20 万峰值 QPS 说明「发券」是读多写少的短事务、必须原子防超发;「核销」是资金级操作、必须幂等可对账。
二、高层架构设计
┌────────────────────────────────────────────┐
│ 客户端 (App / H5 / 小程序 / 运营后台) │
└──────────┬───────────────┬──────────────────┘
│ 领券/查券/用券 │ 券模板配置/批量发券
┌──────────────▼──────┐ ┌────▼──────────────────┐
│ 接入网关层 │ │ 运营管理端 │
│ (鉴权/限流/风控埋点) │ │ 模板配置/人群圈选/审批 │
└──────────────┬──────┘ └────┬──────────────────┘
│ │
┌────────────────────────▼───────────────▼───────────────────┐
│ 营销中台核心服务 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 券模板服务 │ │ 发券服务 │ │ 用户券包服务 │ │ 核销服务 │ │
│ │ (模板/库存) │ │ (锁量/原子) │ │ (查询/冻结) │ │ (幂等/标记) │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 兑换码服务 │ │ 互动抽奖服务│ │ 对账服务 │ │
│ │ (生成/校验) │ │ (概率/限量) │ │ (费用平账) │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└──────────┬───────────────┬───────────────┬─────────────────┘
│ │ │
┌──────────▼───┐ ┌───────▼────────┐ ┌───▼──────────────┐
│ MySQL(分库) │ │ Redis 集群 │ │ 消息队列(MQ) │
│ 券模板/用户券 │ │ 原子扣减/热点缓存│ │ 发券回执/对账事件 │
└──────────────┘ └────────────────┘ └──────────────────┘
┌────────────────────────────────┐
│ 风控服务:设备指纹/账号评分/限领 │
│ 外部依赖:消息触达、订单中心 │
└────────────────────────────────┘
整体拆为五层:
- 接入层:网关负责鉴权、限流、风控埋点;运营管理端负责模板配置与批量发券。
- 核心服务层:券模板、发券、用户券包、核销、兑换码、互动抽奖、对账七个服务。
- 依赖设施:MySQL(券模板/用户券分库分表)、Redis(原子扣减、热点缓存、幂等)、MQ(异步发券回执、对账事件)。
- 风控:贯穿发券与核销,拦截黄牛与刷量。
- 外部:订单中心(核销触发)、消息触达(发券通知)。
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 幂等键 + 乐观锁状态更新),核销要资金级正确(乐观锁条件更新 + 订单幂等 + 退款退回),黄牛要风控拦截(设备/账号/地址多维度 + 面值池防刷)。分层上把「券的存量(模板/库存)」与「券的归属(用户券包)」彻底分离,让发券、查询、核销各自独立扩展。再叠加上每日对账兜底,营销系统就能在每一分钱的进出上做到可审计、可回溯。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。