百万QPS高并发抢券系统架构设计:分层拦截与Redis原子扣减
工试云启 考证服务中心整理

架构二面如何设计一个百万级别QPS的高并发抢券系统这个问题我在模拟面试里被问到过很多次也在真实的大促活动中见过好几版方案。先说结论百万QPS的抢券系统绝大多数流量根本不该到达业务后端真正扛压的只有网关和Redis而数据库在最理想的情况下也只是“异步落账”的角色。很多人一上来就画一堆服务、消息队列、分库分表看起来架构完整可一旦被追问“每个组件到底扛多少QPS”“Redis挂了怎么办”“超发了怎么赔”就很容易露馅。这篇文章我把整套设计思路完整梳理一遍包括流量模型估算、分层拦截、库存扣减的原子性方案、削峰异步化、幂等防刷以及面试官最喜欢追问的几个问题。适合正在准备架构师岗位面试的同学也适合真打算做大促活动的后端团队参考。我会尽量把“为什么这么设计”讲清楚而不是只给一张架构图。1. 拿到题目先别画图把流量模型和业务约束算清楚1.1 百万QPS是怎么算出来的先感受一下这个量级100万QPS是什么概念假设一次请求从网关到Redis平均耗时10毫秒那么同一时刻系统里大约有100万乘以0.01也就是1万个请求在飞行中。如果所有请求都打到数据库按单库单机3000 QPS的上限来算需要300多台数据库实例这显然不合理。所以第一件事不是选技术栈而是把流量拆开看。抢券场景和普通的读多写少业务不一样它是“瞬时写热点”。平时系统可能只有几千QPS活动开启的那一秒突然冲到百万级。但有个容易被忽略的地方这100万QPS不会是均匀分布的。绝大部分用户会在前3秒内完成点击尤其是倒计时归零的那一瞬间。真正要扛住的是这个“尖峰”而不是平均流量。另一个需要拆解的是请求类型。一次抢券请求链路里流量大头其实在“判断能否抢”、做库存扣减、返回结果这三步。查询用户是否已领取、检查活动状态、校验参数这些前置操作允许重复执行适合用缓存和本地标记挡住。只有库存扣减是真正不能出错的“关键路径”必须保证原子性。把这个拆清楚后面每一层挡多少流量就有数了。1.2 先定义清楚“不超发”的判定口径很多人一上来就说“用Redis原子扣减防超卖”但“超卖”这个词在不同口径下意义完全不同。抢券系统最常见的口径是发出的券数量不能超过活动配置的券总数。比如配置了10万张券最终落到数据库的成功记录最多10万条这就是底线。但要注意用户看到的“抢券成功”和数据库里的“发券记录”可能是两回事。如果用户在Redis扣减成功就立刻返回“恭喜抢到”但异步发券时发现有100条记录因为幂等冲突被丢弃那用户侧和后台侧对账就会对不上。所以设计时我会明确一条口径以Redis扣减成功为“用户侧成功”以最终落库去重后的记录数为“财务侧成功”两者允许存在一个很小的窗口期但最终必须收敛到一致。这个口径一定在动手设计前和业务方对齐否则后面做的对账、补偿、异常处理都是空中楼阁。1.3 先算容量再定方案关键路径上只有三种操作把链路简化之后百万QPS的核心问题就变成三个100万次“是否已领取”判断怎么做才不把Redis打爆100万次库存扣减怎么做才不超发成功结果怎么通知用户怎么落库。第一个问题用本地缓存加布隆过滤器就能挡住大部分流量。第三个问题靠异步化和消息队列解决。第二个问题才是真正不能妥协的部分后面单独用一整章来讲。我一般会在白板上先画一条横向的时间线用户点击、接入层、业务层、Redis扣减、异步落地、通知结果。标出每一段允许的耗时和最多能承载的QPS然后才开始聊组件选型。这个习惯帮我避免了很多“方案很丰满、容量很骨感”的尴尬。2. 分层拦截每一层分别挡住多少流量2.1 接入层最容易被低估的一层很多方案里接入层只提一句“用Nginx做负载均衡”但抢券场景里接入层承担的责任远不止转发。第一道防线其实是前置校验活动是否开启、用户是否在白名单内、请求头是否合法。这些校验不依赖后端服务直接在接入层用少量规则就能完成。比如活动未开始时接入层直接返回“未开始”这能挡住一大半因为前端倒计时不准而提前打进来的请求。还有一点是连接数管理。100万QPS意味着每秒钟要建立和复用大量TCP连接。如果接入层每个请求都新建连接光握手开销就能把带宽打满。所以必须启用长连接和连接池同时调大HTTP Keep-Alive超时时间。我这里有个实测数据开启连接复用后同样一批机器扛住的QPS能翻一倍以上。接入层还要做最基本的限流比如全局限流和按IP限流。全局限流是为了防止恶意流量把后端打垮按IP限流是为了防止单个用户或单台机器刷量。限流算法选令牌桶就行不需要很复杂但要注意限流阈值必须留出20%到30%的余量不然流量毛刺一到正常用户也会被误伤。2.2 本地缓存把“是否已领取”的查询挡在Redis之前抢券场景里有一个很强的局部性同一个用户会在短时间内反复点击而且绝大多数用户其实已经领取过或者根本不符合条件。如果每次都穿透到Redis查一次用户领取状态Redis上就会积压大量重复读请求。更合理的做法是在业务实例内存里维护一个最近领取成功的用户ID集合用短TTL同步。具体来说业务进程启动时从Redis加载已领取用户集合的布隆过滤器定时增量更新。用户请求到达业务层后先查本地布隆过滤器如果命中“已领取”直接返回“你已经抢过了”这一步几乎零成本。布隆过滤器会有一定误判率也就是可能把“没领取”的用户误判成“已领取”但绝对不会把“已领取”的用户漏掉方向是安全的。误判的那部分用户会收到“已抢过”的提示虽然体验上略有瑕疵但可以通过设置较低的误判率比如1%来降低影响。这里要注意一个坑布隆过滤器不能替代Redis里的最终校验只能用来拦流量。真正的幂等判断还是得靠Redis里的用户维度记录原因后面细讲。2.3 业务层限流要做在“打Redis之前”而不是之后业务层主要做三件事参数校验、限流、调用库存扣减。限流的位置很关键我见过不少系统把限流放在Redis扣减之后也就是先扣减再判断流量是否超限这等于没限流。正确的做法是在业务层入口先做用户维度和全局维度的限流通过之后才允许走到扣减逻辑。用户维度限流在抢券场景里非常好用。正常用户一次活动最多点几次按钮抢到之后按钮就置灰了。所以完全可以把单用户每秒请求数限制在3次以内超出直接拒绝。这个策略能挡掉绝大部分脚本刷量和手误连点而且不会误伤真实用户。实现上可以用Redis里的INCR加过期时间或者用本地内存做滑动窗口考虑到业务层是分布式多实例本地限流需要配合Redis共享计数才能精确但简化处理时本地限流也能挡掉一大半。业务层还需要提前准备降级开关。比如活动进行中如果发现Redis延迟升高就立刻切换为“全员排队”模式不再直接扣减库存而是把请求写入一个临时队列由后端平滑处理。这个开关最好用配置中心动态推送不要重启服务。下面用一张表总结各层职责和容量预期层级主要职责目标QPS关键手段接入层前置校验、连接管理、IP维度限流100万Keep-Alive、令牌桶业务层参数校验、用户限流、幂等检查10万左右本地缓存、Redis计数Redis层库存扣减、领取记录、分布式锁10万以上Lua原子脚本、分片异步层发券落库、通知用户、对账几千到几万消息队列、定时任务数据库最终存储、财务对账几千以下批量写入、幂等约束3. 防超卖的核心库存扣减方案从数据库到Redis Lua的演进3.1 数据库方案为什么撑不住老一代方案喜欢用数据库行锁或者乐观锁扣库存比如UPDATE coupon SET stock stock - 1 WHERE id ? AND stock 0。这个SQL用条件更新来保证不超发思路没错但问题是数据库的行锁机制在百万QPS下完全扛不住。即使只有1万QPS打到这条SQL上数据库的锁等待和事务日志也能把CPU和磁盘IO打满。更麻烦的是数据库方案会放大热点。所有用户抢同一张券相当于所有请求都在争抢同一行的行锁数据库的并发能力会被这一个热点行锁死。有些方案尝试把一张券拆成多行库存然后随机选一行扣减这样能缓解锁竞争但每行库存容易先被扣完导致有货也抢不到。这个“拆行”的思路本身是对的但它更适合放在Redis里做而不是数据库里做。所以数据库在抢券链路里的正确位置是异步落账不参与实时扣减。实时扣减必须放在内存型存储里而Redis是最成熟的选择。3.2 Redis Lua脚本怎么做到原子扣减Redis里的扣减操作不能简单地用DECR因为需要先判断库存是否大于0。如果有两个请求同时读到库存为1然后都执行DECR库存就会变成-1。Redis单线程模型保证了单个命令是原子的但“读-判断-写”这个组合不是原子操作所以要把这三个动作放进同一个Lua脚本里。核心脚本长这样-- KEYS[1] 是库存key比如 activity:2025:coupon:001:stock local stock redis.call(INCRBY, KEYS[1], -1) if stock 0 then -- 扣多了回补库存返回失败 redis.call(INCRBY, KEYS[1], 1) return -1 end return stock这个脚本用INCRBY -1一步完成读和写返回扣减后的剩余值。如果剩余值小于0就把库存加回去返回-1表示失败。因为Redis是单线程执行Lua脚本整个脚本执行期间不会被其他命令插入所以不会有并发扣超的问题。要注意一个细节脚本执行完如果返回失败库存已经被临时扣成负数又回补了。回补操作本身也是原子操作所以不会有中间态暴露给其他请求。从性能上说这个脚本非常轻量单次执行在0.1毫秒级别。单台Redis实例保守能扛5万到10万次这样的操作百万QPS只需要把库存拆成多个key分片就行。3.3 热点key问题的解法库存分片单张券的库存如果只用一个Redis key百万QPS会让这一个key成为热点。Redis虽然是单线程处理命令但高并发下单个key的读写会让CPU单核打满而且一旦key访问量太大主从复制和持久化的压力也会集中在这一个key上。解决办法是把库存拆成多个分片。具体做法是把总库存按一定规则拆到N个key里比如活动ID加券ID加分片号activity:2025:coupon:001:stock:01到:stock:10。每个分片初始化时分配总库存的1/N。用户请求到达时按用户ID哈希到固定分片然后对该分片执行上面的Lua脚本。固定的用户哈希保证同一个用户始终落在同一个分片避免一个用户多次点击消耗不同分片的库存。分片方案有一个必须接受的代价某些分片可能先被扣完而其他分片还有库存导致部分用户明明活动还有余量却抢不到。缓解办法是分片数不要太多比如8到16片这样各分片消耗速度会比较接近。如果想要更精细可以做一个分片库存水位监测当某个分片快耗尽时后台把其他分片的库存重新均衡一次。我实际用下来库存分片加上Lua脚本是性能和准确性之间最平衡的方案。它没有引入分布式锁也不依赖消息队列确认扣减结果实时返回整个逻辑可以直接跑在Redis Cluster的多主节点上每一组分片天然分布在不同节点热点问题也随之解决。3.4 扣减成功之后立刻记录用户维度幂等信息库存扣减成功只是第一步紧接着必须把“用户已领取”这件事记录下来供幂等判断使用。这个记录同样要用原子操作不然两个并发请求都可能通过前面的“是否已领取”检查然后一起扣库存。最稳妥的做法是把“判断已领取”和“扣减库存”放进同一个Lua脚本里一次调用完成。Redis支持在脚本里执行多个命令所以脚本可以这样设计-- KEYS[1] 库存key -- KEYS[2] 用户已领取集合key -- ARGV[1] 用户ID local existed redis.call(SISMEMBER, KEYS[2], ARGV[1]) if existed 1 then return -2 end local stock redis.call(INCRBY, KEYS[1], -1) if stock 0 then redis.call(INCRBY, KEYS[1], 1) return -1 end redis.call(SADD, KEYS[2], ARGV[1]) return 1这样一次Redis调用就完成了幂等判断、库存扣减、领取记录三个操作全程原子。这里再用SADD记录用户ID后续的“是否已领取”查询就是一次SISMEMBER非常快。需要注意这个用户集合会随着活动进行越来越大如果活动规模很大可以给集合设置一个过期时间活动结束后自动清理。4. 削峰与异步化把“硬扛”变成“缓接”4.1 同步抢券与异步放券的拆分先明确一个概念抢券动作本身必须同步返回结果因为用户需要立刻知道“抢到了”还是“没抢到”。但“发券”这个动作可以异步做。用户点击按钮系统扣减库存、记录用户ID、返回成功然后把用户ID丢进消息队列由后端服务慢慢给用户分配券码、更新数据库、发送通知。这样把100万QPS的瞬时压力分摊到之后几秒甚至几分钟慢慢消化。异步放券带来的另一个好处是券码生成、数据库写入这类重操作不再阻塞用户请求。用户点完按钮立刻看到结果哪怕后台数据库写入慢一点用户也感知不到。我自己做过的系统里异步写库最慢的一批请求可能延迟几十秒但用户早就离开页面了完全无感。当然异步化会引入一致性问题比如Redis里显示用户抢到了但异步任务因为各种原因没写库成功。这个靠定时对账任务来纠正先往下看。4.2 消息队列在抢券链路里的定位很多人会把消息队列画在“用户请求到业务服务”之间意思是削峰填谷。但抢券场景里如果用户请求先落消息队列再让消费者慢慢扣减库存用户就无法立刻知道结果体验很差。所以消息队列不能用来处理“抢”这个动作只能用来处理“发券”这个后续动作。流程是这样用户在业务层完成Redis扣减和幂等记录之后业务层把一条消息发到MQ消息内容包括活动ID、券ID、用户ID、请求唯一ID。下游消费者收到消息后查一次数据库看这个用户是否已经有发券记录没有就插入一条有就跳过。这样即使异步任务重复执行也不会造成重复发券。这里要特别强调请求唯一ID。客户端每次点击按钮时生成一个UUID作为请求的唯一标识随请求带上。所有下游处理都以这个UUID作为幂等键Redis判断过了数据库也做唯一索引双保险。4.3 失败补偿与对账防止“券发了但账没记”异步链路跑久了难免出现消息丢失、消费者宕机、数据库暂时不可用等问题。必须有一套对账机制把缺口补上。我会在服务里加一个定时任务每隔几分钟扫描一次Redis里的“已领取用户集合”和数据库里的发券记录做对比。如果发现Redis有记录、数据库没有就触发一次补偿发券。补偿时同样要走幂等逻辑避免补了两张券。这个对账任务在活动结束后的一段时间内格外重要因为最后一批异步消费可能还没完成。对账任务本身不能太频繁否则会放大数据库压力。一般活动期间五分钟跑一次活动结束后半小时跑一次全量对账就够了。另外所有的补偿操作都要记录日志方便回溯。如果补券也失败那就得走人工介入流程但这种情况在正常运行的架构里非常少见。5. 幂等、防刷与用户体验容易被忽略的三件小事5.1 幂等键设计同一个用户连点一百下也只算一次抢券系统里最常见的异常流量其实就是用户自己连点。网络慢的时候用户会疯狂点按钮一次活动页的重复点击率经常超过50%。如果每次都执行完整扣减逻辑100万真实用户可能产生500万次请求。幂等键是解决这个问题的核心。最简单的设计是userId activityId 日期作为幂等键用户第一次请求成功后后续相同键的请求直接返回第一次的结果。实现上用Redis的SETNX即可键存在说明已经处理过。三个字段拼接能满足绝大多数场景但有一种情况会被漏掉同一个用户在同一天参与两场相同活动键就冲突了。所以更稳妥的是让客户端每次点击生成一个requestId服务端用活动ID 用户ID requestId做幂等键。requestId方案还有一个好处服务端可以配合返回码告诉前端“这个请求之前已经成功处理过”前端就能根据结果展示正确的页面状态不会出现用户明明抢到了却看到失败页的尴尬。5.2 设备纬度与用户维度的防刷策略防刷不是安全团队的专利架构设计里也必须考虑。抢券活动最怕的是脚本批量注册小号来薅羊毛。单纯限制用户维度不够同一台手机不停切换账号用户维度限流完全失效。所以在接入层和业务层之间我通常会加一层设备维度判断。最简单的做法是前端生成一个设备ID存在本地存储里随请求上送。服务端按设备ID做频控一个设备每秒最多允许几个请求。这个方案防不了高手但能挡住绝大多数简单脚本。遇到更猛的对抗就要上验证码或者滑块。抢券场景里不能每个请求都弹验证码那样真实用户也会被劝退。正确的做法是分级低风险请求直接放行中风险请求要求一次滑块验证高风险请求直接拒绝。风险等级的判定可以依据IP、设备、账号的注册时间、历史行为等。二面时能讲出这套分级思路比单纯说“上验证码”要加分不少。5.3 前端也要参与削峰别把压力全甩给后端后端能做的是优化处理能力但更聪明的做法是让流量本身变小。前端可以做几件事倒计时结束前按钮置灰不允许点击点击后立即进入“请求中”状态防止连点活动开始时间做一个小的本地随机偏移避免所有用户同时出发。第三点需要产品同意才行因为抢券讲究公平用户角度肯定是倒计时归零瞬间点击。但技术层面可以解释随机偏移只影响几百毫秒对真实用户感知几乎没有差别却能明显打散流量尖峰。有些活动的策略是允许用户提前点击请求在前端本地排队到点自动发出这样服务器收到的流量更平滑。6. 容量评估与压测验证拿数据说话而不是拍脑袋6.1 从100万QPS倒推需要多少台机器每次聊到容量面试官都希望听到具体的计算过程而不是“我们用了很多机器”。这里给一个完整的推演接入层单台Nginx长连接模式下能扛5万到10万QPS按5万保守估算100万需要20台。实际会留50%冗余准备30台以上。业务层业务节点如果只做校验、限流和Redis调用不做重计算单实例能扛1万QPS100万需要100台。这看起来很多但业务节点可以弹性伸缩活动开始前提前扩容活动结束后缩容。Redis层单分片Lua扣减大约能扛5万QPS考虑热点预留按2万到3万配置分片库存拆成16片再加副本和分片间的buffer一个活动两个Redis Cluster集群足够。数据库如果每秒钟只写入几千条发券记录单库单表完全可以承受。但表数据量大会影响查询所以按活动维度分表再配合批量插入问题不大。上面这些数字不是死的要根据实际压测结果调整。但推导过程本身能体现你对每个组件能力的理解这在面试里非常加分。6.2 压测的重点不在于“压出峰值”而在于“泡出问题”很多人压测只看最大QPS实际上压测最有价值的部分是长时间维持高负载让问题自己浮现出来。真实抢券活动只有前几秒是峰值但压测时我会跑至少30分钟的高负载场景重点观察这几个指标Redis连接数是否被打满业务线程池是否因为Redis慢查询耗尽MQ消费是否堆积积压量是否持续增长数据库连接是否被异步任务占满网关的响应时间P99是否逐渐变差。这些都是“泡出来”的问题短时间压测根本看不到。比如我遇到过Redis连接数因为一个bug持续泄漏压测到第20分钟才把连接数打满服务瞬间不可用。如果不是长压测这个问题在上线当天才会爆发。压测工具方面单机wrk很容易打出高并发但要模拟真实的用户行为还是用分布式压测平台更靠谱。关键是压测请求要尽量真实包括权重随机、思考时间、重复点击等而不是简简单单地循环发同一个请求。6.3 我踩过的几个坑写出来供参考第一个坑是Redis的持久化策略。活动期间如果把appendonly设为always每条写命令都要刷盘性能会掉一个量级。正确做法是设为everysec同时接受最多丢一秒数据的风险。抢券场景里扣减已经成功、发券记录还没落库的数据丢了靠对账任务能补回来所以这个风险是可控的。第二个坑是本地缓存过期瞬间的缓存穿透。我曾在布隆过滤器更新策略上犯过错误活动进行到一半时过滤器还没加载新数据结果大量请求穿透到Redis做SISMEMBER。后来改成活动开始前预热、活动中定时增量更新穿透问题才解决。第三个坑是超时配置。Redis一旦慢下来所有请求都卡在等待上线程池很快被打满然后网关开始报超时用户看到的全是失败。正确的做法是所有Redis操作都设置超时时间超时就走降级逻辑而不是死等。降级逻辑可以是返回系统繁忙但绝不能把线程池拖死。7. 面试官追问里的三个送分点7.1 如果Redis主节点挂了库存扣减怎么办这个问题几乎必问。先说兜底Redis Cluster本身有多副本机制主节点宕机后哨兵或集群会自动把从节点提升为新的主节点期间会有几十秒的不可用。真正的风险不在于宕机而在于丢数据。如果主从切换瞬间丢失了最新扣减记录就会造成多发券。我的处理思路是这样库存扣减成功但数据丢失的情况靠“用户已领取集合”来做最终兜底。即使库存数据丢了导致多扣了几张数据库层面的幂等约束和唯一索引仍然能保证最终落库的记录不重复。多发的部分从财务口径看就是超发这就要靠活动预留在数据库里的“备用库存”来补。也就是说Redis负责速度数据库和备用库存负责兜底两边配合才能在极端情况保持基本正确。更细一点每条扣减记录可以带着时间戳和请求ID主从切换后对账任务根据这些信息把差额补齐。这套方案不能保证100%不超发但能把超发控制在很小的比例并且有后续补偿手段。7.2 如果QPS再翻十倍方案哪里会先扛不住QPS到千万级单活动的热点问题会彻底暴露。即使库存拆了16个分片每个分片依然要面对大量请求。这时候有两个调整方向第一是横向拆活动。把一张全国通用的券按照省、市或者业务线拆成多个子活动每个子活动独立库存、独立限流。用户请求先根据归属地路由到对应子活动这样热点从“全国一张券”变成“每个城市一张券”压力自然分散。这个思路叫做“分地域分桶”在大促场景里非常常用。第二是提前做预扣减。活动开始前先按渠道把一部分库存预授权给前端资源位用户点击时前端本地直接扣减后续再批量同步到后端。这种方式能削掉大量实时流量但库存分配不均的问题需要精细运营。当流量到千万级任何单点组件都不再成立核心思路永远是“分”和“提前”。7.3 如何证明这套方案真的能扛住百万QPS这道追问考察的是工程落地能力。不能只说“我们压测过”要说清楚压测的环境、指标和结论。我会这样回答先做容量估算得出各层机器的数量和Redis分片数然后搭建一套与生产环境等比例的压测环境。压测过程中重点记录三组数据各层实际QPS、RT的P99、错误率。再配合全链路监控观察每个组件在高压下的表现。最后做一次故障演练手动杀掉某一台Redis主节点验证自动切换和降级逻辑是否按预期执行。这套流程跑完之后输出一份包含容量估算、压测报告、调优记录、故障演练结果的文档。上线当天还有一套预案包括限流阈值、降级开关、扩容脚本和值班人员的手册。面试官听到这些基本就能确定你不只是会画图还真的做过。最后说一点个人体会。抢券系统这类题目技术方案本身并不复杂复杂的是每个决策背后的权衡为什么库存要分片为什么消息队列只放异步任务不放主链路为什么幂等判断要和扣减放在同一个脚本里。把这些“为什么”想透比背下一堆组件名称重要得多。面试时哪怕方案不是最优但你能把每一步的取舍讲清楚就已经超过了绝大多数候选人。