【架构实战】电商秒杀架构:高并发场景的终极挑战

电商秒杀架构:高并发场景的终极挑战

一、什么是秒杀系统?

秒杀是电商平台常见的营销活动:商家以极低价格限量售卖商品,用户在同一时间集中抢购,具有瞬时高并发、库存少、读写频繁的特点。比如某品牌手机新品首发,1000台手机在1秒内被10万用户抢购,这就是典型的秒杀场景。

秒杀系统的核心挑战有四个:

  1. 瞬时高并发:瞬间QPS可能是平时的100倍以上,普通架构无法承受;
  2. 库存一致性:要避免超卖(卖的数量超过库存)和少卖(库存没卖完);
  3. 系统稳定性:不能被秒杀流量冲垮,影响其他正常业务;
  4. 用户体验:响应要快,不能出现长时间等待或错误。

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)

java 复制代码
@Service
public class SeckillService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    // 秒杀商品库存Key前缀
    private static final String SECKILL_STOCK_KEY = "seckill:stock:";
    
    public SeckillResult seckill(Long userId, Long goodsId) {
        String stockKey = SECKILL_STOCK_KEY + goodsId;
        // 1. 先查库存,如果<=0直接返回
        String stockStr = redisTemplate.opsForValue().get(stockKey);
        if (stockStr == null || Integer.parseInt(stockStr) <= 0) {
            return new SeckillResult(false, "已售罄");
        }
        
        // 2. Redis预减库存,DECR是原子操作,避免并发问题
        Long remainStock = redisTemplate.opsForValue().decrement(stockKey);
        
        // 3. 扣减后库存<0,说明没抢到,回滚库存
        if (remainStock < 0) {
            redisTemplate.opsForValue().increment(stockKey);
            return new SeckillResult(false, "未抢到,请重试");
        }
        
        // 4. 抢购成功,生成唯一RequestId,发送消息到MQ
        String requestId = UUID.randomUUID().toString();
        SeckillOrderMessage message = new SeckillOrderMessage();
        message.setRequestId(requestId);
        message.setUserId(userId);
        message.setGoodsId(goodsId);
        message.setQuantity(1);
        
        rabbitTemplate.convertAndSend("seckill.order.queue", message);
        
        return new SeckillResult(true, "抢购成功,订单处理中");
    }
}

2. MQ异步下单代码实现

java 复制代码
@Component
@RabbitListener(queues = "seckill.order.queue")
public class SeckillOrderConsumer {
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private SeckillGoodsService seckillGoodsService;
    
    // 用Redis存储已处理的RequestId,实现幂等
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String SECKILL_REQ_ID_KEY = "seckill:req:id:";
    
    @RabbitHandler
    public void process(SeckillOrderMessage message) {
        String requestId = message.getRequestId();
        String reqIdKey = SECKILL_REQ_ID_KEY + requestId;
        
        // 幂等校验:如果RequestId已存在,说明已经处理过,直接返回
        Boolean exists = redisTemplate.opsForValue().setIfAbsent(reqIdKey, "1", 10, TimeUnit.MINUTES);
        if (Boolean.FALSE.equals(exists)) {
            log.info("请求已处理,requestId:{}", requestId);
            return;
        }
        
        try {
            // 1. 校验DB库存
            SeckillGoods goods = seckillGoodsService.getById(message.getGoodsId());
            if (goods.getStock() <= 0) {
                log.warn("DB库存不足,goodsId:{}", message.getGoodsId());
                return;
            }
            
            // 2. 生成订单
            Order order = new Order();
            order.setUserId(message.getUserId());
            order.setGoodsId(message.getGoodsId());
            order.setQuantity(message.getQuantity());
            order.setStatus(OrderStatus.PENDING_PAY);
            orderService.save(order);
            
            // 3. 扣减DB库存(乐观锁,防止并发超卖)
            boolean success = seckillGoodsService.decreaseStockWithOptimisticLock(
                message.getGoodsId(), 
                message.getQuantity(), 
                goods.getVersion() // 版本号,用于乐观锁
            );
            
            if (!success) {
                // 扣减失败,回滚订单
                orderService.updateStatus(order.getId(), OrderStatus.FAILED);
                log.warn("DB库存扣减失败,goodsId:{}", message.getGoodsId());
            }
        } catch (Exception e) {
            log.error("处理秒杀订单异常,requestId:{}", requestId, e);
            // 异常时删除RequestId,允许重试
            redisTemplate.delete(reqIdKey);
        }
    }
}

3. 乐观锁扣减DB库存SQL

sql 复制代码
UPDATE seckill_goods 
SET stock = stock - #{quantity}, 
    version = version + 1 
WHERE id = #{goodsId} 
  AND stock >= #{quantity} 
  AND version = #{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、主从同步优化 防止库存不一致,避免超卖

七、总结

秒杀系统是高并发场景的"试金石",涵盖了前端、网关、服务、缓存、消息队列、数据库等多个领域的技术点。架构演进的核心思路是:分层过滤、逐层限流、异步化、最终一致性

从单机到分布式的演进过程中,我们踩过超卖、缓存击穿、库存不一致、消息丢失等各种坑,也总结出了一套成熟的架构方案。对于要做秒杀系统的团队,建议从一开始就采用分布式的架构思路,不要等流量上来再重构,升级的成本会高很多。

最后记住:秒杀系统的目标不是支持无限高的并发,而是在保证系统稳定的前提下,尽可能多的处理用户请求,同时保证库存一致性和用户体验。