【架构实战】电商秒杀架构:高并发场景的终极挑战
电商秒杀架构:高并发场景的终极挑战
一、什么是秒杀系统?
秒杀是电商平台常见的营销活动:商家以极低价格限量售卖商品,用户在同一时间集中抢购,具有瞬时高并发、库存少、读写频繁的特点。比如某品牌手机新品首发,1000台手机在1秒内被10万用户抢购,这就是典型的秒杀场景。
秒杀系统的核心挑战有四个:
- 瞬时高并发:瞬间QPS可能是平时的100倍以上,普通架构无法承受;
- 库存一致性:要避免超卖(卖的数量超过库存)和少卖(库存没卖完);
- 系统稳定性:不能被秒杀流量冲垮,影响其他正常业务;
- 用户体验:响应要快,不能出现长时间等待或错误。
2019年某电商平台的秒杀活动中,就因为架构设计缺陷,出现了超卖1200台手机的严重事故,最终赔付用户超过200万元,还影响了平台口碑。接下来我们就从架构演进的角度,拆解秒杀系统的设计要点和避坑指南。
二、秒杀系统架构演进:从单机到分布式
1. 单机秒杀架构(早期阶段)
早期的秒杀系统往往和正常订单系统共用架构,流程如下:
用户请求 → Nginx → Tomcat → 查库存(DB) → 扣库存(DB) → 生成订单这种架构的问题非常明显:
- 所有请求直接打到数据库,秒杀开始时DB的QPS瞬间飙升到几万,直接被打死;
- 没有限流措施,所有用户请求都能进来,服务器线程池很快耗尽;
- 库存扣减没有并发控制,超卖问题严重。
当时我们团队做第一个秒杀活动时,就用了这种架构,结果活动开始3秒后数据库就宕机了,库存1000件的商品超卖了237件,最后只能用拉黑用户、补偿优惠券的方式收场。
2. 分布式秒杀架构(成熟阶段)
经过多次踩坑,我们逐步演进出了分布式秒杀架构,整体分为四层:前端层、网关层、服务层、数据层,每层都有对应的优化手段。
(1)前端层优化
- 静态化:秒杀页面、商品详情、JS/CSS都放到CDN,用户请求直接从最近的CDN节点返回,不回源到服务器;
- 按钮置灰:秒杀开始前购买按钮置灰,防止用户重复点击;同时前端做限流,单个用户1秒内只能发送1次请求;
- 答题/验证码:增加秒杀门槛,防止脚本刷单,同时拉长请求时间,削峰填谷。
(2)网关层优化
- 限流:Gateway层做全局限流,比如总QPS限制在5万,超过的直接返回"活动太火爆,请稍后重试";
- 鉴权:校验用户登录状态、账号是否正常,过滤非法请求;
- IP限流:单个IP每秒最多5次请求,防止单个用户用多个账号刷单。
(3)服务层优化(核心)
- Redis预减库存:先把库存同步到Redis,所有扣库存操作先在Redis中执行,避免直接打DB;
- MQ异步下单:Redis预减库存成功后,把订单信息发送到MQ,由消费者异步生成订单、扣减DB库存;
- 幂等设计:防止MQ消息重复消费导致超卖,每个订单请求带唯一RequestId,消费前校验是否已处理。
(4)数据层优化
- 库存表设计:库存表和订单表分开,库存表用行锁或乐观锁,避免并发扣减问题;
- DB限流:数据库连接池设置最大连接数,超过的连接排队等待;
- 读写分离:普通查询走从库,扣库存走主库。
三、核心设计:库存扣减方案
库存扣减是秒杀系统的核心,既要保证一致性,又要保证高性能。我们采用的是Redis预减库存 + MQ异步落库的方案,流程如下:
1. 用户请求到达服务层 2. 查Redis库存:如果库存 <=0,直接返回"已售罄" 3. Redis预减库存:用DECR命令扣减库存,返回扣减后的库存值 4. 如果扣减后库存 >=0,说明抢购成功,发送订单消息到MQ 5. 如果扣减后库存 <0,说明抢购失败,用INCR回滚库存,返回"未抢到" 6. MQ消费者收到消息,校验DB库存,生成订单,扣减DB库存1. Redis预减库存代码实现(Java + Spring Data Redis)
@ServicepublicclassSeckillService{@AutowiredprivateStringRedisTemplateredisTemplate;@AutowiredprivateRabbitTemplaterabbitTemplate;// 秒杀商品库存Key前缀privatestaticfinalStringSECKILL_STOCK_KEY="seckill:stock:";publicSeckillResultseckill(LonguserId,LonggoodsId){StringstockKey=SECKILL_STOCK_KEY+goodsId;// 1. 先查库存,如果<=0直接返回StringstockStr=redisTemplate.opsForValue().get(stockKey);if(stockStr==null||Integer.parseInt(stockStr)<=0){returnnewSeckillResult(false,"已售罄");}// 2. Redis预减库存,DECR是原子操作,避免并发问题LongremainStock=redisTemplate.opsForValue().decrement(stockKey);// 3. 扣减后库存<0,说明没抢到,回滚库存if(remainStock<0){redisTemplate.opsForValue().increment(stockKey);returnnewSeckillResult(false,"未抢到,请重试");}// 4. 抢购成功,生成唯一RequestId,发送消息到MQStringrequestId=UUID.randomUUID().toString();SeckillOrderMessagemessage=newSeckillOrderMessage();message.setRequestId(requestId);message.setUserId(userId);message.setGoodsId(goodsId);message.setQuantity(1);rabbitTemplate.convertAndSend("seckill.order.queue",message);returnnewSeckillResult(true,"抢购成功,订单处理中");}}2. MQ异步下单代码实现
@Component@RabbitListener(queues="seckill.order.queue")publicclassSeckillOrderConsumer{@AutowiredprivateOrderServiceorderService;@AutowiredprivateSeckillGoodsServiceseckillGoodsService;// 用Redis存储已处理的RequestId,实现幂等@AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalStringSECKILL_REQ_ID_KEY="seckill:req:id:";@RabbitHandlerpublicvoidprocess(SeckillOrderMessagemessage){StringrequestId=message.getRequestId();StringreqIdKey=SECKILL_REQ_ID_KEY+requestId;// 幂等校验:如果RequestId已存在,说明已经处理过,直接返回Booleanexists=redisTemplate.opsForValue().setIfAbsent(reqIdKey,"1",10,TimeUnit.MINUTES);if(Boolean.FALSE.equals(exists)){log.info("请求已处理,requestId:{}",requestId);return;}try{// 1. 校验DB库存SeckillGoodsgoods=seckillGoodsService.getById(message.getGoodsId());if(goods.getStock()<=0){log.warn("DB库存不足,goodsId:{}",message.getGoodsId());return;}// 2. 生成订单Orderorder=newOrder();order.setUserId(message.getUserId());order.setGoodsId(message.getGoodsId());order.setQuantity(message.getQuantity());order.setStatus(OrderStatus.PENDING_PAY);orderService.save(order);// 3. 扣减DB库存(乐观锁,防止并发超卖)booleansuccess=seckillGoodsService.decreaseStockWithOptimisticLock(message.getGoodsId(),message.getQuantity(),goods.getVersion()// 版本号,用于乐观锁);if(!success){// 扣减失败,回滚订单orderService.updateStatus(order.getId(),OrderStatus.FAILED);log.warn("DB库存扣减失败,goodsId:{}",message.getGoodsId());}}catch(Exceptione){log.error("处理秒杀订单异常,requestId:{}",requestId,e);// 异常时删除RequestId,允许重试redisTemplate.delete(reqIdKey);}}}3. 乐观锁扣减DB库存SQL
UPDATEseckill_goodsSETstock=stock-#{quantity},version=version+1WHEREid=#{goodsId}ANDstock>=#{quantity}ANDversion=#{version}四、秒杀系统踩坑实录
1. 超卖问题:乐观锁没加库存校验
2020年我们第一次用分布式秒杀架构时,就出现了超卖问题。当时1000件秒杀商品,最终卖出了1037件。排查后发现:DB扣减库存的SQL没有加stock >= #{quantity}的校验,只加了版本号乐观锁。当多个线程同时扣减库存时,虽然版本号更新了,但库存可能已经变成负数,导致超卖。
修复方法很简单:在UPDATE语句中加上stock >= #{quantity}的条件,只有库存足够时才扣减。同时Redis预减库存时也要严格校验,扣减后库存为负数的要立即回滚。
2. 缓存击穿:热点商品Key过期
2021年双十一秒杀活动中,某款爆款手机的秒杀Key突然过期,导致瞬间10万请求直接打到数据库,数据库直接被打死,秒杀活动被迫中断15分钟。这就是典型的缓存击穿问题:热点Key过期后,大量请求同时查询缓存,发现Key不存在,都去查询数据库,导致数据库压力骤增。
解决方法:
- 热点Key不过期:秒杀期间,热点商品的库存Key设置为不过期,活动结束后再删除;
- 互斥锁:当缓存不存在时,用Redis的SETNX命令获取锁,只有获取到锁的线程才能查询DB并回写缓存,其他线程等待重试;
- 随机TTL:给不同的Key设置随机的过期时间,避免大量Key同时过期。
3. Redis主从切换导致库存不一致
2022年的一次秒杀活动中,Redis主节点突然宕机,哨兵自动切换到从节点,但此时主节点已经预减了500件库存,从节点还没有同步到这个数据,导致切换后Redis库存比实际多500件,最终超卖了500件。
这个问题的根源是Redis主从复制是异步的,主节点的写操作不会等待从节点同步完成就返回,切换时会有数据丢失。解决方法:
- 用Redis集群:Redis Cluster是分片存储,每个分片有主从,主节点写入后,至少等待1个从节点同步完成再返回(wait命令);
- 库存校验:消费者处理MQ消息时,除了校验DB库存,还要再查一次Redis库存,两个都满足才生成订单;
- 活动前检查:秒杀活动开始前,手动同步一次主从数据,确保从节点数据和主节点一致。
4. MQ消息丢失导致库存不一致
2023年的一次秒杀活动中,RabbitMQ集群的某个节点宕机,导致120条秒杀订单消息丢失。这些消息对应的Redis库存已经扣减,但DB库存没有扣减,导致最终库存显示还有120件,但实际已经卖完了,用户下单后一直无法支付,投诉量激增。
解决方法:
- MQ持久化:队列、消息都设置为持久化,Broker重启后消息不丢失;
- 生产者确认:开启RabbitMQ的Confirm机制,生产者发送消息后等待Broker确认,如果失败则重试;
- 消费者手动ACK:关闭自动ACK,消费者处理完消息后再手动ACK,处理失败则不ACK,消息会重新投递;
- 死信队列:处理失败的消息放到死信队列,人工排查处理。
五、业务场景:某电商平台秒杀架构完整演进
接下来以我参与的一家电商平台的秒杀架构演进为例,完整还原从单机到分布式的整个过程。
1. 第一阶段:单机架构(2018年)
当时平台刚起步,秒杀活动最多1000 QPS,用单机架构:
- 1台Nginx,2台Tomcat,1个MySQL实例;
- 库存直接存在MySQL,扣库存用
synchronized关键字加锁; - 结果:QPS到800时数据库CPU就满了,超卖严重,经常宕机。
2. 第二阶段:引入Redis缓存(2019年)
随着用户量增长,QPS到了5000,引入Redis:
- 库存同步到Redis,扣库存先操作Redis,用
DECR原子操作; - 数据库做乐观锁扣减,解决超卖问题;
- 问题:所有请求还是打到Tomcat,Tomcat线程池经常满,而且没有限流,流量大的时候整个系统都慢。
3. 第三阶段:引入MQ异步化(2020年)
QPS到了2万,引入RabbitMQ:
- Redis预减库存成功后,发送消息到MQ,异步生成订单;
- Tomcat只需要处理Redis预减库存,响应时间从500ms降到50ms;
- 问题:Redis主从切换偶尔会导致库存不一致,MQ消息偶尔丢失。
4. 第四阶段:完整分布式架构(2021年至今)
QPS到了10万+,演进为完整分布式架构:
- 前端:CDN静态化 + 按钮置灰 + 答题验证码;
- 网关:Spring Cloud Gateway限流 + 鉴权 + IP限流;
- 服务层:Redis Cluster + RocketMQ(替代RabbitMQ,更可靠) + 幂等设计;
- 数据层:MySQL主从 + 读写分离 + 库存表分库分表;
- 监控:Prometheus + Grafana监控QPS、库存、MQ消息堆积,异常自动告警。
目前这套架构支持过单场秒杀20万QPS,零超卖,系统可用性99.99%,没有出现过大故障。
六、秒杀系统核心优化点总结
| 优化点 | 实现方案 | 解决什么问题 |
|---|---|---|
| 前端优化 | CDN静态化、按钮置灰、答题验证码 | 减少无效请求,削峰填谷 |
| 网关限流 | 全局QPS限流、IP限流、用户限流 | 防止流量冲垮系统 |
| Redis预减库存 | DECR原子操作、热点Key不过期 | 减少DB压力,快速响应用户 |
| MQ异步下单 | 持久化、Confirm机制、手动ACK | 削峰填谷,提升系统吞吐量 |
| 幂等设计 | 唯一RequestId、Redis去重 | 防止重复下单,避免超卖 |
| 乐观锁扣库存 | 版本号+库存校验 | 保证DB库存一致性,防止超卖 |
| 缓存优化 | 互斥锁、随机TTL、热点Key保护 | 防止缓存击穿、雪崩 |
| Redis高可用 | Redis Cluster、主从同步优化 | 防止库存不一致,避免超卖 |
七、总结
秒杀系统是高并发场景的"试金石",涵盖了前端、网关、服务、缓存、消息队列、数据库等多个领域的技术点。架构演进的核心思路是:分层过滤、逐层限流、异步化、最终一致性。
从单机到分布式的演进过程中,我们踩过超卖、缓存击穿、库存不一致、消息丢失等各种坑,也总结出了一套成熟的架构方案。对于要做秒杀系统的团队,建议从一开始就采用分布式的架构思路,不要等流量上来再重构,升级的成本会高很多。
最后记住:秒杀系统的目标不是支持无限高的并发,而是在保证系统稳定的前提下,尽可能多的处理用户请求,同时保证库存一致性和用户体验。
