一、什么是秒杀

秒杀最直观的定义:在高并发场景下而下单某一个商品,这个过程就叫秒杀

【秒杀场景】

  • 火车票抢票
  • 双十一限购商品
  • 热度高的明星演唱会门票

二、为什么使用秒杀

早起的12306购票,刚被开发出来使用的时候,12306会经常出现 超卖 这种现象,也就是说车票只剩10张了,却被20个人买到了,这种现象就是超卖!

还有在高并发的情况下,如果说没有一定的保护措施,系统会被这种高流量造成宕机

【为什么使用秒杀】

  • 严格防止超卖
  • 库存100件 你卖了120件 等着辞职吧!
  • 防止黑客
  • 假如我们网站想下发优惠给群众,但是被黑客利用技术将下发给群众的利益收入囊中
  • 保证用户体验
  • 高并发场景下,网页不能打不开、订单不能支付 要保证网站的使用!

三、非并发情况下秒杀

创建数据库

create database stockdb;
use stockdb;

create table stock(
	id int primary key auto_increment,
	`name` varchar(50),
	`count` int,
	sale int,
	`version` int
);

create table stock_order(
	id int primary key auto_increment,
	sid int,
	`name` varchar(50),
	`create_time` timestamp
);

insert stock value('0','iPhone 13 Pro',15,0,0);
  1. 创建SpringBoot项目,添加以下依赖
<dependencies>    
 	<dependency>
       <groupId>com.alibaba</groupId>
       <artifactId>druid</artifactId>
       <version>1.2.6</version>
   </dependency>
 
   <dependency>
       <groupId>org.mybatis.spring.boot</groupId>
       <artifactId>mybatis-spring-boot-starter</artifactId>
       <version>2.2.2</version>
   </dependency>
 
   <dependency>
       <groupId>mysql</groupId>
       <artifactId>mysql-connector-java</artifactId>
       <version>5.1.48</version>
   </dependency>
 
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-web</artifactId>
   </dependency>
 
   <dependency>
       <groupId>org.projectlombok</groupId>
       <artifactId>lombok</artifactId>
       <optional>true</optional>
   </dependency>
   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-test</artifactId>
       <scope>test</scope>
   </dependency>
</dependencies>

【配置文件】

server.port=8989
server.servlet.context-path=/ms

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/stockdb
spring.datasource.username=root
spring.datasource.password=ok

mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.jiabin.pojo

logging.level.root=info
logging.level.com.jiabin.mapper:debug
  1. 创建实体类
    【实体类】
/**
 * @Author 嘉宾
 * @Data 2022/3/23 20:06
 * @Version 1.0
 * @Desc 订单
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Order {
    private Integer id;
    private Integer sid;
    private String name;
    private Date createDate;
}
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:57
 * @Version 1.0
 * @Desc 商品
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Stock {
    private Integer id;
    private String name;
    private Integer count;      //库存
    private Integer sale;       //已售
    private Integer version;    //版本号
}
  1. 创建mapper层,根据业务编写接口
    【mapper】
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:56
 * @Version 1.0
 * @Desc 商品
 */
@Mapper
public interface StockMapper {

    /**
     * 根据商品id查询库存数量
     */
    Stock checkStock(Integer id);

    /**
     * 根据商品id减少库存
     */
    void updateSale(Stock stock);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jiabin.mapper.StockMapper">

    <!-- 根据商品id查询库存数量 -->
    <select id="checkStock" resultType="Stock" parameterType="int">
        select * from stock where id = #{id}
    </select>

    <!-- 根据商品id扣除库存 -->
    <update id="updateSale" parameterType="Stock" >
        update stock set sale = #{sale} where id = #{id}
    </update>

</mapper>
/**
 * @Author 嘉宾
 * @Data 2022/3/23 20:07
 * @Version 1.0
 * @Desc 订单
 */
@Mapper
public interface OrderMapper {

    /**
     * 创建订单
     */
    void createOrder(Order order);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jiabin.mapper.OrderMapper">

