高并发秒杀系统设计

2025博客之星年度评选已开启 10w+人浏览 2.7k人参与

高并发秒杀系统设计

  通过这篇文章你将了解到秒杀场景的特点、秒杀系统设计的核心挑战、秒杀系统设计的演化(单体应用、引入缓存、引入消息队列、多级缓存、分层过滤等)、如何设计一个高性能&高可用的秒杀系统等。


—— 2025 年 12 月 24 日 乙巳蛇年十一月初五
扫码关注微信公众号   了解最新最全文章

qrcode

前言

  秒杀是网络销售中常用的促销手段,即在规定的时间点或时间段,以极优惠的价格销售某款热门商品或新品上市,为了控制成本或体现商品的稀缺性,这些被促销商品的数量往往是固定且极少的,在短时间内(通常为秒级)抢购有限数量的商品,故称之为秒杀。所以在秒杀活动中往往只有极少数用户才能抢购成功,当然这其中不乏以秒杀为噱头来宣传自己的商家。常见的秒杀场景如淘宝双11、京东618、分发优惠券、演唱会购票、节假日购票等。

⚠️ 注意

文中会涉及到 redis 或消息队列相关知识,如果你不了解她或对她有兴趣,那么你可以看看关于她们的历史文章:

Redis走近科学之《Redis 的秘密》

Kafka走近科学之《apache kafka 的秘密》史上最烂 spring kafka 原理解析

1 秒杀场景特点

  秒杀场景一般具有 瞬时高并发、读多写少、数据一致性、业务隔离性 等特点。

  • 瞬时高并发:因为秒杀活动通常是定时开始,抢购的是同一商品,且活动开始前往往会进行各种预热,以吸引大量潜在用户,在秒杀时间到达前只能浏览商品,不能进行下单操作,所以在秒杀开始后网站流量会瞬时激增,产生瞬时高并发行为。
  • 读多写少:秒杀商品的数量通常是极少的,而参与秒杀的用户却是远远多于商品数量的,且在下单期间需要先检查库存是否剩余,然后再下单,所以这是一个极经典的读多写少场景。
  • 数据一致性:秒杀 -> 减库存 -> 生成订单 -> 支付 -> 完成,这其中涉及到库存与金额。你卖了多少件商品,就收了多少钱,产生了多少条订单数据、支付记录等;你买了几件商品,就付了多少钱。所以这是一个数据强一致性的场景。
  • 业务隔离性:秒杀业务只在特殊时间点进行,且又具有高并发等特点,为了防止出现混乱、交叉感染等现象,建议将秒杀业务与普通业务隔离,如业务隔离、技术隔离等。

2 秒杀系统核心挑战

  秒杀系统的核心挑战通常包括:瞬时高并发、超卖少卖问题、高可用、系统安全、用户体验、架构复杂度 等。

2.1 瞬时高并发

  秒杀时峰值 QPS 可能达到两三百万,是正常业务的几百倍,且可能持续数秒时间。瞬时高并发可能带来服务器瞬间被打垮、连接数耗尽、CPU/内存/网络等打满、正常业务受影响等问题。

  应对瞬时高并发场景,一般需要进行系统性的设计,尽量采用空间换时间、异步换同步、缓存换数据库等思维,如页面静态化、CDN 加速、多级缓存、异步处理、限流等等。

2.2 超卖少卖问题

  超卖是指订单数量多于实际售出数量,少卖是指订单数量少于实际售出数量。超卖和少卖涉及到金额,会造成对账异常问题,很是严重。出现这类问题一般是因为分布式环境下的原子性未得到保证、高并发下的锁竞争、缓存与数据库一致性未得到保证,本质上是数据一致性问题。

  解决数据一致性问题一般着眼于设计问题,如事务与锁的配合、分布式事务/锁、redis 原子操作(decr) + lua 脚本等。

