1. 秒杀系统的本质:一场有预谋的高并发冲击
做过后端开发的,迟早要碰一次秒杀。哪怕没玩过电商,也一定见过那种瞬间流量冲垮服务器的场面。秒杀系统看起来只是“抢购”两个字,实际上背后牵扯的东西特别多:高并发、缓存、消息队列、分布式锁、限流、幂等等等,几乎所有后端进阶的硬骨头都能在秒杀项目里遇到一遍。这也是为什么很多面试官喜欢问秒杀——一套秒杀方案聊下来,基本能摸清一个后端程序员对并发场景的理解深度。
那我今天就把自己的实践经验整理一下,从设计思路、架构选型、核心细节、实操步骤到问题排查,把秒杀系统这条线彻底捋一遍。不求面面俱到,但求每个关键点都给你讲透,让没有做过秒杀的同学也能知道从哪儿下手,有经验的同学也能对照着查漏补缺。
先看秒杀系统的核心矛盾。普通业务是流量平缓,数据库慢慢处理;秒杀业务是极小概率的人抢到极小量的商品,但流量在开抢那一刻瞬间爆炸,峰值往往是平时的几十上百倍。举个例子,某次活动商品只有1万件,结果开抢瞬间涌进来500万请求,真正的成功率只有千分之二。剩下的399万请求如果全部压到数据库,结果只有一个——数据库宕机,谁都买不到。所以秒杀设计的第一性原理不是“提升并发”,而是“尽量在离用户最近的地方挡掉大部分无效流量”。
理清了这个本质,后面所有的技术选型就都有方向了。我们需要在系统的每一层都做减法,让真正到达数据库的请求只有极少数,同时保证这极少数请求的数据一致性。这种思路说起来简单,落地的时候每一步都有细节,下面逐个拆解。
2. 整体架构设计:把压力挡在门外
2.1 分层设防,而不是单点硬扛
秒杀架构最忌讳的就是“单点猛扛”。很多第一次做秒杀的团队,上来就调大服务器配置、加数据库连接池,结果流量一冲就垮。正确做法是分层设防:浏览器端挡掉一部分,网关层挡掉一部分,缓存层挡掉一部分,真正落到数据库的请求,必须少之又少。
我习惯把秒杀链路分成五层:前端页面层、接入网关层、缓存层、异步队列层、数据库层。每一层都有独立的职责。
前端页面层主要做三件事:商品详情页静态化,把秒杀商品的静态页面提前生成好放到CDN,用户刷新时根本不会打到应用服务器;按钮置灰和倒计时,开抢前按钮不可点击,从源头减少无效请求;答题验证码或者滑窗验证,虽然很多人吐槽体验差,但它确实能把机器请求和脚本挡掉一大截。
接入网关层做的是全局限流和黑白名单。所有请求先过这一层,根据当前系统的承受能力,超过阈值的直接拒绝。很多团队在这一层就采用了令牌桶或漏桶算法,保证下游永远不会收到超出处理能力的流量。
缓存层是真正的“分流主力”。秒杀商品的库存量提前加载到缓存里,用户点击抢购时先在缓存里完成库存扣减,扣减成功才继续往后走。底层就是Redis,QPS能到十万级别,比数据库高整整一个量级。配合Lua脚本保证原子性,既快又不会超卖。
异步队列层负责接管“秒杀成功但还未下单”的用户请求。一旦缓存扣减成功,立刻把订单创建消息丢进消息队列,由后端消费者慢慢处理。用户那边先收到一个“抢购成功,请等待订单确认”的提示,订单真正生成是异步完成的。
数据库层只处理最后那极小比例的请求。每天卖出1万件商品,意味着最终只要处理1万条订单插入和1万条库存更新,这种压力对数据库来说根本不值一提。关键是确保这些操作的正确性。
2.2 链路中的削峰艺术
这套分层架构的本质其实是削峰,把开抢那一刻的流量尖峰拉平,让系统以平稳的节奏处理请求。我用一个具体的计算来给大家直观的感受。
假设某次秒杀有1万件库存,开抢后瞬间涌入300万请求。如果所有请求都打数据库,数据库按1万QPS算,需要300秒才能全部处理完,但实际上秒杀业务要求的是“快速决定谁抢到”,撑不到那么久。但是如果我们在网关层用令牌桶限流到每秒钟只放行2万请求,那么第一波300万请求就有280万以上被挡在外面,返回排队中或者稍后再试。放行进来的2万请求继续走缓存层,Redis顶住这2万QPS绰绰有余,其中1万人扣库存成功,剩下1万人秒杀失败被返回。然后这1万条成功的请求进入消息队列,消费者线程池慢慢把它们变成订单,整个数据库侧的压力其实就是平缓的1万次写入而已。
这就是削峰的核心价值:不追求每个环节都有超高并发能力,而是让每个环节只处理自己能力范围内的流量。用户侧体验也说得过去——大部分人瞬间得到结果,少数人需要等几秒钟收到订单确认。实际上多数秒杀系统都是这么干的。
3. 核心细节解析:防超卖与数据一致性
3.1 防超卖的三种方案对比
秒杀系统最怕两件事:一是超卖,明明只有100件商品,结果卖出去200单,平台赔个底朝天;二是少卖,库存明明还有剩余,但因为系统逻辑问题导致一部分请求没有被正确处理。防超卖方案我梳理了三种,各有使用场景。
第一种是数据库乐观锁方案。SQL这么写:UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0。利用数据库行锁和条件判断的特性,同一行数据的更新操作天然串行,stock大于0才允许扣减,从原理上杜绝超卖。这种方式最简单,但缺点是数据库的吞吐量有限,并发高的时候容易成为瓶颈。
第二种是Redis预扣减方案。商品库存先加载到Redis,用Lua脚本完成检查库存、扣减库存两个动作。Ingenious的地方在于单个Lua脚本的执行是原子的,多个请求同时进来会排队执行,不会出现ABA问题。这种方式吞吐量高,是主流大厂的做法,但需要处理缓存和数据库的一致性。
第三种是令牌方案。每个用户抢购前先生成一个唯一令牌,库存扣减时校验令牌是否有效且唯一。这种方式多用于强约束场景,比如限量发售的盲盒或者演唱会门票,但实现复杂度更高,对于普通电商秒杀来说有点重了。
我建议中小团队直接上方案二,也就是Redis预扣减,具体实现在下一节细说。
3.2 Redis-Lua脚本实现原子扣减
Redis预扣减最核心的部分就是Lua脚本。我当时写的脚本大致长这样:
-- KEYS[1]: 商品库存key,如 seckill:stock:1001 -- KEYS[2]: 用户去重key,如 seckill:users:1001 -- ARGV[1]: 用户ID if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then return -1 -- 该用户已抢购过,直接拒绝 end local stock = redis.call('get', KEYS[1]) if not stock or tonumber(stock) <= 0 then return 0 -- 库存不足 end redis.call('decr', KEYS[1]) redis.call('sadd', KEYS[2], ARGV[1]) redis.call('expire', KEYS[2], 86400) -- 保留一天,方便后续对账 return 1 -- 扣减成功调用方通过Redis执行这段脚本,返回值1代表扣减成功,0代表库存不足,-1代表重复抢购。很多第一次写秒杀的人容易犯一个错:先get库存,判断库存大于0,再执行decr。这是两个独立步骤,在高并发下会出问题。两个请求同时get到库存为1,都判断大于0,然后各自decr一次,库存就变成-1了,超卖就这样产生了。Lua脚本的核心价值就是把检查和扣减封装到一个原子操作里,从根本上杜绝这个间隙。
3.3 缓存与数据库的一致性处理
Redis和数据库天然存在状态不一致的问题。Redis先扣减了库存,但是数据库还没扣减,这时候如果Redis重启或者数据丢失怎么办?还有,秒杀结束后,数据库里的库存要怎么跟Redis对平?
我的做法是启用一个对账任务。秒杀活动结束后,用Redis中实际扣减的数量作为基准,批量更新数据库库存。比如商品原库存1000,Redis最终剩余0,说明实际卖出了1000件,那数据库直接设置为0。因为有去重集合的存在,每个用户的抢购记录都在Redis里,我们可以根据这些记录反推数据库的订单插入,不会因为Redis的扣减不稳导致数据和数据库全部错乱。
不过还有个细节需要格外注意:Redis是内存数据库,不是持久化数据库,极端情况下会丢数据。如果Redis挂了,重启后库存数据丢失,再次秒杀就会出问题。所以比较好的实践是秒杀开始前,从数据库加载库存到Redis时,同时持久化一份到本地文件;如果Redis挂了,允许秒杀活动短暂暂停,等恢复后再继续。不要为了省事放弃一致性,秒杀场景下宁可少卖也不能超卖。
4. 实操过程:从零搭建一个最小秒杀系统
4.1 基础设施与表结构设计
动手写代码之前,先把工程结构和数据库表设计好。我习惯用经典的Spring Boot + Redis + RabbitMQ这套组合,不引入太多中间件,方便大家理解核心逻辑。
数据库只需要两张核心表。商品表goods,字段包括id、goods_name、stock、version,这里的version字段用来做乐观锁兜底。订单表orders,字段包括id、goods_id、user_id、create_time、order_status。订单表一定要对goods_id和user_id建联合唯一索引,防止同一个用户对同一商品重复下单。
Redis中需要初始化几个key。商品库存key:seckill:stock:{goodsId},类型是String,值为库存数量。用户去重key:seckill:users:{goodsId},类型是Set,存放所有已抢购成功的用户ID。这两个key在秒杀活动开始前通过一个初始化接口写入Redis。
4.2 秒杀接口的完整流程
秒杀核心接口往往就一个,但内部流程环环相扣。我把当时写的流程记录下来,供参考。
第一步,网关限流。这一步我选用了分布式令牌桶,用Redis存储令牌桶的状态,每个商品秒杀一个桶,容量为20000,每秒补充2000个令牌。请求来时先执行lua脚本尝试获取一个令牌,没有令牌直接返回“活动太火爆,请稍后再试”。
注意:单机限流比如Google Guava RateLimiter在单机场景没问题,但秒杀通常多机部署,每台机器限流的话,总流量其实是“机器数乘单机阈值”,可能把后端打垮。一定要用分布式限流,让所有机器共用一个令牌桶。
第二步,用户资格预检。拿到令牌后,先查询Redis中的用户去重集合,看该用户是否已经购买过。这一步也能在Lua脚本里做,避免重复请求打到下游。
第三步,执行库存扣减Lua脚本。接收的入参是商品ID和用户ID,返回值按照前面说的0/1/-1三种情况处理。因为Lua是原子的,并发问题在这一步彻底解决。
第四步,异步发送MQ消息。扣减成功后的用户请求,马上返回“抢购成功,订单正在确认中”,同时把消息内容(goodsId、userId、timestamp)发送到订单消息队列。
第五步,MQ消费者接收消息,创建订单,更新数据库库存。消费者里需要开启事务,先查订单表确认不存在该用户该商品的订单,再insert,然后update商品表库存减一。为了避免数据库库存被扣成负数,SQL中要加上stock大于0的条件。这种情况理论上不该发生,因为Redis已经扣过了,但兜底逻辑宁可多写不能漏。
第六步,通知用户。订单创建成功后,通过短信或者App推送通知用户。这步可以异步执行,秒杀业务对“通知延迟”没那么敏感。
4.3 线程池与MQ的参数选择
很多初学者在走到MQ消费者这一步时会卡住:不知道消费者线程池开多大,queue容量设置多少。这里我分享一个比较实用的估算方法。
秒杀需求通常明确:1万件商品,希望在5分钟内处理完所有订单。假设单条订单消息的处理耗时是100ms,也就是单线程每秒能处理10条,那处理1万条需要1000秒,远远超出5分钟。如果消费者开50个线程,每秒能处理500条,1万条只需要20秒。如果处理耗时变成200ms,50个线程每秒能处理250条,1万条需要40秒。得到一个结论:线程池大小=(总订单数/期望处理秒数)/(单条处理耗时秒数),再留一半余量。
从我实践的经验看,消费线程池核心线程数设50,最大线程数设100,队列容量设10000足够了。RabbitMQ的prefetch设置为100,意思是一次性从队列取100条消息到本地处理,避免频繁网络请求。既然消费者这边接收的已经是秒杀成功的消息,数量可控,处理节奏稳定,完全没必要把线程池配置得过大。线程池过大反而可能引起数据库连接不够用的问题。
4.4 压测与效果验证
代码写完以后,压测是必须的。当时我用压测工具本地模拟了20万并发请求,大家可以把关注点放在这几个指标上。
第一,QPS曲线。观察网关层的通过率和拒绝率,正常情况下通过率应该稳定在限流阈值附近,拒绝率极高,这代表我们的限流生效了。
第二,Redis的扣减成功率。20万请求里大概有几个请求扣减成功,除非库存设置为很大的值,否则成功率会非常低,这才正常。如果扣减成功数多于库存数,那肯定有超卖。
第三,MQ积压量。正常情况下积压量会快速上涨然后快速下降,如果一直积压不动,消费者的处理能力肯定出了问题。
第四,数据库连接池水位。这步最关键,如果数据库连接池使用率居高不下,说明有大量请求穿透到了数据库层,缓存和队列没有起到拦截作用,需要回头排查限流和Lua脚本的拦截逻辑。
实测下来,这套方案数据库最多只会接收1万次左右的写操作,连接池水位完全平滑。倒是网络IO和Redis的内存占用需要留意,去重Set在大量用户抢购后会占用不少内存,记得设置过期时间释放空间。
5. 常见问题与排查技巧实录
5.1 超卖还是发生了,从哪儿查?
超卖是最严重的事故,一旦发现超卖,第一时间要把问题定位清楚。我总结了一个排查优先级清单。
先查Redis的扣减记录。通过MONITOR命令或者慢日志,确认Lua脚本执行过程中有没有异常,比如脚本执行超时、Redis集群主从切换导致部分命令丢失。再看去重集合的成员数量,如果集合成员数大于实际库存数,说明Redis侧出了问题。
再查MQ消息量。如果发送到队列的消息数大于商品库存数,说明在发送MQ之前就已经扣多了。可能的原因有两种:Lua脚本判断了库存但没在脚本里加入用户去重判断,导致同一用户重复抢购;或者脚本执行过程中服务异常,扣减成功但没发送消息。
最后查数据库订单。SELECT统计订单表中某个商品的订单数量,如果大于库存数,且Redis和MQ侧数据都正常,那就是最终创建订单时重复插入了。检查订单表明细,看是否有同一用户同一商品的多条重复记录,也就是唯一索引没生效。
提示:所有秒杀系统的数据操作都应该支持一键对账。我的习惯是秒杀结束后跑一个定时脚本,对比Redis扣减数量、MQ消费成功数量、数据库订单表数量,任何不一致都会报警。没有对账机制的秒杀系统,就像没有仪表盘的飞机,早晚要出事。
5.2 大流量冲击下Redis集群怎么保命
Redis扛高并发也有自身的极限,集群环境下有几点特别容易踩坑。
第一个坑是热key问题。秒杀商品的库存key就那一个,所有请求同时打它,很容易让Redis单节点CPU飙升,甚至超过单节点处理能力。解决方案有几个:一是把热key复制多份,比如拆成stock:1001_0、stock:1001_1等多份,请求随机命中,扣减时再汇总;二是引入本地缓存,但是秒杀场景下本地缓存容易导致超卖,不推荐;三是升级Redis集群,让热key分散在多个分片上,但这要看具体架构支不支持。
第二个坑是Redis连接耗尽。每个请求都要执行Lua脚本,如果连接池设置太小,请求就会排队等待连接超时。我建议把lettuce连接池的maxTotal设置到300以上,maxIdle设到100,同时把timeout调到2000ms,避免高并发下大量超时重试加剧压力。
第三个坑是主从切换导致的数据丢失。Redis哨兵或者集群模式下,如果主节点挂了,从节点顶上之前可能丢失部分已扣减的库存记录。这种场景下,宁可秒杀暂停,也不能继续扣减。我见过一个团队因为Redis主从切换导致超卖了几十单,最后只能赔付处理。解决方法是让Redis的持久化策略改成AOF每秒刷盘,并开启wait写机制,虽然不是绝对安全,能大幅降低丢失概率。
5.3 前端体验与后端性能的平衡术
秒杀系统的用户体验是一个很难平衡的点:用户想要的是“快点告诉我抢到没有”,后端想要的是“别给我那么大压力”。很多团队在这块做出了不太好的折中,比如用户点击后一直转圈,几十秒后才返回结果。这种体验非常劝退。
我采用的方案是大家现在常见的“双阶段”模式。第一阶段:用户点击抢购,如果扣减成功立刻返回“抢到啦!正在为你锁定订单”,这个响应时间必须控制在200ms以内。第二阶段:订单创建过程异步化,用户可以在页面上看到一个进度条,或者收到一条推送通知“订单已生成,请及时支付”。这种模式用户心理上可以接受,后端压力也能分散。
还有一个细节可以做:将商品详情页和大促页面的资源全部静态化,放到CDN上。图片和静态资源的请求不要打到应用服务器,省下来的带宽和CPU全部用于处理业务请求。我见过一个团队,没做静态化之前应用服务器CPU一直在90%徘徊,做完全站静态化后CPU直接掉到30%以下。
5.4 兜底方案:降级与熔断
即使架构再合理,突发情况永远存在。比如依赖的Redis集群全部故障,比如消息队列消费端代码出现bug,导致订单大量堆积。这时候必须有预案。
缓存层降级:如果Redis不可用,可以降级为数据库直接扣减,虽然性能差一些,但至少保证核心购买链路可用。当然这需要提前在接口层做一个开关控制,不能临时改代码。
MQ降级:如果MQ挂了,消费者直接从数据库层面处理扣减,但要做幂等控制和去重,防止重复下单。最简单的做法是订单表唯一索引配合catch异常处理。
还有一个容易被忽视的:对恶意刷单和脚本的防护。秒杀场景下黄牛党非常多,他们会用脚本并发请求,绕过前端限制直接打接口。网关层可以做IP维度的限流,比如同一IP在一秒内最多只能请求3次;同一个用户维度也要做限流。还可以引入验证码服务,但验证码会伤用户体验,取舍的时候要结合业务场景判断,一般价值比较高的商品才会强制验证码。
6. 复盘:我对秒杀架构的几条认知
秒杀系统的很多经验,其实是通用的并发处理思想。这里做一个经验性的沉淀。
第一条,永远不要在数据库层面扛高并发。数据库是系统最核心的资源,连接数有限、锁竞争严重,一旦被打垮恢复很痛苦。所有能往前挡的流量,尽量在缓存层、网关层挡掉。
第二条,一致性比性能重要,宁可慢不可错。超卖的赔付成本远比技术成本高,为了追求性能而牺牲数据正确性,最终一定会花更大的成本来修复。
第三条,异步化是杀手锏。把同步接口拆成异步流程,把用户等待时间转化成系统处理时间,这是秒杀系统最核心的架构策略之一,其他高并发场景一样适用。
第四条,监控和报警比代码更重要。没有监控,系统挂了都不知道从哪里查起。我当时给秒杀系统加了三层监控:基础设施监控看CPU、内存、磁盘、网络;中间件监控看Redis命中率、MQ积压量、连接池水位;业务监控看QPS、成功率、超卖报警。任何一个指标异常,都能第一时间定位到具体环节。
第五条,测试永远不够。压测一定要覆盖真实场景,模拟偶然情况、模拟Redis节点抖动、模拟消息队列故障。平时多跳坑,线上才不踩坑。
当初我接手第一个秒杀项目的时候,一开始也想把所有逻辑都放在同步链路里处理完,结果压测一跑,数据库连接池直接炸了。后来把异步队列引入,问题迎刃而解。很多同学在面试或者做项目的时候经常纠结“方案够不够高级”,我觉得更关键的是“方案够不够合理”,能针对具体场景做出取舍,比背一堆概念重要得多。如果在座各位正在设计自己的秒杀系统,建议从最简单的防超卖和限流开始,一步步把链路延长,复杂度是随需求增长的,不要一上来就追求大而全的架构。先把核心的抢购、扣库存、防超卖做对了,再慢慢加消息队列、加分布式锁、加对账系统,这条路走下来,你会对整个高并发体系有特别深的理解。