    <!-- 创建订单 -->
    <insert id="createOrder" parameterType="Order" useGeneratedKeys="true" keyProperty="id">
        insert stock_order value(#{id},#{sid},#{name},#{createDate});
    </insert>
</mapper>
  1. 创建service,添加订单信息
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:51
 * @Version 1.0
 */
public interface OrderService {

    /**
     * 处理秒杀下单方法,返回订单id
     * @param id
     * @return
     */
    Integer kill(Integer id);
}
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:53
 * @Version 1.0
 */
@Service
@Transactional  //控制事务
public class OrderServiceImpl implements OrderService {

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private OrderMapper orderMapper;

    //在非并发情况下无问题
    @Override
    public Integer kill(Integer id) {
        //根据商品id校验库存是否还存在
        Stock stock = stockMapper.checkStock(id);
        //当已售和库存相等就库存不足了
        if(stock.getSale().equals(stock.getCount())){
            throw new RuntimeException("库存不足!");
        }else{
            //扣除库存  (已售数量+1)
            stock.setSale(stock.getSale()+1);
            stockMapper.updateSale(stock);   //更新信息
            //创建订单
            Order order = new Order();
            order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
            orderMapper.createOrder(order); //创建订单
            return order.getId();   //mybatis主键生成策略 直接返回创建的id
        }
    }
}
  1. 创建StockController 提供访问路径
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:49
 * @Version 1.0
 */
@RestController
@RequestMapping("/stock")
public class StockController {

    @Autowired
    private OrderService orderService;

    //开发秒杀方法
    @GetMapping("/kill/{id}")
    public String kill(@PathVariable("id") Integer id){
        System.out.println("秒杀商品的ID=====================>"+id);
        try {
            //根据秒杀商品id调用秒杀业务
            Integer orderId = orderService.kill(id);
            return "秒杀成功,订单ID为:"+String.valueOf(orderId);
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

}
  1. 访问项目地址 http://localhost:8989/ms/stock/kill/1进行测试
    【第一次下单,可以看到一套事务完成,对应订单信息也随着创建】 【当我们连续购买多次,商品库存达到了极限后,提示我们库存不足!此时订单是无法创建的!】

以上只是可以在非高并发场景下可以使用,如果说大量的请求涌向我们的接口,可能会出现问题,下面我们测试一下高并发场景下会出现什么问题!

四、高并发测试工具 JMeter

  1. 打开我们的测试工具JMeter,创建线程组设置请求数
    【设置1000个线程足够!】
  2. 配置访问的基础路径配置
    【ip、端口、请求类型、访问路径】
  3. 创建监听,监听线程执行的输出
  4. 配置完毕!启动我们的测试工具 高并发访问系统
    【清空数据库数据】
#清空数据库数据
truncate table stock;
truncate table stock_order;

insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;

【启动压力测试工具】

【查看监听输出】

【查看数据库订单表,发现已经严重超卖,高并发场景下是无法抵御的!】

五、解决商品超卖问题

1、使用悲观锁解决商品超卖

上述章节我们可以看到,高并发场景下我们的商品已经严重超卖了,公司中是严重不允许出现该问题的,下面我们看一下如何解决该问题!

【使用 synchronized 悲观锁】

  • 顾名思义十分悲观,它总是认为会出问题,无论干什么都会上锁!再去操作!
  • synchronized中文意思是同步,也被称之为”同步锁“。
  • synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
  • synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。

秒杀超卖问题 java 秒杀系统解决超卖_Data

  1. 我们使用synchronized 修饰我们的业务代码
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:53
 * @Version 1.0
 */
@Service
@Transactional  //控制事务
public class OrderServiceImpl implements OrderService {

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 秒杀商品
     **/
    @Override
    public synchronized Integer kill(Integer id) {
        //根据商品id校验库存是否还存在
        Stock stock = stockMapper.checkStock(id);
        //当已售和库存相等就库存不足了
        if(stock.getSale().equals(stock.getCount())){
            throw new RuntimeException("库存不足!");
        }else{
            //扣除库存  (已售数量+1)
            stock.setSale(stock.getSale()+1);
            stockMapper.updateSale(stock);   //更新信息
            //创建订单
            Order order = new Order();
            order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
            orderMapper.createOrder(order); //创建订单
            return order.getId();   //mybatis主键生成策略 直接返回创建的id
        }
    }
}

【继续清空数据库数据】

#清空数据库数据
truncate table stock;
truncate table stock_order;

insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;
  1. 继续使用压力测试工具进行测试
    【测试后发现确实可以帮我们解决大量超卖问题,但是仔细观察还是存在超卖现象】

注意:这里存在一个坑!

  • 由于我们的业务实体类中我们添加了事务 @Transactional ,添加了事务后会导致我们的事务也存在 线程同步,而事务的线程同步要比我们 synchronized 的线程同步 范围更大
  • 我们的 synchronized 代码块确实可以帮我们实现线程同步,但是当我们代码块流程结束后事务可能还没有结束,就例如当前线程A的锁已经释放了 而事务还没有提交, 此时下一个线程B来了,来了之后事务开始提交,当线程B开始执行,线程B一执行数据库也跟着提交,这样可能会出现多提交这种问题!
  • 所以说在这里添加 synchronized 也会出现超卖问题!以后不要在业务方法上添加 synchronized !

【解决办法,在控制层中添加 synchronized 】

/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:49
 * @Version 1.0
 */
@RestController
@RequestMapping("/stock")
public class StockController {

    @Autowired
    private OrderService orderService;

    //开发秒杀方法
    @GetMapping("/kill/{id}")
    public String kill(@PathVariable("id") Integer id){
        System.out.println("秒杀商品的ID=====================>"+id);
        try {
            //使用悲观锁
            synchronized (this){
                //根据秒杀商品id调用秒杀业务
                Integer orderId = orderService.kill(id);
                return "秒杀成功,订单ID为:"+String.valueOf(orderId);
            }
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

}

【测试结果(前提继续清空数据库)】

【总结:】

  • synchronized 要在控制层中添加!
  • 注意事务 和 synchronized 存在的问题!

【悲观锁缺点:】

  • 会造成线程阻塞,线程排队问题
  • 对用户的体验不是很好

注意:我们不推荐使用 悲观锁 解决该问题,因为使用悲观锁会出现线程排队问题!下面介绍乐观锁方式

2、使用乐观锁解决商品超卖 推荐乐观锁

上述章节我们使用悲观锁解决了商品超卖问题,但是存在一定的缺陷,就是会出现线程一个个排队问题,会造成线程阻塞,给用户体验也不是很好!我们可以使用乐观锁解决该问题

【使用乐观锁】

  • 顾名思义十分乐观,它总是认为不会出现问题,无论干什么都不去上锁!如果出现问题,再次更新值测试
  • 乐观锁相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。
  • 乐观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性

秒杀超卖问题 java 秒杀系统解决超卖_高并发_02

【代码重构】

  1. 我们将服务层代码重构,将一个个业务抽取成方法
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:53
 * @Version 1.0
 */
@Service
@Transactional  //控制事务
public class OrderServiceImpl implements OrderService {

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private OrderMapper orderMapper;

    // 秒杀	
    @Override
    public Integer kill(Integer id) {
        // 校验库存
        Stock stock = checkStock(id);
        // 存在扣住库存 未抛出异常则满足!
        updateSale(stock);
        // 创建订单
        return createOrder(stock);
    }

    /**
     * 校验库存
     * @param id
     * @return
     */
    public Stock checkStock(Integer id){
        //根据商品id校验库存是否还存在
        Stock stock = stockMapper.checkStock(id);
        //当已售和库存相等就库存不足了
        if(stock.getSale().equals(stock.getCount())){
            throw new RuntimeException("库存不足!");
        }
        return stock;  //满足情况下返回商品信息
    }

    /**
     * 扣除库存
     * @param stock
     */
    public void updateSale(Stock stock){
        //扣除库存  (已售数量+1)
        stock.setSale(stock.getSale()+1);
        stockMapper.updateSale(stock);   //更新信息
    }

    /**
     * 创建订单
     * @param stock
     * @return
     */
    public Integer createOrder(Stock stock){
        //创建订单
        Order order = new Order();
        order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
        orderMapper.createOrder(order); //创建订单
        return order.getId();   //mybatis主键生成策略 直接返回创建的id
    }
}
  1. 控制层
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:49
 * @Version 1.0
 */
@RestController
@RequestMapping("/stock")
public class StockController {

    @Autowired
    private OrderService orderService;

    //开发秒杀方法
    @GetMapping("/kill/{id}")
    public String kill(@PathVariable("id") Integer id){
        System.out.println("秒杀商品的ID=====================>"+id);
        try {
            //根据秒杀商品id调用秒杀业务
            Integer orderId = orderService.kill(id);
            return "秒杀成功,订单ID为:"+String.valueOf(orderId);
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

}
  1. 启动项目测试下确保无问题!

【使用乐观锁解决商品超卖】

  1. 修改我们扣除库存的方法,通过版本号控制抵挡高并发涌入
/**
 * 扣除库存
 * @param stock
 */
public void updateSale(Stock stock){
    //在sql层面完成销量+1 和 版本号 +1 并且根据商品id和版本号同时查询更新的商品
    Integer updRows = stockMapper.updateSale(stock);   //更新信息
    if(updRows == 0){   //代表没有拿到版本号
        throw new RuntimeException("抢购失败,请重试!");
    }
}
  1. 修改mapper文件中的映射,我们修改 销量的同时 也修改版本号 并且需要根据 id + 版本号进行修改
<!-- 根据商品id扣除库存 -->
<update id="updateSale" parameterType="Stock" >
    update stock set sale = sale + 1,version = version + 1 where id = #{id} and version = #{version}
</update>
  1. 其余代码我们没有任何改动,启动项目进行测试
    【依旧清空数据库数据】
#清空数据库数据
truncate table stock;
truncate table stock_order;

insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;

【启动压力测试工具,高并发访问我们的请求,发现无任何问题】

【每秒杀一件商品都会修改一次版本号】

总结:

  • 相对悲观锁而言乐观锁保证了一定的效率,而不像悲观锁那样会造成线程阻塞
  • 使用乐观锁需要使用版本号,在操作数据的时候要对版本号进行更新

商品超卖总体流程



以上已经解决了我们商品超卖的问题,我们还需要解决前端请求限流的问题,我们目前只是1000个请求访问,如果说有更大的请求过来 我们的后端绝对是承受不住的 我们需要在请求做一定的限流处理!

六、使用令牌桶+乐观锁限流

1、什么是令牌桶

最初来源于计算机网络,在网络传输时,为了限制网络的拥塞,而限制网络的流量,使流量比较均匀的速度向外发送。

令牌桶允许请求的突发,它是以恒定的速度向桶中生成令牌,就比如我们向桶中生产100个令牌,它会以一定的速度生产令牌。当请求过来的时候会先向桶中拿取令牌,拿到了令牌才能进行业务处理,没有拿到令牌的请求可以暂时等待,直到令牌生产完毕后再拿到令牌去做业务处理,另外一种方式就是给请求一定的时间,在一定时间内拿到令牌就进行业务处理,拿不到的话就抛弃请求。

秒杀超卖问题 java 秒杀系统解决超卖_秒杀超卖问题 java_03

2、测试令牌桶

  1. 导入依赖
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.2-jre</version>
</dependency>
  1. 创建令牌桶实例,编写测试访问接口
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:49
 * @Version 1.0
 */
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {

    @Autowired
    private OrderService orderService;

    //创建令牌桶实例
    private RateLimiter rateLimiter = RateLimiter.create(20);   //放行20个请求

    /**
     * 基础令牌桶demo
     * @param id
     * @return
     */
    @GetMapping("/sale/{id}")
    public String sale(@PathVariable("id") Integer id){
        //1、没有获取到令牌的阻塞,直到获取到令牌token
        log.info("等待的时间:"+rateLimiter.acquire());
        System.out.println("========================>处理业务!");
        return "抢购成功!";
    }
    
    ...

}
  1. 启动项目测试,使用压力测试工具访问
    【我们设置2000个线程进行访问,可以发现每个请求逐个等待执行,令牌桶会没隔断时间发放令牌,只有得到令牌才能访问业务】【修改代码,设置线程需要在5秒内获取令牌,若获取不到就抛弃该请求】
/**
 * 基础令牌桶demo
 * @param id
 * @return
 */
@GetMapping("/sale/{id}")
public String sale(@PathVariable("id") Integer id){
    //1、没有获取到令牌的阻塞,直到获取到令牌token
    //log.info("等待的时间:"+rateLimiter.acquire());
    ///2、设置超时时间,如果在等待时间内获取到了令牌则处理业务,如果在等待时间外没有获取到令牌 则抛弃请求
    if(!rateLimiter.tryAcquire(5, TimeUnit.SECONDS)){    //5秒内能获取
        log.info("当期请求被限流,被抛弃了,无法调用后续秒杀业务!");
        return "抢购失败!";
    }
    System.out.println("========================>处理业务!");
    return "抢购成功!";
}

【启动线程组测试,可以看到大部分请求均被抛弃】

3、使用令牌桶+乐观锁限流

开发步骤
  1. 在控制器中添加业务代码
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:49
 * @Version 1.0
 */
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {

    @Autowired
    private OrderService orderService;

    //创建令牌桶实例
    private RateLimiter rateLimiter = RateLimiter.create(20);   //放行20个请求 20个令牌

    /**
     * 乐观锁 + 令牌桶算法限流 version2.0
     * @param id
     * @return
     */
    @GetMapping("/killtoken/{id}")
    public String killtoken(@PathVariable("id") Integer id){
        System.out.println("秒杀商品的ID=====================>"+id);
        //加入令牌桶的限流措施
        if(rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
            System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!");
            return "抢购失败,当前秒杀活动过于火爆,请重试!";
        }
        try {   //2秒内能拿到令牌才能进入
            //根据秒杀商品id调用秒杀业务
            Integer orderId = orderService.kill(id);
            return "秒杀成功,订单ID为:"+String.valueOf(orderId);
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }

}
  1. 清空数据库
#清空数据库数据
truncate table stock;
truncate table stock_order;

insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;
  1. 启动项目测试,通过压力测试工具测试 2000个线程
    【订单已经生成15个,2秒内未拿到令牌的线程会被抛弃】

七、其它问题

在上面章节中,我们完成了防止商品超卖以及限流,以及可以防止高并发情况下服务器宕机了,本章节中我们会继续完成一些问题

  • 秒杀都是在一个限定的时间内可以秒杀的,而不是每时每刻用户都可以秒杀,我们需要对秒杀系统加入限时处理!限时抢购
  • **如果说黑客通过抓包的方式获取到了我们秒杀接口的地址,通过脚本抢购我们的地址怎么办? **隐藏接口
  • 秒杀开始之后 一个用户请求频率过高 如何单位时间内限制访问次数? 单用户限制频率

1、Redis限时抢购

使用Redis完成商品秒杀时间限制,商品只有有效时间内可以秒杀

# 设置redis键存在时间
set Key Value EX 有效时间

# 我们设置商品的过期时间
set kill商品编号 value Ex 有效时间
开发步骤
  1. 在项目中加入整合redis依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 在配置文件中加入配置
# redis
spring.redis.host=192.168.171.134
spring.redis.port=6379
spring.redis.database=0
  1. 在秒杀接口中添加redis响应代码
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:53
 * @Version 1.0
 */
@Service
@Transactional  //控制事务
public class OrderServiceImpl implements OrderService {

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    //秒杀!
    @Override
    public Integer kill(Integer id) {
        // 验证redis中的秒杀商品是否超时
        if(!stringRedisTemplate.hasKey("kill"+id)){ //当redis不存在秒杀的商品    当我们设置的限时字段过期了就不存在了,不存在就代表秒杀结束了!
            throw new RuntimeException("当前商品的抢购活动已经结束了!");
        }
        // 校验库存
        Stock stock = checkStock(id);
        // 存在扣住库存 未抛出异常则满足!
        updateSale(stock);
        // 创建订单
        return createOrder(stock);
    }

   	 ....
}
  1. 启动redis
# 后台启动
redis-server jconfig/redis.conf

【设置一个商品有效秒杀时间】

# 单位为秒
set kill1 1 EX 60
  1. 启动项目,通过压力测测试工具进行测试!
    【清空数据库数据】
#清空数据库数据
truncate table stock;
truncate table stock_order;

insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;

【在商品有效时间内秒杀商品,秒杀正常】

【清空数据库数据,在商品无效时间内秒杀商品,提示当前商品秒杀已经结束,数据库也不会创建对应订单信息】

2、隐藏接口

在以上章节中,我们实现了Redis限时抢购等问题,虽然解决了限时抢购问题,但是我们系统还存在一定的问题,就比如我们的秒杀接口,如果说有不法人员利用某种技术手段获取到了我们的接口信息,在我们秒杀还没有开启的时候,不法人员提前进行对我们接口的连续访问,这样就对我们的用户不公平了,或许直接通过脚本访问接口 通过不进行按钮点击完成抢购,这样就成就了成千上万的薅羊毛军团!

我们需要对秒杀接口进行隐藏处理,抢购接口隐藏(接口加盐)的具体做法:

  • 每次点击秒杀接口,先从服务器获取一个秒杀验证值(接口内判断是否是秒杀时间)
  • Redis以缓存用户ID和商品的ID为Key(MD5-用户id-商品id),秒杀地址为 redis中存的秒杀验证值
  • 用户请求秒杀的时候,需要带上秒杀验证进行校验
  • 具体流程:
  • 即使黑客获取到了我们生成md5的接口,我们生成md5的时候为它指定一个随机盐,并且再进行md5的限时处理,这样就有效的防止了脚本
代码实现
  1. 在我们的数据库中添加新的表user
create table `user`(
	uid int primary key auto_increment,
	uname varchar(100),
	upwd varchar(50)
);
insert `user` values('0','jiabin','123');
  1. 在controller中创建对应生成md5的方法
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:49
 * @Version 1.0
 */
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {

    @Autowired
    private OrderService orderService;

    @Autowired
    private UserService userService;

    //创建令牌桶实例
    private RateLimiter rateLimiter = RateLimiter.create(20);   //放行20个请求

    /**
     * 生成MD5
     * @param id
     * @param uid
     * @return
     */
    @GetMapping("/md5/{id}/{uid}")
    public String getMD5(@PathVariable("id")Integer id,@PathVariable("uid") Integer uid){
        String md5;
        try {
            md5 = orderService.getMD5(id,uid);
        }catch (Exception e){
            e.printStackTrace();
            return "获取MD5失败:"+e.getMessage();
        }
        return "获取到的MD5信息为:"+md5;
    }

    ...

}
  1. 在业务层实现我们具体的业务
    【OrderServiceImpl】
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:53
 * @Version 1.0
 */
@Service
@Transactional  //控制事务
public class OrderServiceImpl implements OrderService {

    @Autowired
    private StockMapper stockMapper;

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 根据商品ID与用户ID生成MD5
     * @param id
     * @param uid
     * @return
     */
    @Override
    public String getMD5(Integer id, Integer uid) {
        // 验证uid 用户是否存在
        User user = userMapper.findUserById(uid);
        if(null == user) throw new RuntimeException("用户信息不存在!");
        // 验证id 商品是否存在
        Stock stock = stockMapper.checkStock(id);
        if(null == stock) throw new RuntimeException("商品信息不存在!");
        // 生成MD5存入Redis
        String hashKey = "KEY_"+uid+"_"+id;
        // 生成MD5 !JiaBin16是一个盐
        String key = DigestUtils.md5DigestAsHex((uid+id+"!JiaBin16").getBytes());
        // 存入redis key value 时间
        stringRedisTemplate.opsForValue().set(hashKey,key,120, TimeUnit.SECONDS);
        return key;
    }

    ...
}
  1. 创建对应的实体类以及校验用户接口
    【User】
/**
 * @Author 嘉宾
 * @Data 2022/3/25 19:25
 * @Version 1.0
 * @Description
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
@ToString
public class User {
    private Integer uid;
    private String uname;
    private String upwd;
}

【UserMapper】

/**
 * @Author 嘉宾
 * @Data 2022/3/25 19:25
 * @Version 1.0
 * @Description
 */
@Mapper
public interface UserMapper {
    /**
     * 根据用户id查询用户
     */
    User findUserById(Integer uid);
}

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jiabin.mapper.UserMapper">

    <!-- 根据用户id查询用户 -->
   <select id="findUserById" resultType="User" parameterType="int">
       select * from user where uid = #{uid}
   </select>

</mapper>
  1. 这里可以启动测试一下是否可以获取到md5
  2. 改造秒杀接口
    【复制秒杀方法,进行改造 controller】
/**
 * 乐观锁 + 令牌桶算法限流 + MD5签名 version3.0
 * @param id
 * @return
 */
@GetMapping("/killtokenMD5/{id}/{uid}/{md5}")
public String killtokenMD5(@PathVariable("id") Integer id,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5){
    System.out.println("秒杀商品的ID=====================>"+id);
    //加入令牌桶的限流措施
    if(rateLimiter.tryAcquire(5,TimeUnit.SECONDS)){
        System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!");
        return "抢购失败,当前秒杀活动过于火爆,请重试!";
    }
    try {   //2秒内能拿到产品才能进入
        //根据秒杀商品id调用秒杀业务
        Integer orderId = orderService.killMD5(id,uid,md5);
        return "秒杀成功,订单ID为:"+String.valueOf(orderId);
    }catch (Exception e){
        e.printStackTrace();
        return e.getMessage();
    }
}

【复制秒杀方法,进行改造 service-----------这里我们将redis限时注释掉了 利于我们测试】

/**
   * 用来处理秒杀下单方法 返回订单id 加入md5签名 (接口隐藏)
   * @param id
   * @param uid
   * @param md5
   * @return
   */
  @Override
  public Integer killMD5(Integer id, Integer uid, String md5) {
      // 验证redis中的秒杀商品是否超时
//        if(!stringRedisTemplate.hasKey("kill"+id)){ //当redis不存在秒杀的商品    当我们设置的限时字段过期了就不存在了,不存在就代表秒杀结束了!
//            throw new RuntimeException("当前商品的抢购活动已经结束了!");
//        }
        // 验证签名
        String hashKey = "KEY_"+uid+"_"+id;
        // 通过redis
        String md5DB = stringRedisTemplate.opsForValue().get(hashKey);
        if(null == md5DB){
            throw new RuntimeException("没有携带签名,请求不合法!");
        }
        if(!md5DB.equals(md5)){ //请求的md5与数据库中的md5作比较
            throw new RuntimeException("当前请求数据不合法,请稍后再试!");
        }

        // 校验库存
        Stock stock = checkStock(id);
        // 存在扣住库存 未抛出异常则满足!
        updateSale(stock);
        // 创建订单
        return createOrder(stock);
    }
  1. 启动项目进行测试
    【首先我们要生成md5,携带我们的md5进行访问,并且保证商品有一定的库存!】
    【携带正确md5情况下,可以秒掉的原因是因为我注释掉了令牌桶,不然抢不到令牌访问】 【携带非法令牌情况下】 【解开令牌桶注释,进行压力工具测试,记得清空数据库数据保留库存以及令牌的持久性!】
    【携带正确md5情况下,一切秒杀成功!500个线程组】 【携带非法md5情况下,清空数据库保证库存数量,500个线程,秒杀失败!】

3、单用户限制频率

假设我们已经做好了接口隐藏,但是总会有一些无聊的人会再去写一个获取md5的脚本,先获取md5的加密值再去请求购买,如说过你的抢购按钮做的很差,需要在开启0.5秒后才能请求成功,那么脚本可能就会在用户请求之前就请求成功。

我们需要做一个保护措施,用来限制单用户的抢购频率,每个用户在一定时间内只能请求一定的次数!

实现思路:

  • 使用redis对每个用户进行访问统计,统计的字段名包含商品的id以及用户的id
  • 写一个对用户访问效率限制,在用户下单申请的时候,检查用户的访问次数是否超过了我们指定的次数,如果超过了就抛弃该请求
  • 具体流程:
开发步骤
  1. 开发controller代码
/**
 * @Author 嘉宾
 * @Data 2022/3/23 19:49
 * @Version 1.0
 */
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {

    @Autowired
    private OrderService orderService;

    @Autowired
    private UserService userService;

    //创建令牌桶实例
    private RateLimiter rateLimiter = RateLimiter.create(20);   //放行20个请求

    /**
     * 乐观锁 + 令牌桶算法限流 + MD5签名 + 单用户访问频率限制 version4.0
     * @param id
     * @return
     */
    @GetMapping("/killtokenMD5limit/{id}/{uid}/{md5}")
    public String killtokenMD5limit(@PathVariable("id") Integer id,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5){
        //加入令牌桶的限流措施
//        if(rateLimiter.tryAcquire(5,TimeUnit.SECONDS)){
//            System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!");
//            return "抢购失败,当前秒杀活动过于火爆,请重试!";
//        }
        try {   //2秒内能拿到产品才能进入
            //单用户调用接口频率限制
            Integer readCount = userService.addUserReadCount(uid);
            log.info("===>当前该用户"+uid+"访问次数:"+readCount);
            Boolean isBannd = userService.getUserCount(uid);
            if (isBannd){	//判断是否超过指定访问次数
                log.info("购买失败,您当前超过了频率限制!");
                return "购买失败,您当前超过了频率限制!";
            }
            //根据秒杀商品id调用秒杀业务
            Integer orderId = orderService.killMD5(id,uid,md5);
            return "秒杀成功,订单ID为:"+String.valueOf(orderId);
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }
	...    

}
  1. Service代码
    【UserService】
/**
 * @Author 嘉宾
 * @Data 2022/3/25 19:33
 * @Version 1.0
 * @Description
 */
public interface UserService {
    /**
     * 向reids中写入访问次数
     */
    Integer addUserReadCount(Integer uid);

    /**
     * 判断单位时间调用次数
     */
    Boolean getUserCount(Integer uid);
}

【UserServiceImpl】

/**
 * @Author 嘉宾
 * @Data 2022/3/25 19:34
 * @Version 1.0
 * @Description
 */
@Service
@Slf4j
public class UserServiceImpl implements UserService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Integer addUserReadCount(Integer uid) {
        // 根据不同uid生成调用次数的key
        String readKey = "READ_"+uid;
        // 获取redis中key的调用次数
        String readCount = stringRedisTemplate.opsForValue().get(readKey);
        int read = -1;
        if(readCount == null){ // 第一次调用
            // 存入redis中设置0
            System.out.println("=======================>第一次调用");
            stringRedisTemplate.opsForValue().set(readKey,"0",3600, TimeUnit.SECONDS);
        }else{  // 不是第一次
            // 每次调用+1
            System.out.println("=======================>不是第一次调用");
            read = Integer.parseInt(readCount) + 1;
            stringRedisTemplate.opsForValue().set(readKey,String.valueOf(read),3600,TimeUnit.SECONDS);
        }
        return read;    //返回调用次数   (如果返回-1就代表没有调用过)
    }

    @Override
    public Boolean getUserCount(Integer uid) {
        // 根据用户id生成key
        String readKey = "READ_"+uid;
        // 根据当前key获取用户的调用次数
        String readCount = stringRedisTemplate.opsForValue().get(readKey);
        if(readCount==null){    // 基本不会出现
            //为空直接抛弃说明key出现异常
            log.error("该用户没有访问申请验证值记录,疑似异常!");
            return true;
        }
        return Integer.parseInt(readCount)>10;  //大于10代表超过:true超过 false 没超过
    }
}
  1. 启动项目进行测试
    【这里我们注释掉了令牌桶,方便我们的测试】
    【第一次访问,秒杀成功,控制台输出对应信息 我们定义的第一次访问就会返回-1】 【当我们连续进行秒杀,访问超过10次后就会限制用户,控制台打印对应信息】