如何设计一个高并发的秒杀架构?
- 1、瞬时高并发
- 2、页面静态化
- 3、秒杀按钮
- 4、读多写少
- 5、缓存问题
- 5.1 缓存击穿
- 5.2、缓存穿透
- 6、库存的问题
- 6.1、使用lua脚本进行扣减库存
- 7、分布式锁
- 7.1 redisson
- 8、MQ异步处理
- 9、如何限流
- 9.1、对同一个用户限流
- 9.2、对同一ip限流
- 9.3、对接口限流
- 9.4、加验证码
- 9.5、提高业务门槛
虽然说秒杀只是一个促销活动,但对技术要求不低。下面给大家总结一下设计秒杀系统需要注意的9个细节。掌握了这些,以后就可以和面试官好好聊一聊了。
1、瞬时高并发
一般秒杀活动开始的前几分钟,用户的访问并发量会突然增多,到达秒杀时间时,并发量就会达到顶峰。
但由于这类活动是大量用户抢少量商品的场景,必定会出现狼多肉少的情况,所以其实绝大部分用户秒杀会失败,只有极少部分用户能够成功。
正常情况下,大部分用户会收到商品已经抢完的提醒,收到该提醒后,他们大概率不会在那个活动页面停留了,如此一来,用户并发量又会急剧下降。所以这个峰值持续的时间其实是非常短的,这样就会出现瞬时高并发的情况
像这种瞬时高并发的场景,传统的系统很难应对,我们需要设计一套全新的系统。可以从以下几个方面入手:
页面静态化
CDN加速
缓存
mq异步处理
限流
分布式锁
2、页面静态化
活动页面是秒杀活动的第一入口,所以也是并发量最大的地方。如果这些访问都直接访问到服务器上的话,可能在秒杀前,服务就会崩掉。结合活动页面的独特性,一般活动页面绝大多数内容是固定的,比如:商品名称、商品描述、商品图片等,为了减少不必要的服务请求,常情况下,会对活动页面做静态化处理。这样用户浏览活动页面的话就不会访问服务器,只有秒杀活动开始时才允许点击按钮访问服务器。这样能过滤掉很大一部分无效的请求。
但只做页面静态化还不够,因为用户分布在全国各地,有些人在北京,有些人在上海,有些人在深圳,地域相差很远,网速各不相同。
如何才能让用户最快访问到活动页面呢?
这就需要使用CDN,它的全称是Content Delivery Network,即内容分发网络。
使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。
3、秒杀按钮
大部分用户怕错过秒杀时间点,一般会提前进入活动页面。此时看到的秒杀按钮是置灰,不可点击的。只有到了秒杀时间点那一时刻,秒杀按钮才会自动点亮,变成可点击的。
但此时很多用户已经迫不及待了,通过不停刷新页面,争取在第一时间看到秒杀按钮的点亮。
从前面得知,该活动页面是静态的。那么我们在静态页面中如何控制秒杀按钮,只在秒杀时间点时才点亮呢?没错,使用js文件来控制。
为了性能考虑,一般会将css、js和图片等静态资源文件提前缓存到CDN上,让用户能够就近访问秒杀页面。看到这,大家可能就会有疑问,CDN上的js如何更新,其实很简单,在秒杀开始前设置两个参数,flag
: false和一个随机参数radom: 1
当秒杀开始的时候系统会生成一个新的js文件,此时标志为true,并且随机参数生成一个新值,然后同步给CDN。由于有了这个随机参数,CDN不会缓存数据,每次都能从CDN中获取最新的js代码。
此外,前端还可以加一个定时器,控制比如:10秒之内,只允许发起一次请求。如果用户点击了一次秒杀按钮,则在10秒之内置灰,不允许再次点击,等到过了时间限制,又允许重新点击该按钮。
4、读多写少
在秒杀的过程中,系统一般会先查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。
由于大量用户抢少量商品,只有极少部分用户能够抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。
这是非常典型的:读多写少 的场景。
以上如果50万个请求同事查询库存的话,可能数据库也会承受不住这个压力,导致数据库挂掉,所以我们应该采用redis,并且要多部署几个节点来支撑。
5、缓存问题
通常情况下,我们会在redis中存入商品的信息,如编号、名称、库存等,这个时候,我们就需要考虑redis的稳定性及一些关于redis的问题了,所以redis和DB中都会存入商品的信息。
根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果商品不存在,则直接提示失败。
这个过程表面上看起来是OK的,但是如果深入分析一下会发现一些问题。
5.1 缓存击穿
比如商品A第一次秒杀时,缓存中是没有数据的,但数据库中有。虽说上面有如果从数据库中查到数据,则放入缓存的逻辑。
然而,在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中没有数据,然后又同时访问数据库。结果悲剧了,数据库可能扛不住压力,直接挂掉。
如何解决这个问题呢? 这就需要加锁,最好使用分布式锁。
当然,针对这种情况,最好在项目上线之前,先把缓存进行预热。即事先把所有的商品,同步到缓存中,这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题了。
是不是上面加锁这一步可以不需要了?
表面上看起来,确实可以不需要。但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了,如果不加速同样可能出现缓存击穿。
其实这里加锁,相当于买了一份保险。
5.2、缓存穿透
如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库了。
由于前面已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接挂掉。 但很显然这些请求的处理性能并不好,有没有更好的解决方案?
这时可以想到布隆过滤器。系统根据商品id,先从布隆过滤器中查询该id是否存在,如果存在则允许从缓存中查询数据,如果不存在,则直接返回失败。
虽说该方案可以解决缓存穿透问题,但是又会引出另外一个问题:布隆过滤器中的数据如何跟缓存中的数据保持一致?
这就要求,如果缓存中数据有更新,则要及时同步到布隆过滤器中。如果数据同步失败了,还需要增加重试机制,而且跨数据源,不能保证数据的实时一致性。
所以布隆过滤器绝大部分使用在缓存数据更新很少的场景中。 如果缓存数据更新非常频繁,又该如何处理呢?
这时,就需要把不存在的商品id也缓存起来。
下次,再有该商品id的请求过来,则也能从缓存中查到数据,只不过该数据比较特殊,表示商品不存在。需要特别注意的是,这种特殊缓存设置的超时时间应该尽量短一点。
6、库存的问题
关于库存这块,不是说扣完库存,就完事了,如果用户在一段时间内,还没完成支付,扣减的库存是要加回去的;所以这里我们需要进行预扣库存。
扣减库存中除了上面说到的预扣库存和回退库存之外,还需要特别注意的是库存不足和库存超卖问题。
6.1、使用lua脚本进行扣减库存
我们都知道lua脚本,是能够保证原子性的,它跟redis一起配合使用,会很好的解决扣减库存的问题
lua脚本有段非常经典的代码:
StringBuilder lua = new StringBuilder();
lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
lua.append(" local stock = tonumber(redis.call('get', KEYS[1]));");
lua.append(" if (stock == -1) then");
lua.append(" return 1;");
lua.append(" end;");
lua.append(" if (stock > 0) then");
lua.append(" redis.call('incrby', KEYS[1], -1);");
lua.append(" return stock;");
lua.append(" end;");
lua.append(" return 0;");
lua.append("end;");
lua.append("return -1;");
该代码的主要流程如下:
先判断商品id是否存在,如果不存在则直接返回。
获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。 如果库存大于0,则扣减库存。
如果库存等于0,是直接返回,表示库存不足。
7、分布式锁
根据之前梳理的秒杀流程,大家试想一下,如果在高并发下,有大量的请求都去查一个缓存中不存在的商品,这些请求都会直接打到数据库。数据库由于承受不住压力,而直接挂掉。
为了避免出现这种情况,我们就必须选择使用分布式锁。
7.1 redisson
百度上有很多相关资料,可自行查阅;
引入包:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.15.0</version>
</dependency>
@Autowired
private RedissonClient redissonClient;
// 自定义一个key
String key = "check:save:" + dto.getWarehouseNo();
RLock lock = redissonClient.getLock(key);
// 加分布式锁
boolean success = lock.tryLock(10, TimeUnit.SECONDS);
if (success) {
try{
// 书写你的业务逻辑
} catch (Exception e) {
log.error("xxx出现异常", e);
throw new RuntimeException("出现异常");
} finally {
lock.unlock();
}
}
8、MQ异步处理
我们都知道在真实的秒杀场景中,有三个核心流程:
秒杀
下单
支付
而这三个核心流程中,真正并发量大的是秒杀功能,下单和支付功能实际并发量很小。所以,我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的。而支付功能,比如支付宝支付,是业务场景本身保证的异步。
于是,秒杀后下单的流程变成如下:
发送mq消息
消费MQ消息
秒杀
MQ服务端
下单
大家都知道,如果选择了MQ,就需要注意关于MQ的一些问题,比如:数据丢失、重复消费、垃圾消息、延迟消费等,所以需要提前设计好MQ的架构来支撑异步处理问题。这里就不多少,有机会,我会补充一下关于这些问题的处理方法。
9、如何限流
通过秒杀活动,用户可能会用很低的价格买到不错的商品,但有些高手,并不会像我们一样老老实实,通过活动秒杀页面点击秒杀按钮,抢购商品。他们可能在自己的服务器上,模拟正常用户登录系统,跳过秒杀页面,直接调用秒杀接口。
如果是我们手动点击秒杀的话,一般情况一秒只能点击一次,但是如果是服务器的话一秒可能会调用上千次秒杀接口,这种差距实在太明显了,如果不做任何限制,绝大部分商品可能是被机器抢到,而非正常的用户,有点不太公平。
所以,我们有必要识别这些非法请求,做一些限制。
目前有两种常用的限流方式:
1、基于nginx限流
2、基于redis限流
9.1、对同一个用户限流
为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制。 限制同一个用户id,假如每分钟只能请求5次。
9.2、对同一ip限流
有时候只对某个用户限流是不够的,有些高手可以模拟多个用户请求,这种nginx就没法识别了。
这时需要加同一ip限流功能。限制同一个ip,假如每分钟只能请求5次接口。
但这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。
9.3、对接口限流
别以为限制了用户和ip就万事大吉,有些高手甚至可以使用代理,每次都请求都换一个ip。
这时可以限制请求的接口总次数。在高并发场景下,这种限制对于系统的稳定性是非常有必要的。但可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失。
9.4、加验证码
相对于上面三种方式,加验证码的方式可能更精准一些,同样能限制用户的访问频次,但好处是不会存在误杀的情况。
通常情况下,用户在请求之前,需要先输入验证码。用户发起请求之后,服务端会去校验该验证码是否正确。只有正确才允许进行下一步操作,否则直接返回,并且提示验证码错误。
此外,验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。
普通验证码,由于生成的数字或者图案比较简单,可能会被破解。优点是生成速度比较快,缺点是有安全隐患。
还有一个验证码叫做:移动滑块,它生成速度比较慢,但比较安全,是目前各大互联网公司的首选。
9.5、提高业务门槛
上面说的加验证码虽然可以限制非法用户请求,但是有些影响用户体验。用户点击秒杀按钮前,还要先输入验证码,流程显得有点繁琐,秒杀功能的流程不是应该越简单越好吗?其实,有时候达到某个目的,不一定非要通过技术手段,通过业务手段也一样。比如只有会员才能参与秒杀活动,普通注册用户没有权限。或者,只有等级到达5级以上的普通用户,才有资格参加该活动。
这样简单的提高一点门槛,即使是黄牛党也束手无策,他们总不可能为了参加一次秒杀活动,还另外花钱充值会员吧?
以上,今天的分享就先这里,希望能帮助各位小伙伴!