2.3 高可用

  在瞬时高并发下,系统可能会出现连接池资源耗尽、应用线程堆积、应用组件宕机等问题,这些问题可能会造成雪崩效应,如数据库慢 -> 应用线程堆积 -> 内存溢出 -> 服务宕机 -> 其它节点压力增大 -> 全部宕机,或是某个应用节点或组件节点宕机导致下游服务崩溃等。这些现象都会造成系统的可用性出现问题。

  一般情况下,我们可能会采用心跳、ping/echo、主动冗余、被动冗余、选举、集群等策略来保证系统的高可用,但在秒杀系统中,通常会采用 redis、mq、分布式服务等这些外部组件,这些组件本身就具有高可用,所以我们的重点在于系统的整体设计,如服务熔断、限流降级、多级缓存架构等等。

2.4 系统安全

  秒杀系统中的系统安全问题通常是指刷单机器人、黄牛党等,这些团体或组织使用脚本抢购,速度远超正常用户,严重破坏公平性。

  通常可使用人机验证(如点选验证、滑块拼图)、设备指纹识别、行为分析防作弊、限流策略(IP 级、用户级)等技术手段防止恶意攻击。

2.5 用户体验

  用户体验主要体现在公平性和响应速度上。如因用户网络延迟、设备性能、地域差异亦或是黄牛与恶意攻击等都可能影响公平性。瞬时高并发以及系统本身的复杂性可能会影响系统响应速度,从而影响用户体验。

  上述的所有挑战,其实都可能会影响到用户体验,所以保证用户体验是一个系统性的工程,本质上来讲,上述手段都可以保证和提升用户体验。

2.6 架构复杂度

  为了保证秒杀系统的高性能、高并发、高可用、安全性等非功能性需求,通常需要更加复杂的设计,如多级缓存、限流降级熔断、数据一致性/操作原子性保证等等,此外,引入的外部组件如 redis、消息队列、CDN等都会增加架构的复杂度。所以整个秒杀系统架构的设计,要求设计人员具有丰富的设计经验和扎实的理论基础。

3 基础版本-单体应用

  最基础、最简单的实现,基本流程为:用户请求 -> web 服务器 -> 数据库。当然业务逻辑也是最简单的 库存查询 -> 库存扣减 -> 创建订单。你可能会依赖数据库事务的 ACID 特征保证数据一致性,也可能会通过事务与锁配合来保证操作原子性。受限于数据库并发(如 mysql),该方案最高支持 1000 QPS。

  该方案可能存在的问题:

  • 数据库成为性能瓶颈:大量读操作直接落在数据库上,甚至会直接干死数据库。
  • CPU 瓶颈:大量 SQL 解析、事务处理、锁竞争等。
  • 响应时间长,用户体验差:因为锁竞争以及各环节的性能瓶颈,会导致响应时间变长,影响用户体验,

3.1 @Transactional + Lock

  下述代码中,因为 库存查询 + 库存扣减 + 创建订单 是一个原子操作,所以你加了锁(Lock),同时为了保证事务原子性你用了 @Transactional 注解。这样的设计在逻辑上是没问题的,不会出现超卖的情况,但是并发太低,而且可能会出现少卖现象。

private final Lock lock = new ReentrantLock();
private final SecondKillService secondKill;   // 构造注入

@Override
public void secondKill(long userId, long goodsId) {
    try {
        lock.lock();
        this.secondKill.process(userId, goodsId);
    } finally {
        lock.unlock();
    }
}

@Transactional
@Override
public void process(long userId, long goodsId) {
    // todo 库存查询 库存扣减 创建订单
}

⚠️ 注意

千万别写成下面这样,否则会出现超卖的情况。因为 @Transactional 是通过 AOP 实现事务的,事务的增强代码会将整个 secondKill() 方法包裹,所以在下面的代码中,锁释放后才提交事务,会让其它线程有机可乘。我们要的效果是在事务开始前加锁,事务提交后再释放锁,所以下面这种方式是不行的,得是上面那种才行。

