秒杀笔记(乐观锁+令牌桶+Redis缓存)

1. 秒杀系统

1.1 秒杀场景

  • 电商抢购限量商品
  • 售卖明星演唱会门票
  • 火车票抢座12306

1.2 为什么要做秒杀系统

如果你的项目流量非常小,完全不用担心并发的购买需求,那么做这样一个系统意义并不大。

但如果你的系统要像12306那样接收高并发访问和下单的考验,那么你就需要一套完整的流程保护措施,来保证你系统在用户流量高峰期不会挂了。

  • 严格防止超卖:库存100件,你卖了120件,等着辞职吧
  • 防止黑产:防止不怀好意的人群通过各种技术手段把你本该下发给群众的利益全收入囊中
  • 保证用户体验:高并发下,别网页打不开了,支付不成功了,购物车进不去了,地址改不了了。这个问题非常之大,涉及到各种技术,也不是一下子就能讲完的,甚至根本就讲不完。

1.3 保护措施有哪些

  • 乐观锁防止超卖 -----核心基础
  • 令牌桶限流
  • Redis缓存
  • 消息队列异步处理订单

2. 防止超卖

毕竟,你网页可以卡住最多是大家没参与到活动,上网口吐芬芳,骂你一波。但是你要是卖多了,本该拿到商品的用户可就不乐意了
轻则投诉你,重则找漏洞起诉赔偿。让你吃不了兜着走。

2.1 数据库表

DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock`(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',
`count` int(11) NOT NULL COMMENT '库存',
`sale` int(11) NOT NULL COMMENT '已售',
`version` int(11) NOT NULL COMMENT '乐观锁,版本号',
PRIMARY KEY (`id`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

DROP TABLE IF EXISTS `stock_order`;
CREATE TABLE `stock_order`(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`sid` int(11) NOT NULL COMMENT'库存ID',
`name` varchar(30) NOT NULL DEFAULT '' COMMENT'商品名称',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.2 分析业务

2.2.1 Controller

package com. baizhi.controller;
import com.baizhi.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping ("stock")
public class StockController{
   
@Autowired
private OrderService OrderService;
//开发秒杀方法
@GetMapping("kill")
    public String kill(Integer id){
   
    try{
   
        system.out.println(“秒杀商品的id = " + id);//根据秒杀商品id 去调用秒杀业务
        int orderId =orderService.kill(id);
        return "秒杀成功,订单id为:|"+String.value0f(orderId);
        }catch(Exception e){
   
            e.printStackTrace();
            return e.getMessage();
        }    
   }
 }

2.2.2 Service

package com.baizhi.service;
public interface OrderService {
   
    //用来处理秒杀的下单方法|
    int kill (Integer id);
}

2.2.3 ServiceImpl

package com.baizhi.service;
import com. baizhi.dao. StockDAO;import com.baizhi.entity.Stock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class OrderServiceImpl implements OrderService{
   
    @Autowired
    private StockDAO stockDAO;
    @Autowired
    private OrderDAO orderDAO;
    @Override
    public  int kill(Integer id){
   
    //根据商品id校验库存
    Stock stock = stockDAO.checkStock (id);
    if(stock.getsale().equals(stock.getCount()))
      {
   
          throw new RuntimeException("库存不足!!!");
      }else{
   
        //扣除库存
        stock.setsale (stock.getSale()+1);stockDAO.updatesale(stock);
        //创建订单
        Order order = new Order();
        order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
        orderDAO.createorder(order);
        return order.getId();
    }
    return 0;
}

useGeneratedKeys=“ture” 使用主键自动生成策略

keyProperty=“id” 把自动生成的主键id的值回赋给Order对象的id属性

2.3 正常测试

在正常测试下没有发现任何问题

2.4 使用Jmeter进行压力测试

2.4.1 Jmeter介绍

Apache JMeter是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,它最初被设计用于Web应用测试,但后来扩展到其他测试领域。它可以用于测试静态和动态资源,例如静态文件、Java 小服务程序、CGl脚本、Java对象、数据库、FTP服务器,等等。JMeter可以用于对服务器、网络或对象模拟巨大的负载,来自不同压力类别下测试它们的强度和分析整体性能。另外,JMeter能够对应用程序做功能/回归测试,通过创建带有断言的脚本来验证你的程序返回了你期望的结果。

2.4.2 安装Jmeter

#1.下载jmeter
https:/ ljmeter.apache.org/download_jmeter.cgi
下载地址:https: //mirror.bit.edu.cn/apache//jmeter/binaries/apache-jmeter-5.2.1.tgz
#2.解压缩
backups
---用来对压力测试进行备份目录
bin
---Jmeter核心执行脚本文件
docs
-―-官方文档和案例
extras---额外的扩展lib---第三方依赖库licenses ---说明
printable_docs ---格式化文档
#3.安装Jmeter
日.要求:必须事先安装jdk环境1.配置jmeter环境变量
export JMETER_HOME=/Users/chenyannan/dev/apache-jmeter-5.2
export PATH=$SCALA_HOME/bin:$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH:$JMETER._HONE/bin2.是配置生效
source ~/ .bash_profile3.测试jemeter

2.4.3 Jmeter使用

Don’t use GUl mode for load testing !, only for Test creation and Test debugging.For load testing,

use CLl Mode (was NON GUI):

jmeter -n -t [jmx file] -l [results file] -e -o [Path to web report folder]

& increase Java Heap to meet your test requirements:
Modify current env variable HEAP="-Xms1g -Xmx1g -XX:MaxMetaspaceSize=256m" in the jmeter batch file

Check : https://jmeter.apache.org/usermanual/best-practices.html

2.4.4 Jmeter压力测试

jmeter -n -t [jmx file](jmx压力测试文件)-l [results file](结果输出的文件) -e -o [Path to web report folder](生成html版压力测试报告)

synchronized线程如果比事务范围小, 释放锁后 ,事务没有结束,数据没有提交到数据库,库存没有改,第二个线程如果执行比较快,跑过了第一个,将数据提交后,第一个才提交事务,这样就可能出现了超卖。

注意:最好不要在业务方法上加同步代码块。

在控制器调用方法处加Synchronized,可以保证不影响事务的提交,保证线程的执行范围比事务的执行范围大

2.5 乐观锁解决超卖问题

CAS+Version

使用乐观锁解决商品超卖问题,实际上主要是把防止超卖的问题交给数据库解决,利用数据库中定义的 Version 字段以及数据库的 事务 实现在并发情况下解决商品的超卖问题。

2.5.1 校验库存方法

//校验库存
private stock checkStock (Integer id){
   
Stock stock = stockDAO.checkStock (id);
if(stock.getsale().equals (stock.getCount ()))
{
   
    thrownew RuntimeException("库存不足!!!");
}
    return stock;
}

2.5.2 更新库存的方法改造

//扣除库存
private void updateSale(Stock stock){
   
//在sq1层面完成销量的+1 和版本号的+1 并且根据商品id和版本号同时查询更新的商品
 stockDAO.updateSale(stock);
}
<!--根据商品id扣除库存-->
<update id= "updateSale" parameterType=" Stock ">
    update stock set
    sale=sale+1,
    version=version+1
    where
    id=#{id}
    and
    version =#{version}
</update>

2.5.3 创建订单

    //创建订单
    private Integer createOrder (Stock stock){
   
    Order order =new Order();
    order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
    orderDAO.createOrder(order);
    return order.getId();
    }  
<!--创建订单-->
<insert id="createOrder" parameterType= "order" useGeneratedKeys= "true" keyProperty="id">
	insert into stock_order values(#{id},#{sid},#{name},#{createDate})
</insert>

2.5.4 完整的业务方法与Mapper.xml

  • Service方法
package com.baizhi.service;
import com. baizhi.dao. StockDAO;import com.baizhi.entity.Stock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class OrderServiceImpl implements OrderService{
   
    @Autowired
    private StockDAO stockDAO;
    @Autowired
    private OrderDAO orderDAO;
@override
public int kill(Integer id){
   
    //校验库存
    stockstock = checkStock(id) ;
    //更新库存
    updatesale(stock);
    //创建订单
    return createOrder(stock);
    }
    //校验库存
    private stock checkStock (Integer id){
   
    Stock stock = stockDAO.checkStock (id);
    if(stock.getsale().equals (stock.getCount ()))
    {
   
        thrownew RuntimeException("库存不足!!!");
    }
        return stock;
    }
    //扣除库存
    private void updatesale (Stock stock){
   
    //在sql层面完成销量的+1 和版本号的+1
    //并且根据商品id和版本号同时查询更新的商品
    int updateRows = stockDAO.updatesale(stock) ;
    if (updateRows==0){
   
    	throw new RuntimeException("请购失败,请重试!!!" );
       }
    }
    //创建订单
    private Integer createOrder (Stock stock){
   
    Order order =new Order();
    order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
    orderDAO.createOrder(order);
    return order.getId();
    }     
  • StockDaoMapper.xml
<!--根据秒杀商品id查询库存-->
<select id="checkStock" parameterType="int " resultType="Stock ">
select id ,name , count,sale, version from stock
where id =#{id}
</select>
<!--根据商品id扣除库存-->
<update id= "updateSale" parameterType=" Stock ">
    update stock set
    sale=sale+1,
    version=version+1
    where
    id=#{id}
    and
    version =#{version}
</update>
  • OrderDaoMapper.xml
<!--创建订单-->
<insert id="createOrder" parameterType= "order" useGeneratedKeys= "true" keyProperty="id">
	insert into stock_order values(#{id},#{sid},#{name},#{createDate})
</insert>

3.接口限流

限流:是对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机

3.1 接口限流

在面临高并发抢购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大的压力,大量的请求抢购成功时需要调用下单的接口,过多的请求达到数据库会对系统的稳定性造成影响。

3.2 如何实现接口限流

常用的限流算法有 令牌桶漏桶(漏斗算法),而Google开源项目Guave中的Raeuiniter使用的就是令牌桶控制算法。

在开发高并发系统时有三把利器用来保护系统: 缓存、降级 和 限流

  • 缓存 :缓存的目的是提升系统访问速度和增大系统处理容量
  • 降级︰降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行
  • 限流︰限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。

3.3 令牌桶算法和漏斗算法

  • 漏斗算法:漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
  • 令牌桶算法:最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。
  • 令牌桶算法的扩展性比漏斗算法要强很多,漏桶算法比较粗暴,令牌桶算法可以设置超时时间

3.4 使用令牌桶算法实现乐观锁 + 限流

3.4.1 项目中引入依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
3.4.2 令牌桶算法的基本使用
package com. baizhi.controller;
import com.baizhi.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping ("stock ")
@Slf4j
public class StockController{
   
@Autowired
private OrderService OrderService;
//创建令牌桶实例
 private RateLimiter rateLimiter = RateLimiter.create(30);   
/*@GetMapping("sale") public String sale(Integer id){ //处理策略: //1.没有获取到token请求 就一直阻塞 直到获取到token令牌 //log.info("等待时间:"+rateLimiter.acquire()); //2.设置一个等待时间,如果等待时间内获取到了令牌,则处理业务 // 如果等待时间内没有获取到了令牌,则抛弃业务 // 如果5秒内能拿到 if(rateLimiter.tryAcquire(5,TimeUnit.SECONDS)){ sout("当前请求被限流,直接抛弃,无法调用后续秒杀逻辑......"); return "抢购失败"; } sout("处理业务.............") return "抢购成功"; }*/
//开发秒杀方法,使用乐观锁防止超卖
@GetMapping("kill")
    public String kill(Integer id){
   
    try{
   
        system.out.println(“秒杀商品的id = " + id);//根据秒杀商品id 去调用秒杀业务
        int orderId =orderService.kill(id);
        return "秒杀成功,订单id为:|"+String.value0f(orderId);
        }catch(Exception e){
   
            e.printStackTrace();
            return e.getMessage();
        }    
   }
 //开发秒杀方法,使用乐观锁防止超卖 + 令牌桶限流
  @GetMapping("killtoken")
    public String killtoken(Integer id){
   
        //加入令牌桶的限流措施
        if(!Ratelimiter.tryAcquire(3,TimeUnit.SECONDS))//超时时间:3s
        {
   
            log.info("抛弃请求:抢购失败,当前秒杀活动过于火爆,请重试");
            return "抢购失败,当前秒杀活动过于火爆,请重试";
        }
    try{
   
        system.out.println(“秒杀商品的id = " + id);//根据秒杀商品id 去调用秒杀业务
        int orderId =orderService.kill(id);
        return "秒杀成功,订单id为:|"+String.value0f(orderId);
        }catch(Exception e){
   
            e.printStackTrace();
            return e.getMessage();
        }    
   }                         
 }

注意:加了该限流措施后,在并发抢购的情况下,是不可能百分之百全部卖掉的,

​ 可能是卖掉大部分商品,但是这也并不算是缺点,因为,在正式的电商场景中,

​ 我们需要留一部分库存去解决部分产品质量不合格用户退换问题;

4.隐藏秒杀接口

之前设计的系统存在的一些问题:

1.我们应该在一定时间内执行秒杀处理,不能在任意时间都接受秒杀请求,如何加入时间验证?

2.对于稍微懂点电脑的,又会动歪脑筋的人来说,通过抓包的方式获取我们的接口地址,然后通过脚本进行抢购怎么办?

3.秒杀开始之后如何限制单个用户的请求频率,即单位时间内访问次数?

这个章节主要讲解秒杀系统中,关于抢购(下单)接口相关的单用户防刷措施,主要说明几块内容:

  • 限时抢购
  • 抢购接口隐藏
  • 单用户限制频率(单位时间内限制访问次数)

4.1 限时抢购的实现

使用Redis来记录秒杀商品的时间,对秒杀过期的请求进行拒绝处理!!

4.1.1 启动Redis服务

4.1.2 引入Redis依赖

<!--spring boot stater data redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

4.1.3 配置redis

spring.redis.database=0
spring.redis.port=6379
spring.redis.host=localhost

4.1.4 代码

package com.baizhi.service;
import com. baizhi.dao. StockDAO;import com.baizhi.entity.Stock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class OrderServiceImpl implements OrderService{
   
    @Autowired
    private StockDAO stockDAO;
    @Autowired
    private OrderDAO orderDAO;
    //注入stringRedisTemplate
    @Autowired
    private stringRedisTemplate stringRedisTemplate;
@override
public int kill(Integer id){
   
    //校验redis中的秒杀商品是否超时
         
    if(!stringRedisTemplate.hasKey("kill"+id))//常量key+商品id 作为键
    {
   
        throw new RuntimeException("当前商品的抢购活动已经结束啦---");
    }
        //校验库存
        stockstock = checkStock(id) ;
        //更新库存
        updatesale(stock);
        //创建订单
        return createOrder(stock);
    }
    
    
    //校验库存
    private stock checkStock (Integer id){
   
    Stock stock = stockDAO.checkStock (id);
    if(stock.getsale().equals (stock.getCount ()))
    {
   
        thrownew RuntimeException("库存不足!!!");
    }
        return stock;
    }
    //扣除库存
    private void updatesale (Stock stock){
   
    //在sql层面完成销量的+1 和版本号的+1
    //并且根据商品id和版本号同时查询更新的商品
    int updateRows = stockDAO.updatesale(stock) ;
    if (updateRows==0){
   
    	throw new RuntimeException("请购失败,请重试!!!" );
       }
    }
    //创建订单
    private Integer createOrder (Stock stock){
   
    Order order =new Order();
    order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
    orderDAO.createOrder(order);
    return order.getId();
    }   

4.1.5 Jmter测试

4.1.6 设置键和超时时间

EX 15 超时时间 15s

4.2 抢购接口隐藏

对于稍微懂点电脑的,又会动歪脑筋的人来说,点击F12打开浏览器的控制台,就能在点击抢购按钮后,获取我们抢购接口的链接。(手机APP等其他客户端可以抓包来拿到)一旦坏蛋拿到了抢购的链接,只要稍微写点爬虫代码,模拟一个抢购请求,就可以不通过点击下单按钮,直接在代码中请求我们的接口,完成下单。所以就有了成千上万的藕羊毛军团,写一些脚本抢购各种秒杀商品。

他们只需要在抢购时刻的000毫秒,开始不间断发起大量请求,觉得比大家在APP上点抢购按钮要快,毕竟人的速度又极限,更别说APP说不定还要经过几层前端验证才会真正发出请求。

所以我们需要将抢购接口进行隐藏,抢购接口隐藏(接口加盐)的具体做法︰

  • 每次点击秒杀按钮,先从服务器获取一个秒杀验证值(接口内判断是否到秒杀时间)Redis以缓
  • 存用户ID和商品ID为Key,秒杀地址为value缓存验证值
  • 用户请求秒杀商品的时候,要带上秒杀验证值进行校验。
  • 具体流程:

最后一句话写错了,是根据用户id和商品id 加上随机盐生成的md5存入redis并且设置超时时间,每次抢购都只是在很短的时间内有效,超过了时间,需要重新生成md5,重新发送请求。

4.2.1 库表结构

4.2.2 控制器代码

///生成md5值的方法
@RequestMapping("md5")
public String getMd5(Integer id,Integer userid)
{
   
    String md5;
    try {
   
        md5 = orderService.getMd5(id,userid);
    }
	catch (Exception e){
   
	e.printStackTrace( );
	return"获取md5失败: "+e.getMessage();
	return"获取md5信息为: "+md5 ;		
		}
}

4.2.3 ServiceImpl

package com.baizhi.service;
import com. baizhi.dao. StockDAO;import com.baizhi.entity.Stock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@Transactional
public class OrderServiceImpl implements OrderService{
   
    @Autowired
    private StockDAO stockDAO;
    @Autowired
    private OrderDAO orderDAO;
    //注入stringRedisTemplate
    @Autowired
    private stringRedisTemplate stringRedisTemplate;
    
    @override
    public int kill(Integer id,Integer userid,String md5){
   
        
                //校验redis中的秒杀商品是否超时
                // if(!stringRedisTemplate.hasKey("kill"+id))//常量key+商品id 作为键
                 // {
   
                // throw new RuntimeException("当前商品的抢购活动已经结束啦---");
                // }
            if(md5==null){
   
                
            }
            //先验证签名
            String  hashKey = "KEY_"+userid+"_"+id;
            if(!String s = StringRedisTemplate.opsForValue().get(hashKey).equals(md5)){
   
                throw new RuntimeException("当前请求数据不合法,请稍后再试");
            }
            //校验库存
            stockstock = checkStock(id) ;
            //更新库存
            updatesale(stock);
            //创建订单
            return createOrder(stock);
        }
    @override
    private String getMd5(Integer id,Integer userid){
   
         //检验用户的合法性
        User user = userDAO.findById(userid);
        if(user==null)throw new RuntimeException("用户信息不存在! ");
            log.info("用户信息:[{}]", user.toString () );
        //检验商品的合法性
        Stock stock = stockDAO.checkStock(id);
        if(stock==null) throw new RuntimeException("商品信息不合法! ");
        log .info("商品信息:[{}]" , stock.toString() );
        //生成hashkey
        String hashKey = "KEY_"+userid+"_"+id;

        //生成md5//这里!Q*jS#是一个盐 随机生成
        String key = DigestUtils.md5DigestAsHex((userid+id+"!Q*jS#" ).getBytes());
        stringRedisTemplate.opsForValue().set(hashKey,key,3600,TimeUnit.SECONDS);
        log.info("Redis写入:[{}][{}]",hashKey, key) ;
        return key ;
    }
    @override
    public int kill(Integer id){
   
        //校验redis中的秒杀商品是否超时

        if(!stringRedisTemplate.hasKey("kill"+id))//常量key+商品id 作为键
        {
   
            throw new RuntimeException("当前商品的抢购活动已经结束啦---");
        }
        //校验库存
        stockstock = checkStock(id) ;
        //更新库存
        updatesale(stock);
        //创建订单
        return createOrder(stock);
        }
    
        //校验库存
        private stock checkStock (Integer id){
   
        Stock stock = stockDAO.checkStock (id);
        if(stock.getsale().equals (stock.getCount ()))
        {
   
            thrownew RuntimeException("库存不足!!!");
        }
            return stock;
        }
        //扣除库存
        private void updatesale (Stock stock){
   
        //在sql层面完成销量的+1 和版本号的+1
        //并且根据商品id和版本号同时查询更新的商品
        int updateRows = stockDAO.updatesale(stock) ;
        if (updateRows==0){
   
            throw new RuntimeException("请购失败,请重试!!!" );
           }
        }
        //创建订单
        private Integer createOrder (Stock stock){
   
        Order order =new Order();
        order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
        orderDAO.createOrder(order);
        return order.getId(); 
    }  

4.2.4 业务层代码

@override
public String getMd5(Integer id,Integer userid) {
   
    //检验用户的合法性
    User user = userDAO.findById(userid);
    if(user==null)throw new RuntimeException("用户信息不存在! ");
        log.info("用户信息:[{}]", user.toString () );
    //检验商品的合法性
    Stock stock = stockDAO.checkStock(id);
    if(stock==null) throw new RuntimeException("商品信息不合法! ");
    log .info("商品信息:[{}]" , stock.toString() );
    //生成hashkey
    String hashKey = "KEY_"+userid+"_"+id;

    //生成md5//这里!Q*jS#是一个盐 随机生成
    String key = DigestUtils.md5DigestAsHex((userid+id+"!Q*jS#" ).getBytes());
    stringRedisTemplate.opsForValue().set(hashKey,key,3600,TimeUnit.SECONDS);
    log.info("Redis写入:[{}][{}]",hashKey, key) ;
    return key ;
}

4.2.5 Dao代码和Entity

@Data
public class User {
   
        private Integer id;
        private String name;
        private String password;
}
@Mapper
public interface UserDAO{
   
     User findById(Integer id);
}
<mapper namespace = "com.baizhi.dao.UserDAO">
    <!--根据id查询用户方法-->
<select id="findById" parameterType="Integer" resultType="User">
    select id,name,password from user where id=#{id}
</select>
</mapper> 

4.2.6 数据库添加用户记录

4.2.7 查看商品信息

4.2.8 启动项目访问生成md5接口

4.2.9 携带验证码下单即可

Controller代码
//开发一个秒杀方法乐观锁防止超卖+令牌桶算法限流
GetMapping ("killtokenmd5")
public String killtoken( Integer id,Integer userid, String md5) {
   
    System.out.println("秒杀商品的id = " + id);
    //加入令牌桶的限流措施
    if (!rateLimiter.tryAcquire(3,TimeUnit.SECONDS)) {
   
    	log.info("抛弃请求:抢购失败,当前秒杀活动过于火爆,请重试");
        return"抢购失败,当前秒杀活动过于火爆,请重试!";
    }
    try {
   
    //根据秒杀商品id 去调用秒杀业务
    	int orderId = orderService.kil1(id , userid , md5);
    	return"秒杀成功,订单id为:" + String. valueof(orderId);
    } catch (Exception e) {
   
    e.printStackTrace();return e.getMessage();
}
Service代码
@override
public int kill(Integer id,Integer userid,String md5){
   
    //校验redis中秒杀商品是否超时
    //
    //if( !stringRedisTemplate.hasKey ( "ki11"+id))
    //throw new RuntimeException("当前商品的抢购活动已经结束啦~~" );
    
    //先验证签名
    String hashkey = "KEY_"+userid+"_"+id ;
    String s = stringRedisTemplate.opsForValue().get(hashKey);
    if(s==null) throw new RuntimeException{
   "没有携带验证签名,请求不合法!"};
    if (!s.equals(md5)  throw new RuntimeException("当前请求数据不合法,请稍后再试! ");
    //校验库存
    Stock stock = checkStock(id);
    //更新库存
    updateSale(stock);
    //创建订单
	return createOrder( stock);
}

4.3 单用户接口限制频率

假设我们做好了接口隐藏,但是像我上面说的,总有无聊的人会写一个复杂的脚本,先请求hash(md5)值,再立刻请求购买,如果你的app下单按钮做的很差,大家都要开抢后0.5秒才能请求成功,那可能会让脚本依然能够在大家前面抢购成功。
我们需要在做一个额外的措施,来限制单个用户的抢购频率。
其实很简单的就能想到用redis给每个用户做访问统计,甚至是带上商品id,对单个商品做访问统计,这都是可行的。我们先实现一个对用户的访问频率限制,我们在用户申请下单时,检查用户的访问次数,超过访问次数,则不让他下单!

  • 具体流程:

4.3.1 Controller代码

//开发一个秒杀方法乐观锁防止超卖+令牌桶算法限流
@GetMapping( "killtokenmd5limit" )
public String killtokenlimit(Integer id, Integer userid,String md5)
{
   
    //加入令牌桶的限流措施
    if(!rateLimiter.tryAcquire(3,TimeUnit.SECONDS))
    {
       
         log.info("抛弃请求:抢购失败,当前秒杀活动过于火爆,请重试");
         return"抢购失败,当前秒杀活动过于火爆,请重试! ";
    }
    try {
   		
        //加入单用户限制调用频率
        int count = userService.saveUserCount(userid);
        log.info("用户截至该次的访问次数为:[{}]", count) ;
        boolean isBanned = userService.getUserCount(userid);
        if(isBanned){
   
            log.info("购买失败,超过频率限制!");
            return"购买失败,超过频率限制!";
        }
        //根据秒杀商品id 去调用秒杀业务
        int orderId = orderService.kil1(id,userid,md5) ;
        return"秒杀成功,订单id为: " + String.valueOf(orderId);
     }catch (Exception e) {
   
        e.printStackTrace();return e.getMessage();
      }
}

4.3.2 Service接口及实现

接口
public interface UserService {
   
    //向redis中写入用户访问次数
    int saveUsercount(Integer userId);
    //判断单位时间调用次数
    boolean getUserCount(Integer userId);
}    
实现
@Service
@Transactional
@Slf4j
public class UserServiceImpl implements UserService {
   
    @Autowired
    private stringRedisTemplate stringRedisremplate;
    
    @override
    public int saveUserCount (Integer userId){
   
    //根据不同用户id生成调用次数的key
    string limitKey = "LIMIT"+ "_" + userId;//获取redis中指定key的调用次数
    string limitNum = stringRedisTemplate.opsForValue().get (limitKey);
    int limit =-1;
    if(limitNum -= nul1){
   
    //第─次调用放入redis中设置为0
    stringRedisTemplate.opsForValue().set(limitKey,"0",3600TimeUnit.SECONDS);
    } else {
   
    //不是第一次调用每次+1
    limit = Integer.parseInt(limitNum)+l;
    stringRedisTemplate.opsForValue().set(limitKey,String.valueOf(limit),3600,TimeUnit.SECONDS);
    return limit; //返回调用次数
   }
        
    @override
    public boolean getUserCount (Integer userId){
   
    //根据userid对应key获取调用次数
    String limitKey = "LIMIT"+ "_"+ userId;
    //根据用户调用次数的key获取redis中调用次数
    string limitNum = stringRedisTemplate.opsForValue().get (limitKey);
    if(limitNum == null)
    {
   
    	//为空直接抛弃说明key出现异常
    	log.error( "该用户没有访问申请验证值记录,疑似异常");
        return true;
    	return Integer.parseInt(limitNum)> 10; //false代表没有超过true代表超过
    }
        
}

4.3.3测试调用

总结:

乐观锁防止超卖+令牌桶限流+md5签名(hash接口隐藏,防止脚本跨过前端秒杀按钮直接发起秒杀请求)+单用户限制频率
1.在秒杀开始前,我们会把相应的秒杀请求(kill+商品id)存入Redis并设置超时时间,秒杀开启后,当我们进入某个秒杀页面的时候点击秒杀按钮的时候发起请求,服务器端会根据用户id和商品id还有随机盐生成md5(签名),Key=“KEY_”+userid+"_"+id;
2. 存入Redis的同时返回给用户界面,当用户想要秒杀某个商品需要输入该md5值才能继续下一步,该请求一定要先从令牌桶获取令牌rateLimiter.tryAcquire(3, TimeUnit.SECONDS),获取到令牌,才能执行下一步,
3.获取到令牌后,先校验用户请求的次数是否超过限制,如果没有超过限制,先校验redis中存的秒杀请求是否过期,再验证签名(也是从Redis根据Key中获取),(如果要求每个人只能限购一个就验证下是否重复下单),然后校验库存,更新库存,创建订单(创建订单入库时,服务端创建id,使用insert ignore语句,如果受影响行数为0则说明已经下过单),需要注意的是更新库存使用的是乐观锁,在sql层面完成库存的-1 和 版本号的+1 ,如果返回的受影响行数>0则扣减库存成功,再校验库存。
insert ignore表示,如果中已经存在相同的记录,则忽略当前新数据;

为什么更新库存后还要校验库存?

因为并发环境下,比如有AB两个用户同时抢购了该商品,他们的秒杀请求同时通过了层层关卡,通过了重复下单校验,也同时通过了库存校验,更新库存是没问题,保证不会超卖没问题,但是可能会存在,有两件商品,A抢购成功了,B由于乐观锁版本号原因,没能抢购成功,库存还剩下一件没卖出去,这就有问题了。

如何解决呢?

我们需要在更新库存操作的后边再加一个查询库存操作,如果库存还有,且他还没有该商品的订单,让他继续抢购。

 <update id="updateSale" parameterType="Stock">
        update stock set
        cout=count-1,
        version=version+1
        where
        id =#{id}
        and
        version = #{version}
 </update>

说明:本文非原创,是看着B站编程不良人的视频记得笔记,

如果感觉对你个人有用的话,不要忘了点赞三连哟!

全部评论

相关推荐

nbdy:她的意思是,有的话就有,没有的话就没有
点赞 评论 收藏
分享
评论
点赞
5
分享
牛客网
牛客企业服务