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

前言:
秒杀是网络销售中常用的促销手段,即在规定的时间点或时间段,以极优惠的价格销售某款热门商品或新品上市,为了控制成本或体现商品的稀缺性,这些被促销商品的数量往往是固定且极少的,在短时间内(通常为秒级)抢购有限数量的商品,故称之为秒杀。所以在秒杀活动中往往只有极少数用户才能抢购成功,当然这其中不乏以秒杀为噱头来宣传自己的商家。常见的秒杀场景如淘宝双11、京东618、分发优惠券、演唱会购票、节假日购票等。
⚠️ 注意
文中会涉及到 redis 或消息队列相关知识,如果你不了解她或对她有兴趣,那么你可以看看关于她们的历史文章:
Redis:走近科学之《Redis 的秘密》;
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 的秘密》;

1008

被折叠的 条评论
为什么被折叠?