private final Lock lock = new ReentrantLock();

@Transactional
@Override
public void secondKill() {
    lock.lock();
    try {
        // todo 库存查询 库存扣减 创建订单
    } finally {
        lock.unlock();
    }
}

3.2 AOP 实现锁

  你可能会觉得 @Transactional + Lock 的实现优点复杂或不太优雅(尤其是自己注入自己这种操作),没关系,你还可以把它设计成一个注解。

// 自定义 AOP 注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SecondKillLock {
    String value() default "";
}
// 定义切面类 
// order 表示切面作用优先级 值越小最先执行也最后结束
// 因为 @SecondKillLock 是和 @Transactional 作用在同一个方法上的 
// 所以该切面的增强代码要最先开始执行 待 @Transactional 的事务代码执行完毕后再结束
@Order(1)   
@Aspect
@Component
public class SecondKillAop {
  
  	private static final Lock lock = new ReentrantLock();

    @Pointcut("@annotation(org.xgllhz.test.annotation.SecondKillLock)")
    public void pointcut() {}

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        lock.lock();
        try {
            return joinPoint.proceed();
        } catch (Exception e) {
            throw e;
        } finally {
            lock.unlock();
        }
    }
}
// 使用
@SecondKillLock
@Transactional
@Override
public void secondKill(long userId, long goodsId) {
    try {
        // todo 库存查询 库存扣减 创建订单
    } catch (Exception e) {
        // 
    }
}

3.3 悲观锁 for update

  除了使用 Lock 锁之外,还可以使用数据库提供的悲观锁 for update 行级锁 + @Transactional 事务实现。在库存查询时使用行级锁,在事务提交之前其它线程不能访问该行记录,事务提交后会释放行级锁。

@Transactional
@Override
public void secondKill(long userId, long goodsId) {
    try {
        // 库存查询 select total from stock where goods_id = #{goodsId} for update
      	if (total <= 0) {
          	return;   // 已售罄
        }
      	
      	// todo 库存扣减 创建订单
    } catch (Exception e) {
        // 
    }
}

4 引入缓存

  引入 redis 缓存,基本流程为:用户请求 -> web 服务器 -> redis 检查库存 -> 库存扣减 -> 异步写数据库。可支持 1 万人。核心优化方案为:

  • 预热缓存:秒杀开始前将秒杀商品库存加载到 redis 中。
  • redis 原子扣减:使用 redis 的 decr 命令实现库存扣减。

  通过添加缓存层,避免大量读请求打在数据库,极大程度的减缓了数据库压力,提升了库存扣减性能并解决了大部分超卖问题。伪代码如下:

public void secondKillV2(long goodsId, long userId) {
    Long stock = redisTemplate.opsForValue().decrement("secondkill:stock:" + goodsId);
    if (stock == null || stock < 0) {
        redisTemplate.opsForValue().increment("secondkill:stock:" + goodsId);
        return;   // 已售罄
    }
  
    // 创建订单
}

  该方案可能存在的问题:

  • redos 和数据库的数据一致性问题;
  • 前端请求未做限制;
  • 创建订单未异步化处理;
  • 当然也要做好 redis 相关的设计,如缓存雪、穿透、击穿问题、过期策略、持久化配置、高可用部署等;
  • 热点 key 限制并发量:虽然 redis 可集群部署,但实际上秒杀商品只会被哈希分布到其中一个节点上,所以就变成了热点 key 问题,极限并发量也就等同于 redis 单节点的最高并发量,即 10万 QPS。

