Java 业务场景深度面试专题

9 篇 · 免费在线阅读

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 核心不变量

  1. 同一券模板实际发出的用户券数不能超过批准发行量。
  2. 同一用户领取数量不能超过模板限领规则。
  3. 一张用户券在同一时刻最多被一个有效订单占用。
  4. 一张券最多完成一次最终核销。
  5. 重复请求、重复消息和客户端超时重试不能重复发券或重复退券。
  6. 模板规则版本一旦用于发券,后续修改不能悄悄改变已领取用户券的核心权益。
  7. 返回“排队中”只代表请求被可靠受理,不代表券已到账。

二、业务状态机

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 原子脚本要一次判断哪些条件

  1. 活动是否 ACTIVE
  2. 当前服务端时间是否在领取区间;
  3. 请求幂等键是否已有结果;
  4. 用户是否超过限领次数;
  5. 剩余名额是否大于零;
  6. 通过后同时扣减名额、增加用户受理计数、登记请求并写入可靠流。

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 限流顺序

  1. 平台级总量保护;
  2. 活动模板级限流,防止一个活动拖垮全站;
  3. 用户、设备、IP 和风险主体维度限速;
  4. Redis、MQ、数据库各下游自适应并发;
  5. 消费积压过大时降低新受理速率或暂停活动;
  6. 售罄后使用可信售罄标记快速返回。

限流值来自压测和剩余容量,不是固定抄一个数字。被限请求要返回明确状态和重试建议,不能让客户端无间隔重试。

十一、热点库存分桶的完整取舍

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 一个故障排查示例

现象:活动仍显示有库存,但用户长时间处于排队中。

  1. 检查请求流最老消息年龄和消费者流入流出速率;
  2. 检查数据库分片延迟、唯一冲突、连接池和慢 SQL;
  3. 对比 ACCEPTED 请求、DELIVERED 数和结果未知数;
  4. 若数据库已到安全上限,降低新受理或暂停活动;
  5. 按 request_id 查询超时写入是否实际成功,禁止直接回补;
  6. 恢复消费者并监控积压清空时间;
  7. 对库存恒等式和每用户限领做完整对账。

十五、测试与验证

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 高频追问

  1. 为什么不能先 Redis DECR 再普通发 MQ?两步之间宕机会扣名额却没有发券任务,需要可靠请求日志或对账补偿。
  2. 接口返回成功代表领到券了吗?要区分 ACCEPTEDDELIVERED,异步方案前者只表示受理。
  3. 数据库超时为什么不能马上回补?事务可能已提交,立即回补会产生额外名额和超发。
  4. Redis Lua 就不会超发了吗?脚本只保证单次原子裁决,初始化、故障恢复、分桶、补偿和数据库落库仍可能破坏总量,需要唯一约束和对账。
  5. 每人限一张怎么做?入口计数用于快速拒绝,数据库 template_id + user_id 唯一约束最后兜底。
  6. 单 Key 扛不住怎么办?隔离活动,实测后拆库存桶,并设计尾部回收和总量恒等式。
  7. MQ 积压怎么办?扩到数据库安全并发上限,同时入口背压或暂停活动,不能无限加消费者。
  8. 订单取消怎么退券?按用户券占用状态和模板退回规则幂等迁移,已过期或不可退则作废。
  9. 券规则改了,旧券怎么办?用户券绑定领取时规则版本,不静默跟随新配置。
  10. 怎样证明支持 10 万 QPS?给出流量漏斗和端到端压测报告,包括 Redis、消息、数据库、查询放大和故障条件。

17.3 项目口径

如果实际项目只有数据库条件扣减,应讲清唯一约束、幂等和订单核销,不要声称使用了 10 万 QPS 架构。如果只实现了 Redis 领券入口,就明确用户券落库、回补、占用和对账属于团队方案或演进设计。设计能力可以展示,但个人职责和生产成绩必须有代码、测试或监控证据。

总结

高并发优惠券系统的难点不是让 Redis 中的数字快速减一,而是让“模板发行量、已受理请求、已发用户券和订单使用状态”在重复、宕机、超时和积压后仍能解释和恢复。

10 万 QPS 通过静态资源、风控限流、原子裁决和异步落库逐层收敛;Redis 负责热点名额受理,数据库负责用户券事实和唯一约束,消息流负责削峰,状态机、幂等补偿和三类对账负责故障恢复。只有把 ACCEPTEDDELIVERED 分开、把结果未知与真正失败分开,并给出端到端容量证据,才算完整回答了这道具体业务场景题。