什么是秒杀系统?
在多个用户同时访问到网站的时候,你的处理方式和方法将直接影响网站的质量,比如我们经常熟知的双十一,那网站需要抗住多大的压力啊,短时间处理亿次请求,所以秒杀这个功能足以单拎出来形成一个系统。
我们去淘宝秒杀的时候,正常就是我们“狂点”秒杀按钮,等哪一次请求成功了,系统就会把我们秒杀的商品添加进购物车我们再付款就ok了,但其实我们点击按钮这一简单的动作,背后藏着许多的业务处理流程。
假如说在秒杀时刻到来时(如果一个人进行秒杀的时候是不需要秒杀系统的),当我们点击秒杀按钮后,后台其实做了三件事。
校验库存、扣除库存、生成订单
这就是正常的下单流程,我们需要做的就是在多线程并发的时秒杀场景下出现的一些问题。
一、防止超卖问题。
超卖问题:一共10个商品,但是同时1000个人去抢商品是如何解决的。
首先使用synchronized:在方法秒杀(业务方法)kill前加上synchronized关键字 是可以解决超卖问题的 ,有多少卖多少。
不建议使用同步代码块,(秒杀的时候)因为每次只允许一个线程操作用户体验非常差。
因此我们采用乐观锁的方式解决,给需要秒杀的商品加一个自字段(版本号),这样防止多个用户同时抢到同一件商品。
<update id="updatekucun" parameterType="Stock">
update stock set sale = sale+1,
version = version+1
where id =#{id}
and version = #{version}
</update>
用户下单后,根据商品id和版本号同时查询需要更新的商品信息
版本号:使用乐观锁主要是把防止超买问题交给数据库,利用数据库中的版本号字段以及数据库中的事务实现并发情况下的超卖问题。
二、接口限流问题。
我们目前解决了商品超卖的问题,接下来的问题是。大量的请求,会对服务器造成影响,从而影响其他的服务。
我们需要对某一时间窗口内的请求数加以限制,就是限流。
假如说有2000个请求过来,会让这些请求等待一段时间,不会马上处理。
令牌桶算法和漏桶算法。
高并发系统中 ,保护系统的利器:缓存、降级、限流
漏斗算法:不管多少请求过来,漏桶还是以一定的速度出水,强行限制数据的传输速率,是一种比较生硬的控制算法。
令牌桶算法: 对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。
传送到令牌桶的数据包需要消耗令牌。不同大小的数据包,消耗的令牌数量不一样。令牌桶这种控制机制基于令牌桶中是否存在令牌来指示什么时候可以发送流量。令牌桶中的每一个令牌都代表一个字节。如果令牌桶中存在令牌,则允许发送流量;而如果令牌桶中不存在令牌,则不允许发送流量。因此,如果突发门限被合理地配置并且令牌桶中有足够的令牌,那么流量就可以以峰值速率发送。
令牌桶简单实现:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
令牌桶的简单实现,放在控制器中。
public String sell(Integer id){
//没有获取到请求知道获取到令牌
// log.info("等待的时间"+rateLimiter.acquire());
//设置一个等待时间,如果获取到令牌就处理业务,没有的话就抛弃。
if(!rateLimiter.tryAcquire(5, TimeUnit.SECONDS)){
System.out.println("当前请求无法调用");
return "失败";
}
System.out.println("处理业务中-----");
return "成功";
}
三、接口隐藏问题。
1、秒杀不能没有时间限制,在一定的时间段内可以进行秒杀(狂点)时间验证的问题。
使用redis实现限时抢购,使用redis记录秒杀商品的时间,对过期的请求进行拒绝处理。
在校验库存前加入Redis设置过期时间
set key kill1(键名) 1 EX 180
private StringRedisTemplate stringRedisTemplate;
if (!stringRedisTemplate.hasKey("kill" + id)) {
throw new RuntimeException("当前商品的抢购活动已经结束");
}
2、如果有黑客设置脚本进行模拟秒杀,会让普通用户毫无游戏体验感的。
我们需要防止薅羊毛军团,采取下列方式。
1、每次点击秒杀按钮,先从服务器获取一个秒杀MD5,在一定程度上避免黑客
如果黑客想获取MD5接口 那就再给MD5设置一个时效。
public String getMD5(Integer id, Integer userid) {
//是否存在用户信息
if (userMapper.findUserById(userid) == null) {
throw new RuntimeException("用户信息不存在");
}
//是否存在商品信息
if (stockMapper.checkkucun(id) == null) {
throw new RuntimeException("商品信息不存在");
}
//生成hashKey
String hashKey = "KEY_" + userid + "_" + id;
//生成MD5签名 随机盐
String key = DigestUtils.md5DigestAsHex((userid + id + "!QS#").getBytes());
stringRedisTemplate.opsForValue().set(hashKey, key, 3600, TimeUnit.SECONDS);
return key;
}
2、Redis以缓存用户ID和商品ID为key,秒杀地址为value
3、用户请求秒杀商品的时候,需要带上秒杀验证至进行校验
//验证签名
String hashKey = "KEY_" + userid + "_" + id;
String s = stringRedisTemplate.opsForValue().get(hashKey);
if (!s.equals(md5)) {
throw new RuntimeException("当前请求数据不合法");
}
3、如果黑客写了一个非常复杂的脚本,破解了我们的MD5密码,那么还是有可能赶在普通用户前面抢到的,需要额外做一个措施。秒杀开始之后限制单个用户的请求频率,就是单位时间内的请求频率的限制。
使用Redis对用户访问次数做统计,用户申请下单时,检查用户的访问次数,超过访问次数,就不让他下单。
首先在Redis中保存用户的访问次数。这里的时效我们设置为3600秒。
//redis中保存用户的访问次数
public int saveUserCount(Integer userid) {
String limitKey = "LIMIT" + "_" + userid;
String s = stringRedisTemplate.opsForValue().get(limitKey);
int limit = 0;
if (s == null) {
stringRedisTemplate.opsForValue().set(limitKey, "0", 3600, TimeUnit.SECONDS);
} else {
limit = Integer.valueOf(s) + 1;
stringRedisTemplate.opsForValue().set(limitKey, String.valueOf(limit), 3600, TimeUnit.SECONDS);
}
return limit;
}
当用户的请求过来的时候,我们调用方法查询当前用户在这3600秒内访问了多少次,这里设置为10次为界限。
public boolean getUserCount(Integer userid) {
//根据用户id生成key
String limitKey = "LIMIT" + "_" + userid;
String s = stringRedisTemplate.opsForValue().get(limitKey);
if (s == null) {
System.out.println("无访问记录");
return true;
}
return Integer.valueOf(s)>10;
//这里设置为10,如果返回true,说明超过10次,拒绝访问,返回false的话 就可以正常访问购买
}
目前一个简单的秒杀小系统就完成了,这是跟着B站一位大神 @编程不良人 做的,都是使用简单的方法对秒杀问题中出现的问题进行解决,真实的场景下,比这个要复杂的多得多,先告一段落。