5 引入消息队列

  引入消息队列,利用消息对立异步、削峰、解耦的优势,解决瞬时流量冲击、异步处理订单降低数据库压力、提升系统吞吐量。基本流程为:用户请求 -> web 服务器 -> redis 检查库存 -> 消息队列 -> 订单服务异步处理 -> 数据库。可支持10万人。

  • 当请求到达服务器后,先检查 redis 库存,若库存不足则直接返回;若库存够则扣减 redis 库存并生成唯一消息标识,将消息写入消息队列后直接返回(此时用户将看到下单成功的结果)。
  • 订单处理服务消费消息队列消息,创建订单并将订单写入数据库,扣减数据库库存,然后返回用户支付。
  • 若订单创建失败或用户支付超时,则回退库存。

  该方案可能遇到的问题:

  • 消息丢失问题,如在生产者开启发送确认,在消息队列服务器开启持久化机制,在消费者端开启手动 ack 机制等。
  • 重复消费问题,如采用幂等校验,利用 redis 去重或数据库唯一索引过滤已消费的消息。
  • 顺序消费问题,如采用分区机制,将相同 key(用户 id、订单 id 等)的消息路由到同一分区。
  • 消费失败问题,如采用重试机制,私信队列,再到人工介入。

6 分层架构-终极版

  基本流程:用户请求 -> CDN 加速 -> 负载均衡 -> 网关层 -> 秒杀服务集群 -> redis 集群 -> 消息队列 -> 订单服务 -> 数据库集群,我们按照层次型架构设计系统,采用 分层拦截、逐层过滤的流量漏斗式 策略,将无效请求尽可能拦截在链路上游,最终尽让少量的有效请求到达数据库。根据设计思想,将这些流程分配到各层次,将会是这样:

  • 接入层:挡住第一波流量,过滤掉大部分无效请求。如CDN 加速、负载均衡、流量控制等。
  • 网关层:流量分发与限流。如限流策略、风控系统、人机验证等。
  • 应用层:快进快出,不做耗时操作。如集群部署、服务隔离、异步处理、请求校验等。
  • 数据层:多级缓存与最终一致性。如本地缓存、分布式缓存(redis 集群)、数据库集群等。

6.1 接入层设计

6.1.1 页面静态化与CDN加速

  活动页面是秒杀系统的第一入口,是并发量最大的地方,所以尽可能在这里挡住第一波流量。

  活动页面大部分内容是固定的,如商品信息、描述、图片等,为了减少不必要的服务端请求,一般情况下会将活动 页面静态化。在秒杀开始前,用户浏览商品信息并不会请求到服务端,只有秒杀开始后,点击了秒杀按钮才允许前端访问服务端。因为活动页面是静态的,且用户分散在全国各地,网速各不相同,而且,大部分用户为了防止错过秒杀,会不停重入活动页面以求获取最新数据,所以为了使用户能够快速访问到活动页面,我们可以使用 CDN 加速,将活动页面等静态资源放在 CDN 节点,用户访问时可直接从就近的 CDN 获取,无需请求源站,降低网络拥堵的同时又提高了响应速度。

  通常在秒杀开始前,秒杀按钮是置灰的,且会有倒计时显示,在秒杀开始的前几秒以及秒杀开始后,为了能够第一时间抢购,用户会一直重复点击秒杀按钮,这会造成大量无效请求,且又可能会出现重复下单的情况,通常的做法是限制用户点击频率(如 3 秒内只能点击一次),同时可以添加图形验证码或答题验证机制。这两套操作下来不仅可以减少大量无效请求,还将用户请求从 秒级 拉长到 3 ~ 10 秒,实现了自然流量削峰,给下游服务带来了更多缓冲时间。

6.1.2 负载均衡

  负载均衡通常是整个站点的流量入口,通过负载均衡可将流量平衡到网关层的各节点,分摊了各节点压力,同时,可根据网关层各节点的具体情况(如服务器性能等)采用不同的负载均衡算法,如静态算法(轮询、加权轮询、随机、源地址 IP 哈希、目标地址 IP 哈希等)、动态算法(动态加权等)。

  同时,在负载均衡层可通过令牌桶算法实现流量控制。如通过 nginx + lua 脚本 实现令牌桶算法,限制单 IP 每秒 3 次请求等。这样在请求到达应用服务器前就进行了过滤,减轻了应用层压力,同时可拦截恶意请求。

