高并发抽奖系统设计:从量级预估到Redis原子扣减的完整方案

发布时间:2026/10/13 3:42:24
高并发抽奖系统设计:从量级预估到Redis原子扣减的完整方案
说个真实的场景。前几天一个准备跳槽的朋友面试完找我吐槽说面试官给了他一个老掉牙的题目“如何设计一个高并发抽奖系统”他本来以为准备得很充分张口就是Redis加锁、消息队列削峰、库存预扣结果面试官连续追问了几个问题之后他就卡住了。他回来后问我这道题到底该怎么答才算答到点子上。这个话题我太有共鸣了。我自己既作为面试官面过不少人也在线上真实运营过几轮高并发抽奖活动。老实说这道题确实非常容易答错因为它考的根本不是“你会不会用Redis”或者“你知不知道消息队列”而是你能不能把一个看似简单的业务问题翻译成一套有取舍、有边界、有兜底的系统设计方案。今天我就以“设计高并发抽奖系统”为场景把一个完整的思考链路拆开讲清楚。1. 为什么这道题很多人都答错先搞清楚抽奖系统到底难在哪1.1 三个典型的错误回答我先说最常见的错误答案。第一种上来就说“用Redis加分布式锁然后查库存、扣库存”仿佛锁就是高并发的万能解药。第二种张口就是“用MQ削峰把请求全部异步化”但追问异步之后用户的抽奖结果怎么返回、库存怎么保证不超卖就开始支支吾吾。第三种把中奖概率当成一个固定值在数据库里实时算完全没考虑数据库扛不扛得住。这三个答案的共同问题是没有把抽奖系统的业务特征和技术特征分开来看。锁是手段异步是手段Redis也是手段但你要回答的核心问题是高并发场景下的抽奖系统它的数据一致性和用户体验要求极其矛盾——既要抗住瞬时流量又要在几十毫秒内给出一个不可撤销的抽奖结果。1.2 抽奖系统区别于普通商品秒杀的三点不同做过秒杀的人可能会说抽奖不就是秒杀的一种吗还真不完全一样。普通秒杀是“一个SKU有一万件库存用户抢购”而抽奖系统通常有这几个特征。奖品不是单一SKU而是多种奖品组合每种奖品的库存数量差异巨大可能一等奖只有3台手机三等奖有5000张优惠券。其次抽奖行为的读操作和写操作都极其密集每个用户都要调一次抽奖接口不像秒杀那样用户刷页面居多、真正下单的比例不高。最麻烦的是抽奖结果对用户是可见且不可逆的用户抽中了一等奖系统事后不能偷偷改掉这意味着所有数据操作必须在返回结果的那一刻就具备最终一致性。1.3 面试官真正想看的能力如果只把这道题当成“高并发技术方案题”那就理解偏了。面试官真正在观察的是你怎么澄清需求、怎么评估量级、怎么做技术选型权衡、怎么谈兜底与降级。一个人对量级的估算能力对超卖风险的理解深度对Redis和数据库使用边界的把握几句话就能看出来。所以这篇文章不是给你一套“标准答案”而是给你一套思考框架从量级预估到架构选型到核心一致性方案对比再到链路上的细节和边角料一条线走下来。这套框架你在任何类似的“高并发写操作系统”设计题里都能复用。2. 开口之前先对齐量级高并发的分母决定你的技术方案2.1 没有量级的架构设计都是空谈很多人在面试问答里犯的最大的错误是没有先定义一个具体的业务场景。你说“高并发”到底多高是每秒1000请求还是每秒10万请求方案完全不同。我通常在回答这道题时会先花半分钟给自己设定一个明确场景例如某运营活动开放抽奖预计总参与人数500万活动时间持续2小时大多数用户会集中在活动刚开放的前5分钟点击抽奖预估峰值QPS约5万到10万。奖品方面一等奖3份、二等奖50份、三等奖2000份、阳光普照奖若干奖品总库存在一个量级内。这个对齐的过程非常加分因为它表明你不是一个只会背架构图的人。2.2 不同量级下方案的分水岭根据量级的不同我大致把抽奖系统分成三档。第一档是“中小型活动”总参与人数在几万到几十万峰值QPS几千以内。这个量级下直接用数据库配合乐观锁或者应用层排队就够用根本不需要引入复杂的Redis和MQ体系成本最低、运维最简单。第二档是“百万级大促”峰值QPS在几万以上这就必须把RedisLua作为扣减库存的主路径数据库反而退居为异步落库和最终对账的角色。第三档是“亿级流量”峰值QPS可能几十万甚至更高这种场景光靠单份Redis内存奖池已经不够还需要对流量做分层、按照地域或用户ID做分桶、对抽奖链路做彻底异步化甚至提前生成“中奖令牌”让真正的中奖行为在活动开始前已经被决定。比较一下你能看到前两档之间是技术选型的差异后两档之间是架构级的分层差异。面试时不需要你展开亿级方案的每个细节但你要能说清楚“什么量级用哪种方案以及为什么”。2.3 估算完量级之后再判断瓶颈在哪流量估算完了还要判断系统的瓶颈通常在哪里。Web层可以通过无状态化很方便地水平扩容通过压测一台普通的应用服务器处理几千QPS问题不大。网关层和CDN也能扛住大流量。真正的瓶颈集中在两个地方一是Redis单实例或分片后的瓶颈二是数据库并发写的能力上限。理解了瓶颈所在你的架构设计就有明确的目标把流量尽量挡在Web层和网关层把数据层的操作变得极其原子和轻量把对数据库的写操作彻底削峰延后。3. 整体架构怎么做分层拦截把压力挡在进入数据层之前3.1 一条典型的请求链路在我实际项目中一套典型的高并发抽奖系统请求链路是这样的用户先访问活动页页面本身全部走CDN和静态资源分发只有真正点击“抽奖”按钮时才会触发动态请求。这个请求经过负载均衡进入网关层。网关层做最粗粒度的限流比如按用户维度、按IP维度的令牌桶限流一旦发现流量异常就直接返回“排队中”或“稍后再试”。之后到达应用层应用层是一个无状态集群任意一台上线发布都不会影响全局。应用层内部会做几件事校验活动时间和用户资格生成并校验全局唯一的请求幂等ID调用频控服务判断这个用户是否超过抽奖次数然后进入核心的抽奖逻辑模块。抽奖逻辑模块访问预热的奖品配置通过Redis执行原子扣减和结果生成最后把中奖结果写入异步消息队列由消费端异步落到数据库和通知用户。3.2 无状态化的代价与收益应用层无状态化是这套架构的基石。用户登录态、频控计数、抽奖机会数量这些状态全部放Redis应用服务器本身不保存任何和用户相关的数据。好处是可以随时水平扩缩容比如活动开始时扩容到200台活动结束后缩到20台。代价是每次请求都要多一次Redis访问对Redis的网络I/O压力更大。因此在设计时要特别控制Redis的访问次数能用一次Lua脚本完成的事情绝不拆成三次Redis命令。3.3 本地缓存与热点配置的预热奖品配置、活动时间、概率配置这类读多写少的数据在应用启动时就会加载到本地内存并且定时或者通过配置中心推送更新。这样应用层的每次抽奖请求甚至不需要为了“拿配置”而访问Redis或数据库。很多人忽视这块实际上在高并发下一次多余的Redis读取和一次多余的数据库查询都会让整个链路的RT成倍增长最终压垮系统。我做过一次压测加上本地热点缓存之后相同流量下平均RT从45毫秒降到了15毫秒这是一个非常可观的收益。4. 核心难点库存不超卖、中奖不追加的一致性问题4.1 为什么“先查库存再扣减”必然出问题很多新人设计的抽奖逻辑是这样的先查奖品库存如果大于0就执行插入中奖记录然后再更新库存减一。这个逻辑在低并发下没问题但一旦高并发两个请求同时读到库存是1同时判断“可以中奖”同时执行插入最后库存变成负数中奖人数超过奖品数量。这就是超卖的本质判断和扣减不是原子操作。要解决超卖核心就是一句话让“判断库存是否足够”和“扣减库存”变成一个原子操作同时这个操作能直接产出“是否中奖”的结论。4.2 数据库层方案乐观锁与悲观锁各自的边界先看数据库方案。最简单的方案是更新语句带上库存条件比如UPDATE prize_stock SET stock stock - 1 WHERE prize_id ? AND stock 0这条语句在数据库层面是原子的通过影响行数来判断是否扣减成功。如果影响行数为0说明库存已经不足。这是乐观锁的思路。它确实能解决超卖但是问题在于高并发下所有请求都去打这一行记录行锁竞争极其激烈数据库的吞吐可能只有每秒几千。对于第二档和第三档的抽奖系统来说不够用。悲观锁方案就是用SELECT FOR UPDATE把行锁住再操作吞吐更差几乎不适合高并发写场景。4.3 Redis方案的核心Lua脚本为什么是首选于是我们把目光转向Redis。Redis本身的命令是单线程执行、天然原子的比如直接用DECR扣库存确实能做到不超卖。但问题是抽奖业务的逻辑远比“扣一个数”复杂要根据奖品等级和概率决定是否中奖要判断用户剩余抽奖次数要记录中奖流水要控制每个用户的中奖次数上限。这些逻辑如果放在应用层多次调用Redis又会回到“判断和操作分离”的老问题。解决办法就是把整个抽奖逻辑写进一个Lua脚本里通过EVAL命令一次性交给Redis执行。Lua脚本在Redis里是原子执行的执行过程中不会被其他命令插入所有内存操作被一气呵成地完成。我给出一个高度简化的脚本骨架-- keys[1] 奖品库存keykeys[2] 用户已中奖次数keykeys[3] 用户抽奖总次数key -- argv[1] 用户IDargv[2] 当前时间argv[3] 奖品剩余库存阈值 local stock tonumber(redis.call(GET, KEYS[1]) or 0) if stock 0 then return 0 -- 没有库存视为未中奖 end local total_count tonumber(redis.call(GET, KEYS[3]) or 0) if total_count 3 then return -1 -- 超出总抽奖次数拒绝抽奖 end -- 此处原本应接入概率判定逻辑简化处理 local luck math.floor(redis.call(INCR, KEYS[1]) % 1000) if luck 5 then redis.call(INCR, KEYS[2]) end redis.call(INCR, KEYS[3]) return 1这只是一个示意真实场景中Lua脚本会把奖品列表、概率区间、用户频次、库存扣减、中奖记录一次性搞定返回给应用层的可能是“中了一等奖”“未中奖”“奖品已抽完”“今日次数已用完”等明确的枚举值。用Lua的好处非常明显原子性得到保证Redis到应用层的网络往返被压缩成一次并发能力提升一个数量级。我实测过单台Redis在8核机器上处理这个级别的Lua脚本可以达到每秒几万次的吞吐完全覆盖第二档的需求。4.4 方案对比没有银弹只有权衡方案一致性保障单点吞吐实现复杂度适用场景数据库乐观锁强一致低行锁竞争低小型活动、QPS几千内数据库悲观锁强一致很低低后台管理、低并发场景Redis DECR原子但逻辑简单中低只扣库存不涉及复杂逻辑的场景Redis Lua脚本原子支持复杂业务高中百万级大促抽奖首选预生成奖池令牌最终一致最高高亿级流量、活动结果可预先确定表格右边是适用场景很多人犯的错误是到处都用Redis Lua连小活动也上结果光维护脚本就花了一半时间。选方案的前提永远是量级和成本低成本解决不了的就换高成本方案高成本没必要的就降级成低成本方案。4.5 中奖概率的“动态库存”陷阱还有一个细节会被很多面试者漏掉就是中奖概率和库存的关系。假设你在配置里把一等奖概率写死为1%库存只有3份那么如果前300个请求里随机的运气足够差导致没有人中一等奖此时库存还在但概率上已经不足以继续抽中它了反过来如果前1000个请求全部把一等奖抽走了库存为0后续的抽奖就必须在概率判定前先判断库存。正确的做法是“优先校验库存再进入概率判定”当某奖品的库存不足时直接将中奖概率视为0将这次抽奖视为未中奖或者降级到其他奖品。更复杂的系统会引入动态概率调整根据当前库存和剩余活动时间计算实时概率保证库存刚好在活动结束时被抽完但这属于营销策略层面的事情了面试时提到一句“库存优先于概率”就足够拉开差距。5. 一条抽奖请求的完整旅程从点击按钮到用户看到结果5.1 链路的第一步幂等与频控用户在活动页疯狂连点抽奖按钮这是高并发系统最容易忽视的问题。如果不对请求做幂等处理一次连点可能产生五六次抽奖请求用户明明只有3次机会却被扣了5次。我的做法是用户点击按钮时前端生成一个UUID作为请求ID由应用层用“用户ID请求ID”作为Redis键做SETNX只有第一次请求能成功进入后续逻辑其余重复请求直接返回上一次抽奖结果。同时频控服务里维护“用户总抽奖次数”和“用户中奖次数”这两个数值同样在Lua脚本里一并校验。5.2 关键的中间环节抽奖结果的判定与存储应用层把Lua脚本返回的结果映射成业务结果之后就要面临一个选择是先写数据库再返回还是先返回用户再异步写库很多人会下意识选择“先落库再返回”因为总觉得不落库不放心。但你要知道数据库的写入能力和高并发是天然冲突的500万参与用户、每分钟大量中奖流水如果每个请求都同步写库数据库直接被打挂。合理的做法是结果先行。Lua脚本在Redis里已经完成了所有核心状态的变更中奖结果已经是确定的此时应用层直接返回给用户“中奖了奖品是手机”同时把这条中奖记录发送到消息队列。异步消费端拿到消息后再写入中奖流水表、扣减数据库中的奖品库存冗余字段、触发发奖状态流转。万一异步写入失败由对账任务扫描Redis中的中奖记录与数据库流水自动补写保证最终一致。这里有个前提就是Redis不能丢数据因此核心Redis必须开启AOF持久化并部署主从这是运营级抽奖系统不能省的保障。5.3 活动状态机与奖品路由抽奖系统必须是一个状态机驱动的东西。活动时间未到、活动已结束、活动临时暂停、奖品已抽完这几种状态对抽奖接口的返回完全不一样。状态机建议放到Redis里用一个全局活动状态键来标记Lua脚本执行前先校验活动状态。有些系统会犯“活动结束后还能抽抽完无法发奖”的低级错误这也是面试官很爱追问的一个点。奖品路由则是说用户中了一等奖之后要不要马上发放还是要经过风控审核。有的运营规则要求高价值奖品先锁定等待风控确认用户没有作弊行为后再发放而优惠券之类的低价值奖品可以秒到账。所以设计上可以把“中奖结果记录”和“发奖动作”解耦开避免风控逻辑拖累主抽奖链路。5.4 用户维度的分布式锁粒度连接点的防重复虽然已经用了幂等ID但是某些极端情况下比如用户同时在两台手机上登录抢同一个活动幂等ID是各自生成的防不住。这时需要用“用户ID”做一把分布式锁锁的粒度只在用户本身不影响其他用户。用Redis的SET NX EX实现持有锁的时间要短绝对不能把抽奖主流程放在锁外。锁的过期时间要合理太短会导致极端慢请求还没执行完锁就过期太长会拖累用户体验。6. 高并发之外的那些“边角料”面试官最喜欢追问的细节6.1 Redis挂了怎么办这道题里我建议你主动准备一个降级策略。如果核心Redis集群彻底不可用最稳妥的做法是开启“熔断模式”直接对所有抽奖请求返回“活动太火爆请稍后再试”而不是降级到数据库因为数据库在峰值流量下大概率也扛不住反而引发更大事故。等Redis恢复后再重新开放抽奖入口。另一种做法是降级为“数据库预发奖模式”即活动开始前把奖池数据同步到数据库的预发表请求到来时应用层单机本地限流走数据库乐观锁抽奖。这个模式下并发能力大幅下降但至少保证活动不会完全中断用户能抽到奖。降级策略不是面试官嘴上问问而已它是线上系统必须准备好的一套开关。我在系统里预留了配置中心推送的开关活动期间一旦监控到Redis异常可以10秒内完成全链路熔断这是经历过线上事故之后总结出来的经验。6.2 中奖用户作弊怎么防高价值奖品必须接入风控。抽奖接口要采集用户ID、设备指纹、IP、行为特征等数据中奖结果生成后不代表马上发奖而是先进入“待风控审核”状态。如果风控系统判定是批量注册小号刷奖的可以取消中奖资格将奖品回滚到库存。回滚操作同样要走消息队列和异步对账。这里有个体验上的注意点高价值奖品不要做到“先张扬后追回”审核完成再通知用户比较稳妥避免因为误判引发大面积客诉。6.3 奖品库存的对账与准实时监控大型抽奖活动的数据对账是必须做的。Redis中的库存变化、数据库中的奖品流水、发奖系统的发奖记录三个数据源要能对上。我的做法是每分钟跑一次比对任务把过去一分钟的Redis减扣记录和数据库流水做差异分析。同时监控三个关键指标库存扣减失败率、中奖结果返回平均RT、超卖异常告警。超卖一旦发生必须有实时的补偿机制比如某奖品超卖后通过消息队列触发人工干预流程而不是等到活动结束复盘才发现。6.4 配置中心与活动热更新活动开始之后运营很可能临时调整奖品概率、追加库存、延长活动时间系统必须支持热更新。配置项下发之后要保证已经很长时间未刷新的应用节点在最迟几秒钟内拿到新配置。如果配置中心和本地缓存不一致会造成部分用户用的是旧概率。这块我踩过坑曾经有一次修改概率配置后没有等待所有节点同步就重新开放了接口导致一部分流量走了旧逻辑结果又要人工做数据补偿。后来我加了一个“配置版本号”机制Lua脚本执行前会校验当前节点的配置版本号版本不一致的请求会被短暂拦截。7. 面试表达上的三个技巧怎么答才能让面试官觉得你真的做过第一个技巧回答里要有“数字感”。不要光说“用Redis做扣减”你要加一句“以500万用户、峰值5万QPS的场景为例Redis单实例能扛住数据库扛不住”这样的量化分析面试官马上会对你另眼相看。第二个技巧把方案的适用边界讲清楚。你说三档量级的方案时要主动讲各档的优缺点和切换条件这比死记硬背一个“标准大促架构”高明得多。第三个技巧主动暴露你对兜底和应急的重视。提到熔断降级、对账补偿、风控审核、配置热更新这些细节通常只有真正经历过线上流量的人才讲得出来。顺着我的经验再说一点如果你有时间强烈建议自己动手搭一个简化版抽奖系统做压测。不用太复杂Spring Boot写个接口、Redis存库存、写一个Lua脚本执行抽奖、JMeter模拟流量跑一轮你就会直观地感受到“为什么数据库扛不住为什么Redis能扛住”也会理解乐观锁的SQL为什么在高并发下会锁等待。这些体感是任何面试题解析和八股文都给不了你的。最后分享一个我个人的小习惯。每次设计这类系统我都会先花20分钟写一份“如果明天上线哪些环节最可能出问题”的清单优先级从高到低排再针对前三项做预案。抽奖系统这种产品业务逻辑本身不复杂复杂的是极端情况下的容错能力。如果你在面试里能把这份“怕出事的敬畏心”表达出来比任何华丽架构都更能打动人。