秒杀
一、 秒杀业务难做的点
秒杀系统,库存只有一份,所有人会在集中的时间读和写这些数据,多个人读一个数据会导致超卖的发生。并且读写冲突,锁非常严重,这是秒杀业务难的地方。那我们怎么优化秒杀业务的架构呢?
二、秒杀优化
(1)我们要将请求尽量拦截在系统上游(不要让锁冲突落到数据库上去)。传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小。
(2)充分利用缓存中间件
三、优化细节
后端优化:将请求尽量拦截在系统上游
- 削峰:秒杀请求在时间上高度集中于某一个时间点,瞬时流量容易压垮系统,因此需要对流量进行削峰处理,缓冲瞬时流量,尽量让服务器对资源进行平缓处理
- 异步:将同步请求转换为异步请求,来提高并发量,本质也是削峰处理
- 利用缓存:为了减少请求数据库交互,因此可以将商品信息放在缓存中,减少数据库查询。可以在秒杀开始前就预热将商品信息放入redis中。
- 负载均衡:利用 Nginx 等使用多个服务器并发处理请求,减少单个服务器压力
前端优化
- 限流:使用图形验证码,分散请求
- 禁止重复提交:限定每个用户发起一次秒杀后,需等待才可以发起另一次请求,从而减少用户的重复请求
- 本地标记:用户成功秒杀到商品后,将提交按钮置灰,禁止用户再次提交请求
- 动静分离:将前端静态数据直接缓存到离用户最近的地方,比如用户浏览器、CDN 或者服务端的缓存中
四、分布式锁解决库存超卖
- 首先因为当很多用户来抢购商品的时候,同时修改数据库的库存,这个时候,肯定会出现超卖的现象。那么如何解决呢,很多人会想到synchronized同步锁,他们会想到锁住修改商品库存的代码,来实现悲观锁,如下
Synchronized(this){ 修改库存的代码 }
但是这样子是不可取的,因为现在的项目都不再是部署在单机,单点上,至少都是两台机器,当我开启两个端口(使用Nginx 负载均衡分发),我用jmeter测试的时候会发现,还是会出现超卖。
那么怎么办呢,这个时候就要调用第三方来帮助,使用redis来完成分布式锁 在redis中有一个命令叫做setnx(key,value),这个指令是当redis中没有这个key时,再添加value。
在下面的代码中,在操作库存的上面加一个锁,也就是setnx一个值,当setnx还在里面的时候,其他线程无法进入,当本线程走完了,最后将setnx的值释放,这个时候别的线程才会进入,也就实现了一个基本的锁,但是锁最怕的就是死锁,为了防止死锁,所以要用try,finally来让finally中释放setnx的值。但是当线程还没走到释放这一行代码,就被kill,就又进入了死锁,所以还需要给锁加一个生存时间。
这样在普通并发下其实已经没问题了,但是在超高并发下,还是有问题,那就是这个生存时间的设定问题,比如你生存时间设定的10秒,线程1进入,上锁到结束需要15秒,那么当线程1走到10秒的时候,锁已经开了,这时候线程2进入,然后线程2上锁,然后线程1走最后5秒解锁了,他解锁的是线程2的锁。所以为了解决这个问题,要保证每个线程解锁的是自己的锁,可以用uuid或者snowflake随机生成一个value,把他作为锁的value,在最后释放的时候,比对一下value值,这样就可以保证每个线程解锁的是自己的锁。
最后还是有个问题,这个定时生命的问题,设定什么其实都不好,总有可能会出现两个线程同时进入操作,那咋办呢,就要让这个锁每次生命时间要到了,看下他还在不在执行,如果在 那就再次延长生命时间,也就是给锁续命。
@ResponseBody @RequestMapping("seckill") public String deductStock(){ String lockKey = "lockKey"; String clientId = UUID.randomUUID().toString(); try{ //setIfAbsent等同于 setnx boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"peiyuxiang",10, TimeUnit.SECONDS); //防止线程被kill,从而走不到finally造成死锁,所以设定生存时间。但是万一走到这就被kill,还没设定时间。。所以在上面直接set同时设定生存时间 /*stringRedisTemplate.expire(lockKey,100, TimeUnit.SECONDS);*/ if(!result){ return "error_code"; } int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ int realStock = stock - 1; stringRedisTemplate.opsForValue().set("stock",realStock + ""); System.out.println("库存扣减成功,剩余库存" + realStock); }else { System.out.println("扣减失败,库存不足"); } }finally { if(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) stringRedisTemplate.delete(lockKey); } return "end"; }
6.或者使用Redisson就可以完成上述的分布式锁。并且可重入。
@ResponseBody @RequestMapping("seckill") public String deductStock(){ String lockKey = "lockKey"; String clientId = UUID.randomUUID().toString(); //获取锁对象 RLock redissonLock = redisson.getLock(lockKey); try{ //setIfAbsent等同于 setnx //防止线程被kill,从而走不到finally造成死锁,所以设定生存时间。但是万一走到这就被kill,还没设定时间。。所以在上面直接set同时设定生存时间 /*stringRedisTemplate.expire(lockKey,100, TimeUnit.SECONDS);*/ /*boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"peiyuxiang",10,TimeUnit.SECONDS); if(!result){ return "error_code"; }*/ //加锁 redissonLock.lock();//当作setIfAbsent(lockKey,clientId,30,TimeUnit.SECONDS) int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); if(stock > 0){ int realStock = stock - 1; stringRedisTemplate.opsForValue().set("stock",realStock + ""); System.out.println("库存扣减成功,剩余库存" + realStock); }else { System.out.println("扣减失败,库存不足"); } }finally { redissonLock.unlock(); /*if(clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) stringRedisTemplate.delete(lockKey);*/ } return "end"; }
五、测试
使用Jmeter测试 这里使用nginx反向代理开启多个端口,(具体配置nginx可以看我博客主页Nginx。)测试发现,库存并未发生超卖。
六、关于图形验证码验证、用户不可多次秒杀都是利用session完成的。还有kafka中间件的使用来缓冲瞬时流量会在后续整理。