6.2 网关层设计

6.2.1 限流策略

  可通过基于 sentinel 等组件实现接口级限流、用户维度限流(如单用户每秒 1 次请求)等,实现更加细粒度的流量控制。当然,网关层也需要通过负载均衡将流量分发到应用层的服务集群。

6.2.2 风控系统

  可通过设备指纹识别和行为分析,识别并拦截黄牛和脚本请求(因为黄牛或脚本通常是直接通过服务接口进行攻击的),实现 IP 黑名单,以此来严厉打击黄牛党的嚣张气焰!

6.3 应用层设计

6.3.1 服务隔离

  秒杀业务只在特殊时间点进行,且又具有高并发等特点,为了防止出现混乱、交叉感染等现象,建议将秒杀业务与普通业务(如订单、支付等)隔离。当然,做了隔离后且在瞬时流量洪峰过后,需要将秒杀系统所产生的数据(如订单数据等)同步到普通业务。

6.3.2 请求校验

  在应用快速校验用户状态,如是否登录、是否具备秒杀条件等,校验失败的直接返回。

6.3.3 重复下单

  由于用户重复点击、网络超时或故障、前端防抖失效以及消息队列等行为,下单时可能会出现重复下单情况,通常的解决方案是:客户端在秒杀前,先向后端申请一个唯一 token,提交时携带此 token,在应用层做请求校验时检查该 token 是否已使用过(如通过 redis 检查),若已使用则直接返回。

  在预扣库存通过后,向消息队列发送下单消息前,将该 token 存入 redis,以供后续重复下单时检查。

6.3.4 异步处理

  在预扣库存(下文会讲到)通过后,将下单请求发送到消息队列(如 kafka、rocketMQ 等),然后返回秒杀成功(因为理论上来讲,只要预扣库存通过,则意味着秒杀成功)。之后由订单服务异步处理下单逻辑。若下单失败(包括消息消费失败、系统异常等)则需要回滚缓存中库存数量。

6.4 数据层设计

6.4.1 本地缓存(Caffeine)

  秒杀活动开始前,通过本地缓存缓存活动规则、商品信息等基础信息。

6.4.2 分布式缓存(Redis)

  秒杀活动开始前,通过分布式缓存来缓存商品库存、用户秒杀资格、用户 token 等信息,主要参与 预扣库存 环节,可通过 redis + lua 脚本保证预扣库存操作的原子性。

  这里还有一种设计是:在每个秒杀应用的本地缓存中缓存库存状态标记(如 true 标识有库存,false 表示售罄),当请求到达后先检查本地缓存中的库存标记,然后再决定是直接返回还是访问 redis 进行预扣。在最后一个库存预扣完成后,可通过消息队列等方式将 售罄标记 广播到其它应用节点,以更新本地缓存中的售罄标记。

6.4.3 数据库集群

  当然了,数据库服务最好集群部署,实在不行你整个主从、主主也可以,在提高并发量的同时,还可以保证高可用。

6.4.4 最终一致性

  异步更新数据库,即之前的预扣库存减的是缓存中的库存,在下单成功之后,则需要 真扣库存,即减去数据库中的库存。以消息队列为中介,通过异步方式实现缓存数据与数据库数据的最终一致性。

总结:

  通过多层拦截、缓存加速、异步处理、最终一致性等技术手段,遵循空间换时间、异步换同步、缓存换数据库、降级换崩溃、简单不复杂的原则,我们最终可以实现一个高性能、高可用的秒杀系统。

⚠️ 注意

文中会涉及到 redis 或消息队列相关知识,如果你不了解她或对她有兴趣,那么你可以看看关于她们的历史文章:

Redis走近科学之《Redis 的秘密》

Kafka走近科学之《apache kafka 的秘密》史上最烂 spring kafka 原理解析

qrcode

扫码关注微信公众号   了解最新最全文章
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

红衣女妖仙

行行好,给点吃的吧!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值