ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

秒杀系统架构设计深度拆解:高并发、Redis与库存扣减全攻略

秒杀系统架构设计深度拆解:高并发、Redis与库存扣减全攻略 秒杀系统这个话题几乎是Java后端和架构师岗位面试里绕不开的必考题。我当面试官这些年见过太多候选人一上来就口若悬河Redis集群、MQ削峰、分库分表……听起来技术栈全对但一问到“你的Redis扛不住写压力怎么办”、“消息重复消费怎么处理”、“库存扣减如何保证不超卖”立刻就卡壳。很多人是把八股文背熟了根本没有理解秒杀系统真正的难点和设计逻辑。这篇文章我想站在面试官的角度把电商秒杀系统架构从头到尾拆一遍。不仅讲清楚整体方案更重要的是讲明白每一个关键设计背后的原因以及那些最容易答错、最容易露出破绽的细节。无论你是准备面试还是真的要在项目里落地一套秒杀活动这篇文章都值得认真看完。1. 秒杀系统的本质不是技术炫技是流量治理很多面试者开场就讲“我们要高可用、要高性能、要高并发”这几句话说了等于没说。面试官真正想听的是你清不清楚秒杀系统的瓶颈在哪里以及你用什么手段去解决它。1.1 秒杀为什么难三个核心矛盾秒杀系统难难在三件事同时发生第一瞬时流量极高。平时一个商品页一天可能就几万PV可到了秒杀那一刻可能在几百毫秒内涌入几十万甚至上百万请求。这对整个链路都是巨大的冲击从网关到应用服务到数据库任何一环都可能被打垮。第二读写比例极端失衡。秒杀场景下绝大多数请求都是在“查”——查商品信息、查库存余量、查是否已下单。真正能下单成功的用户极少可能只有库存量那么多。这意味着如果你的系统把每个“查”都打成数据库查询那数据库连正常服务都做不到更不用说处理真正的下单了。第三数据一致性要求极高。秒杀的核心是库存扣减绝不能出现超卖。库存只有100件结果卖出120件这在电商里就是严重事故。可要保证不超卖通常需要加锁、需要事务而这又会降低系统并发能力。怎么在“极强的一致性”和“极高的并发量”之间找平衡是整个秒杀系统设计的主线。1.2 面试中常见的错误回答我统计了一下面试中关于秒杀架构答错频率最高的几个点大概是这些张口就是“上Kafka就好了”。消息队列是削峰填谷的工具但如果前面没有限流和过滤Kafka自己也会被巨大的请求量打爆。而且Kafka的消费能力有限如果消费者处理不过来积压会越来越严重最终用户看到的就是“已下单但一直不发货”。用数据库行锁扣库存。比如UPDATE stock SET count count - 1 WHERE id ? AND count 0这种写法确实能防超卖但性能极差。秒杀瞬时可能几十万请求同时打过来数据库的行锁竞争会让整体响应时间飙升最终大批请求超时失败。一说限流就拿“令牌桶算法”搪塞但说不清楚这个限流到底是在哪一层做的限到多少阈值超过阈值的请求返回什么。面试时要的是完整的方案不是名词堆砌。这些答案不是全错而是不完整、不理解取舍。接下来我按一条完整的链路来拆解从用户点击按钮到订单落库每一步为什么这么设计。2. 分层的核心架构请求每一层都在被“拦截”秒杀系统最核心的思想就四个字层层拦截。不能让所有请求都穿透到数据库。每经过一个环节就要想办法杀掉一批无效请求只让少数真正的买家请求走到最后。2.1 前端与网关层拦截在源头第一层拦截不在服务端而在前端。用户在秒杀页面点击“立即抢购”之后前端可以先做一次本地开关校验活动是否已开始、用户是否登录、是否已参与过。这些信息通过接口预取到前端缓存中千万不能让每个请求都去打后端接口。前端还需要做按钮置灰处理。我见过一个反例用户抢完一次后发现按钮还能再点于是疯狂猛点所有人都这么干后端收到的请求量立刻翻几十倍。正确做法是点击后立即置灰并提示“排队中”同时在前端做随机延迟或固定频率的防抖。这一步最容易被忽略但它是最便宜的优化——不消耗服务端资源纯前端就能减少一半以上的无效流量。到了网关层就更重要了。网关Nginx或API Gateway要做的事包括IP维度限流同一个IP在秒杀窗口期内最多允许N次请求超过直接返回“很抱歉抢购太火爆了”。基于Nginx的limit_req模块就能实现按IP或者按用户维度做令牌桶限流。接口维度限流对/seckill/order这一类核心接口做全局限流。比如系统设计容量是每秒2万请求网关就把流量控制在每秒1万多出来的请求全部快速返回失败。这里有一个关键点被限流拦截的请求返回要快响应体要小最好是几十字节的JSON而不是让客户端加载一个冗长的错误页。网关这层的核心价值是保护下游不被打垮。宁可错杀一千也不能放进来一万。因为对秒杀系统来说最不能接受的是整个服务雪崩而不是部分用户抢不到。2.2 应用层本地缓存与预扣减请求穿过网关到达应用服务如果应用层还傻乎乎地每次去查Redis、查数据库连应用自己也扛不住。所以应用层第一个手段是本地缓存。商品信息、活动配置这类变化频率极低的数据完全可以提前加载到应用进程内缓存Caffeine、Guava Cache都行。秒杀页面的大多数“查看商品详情”请求让应用直接从内存返回即可连Redis都不用打。接着要说的是秒杀系统的第二个核心思想读多写少读用缓存写用异步。我在设计一个秒杀链路时库存余量展示走的是Redis的DECR或Lua脚本而且这里要分清楚“展示库存”和“实际库存”的区别。Redis里存的库存可以比数据库里的多因为Redis里的值只是用来做“秒杀资格”的判断确保不让超过库存量的人进入下单环节。至于真正的库存扣减放在数据库事务里做最终校验。这里给一个很关键的实操经验在Redis中使用Lua脚本做库存扣减和用户去重。脚本将“用户是否存在”、“库存是否充足”、“减库存”、“记录用户”这四步合成一个原子操作避免了并发下的超卖问题。-- KEYS[1]: 秒杀商品库存key -- KEYS[2]: 用户去重set -- ARGV[1]: 用户ID -- ARGV[2]: 扣减数量 if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return 0 -- 用户已抢过 end local stock tonumber(redis.call(get, KEYS[1])) if stock tonumber(ARGV[2]) then return -1 -- 库存不足 end redis.call(decrby, KEYS[1], ARGV[2]) redis.call(sadd, KEYS[2], ARGV[1]) return 1 -- 秒杀成功这段脚本我用过很多次实测下来单机Redis每秒能稳定支撑数万次扣减操作。但需要注意一个风险如果秒杀请求量远超Redis的单点性能就需要做Redis集群分片。市面上也有用Redis Cluster的通常按商品ID做哈希让同一商品的库存操作都落在同一个分片这样Lua脚本的原子性仍然成立。不过Redis也有自己的弱点。秒杀开始时热点key会收到超高并发的读写Redis单线程处理能力再强也有上限而且网络带宽也可能被耗尽。所以在秒杀这种极限场景下很多大厂会做多级缓存降级应用层本地缓存挡住一大部分查询Redis挡住一大部分扣减最终数据库只需要处理极少数的真实订单。2.3 消息队列削峰填谷的本质经过应用层过滤后真正拿到“秒杀资格”的请求也还有不少。如果这些请求全部同步写数据库数据库会被打垮。所以这里要起消息队列把下单请求异步化。具体的流程是应用层校验通过后不是直接操作数据库下单而是把一条“用户A抢到了商品B”的消息丢进MQ。系统立即给前端返回“抢购成功正在处理订单”而数据库的落库操作由消费者异步执行。这样做的好处有两个一是削峰填谷。瞬时的高流量被消息队列缓存住消费者根据自己的处理能力按固定速率拉取消息数据库压力变得平滑可控。二是解耦。订单系统、库存系统、通知系统各自消费自己的消息互不影响。面试中这部分的常见追问很密集我几乎百分百会问“消息丢了怎么办” 答如果你们用的是RocketMQ或Kafka可以开启生产者端的确认机制和Broker端的刷盘策略。但光有持久化还不够消费者端要做到消息幂等。重复消费的解决方案是让业务操作本身可重复比如用唯一订单号作为数据库约束重复插入直接报错忽略。“消费者处理不过来怎么办” 答增加消费者实例做水平扩容。但要注意如果数据库是瓶颈光加消费者也没用因为消费者的压力最终还是打到数据库上。所以一般会分两步走第一步先消费消息用事务消息表记录已处理的订单状态第二步延迟批量落库。比如每攒100条订单再一次性批量INSERT或者使用异步批处理的方式将数据库写入次数减少一到两个数量级。消息队列的选型也很值得讲。秒杀场景下我优先推荐RocketMQ或RabbitMQ而不是Kafka。Kafka的高吞吐确实好但它的强项是日志类数据而且消息是分区顺序消费的一旦某个分区出现慢消费后续消息都会被阻塞。RocketMQ天然支持事务消息、延迟消息网络模型上对业务场景更友好。当然现在很多自研系统直接用Kafka也没问题但面试中能讲出这个选型差异比一股脑推荐Kafka要高级得多。3. 库存扣减那笔账怎么做到不超卖还扛得住库存扣减是最核心的一环也是面试里最容易翻车的地方。我先把几种常见的扣减方案列出来说说它们各自的优缺点再说我在生产环境里是怎么取舍的。3.1 三种扣减方案对比方案实现方式优点缺点适用场景数据库行锁UPDATE stock SET countcount-1 WHERE id? AND count0强一致不会超卖并发能力极差行锁竞争严重低并发、对一致性要求绝对高的场景Redis扣减LuaRedis原子扣减成功后再异步落库性能极高能扛住瞬时高并发需要处理Redis与数据库的最终一致性秒杀这类高并发抢购乐观锁数据库增加version字段更新时比对版本号相比行锁性能稍有提升并发冲突多时大量重试CPU浪费严重并发量可控冲突概率低数据库行锁方案在秒杀里基本不适用原因很简单假设你有100件库存秒杀瞬间来了10万请求这10万个并发更新同一行数据库的行锁队列会排得非常长每个事务的等待时间都在几十秒以上大量超时最终用户体验就是“一直转圈”。乐观锁呢在高并发下冲突率极高一次成功需要几十次重试CPU白白被耗掉。所以生产环境里我用的方案很明确第一道防线是RedisLua做预扣减第二道防线是数据库做最终扣减。Redis只负责筛选数据库负责记账。3.2 最终一致性怎么做Redis扣减成功不等于数据库扣减成功。这里面的最常见意外是应用进程在写完Redis、正准备发MQ消息时宕机了用户看到“抢购成功”但数据库里根本没有订单。解决方案是本地消息表定时任务对账。实现方式是这样的应用在同一个本地数据库中开启一个事务插入一条“秒杀资格记录”到seckill_record表状态为“待下单”同时插入一条消息记录到mq_message表状态为“待发送”。本地事务提交成功后将消息发送到MQ并更新消息状态为“已发送”。若应用宕机导致事务提交了但消息没发出去定时任务定期扫描mq_message表中状态为“待发送”的消息重新投递。这样做还有一个好处下游做幂等时可以直接用seckill_record里的唯一ID比如用户ID商品ID活动批次做数据库唯一约束。因为本地的记录和消息是同一事务数据库里的记录存在就说明用户确实拿到了资格。重复消费消息时插入订单会因为唯一约束冲突直接失败但业务上可以把这个失败当成“订单已存在”来处理逻辑不会乱。有些面试者会问为什么Redis扣减成功了还要绕这么大一圈不能直接让Redis作为库存的最终存储吗事实上如果只是秒杀抢购资格Redis完全可以作为最终存储Redis自身有持久化机制。但问题是秒杀之后通常还会接订单系统、支付系统、仓储系统这些系统都强依赖数据库。订单要入库、支付要查库、发货要操作库存表如果库存只有Redis有所有下游系统全都得改造成访问Redis这不仅风险大后续对账也没法做。所以更稳妥的做法是Redis扛住瞬时并发数据库作为权威数据源做最终记账两者通过异步机制做最终一致。3.3 库存表设计的一个细节库存表看起来简单但很多人在字段设计上栽过跟头。我推荐一张表里至少要有这几个字段id主键sku_id或product_id唯一标识某个SKUtotal_stock总库存available_stock可用库存秒杀可抢的量locked_stock锁定库存已取得资格但未支付/未落库的量version乐观锁版本号每次更新1为什么要把“锁定库存”和“已售库存”分离因为秒杀不是“扣一刀就完事”用户抢到之后还有支付时限有的用户抢到但15分钟内不付款此时库存需要回补。如果直接用count count - 1扣掉回补时还得重新加回来而且在“已锁定但未支付”的期间库存已经不可用但这个状态不能被普通查询看到。分离字段后每次业务动作只需要更新对应的字段逻辑清晰排查问题也方便。4. 黑产与风控秒杀系统不能忽视的一层我在面试里问到秒杀风控问题时经常看到候选人一脸茫然好像秒杀系统只要搞定性能就万事大吉了。现实远没有这么简单。电商秒杀面向的是全网用户其中混杂着大量黄牛和黑产——他们用脚本抢购茅台、抢限量球鞋、抢优惠券一次能囤几百份。如果一个秒杀系统不考虑风控这场活动名义上是回馈用户实际上就是给黑产打工。4.1 设备指纹与用户身份识别最简单的风控是限制“一单一账号”但脚本可以通过批量注册账号绕过。稍微进阶一点的做法是设备指纹。用户端SDK采集设备的硬件参数比如CPU核心数、内存大小、屏幕分辨率、MAC地址哈希等生成一串唯一标识。黑产用一台电脑虚拟几十个账号但底层设备指纹相同服务端按设备指纹维度去重后就能识别出批量操作。这个方案的实现成本不低需要在客户端集成SDK并维护指纹的生成算法。就算不做完整SDK至少也要在前端生成一个匿名的Canvas指纹或WebGL指纹后端按指纹频次做限流。实测下来能挡住一大半的脚本党。4.2 访问频次的多维度限流风控的第二个抓手是频次控制。网关层只做“单IP限流”这远远不够。黑产用IP代理池一个IP只发一次请求你按IP限就限不住。所以更可靠的是按“用户设备商品”的多维度限流同一用户ID最多秒杀成功1次同一设备指纹最多参与3次秒杀取各活动之和同一IP在秒杀窗口内最大请求次数同一收货地址关联的多个账号数量这些维度可以组合成一条规则链任何一条触发直接拒绝并返回活动异常提示。到了实施层面可以用Redis的INCREXPIRE来做滑动窗口计数也可以用现成的风控引擎来驱动规则判断。秒杀系统设计里风控不是可选项而是必选项。没有风控兜底你的库存很快会被脚本扫空真正常来光顾的普通用户反而什么都抢不到。4.3 秒杀链路的埋点与监控做风控和做性能优化一样前提是能看到数据。秒杀链路里的每个关键节点都需要打点进入秒杀页的用户量点击“立即抢购”按钮的请求量通过网关限流的请求量通过Redis资格校验的量发送到MQ的下单消息量数据库最终成功落单的量这一层层的数据画成漏斗图每一层的过滤比例到底是多少一目了然。正常秒杀大概是100万点击80万被网关限流拦掉18万进入Redis扣减最终只有1万真实下单假设库存1万。如果发现Redis通过的量和数据库落单量差距很大说明有大量的“抢到未下单”要排查是MQ积压还是消费异常。监控指标里最不能忽略的是Redis的QPS和内存使用率。秒杀开始那几十秒Redis可能是全系统压力最大的组件QPS瞬时直线冲高如果监控没提前配置好告警等发现的时候Redis已经被打垮了。5. 一套可落地的参考架构模版化成你自己的答案讲到这里整套秒杀系统的架构已经清晰了。我来把它串成一条完整链路并标出每一层的核心组件和技术要点。这套架构不仅能应付面试生产落地做适当裁剪也完全够用。5.1 整体链路图链路顺序是这样的用户端APP/Web → CDN静态资源 → 接入层Nginx/LVS → 网关层限流风控 → 应用层商品校验秒杀资格 → RedisLua扣库存 → 本地消息表/MQ → 异步消费者 → 数据库落单每一步对应的核心组件我列了一个表层级核心职责关键组件/技术面试中要能说出的亮点用户端静态资源加速、按钮防抖CDN、前端本地缓存、防重复点击前置拦截减少无效请求接入层高并发转发、限流Nginx/LVS、openresty基于IP和URL的限流规则网关层风控、接口维度限流API Gateway、Sentinel滑动窗口限流、多维度风控应用层本地缓存、资格校验Caffeine/Guava、业务过滤本地缓存扛住查询高频缓存层原子扣减、用户去重Redis Lua原子性避免超卖异步层削峰填谷、可靠投递RocketMQ/Kafka保证消息不丢不重数据层最终一致性落单MySQLInnoDB唯一约束、乐观锁、幂等这一整套链路有个核心设计哲学越靠近用户端流量越大做的事越“无脑”越靠近数据端流量越小做的事越“精细”。每一层都是一个漏斗把无效请求一层层过滤掉最终能到达数据库的请求是真正需要处理的订单请求。5.2 按容量规划反推设计很多面试者只讲组件不讲容量这很容易被面试官抓住追问。你的系统到底能扛多大并发这个问题必须心里有数。如果你是设计一个常规的营销秒杀可以参考这样的容量规划秒杀商品库存10,000件预计参与抢购人数100万秒杀开启前10分钟内登录并等待的用户50万秒杀开启瞬间请求量100万QPS在这个前提下链路各层的容量目标可以这样设定CDN/静态页面扛住1000万QPS没问题由CDN厂商负责网关层限流设置为50万QPS的上限多出来的直接返回“活动太火爆”应用层横向部署20台实例每台能承受2万QPS整体40万QPSRedis单分片8-10万QPS用集群分片后容量翻倍足以扛住被网关过滤后的流量MQ峰值写入10万QPS消费端根据数据库能力动态调整消费速率数据库最终每秒落单约500笔完全在MySQL的正常能力范围内每个数字都能倒推出来当你能讲出这套数字的时候面试官会确信你是真正做过设计的人而不是背八股文。如果被问“如果Redis也扛不住怎么办”可以补充讲多级本地缓存降级熔断本地缓存挡掉重复的查询Sentinel或Hystrix在Redis异常时自动降级拒绝新增秒杀请求保证存量请求正常处理。5.3 这套架构的取舍与代价没有完美的架构只有合适的取舍。这套方案也有自己的代价第一实现复杂度高。需要维护Redis和数据库的最终一致性需要处理MQ的重复消费还需要有定时任务对账。如果团队没有足够的技术储备这套方案落地并不轻松。第二用户体验有损。用户点击“抢购”后看到的是“已提交订单处理中”而不是立即看到“下单成功”。这个延迟感在大促场景中是可以接受的但如果产品经理坚持要“秒开订单”那你得额外投入一套更强的实时处理链路。第三兜底流程不可少。比如用户抢到资格但异步下单失败了怎么给用户补订单、退资格每个异常分支都要有对应的处理流程这也是很多面试者完全不会讲的细节。针对第三点我给一条具体的兜底方案消费者落库失败时将消息转入重试队列。重试超过3次后转入死信队列由告警系统通知开发者手工处理。同时提供一个对外的查询接口“查询我的秒杀订单状态”前端轮询该接口让用户能感知到订单正在处理减少客诉压力。6. 高频面试追问与避坑要点实录最后这里专门整理一下秒杀系统面试中我必问的高频追问以及我认为答得好的思路供大家参考。这些也全部来自我面试他人的实录和我在团队内部分享时的总结。6.1 必问题Redis和数据库库存不一致怎么办这个问题答不好基本就凉了。最好的思路是承认不一致是常态然后讲清楚如何通过补偿和对账让数据最终一致。我的做法是定时对账每5分钟跑一次任务比对Redis中的“已扣减数量”和数据库中的“成功订单数”。发现差异后以数据库记录为准补偿Redis的可用库存或回滚多余的扣减。具体细节如果Redis扣减了但数据库没有订单说明存在未投递或丢失的消息补偿方式是“回补Redis库存清理用户去重记录”。如果数据库有订单但Redis没有扣减记录说明是对账延迟导致的幻读再次执行扣减即可。讲清楚对账逻辑之后追加一句“秒杀结束后会进行全量库存比对确保最终数据一致”整个回答就很完整了。6.2 必问题用户重复提交怎么防我推荐的答案是前端按钮置灰后端用户维度幂等数据库唯一索引三管齐下。前端不用多说。后端在接收到下单请求时用userId productId activityId拼接一个幂等ID在Redis里用SETNX做标记。如果标记已存在说明是重复请求直接返回“您已提交过请勿重复操作”。即使并发请求同时到达Redis单线程的SETNX也能保证只有一个成功。最后数据库落单时在订单表上加唯一索引兜底挡住最后的重复。这个方案层层递进面试官很难挑出毛病。6.3 必问题MQ消息丢失怎么处理消息丢失要分三段来讲生产者到MQ、MQ自身、MQ到消费者。生产者到MQ开启confirm确认机制发送后异步等待Broker确认如果确认失败或超时则重发。MQ自身设置消息持久化Broker收到消息后刷盘持久化成功才返回确认。MQ到消费者消费者处理完成后才手动提交offset如果还没提交就宕机消息会重新投递给其他消费者。在这三层保障之上消息是否真的不丢还有一个前提消费者处理要幂等。因为“至少一次”投递语义下消息可能被重复投递只有幂等才能做到本事上“不丢不重”。6.4 实战中的其它坑最后说几个我踩过的坑这都属于过来人才知道的细节Redis的DECR和EXPIRE不是原子的。设置库存key时要一并设置过期时间否则一旦活动日期调整原来的key残留会导致新活动的库存异常。建议用SET命令直接带过期参数初始化。本地缓存导致库存展示不准确。如果你的秒杀页面是静态化的用户看到“还剩1件”实际可能已经卖完。这个小细节在面试中提一句能加分。解法是库存展示接口单独走Redis动态获取不要跟商品详情的本地缓存混在一起。监控不能只看平均值。秒杀系统的流量是瞬时脉冲看平均QPS没有意义要看峰值和p99。监控项至少要包括每秒请求量、每秒缓存命中率、消息积压量、订单成功量这几个核心指标。压测一定要做全链路。只压Redis没意义因为真实瓶颈往往在数据库连接池、网络带宽、GC停顿这些小地方。用压测工具模拟秒杀场景逐步加压直到链路崩溃找出真正的瓶颈点这个数据比什么设计都值钱。7. 这个架构还能怎么演进秒杀系统的架构不是一成不变的。如果库存规模更大、并发量再高一个量级可以从两个方向演进。第一个方向是静态化与边缘计算下沉。把秒杀页面的HTML、图片等全部放在CDN边缘节点用户请求甚至不经过你的源站。进一步做客户端毫秒级倒计时把“同时起跑”的压力分摊到全网节点避免所有请求同时汇聚到同几个机房。这个方向在超大规模秒杀活动比如平台级大促中非常关键。第二个方向是读写分离与数据分片。订单数据量达到千万级后对订单表按用户ID进行哈希分库分表让每个用户的订单都落在同一个分片里既减少单表压力也方便按用户查询。同时用独立的读库或ES支撑订单搜索与分析把查询和写入彻底分离。演进的方向还有很多比如引入一致性哈希做动态扩缩容、使用自适应限流替代静态阈值等等。但无论怎么演进底层的设计哲学不会变把并发请求一步步收窄把最终一致性做扎实用异步化换取系统的平稳运行。我个人做了这么多年最强的感受是秒杀系统看着技术点多但骨架就那几块。真正拉开差距的是你能不能把每一层为什么这么设计、每一条异常分支怎么兜底都讲得明明白白。这套思路用到别的场景比如优惠券抢购、预约放号、限量报名全部通用。如果面试中能把“骨架数据取舍”组合起来讲你就已经超过大多数候选人了。
返回列表