10 万 QPS 优惠券系统设计:限量发券与状态收敛
如何从零搭建 10 万 QPS 大流量、高并发优惠券系统?
阅读说明与事实边界
“10 万 QPS 优惠券系统”很容易被回答成固定模板:网关限流、Redis 扣库存、MQ 异步、数据库落库。这个答案没有解释最重要的业务问题:运营怎样创建券,用户领到的到底是什么,领券成功以什么为准,下单怎样占用和核销,取消订单是否退券,Redis 与数据库不一致时信谁。
本文从一张“限量新人满减券”的完整生命周期设计:运营创建券模板,审核后投放;活动开始时瞬时请求达到 100,000 QPS;每人限领一张;领取后可在订单中占用、核销、释放或过期;系统能处理重复点击、缓存故障、消息积压和数据库写入失败。
所有规模均是设计目标。真正上线前必须用实际库存、用户分布、请求持续时间、实例规格和故障条件压测,不能把本文数字写成生产成绩。
一、项目基础与业务闭环:先区分“券模板”和“用户券”
1.1 券模板
券模板是运营定义的规则:
- 满 100 减 20;
- 总发行 50,000 张;
- 每个用户最多领取 1 张;
- 仅限指定商品类目;
- 活动时间和领取渠道;
- 用户券有效期是固定日期还是领取后 7 天;
- 订单取消后是否退还;
- 是否能与会员折扣、平台券、商家券叠加。
1.2 用户券
用户券是发给某个用户的实例,拥有独立状态、有效期和领取来源。模板库存减少不代表用户已经拿到券;用户券落库也不代表订单已经使用;下单选择券也不代表最终核销。
1.3 四个业务阶段
高并发主要集中在抢领阶段,但完整系统还要覆盖模板发布、查询、使用、退款、过期和对账。只做一个 Redis DECR 不是优惠券系统。
1.4 核心不变量
- 同一券模板实际发出的用户券数不能超过批准发行量。
- 同一用户领取数量不能超过模板限领规则。
- 一张用户券在同一时刻最多被一个有效订单占用。
- 一张券最多完成一次最终核销。
- 重复请求、重复消息和客户端超时重试不能重复发券或重复退券。
- 模板规则版本一旦用于发券,后续修改不能悄悄改变已领取用户券的核心权益。
- 返回“排队中”只代表请求被可靠受理,不代表券已到账。
二、业务状态机
2.1 券模板状态
已发布模板不能直接原地修改折扣、适用范围和发行量。需要生成新规则版本或走增发、暂停等受控命令,并保留审计。
2.2 用户券状态
引入 ISSUING 能清楚表达“系统已经扣了抢领名额,但用户券尚未最终落库”的窗口。对外接口可以返回 ACCEPTED 和请求 ID,用户稍后查询最终结果。
2.3 领券请求状态
请求状态让客服能回答“为什么我显示排队中”,也让系统知道哪些名额已经扣减但还没有用户券。
三、数据模型与约束
3.1 数据库最后一道防线
至少建立:
template_id + user_id + claim_sequence或适合限领次数的唯一约束;每人限一张时直接template_id + user_id唯一;template_id + idempotency_key或全局请求 ID 唯一;user_coupon_id + order_id占用业务唯一;- 状态迁移用
status + version条件更新; issued_count <= total_quota由条件更新和对账共同守护。
Redis 原子脚本是高并发入口的第一道控制,数据库唯一约束和状态条件是事实落库的最后防线。二者职责不同,不能因为 Redis 已经判断过就删除数据库约束。
3.2 规则快照
用户券保存 rule_version 以及必要的有效期和适用范围快照。运营修改模板后,已发券仍按领取时规则执行,除非业务通过审计流程明确撤销或迁移。
四、技术架构:10 万请求怎样被分层吸收
4.1 哪些请求不应该进入抢领核心
- 活动页面和规则说明走 CDN;
- 未登录、活动未开始、明显机器人请求尽量在网关或风控层拦截;
- 客户端按钮倒计时只改善体验,服务端仍以自己的时间判断;
- 同用户高频重复点击在边缘和接入层限速;
- 活动已售罄后下发短期售罄标记,避免每次都执行库存脚本;
- 查询领券结果与提交领券分开,轮询要退避或使用推送。
10 万入口 QPS 不应等于 10 万次 Redis Lua,更不应等于 10 万数据库事务。
4.2 同步还是异步发券
对于限量热点券,推荐同步完成“资格和名额受理”,异步完成用户券落库:
提交成功响应:ACCEPTED + request_id
最终查询结果:DELIVERED / REJECTED / PROCESSING
若数据库写能力足以承担有效领取 TPS,也可以同步落库后返回 DELIVERED。选择由有效成功量和用户体验决定,而不是因为“高并发必须上 MQ”。库存只有 5 万张,即使入口 10 万 QPS,真正需要落库的成功量有明确上限,失败流量应在前层快速结束。
五、核心技术实现一:领券入口的原子裁决
5.1 Redis 中保存什么
按活动模板保存:
- 剩余可受理名额;
- 活动状态和规则版本;
- 用户已领取或已受理次数;
- 请求处理结果的短期索引;
- 可靠请求流或能重建请求状态的日志。
不要把用户的最终券事实只存在 Redis。入口状态用于快速裁决,数据库保存最终用户券和变更流水。
5.2 原子脚本要一次判断哪些条件
- 活动是否
ACTIVE; - 当前服务端时间是否在领取区间;
- 请求幂等键是否已有结果;
- 用户是否超过限领次数;
- 剩余名额是否大于零;
- 通过后同时扣减名额、增加用户受理计数、登记请求并写入可靠流。
5.3 为什么“先查再减”会超发
下面的自然实现有竞争窗口:
读取 remaining = 1
请求 A 判断可领
请求 B 判断可领
请求 A 扣减并发券
请求 B 扣减并发券
Lua 或具备事务语义的原子命令把判断和变更合并。数据库落库时仍用唯一约束和模板发行量检查,防止缓存初始化错误、脚本缺陷或补偿重复。
5.4 集群槽和活动热点
单个超级活动本质上是一个热写 Key,简单把 Redis 扩成更多分片不会自动拆开同一个 Key。可按容量逐步选择:
- 单活动放到隔离 Redis 实例或专用分片,避免影响普通活动;
- 预先把总库存分成多个逻辑桶,请求按稳定哈希路由;
- 每个桶独立原子扣减,售罄桶少量转移流量到其他桶;
- 库存桶之和严格等于总发行量,并保留分配、回收和对账记录;
- 活动结束或暂停时统一关闭所有桶。
库存分桶提升写吞吐,却增加尾部碎片和回补复杂度。没有证明单 Key 成为瓶颈前,不必过早使用。
六、核心技术实现二:从 ACCEPTED 到用户券到账
6.1 正常时序
若 Redis 使用 Stream 记录请求,消费者需要持久化消费组、Pending 恢复和转储;若使用 MQ,必须解决 Redis 扣名额后 MQ 发送失败的窗口。
6.2 Redis 和 MQ 之间的一致性
三种选择:
方案 A:Lua 同时写 Redis Stream。 名额和请求记录在同一个 Redis 原子操作完成,后台从 Stream 投递或直接消费。优点是闭合窗口;代价是 Redis 承担短期可靠日志,需要规划持久化、集群故障和归档。
方案 B:先数据库受理再写 MQ。 数据库以条件更新和唯一键受理,Outbox 发布。事实最稳,但入口写 TPS 受数据库限制,适合成功量不高且数据库能承受的活动。
方案 C:Redis 扣减后普通发 MQ。 实现简单,但服务在两步之间宕机会扣了名额却没有发券任务。必须有接受态扫描、Redis 操作日志或对账恢复,不能假装没有窗口。
本文优先选 A,并将 Redis Stream 持续归档到数据库请求表。业务若要求更强持久化,可用专门的持久队列或数据库 Outbox 承接受理。
6.3 消费者幂等
消费者用 request_id 唯一插入领取请求,再根据模板限领规则插入用户券。若发现该用户已经有券:
- 同一请求重放:返回已有
user_coupon_id; - 不同请求但每人限一张:把后者收敛为
REJECTED_DUPLICATE,并按补偿规则回补多占名额; - 数据冲突且规则无法判断:进入人工核查,不能直接多发。
七、核心技术实现三:失败后的库存回补
7.1 哪些失败可以回补
- 用户资格在异步落库阶段被权威系统判定无效;
- 数据库永久约束表明用户已领取,当前请求多占了一个名额;
- 模板配置错误导致请求被业务拒绝;
- 请求经过多次恢复仍确认无法生成用户券。
数据库短暂超时不能立即回补,因为原事务可能已经成功但响应丢失。必须先按请求 ID 查询结果,只有确认没有用户券且请求进入最终失败,才发起幂等回补。
7.2 回补状态机
回补也要以 request_id 幂等。不能收到一次失败就 INCR,否则重复失败消息会把库存加大,最终造成超发。
7.3 少卖和超发如何取舍
在结果未知时宁可短暂少卖,也不要先回补再发现原用户券已成功,造成超发。少卖的名额能通过核查后重新放出;超发则可能产生真实补贴损失和用户投诉。
八、核心技术实现四:用户券查询不能引发轮询风暴
领券接口返回 PROCESSING 后,如果每个客户端每 100 毫秒轮询,查询 QPS 会超过领取 QPS。可采用:
- 首次响应给出建议查询间隔;
- 客户端指数退避并限制总次数;
- WebSocket、SSE 或站内消息推送最终结果;
- 查询结果在 Redis 按
request_id短期缓存; - 用户券列表支持增量版本或分页,不每次全表扫描;
- 排队时间超过阈值后明确提示稍后查看,不让客户端无限旋转。
查询服务和领券写服务使用独立线程池及容量池,避免查询风暴拖慢发券消费者。
九、核心技术实现五:下单占用和核销
9.1 结算页查询只是预估
商品、金额、店铺、类目、用户会员状态和券规则都可能变化。列表接口可以展示“预计可用券”,真正提交订单时必须带上:
user_coupon_id
order_id
order_amount
item_scope
pricing_context_version
优惠券服务重新验证用户归属、状态、有效期、适用范围和叠加规则,再创建占用。
9.2 占用与核销时序
券的折扣计算结果应携带规则版本,订单保存优惠分摊明细。退款时是退回原券、发补偿券还是不退,按券模板规则和订单退款范围执行。
9.3 订单服务和券服务谁算最终价格
优惠券服务根据上下文返回可用性和优惠计算,订单定价服务汇总商品价、会员价、平台券、商家券、积分和运费,并保存最终价格快照。优惠券服务不能单独宣称订单最终应付金额,它只对自己的规则和占用负责。
十、容量设计:怎样把 10 万 QPS 算清楚
10.1 先建立流量漏斗
需要测量而不是猜测以下比例:
网关后有效请求比例
单用户重复请求比例
风控拒绝比例
活动开始前和售罄后的无效比例
Redis 裁决成功比例
消息平均大小和消费重试比例
数据库实际发券 TPS
结果查询放大倍数
当库存只有 50,000 张时,成功写入总量上限明确,但必须评估它在几秒内集中落库。消费者并发不能无限提高,受数据库分片、索引、日志、连接池和复制延迟限制;队列负责把入口峰值转成事实库可持续吞吐。
10.2 实例与分片
应用实例数根据目标吞吐除以单实例可持续吞吐,并预留故障容量。Redis 根据脚本耗时、单分片操作数、网络带宽、持久化和主从切换能力规划;MQ 根据分区、消息字节和积压清空目标规划;数据库按用户或模板加用户哈希分片,避免所有用户券落在同一库。
不能只报告接口 QPS。必须同时报告:
- 网关、领券服务和 Redis 的 P95/P99;
- 单活动 Key 或库存桶倾斜;
- Stream/MQ 每秒流入流出、最老消息年龄;
- 数据库每分片 TPS、锁等待、唯一冲突和复制延迟;
- 领取成功到用户券可查询的端到端延迟;
- 限流、拒绝、排队和最终失败比例;
- 售罄后系统能否快速切换到低成本失败路径。
10.3 限流顺序
- 平台级总量保护;
- 活动模板级限流,防止一个活动拖垮全站;
- 用户、设备、IP 和风险主体维度限速;
- Redis、MQ、数据库各下游自适应并发;
- 消费积压过大时降低新受理速率或暂停活动;
- 售罄后使用可信售罄标记快速返回。
限流值来自压测和剩余容量,不是固定抄一个数字。被限请求要返回明确状态和重试建议,不能让客户端无间隔重试。
十一、热点库存分桶的完整取舍
11.1 如何分桶
假设总库存 50,000,拆成 100 个桶,每桶初始化 500。请求根据 hash(user_id, template_id) 路由到固定桶,保证同用户重试仍命中相同幂等状态。
11.2 尾部库存碎片
有的桶先售罄,有的桶仍有库存。可选:
- 返回售罄,接受少量库存稍晚释放;
- 只在桶售罄时查询二级可用桶目录;
- 后台低频回收剩余库存并重新均衡;
- 活动尾声切换到集中库存裁决。
转桶必须保持用户幂等和总量守恒,不能一个请求在多个桶都扣成功。
11.3 库存恒等式
系统应持续校验:
批准发行量
= 所有库存桶剩余名额
+ 已受理待发放请求数
+ 已发放用户券数
+ 已确认失败但尚未完成回补数
+ 运营依法撤销或冻结的名额调整
各项的业务口径必须固定。对账发现不平时先暂停新增受理,保存现场并按请求和流水核查,不能直接把 Redis 改成数据库的数量掩盖差异。
十二、可靠性保证与不保证
| 场景 | 保证 | 不保证 | 恢复方式 |
|---|---|---|---|
接口返回 ACCEPTED | 请求和名额已有可恢复记录 | 不代表用户券已经到账 | 请求状态查询、消费与补偿 |
| 重复点击 | 同一幂等键返回同一结果 | 不保证网络只发送一次 | 原子幂等和数据库唯一键 |
| MQ 或 Stream 重复 | 用户券最终只生成一次 | 不保证消费者只执行一次 | 请求唯一键、限领唯一约束 |
| Redis 主节点故障 | 数据库用户券事实不丢 | 故障窗口内新受理可能暂停 | 故障切换、请求日志、对账 |
| 数据库写超时 | 保持结果未知并查询 | 不立即断言失败或回补 | 按 request_id 查询和重试 |
| 券占用后订单事件丢失 | 可由过期任务核对恢复 | 不保证瞬时释放 | 占用到期扫描和订单核查 |
12.1 Redis 数据丢失怎么办
如果 Redis 中的剩余名额和受理请求是唯一记录,故障就可能超发或少卖。需要根据目标选择:
- Redis AOF、主从和集群提高恢复能力;
- 受理请求持续归档到持久消息或数据库;
- 故障切换期间暂停新受理,先核对已发和已受理水位;
- 从模板总量、数据库用户券、未完成请求和补偿流水重建库存;
- 重建完成前不直接按“总量减数据库已发”开放,因为在途请求可能尚未落库。
12.2 数据库落库积压
积压增长表示受理速率超过事实写入能力。可以扩消费者到数据库安全并发上限,但不能无限加线程。超过可恢复阈值时应降低入口限流或暂停活动,向用户展示处理中;必要时按已受理顺序继续处理,不能清空队列假装恢复。
12.3 活动紧急暂停
运营暂停要版本化并快速同步到所有库存桶。已 ACCEPTED 请求是继续发放还是补偿取消,需要在活动规则中明确。不能简单删除 Redis Key,因为会同时丢失在途请求和幂等结果。
十三、安全、风控和防刷
13.1 不把验证码当唯一方案
验证码只能提高自动化成本,不能解决账号农场、代理 IP 和真实设备批量抢券。风控需要组合:
- 登录账户、设备、IP、支付账号和行为特征;
- 活动前资格名单或分群;
- 签名、时间戳和一次性请求令牌防篡改及重放;
- 用户与设备频率限制;
- 异常账号先进入审核或延迟发放;
- 高价值券在使用时再次校验风险,而不是领到后永不检查。
13.2 权限和运营审计
创建、增发、暂停、作废模板分别授权。增发改变补贴风险,必须记录审批人、原因、前后数量和规则版本。任何人工补券和撤券都通过领域命令写变更流水,禁止直接更新用户券状态。
13.3 防止越权
查询、占用和核销都从登录态或服务身份取得用户和调用方,不能相信客户端传入的 user_id。订单服务占用券时还要校验券所有者与订单所有者一致。
十四、可观测、对账和运维排查
14.1 核心指标
| 层级 | 指标 |
|---|---|
| 活动 | 各模板请求数、受理数、拒绝原因、售罄时间、暂停状态 |
| 风控 | 用户/IP/设备拦截量、风险审核量、误杀申诉 |
| Redis | 脚本耗时、单分片 QPS、热 Key、剩余桶、AOF 与复制延迟 |
| 请求流 | 流入流出、积压、最老请求年龄、Pending 数、重试次数 |
| 发券 | 到账成功率、唯一冲突、数据库超时、端到端到账延迟 |
| 补偿 | 待回补数、回补失败、结果未知最老年龄 |
| 使用 | 占用超时、核销失败、重复释放、过期任务延迟 |
| 不变量 | 超发、同用户超限、库存恒等式差异 |
14.2 三类对账
对账必须带时间水位,避免把仍在正常处理的在途请求误判为差异。修复动作生成新的业务流水,保留原因和操作人。
14.3 一个故障排查示例
现象:活动仍显示有库存,但用户长时间处于排队中。
- 检查请求流最老消息年龄和消费者流入流出速率;
- 检查数据库分片延迟、唯一冲突、连接池和慢 SQL;
- 对比
ACCEPTED请求、DELIVERED数和结果未知数; - 若数据库已到安全上限,降低新受理或暂停活动;
- 按 request_id 查询超时写入是否实际成功,禁止直接回补;
- 恢复消费者并监控积压清空时间;
- 对库存恒等式和每用户限领做完整对账。
十五、测试与验证
15.1 正确性测试
- 同一用户同一幂等键并发请求,返回同一 request_id;
- 同一用户更换幂等键并发抢每人限一张券,最终只有一张;
- 库存为 1 时大量并发,最多一个请求被受理;
- 请求流重复投递,数据库只有一张用户券;
- 数据库超时但事务已提交,不发生错误回补;
- 回补消息重复,库存只增加一次;
- 用户券被订单占用后,另一个订单无法占用;
- 订单取消重复事件不会重复释放;
- 模板规则升级不改变旧用户券规则版本;
- 到期、暂停和售罄边界时间由服务端正确判断。
15.2 10 万 QPS 压测矩阵
| 场景 | 要观察什么 |
|---|---|
| 活动开始瞬时洪峰 | 网关、接入服务和 Redis 脚本的分位延迟及拒绝 |
| 单活动单库存 Key | 单分片 CPU、网络和脚本执行上限 |
| 库存分桶 | 桶倾斜、尾部碎片、总量守恒 |
| 大量重复用户 | 幂等命中成本和用户维度热点 |
| Redis 冷启动或故障切换 | 是否暂停受理、是否产生不明请求 |
| MQ 消费变慢 | 积压、到账延迟、入口背压 |
| 数据库一分片变慢 | 消费隔离、热点用户和恢复时间 |
| 售罄之后持续流量 | 能否低成本快速失败 |
| 查询轮询放大 | 查询服务是否反向拖垮发券链路 |
报告要包含真实数据分布、持续时间、请求字节、实例规格、Redis 持久化方式、数据库索引、消息分区、错误和降级比例。只打空缓存或只测 Redis DECR 不能证明整套发券链路支持 10 万 QPS。
15.3 故障演练
- Redis 主从切换时正在扣减;
- 请求已
ACCEPTED,接入实例立即宕机; - Stream 或 MQ 重复、延迟和分区不可用;
- 数据库提交成功但消费者收到超时;
- 补偿器执行一半重启;
- 运营在高峰中暂停活动;
- 订单取消事件丢失,过期任务恢复占用;
- 对账任务发现库存恒等式不平。
十六、设计取舍与演进
16.1 数据库直接扣库存什么时候够用
如果活动成功量小、入口 QPS 低、数据库有足够余量,使用条件更新加唯一约束最简单,也更容易保证事实一致。不要因为题目出现“优惠券”就默认必须 Redis 加 MQ。
16.2 Redis 单 Key 什么时候够用
压测证明单活动原子脚本在目标 QPS 和故障余量内稳定时,单 Key 更容易守住总量。库存分桶只在单 Key 已成为可观测瓶颈时引入,因为分桶会增加均衡、回补和对账成本。
16.3 同步和异步的选择
| 方案 | 用户响应 | 一致性窗口 | 适用场景 |
|---|---|---|---|
| 数据库同步发券 | 直接告诉是否到账 | 小 | 成功 TPS 可控、体验要求立即确定 |
| Redis 受理加异步落库 | 快速返回排队中 | 需要处理在途和补偿 | 瞬时洪峰、有效请求远高于数据库写能力 |
| 预约后抽签 | 峰值被时间窗口摊平 | 活动结束后出结果 | 不要求先到先得,公平性优先 |
第三种是产品层改造。很多“必须扛 10 万 QPS”的问题,可以通过预约、资格预发和分时放量减少同时竞争,比单纯堆机器更有效。
16.4 演进触发条件
- 单模板 Key 经压测成为 Redis 瓶颈时引入库存分桶;
- 领券结果查询形成明显放大时增加推送和退避;
- 数据库分片写入达到容量阈值时按用户路由扩容;
- 多业务都需要高并发名额受理时抽出统一活动库存服务;
- 复杂券组合与价格规则频繁变化时独立规则决策和版本治理;
- 高价值券的黑产损失超过阈值时增强风险审核,而不是只加限流。
十七、面试复盘与职责边界
17.1 三分钟主回答
我会先把优惠券拆成模板、领取请求、用户券和订单占用四个对象。运营发布模板时固化规则版本并预热库存。10 万 QPS 入口先通过 CDN、网关、用户设备限速和风控过滤;有效请求进入 Redis 原子脚本,一次完成活动时间、请求幂等、每人限领和剩余名额判断。取得名额后返回 ACCEPTED + request_id,明确只是可靠受理,不是券已到账;请求通过 Redis Stream 或可靠 MQ 异步落库,数据库用请求唯一键和用户限领唯一约束兜底,生成用户券与 Outbox。数据库超时先按请求 ID 查询,只有确认最终失败才幂等回补,避免先回补后发现券已落库造成超发。下单时用户券从 AVAILABLE 条件迁移到 RESERVED,支付后核销,取消或超时按规则释放。主链路外还有库存恒等式、受理请求与用户券、券占用与订单三类对账。若单活动 Redis Key 经压测成为瓶颈,再做库存分桶和尾部回收。
17.2 高频追问
- 为什么不能先 Redis
DECR再普通发 MQ?两步之间宕机会扣名额却没有发券任务,需要可靠请求日志或对账补偿。 - 接口返回成功代表领到券了吗?要区分
ACCEPTED和DELIVERED,异步方案前者只表示受理。 - 数据库超时为什么不能马上回补?事务可能已提交,立即回补会产生额外名额和超发。
- Redis Lua 就不会超发了吗?脚本只保证单次原子裁决,初始化、故障恢复、分桶、补偿和数据库落库仍可能破坏总量,需要唯一约束和对账。
- 每人限一张怎么做?入口计数用于快速拒绝,数据库
template_id + user_id唯一约束最后兜底。 - 单 Key 扛不住怎么办?隔离活动,实测后拆库存桶,并设计尾部回收和总量恒等式。
- MQ 积压怎么办?扩到数据库安全并发上限,同时入口背压或暂停活动,不能无限加消费者。
- 订单取消怎么退券?按用户券占用状态和模板退回规则幂等迁移,已过期或不可退则作废。
- 券规则改了,旧券怎么办?用户券绑定领取时规则版本,不静默跟随新配置。
- 怎样证明支持 10 万 QPS?给出流量漏斗和端到端压测报告,包括 Redis、消息、数据库、查询放大和故障条件。
17.3 项目口径
如果实际项目只有数据库条件扣减,应讲清唯一约束、幂等和订单核销,不要声称使用了 10 万 QPS 架构。如果只实现了 Redis 领券入口,就明确用户券落库、回补、占用和对账属于团队方案或演进设计。设计能力可以展示,但个人职责和生产成绩必须有代码、测试或监控证据。
总结
高并发优惠券系统的难点不是让 Redis 中的数字快速减一,而是让“模板发行量、已受理请求、已发用户券和订单使用状态”在重复、宕机、超时和积压后仍能解释和恢复。
10 万 QPS 通过静态资源、风控限流、原子裁决和异步落库逐层收敛;Redis 负责热点名额受理,数据库负责用户券事实和唯一约束,消息流负责削峰,状态机、幂等补偿和三类对账负责故障恢复。只有把 ACCEPTED 与 DELIVERED 分开、把结果未知与真正失败分开,并给出端到端容量证据,才算完整回答了这道具体业务场景题。