原创

简易的秒杀系统


简易的秒杀系统

  • 防止超卖问题

  • 令牌桶限流

  • 整合Redis缓存

控制器

@RestController
@RequestMapping("stock")
public class StockController {

    @Autowired
    private OrderService orderService;

    //秒杀方法
    @GetMapping("kill")
    public String kill(Integer id){
        //根据秒杀商品的id去执行秒杀业务
        try {
           int orderId = orderService.kill(id);
           return "秒杀成功,秒杀ID = "+String.valueOf(orderId);
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }
}

service

public interface OrderService {
    //根据id去秒杀商品
    int kill(Integer id);
}

service的实现类

@Service
@Transactional
public class OrderServiceImpl implements OrderService {

    @Autowired
    private StockDao stockDao;

    @Autowired
    private OrderDao orderDao;

    @Override
    public int kill(Integer id) {
        Order order = new Order();
        //根据商品的id去校验库存
        Stock stock = stockDao.checkStock(id);
        //如果已售的数量和库存数量相等的话,就说明秒杀完毕
        if (stock.getCount().equals(stock.getSale())){
            throw new RuntimeException("该商品已经卖完了");
        }else {
            //校验完毕就扣除库存  给已售+1
            stock.setSale(stock.getSale()+1);
            stockDao.updateSale(stock);
            //创建订单操作
            order.setCreate_time(new Date());
            order.setName(stock.getName());
            order.setSid(stock.getId());
            orderDao.createOrder(order);
            return order.getId();
        }
    }
}

dao层

@Mapper
@Repository
public interface OrderDao {
    //创建订单
    void createOrder(Order order);
}
@Mapper
@Repository
public interface StockDao {
    //根据商品的id查询库存
    Stock checkStock(Integer id);
    //根据商品id扣除库存
    void updateSale(Stock stock);
}

实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
//订单
public class Order {
    private Integer id;
    private Integer sid;
    private String name;
    private Date create_time;
}
@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;
}

mapper.xml文件

<?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.ityang.mstest.dao.OrderDao">
    <!--创建订单-->
    <insert id="createOrder" parameterType="com.ityang.mstest.entity.Order" useGeneratedKeys="true" keyProperty="id">
        insert into stock_order values (#{id},#{sid},#{name},#{create_time})
    </insert>
</mapper>
<?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.ityang.mstest.dao.StockDao">
    <!--根据商品id扣除库存-->
    <update id="updateSale" parameterType="com.ityang.mstest.entity.Stock">
        update  stock set sale = #{sale} where id = #{id}
    </update>
    <!--根据秒杀商品的id来查询库存-->
    <select id="checkStock" parameterType="integer" resultType="com.ityang.mstest.entity.Stock">
        select id,name,count,sale,version  from stock
        where id = #{id}
    </select>
</mapper>

此时没有加任何锁,在单线程下没有问题,在多线程下就会出现超卖的问题

使用jmeter进行压力测试

在这里插入图片描述 在这里插入图片描述

此时数据库库存是100,有110个线程同时访问 ,数据库会有超卖现象,只有100个商品,却生成了更多的订单

悲观锁解决超卖现象

此时可以给controller中的方法加一个悲观锁,不能在service的实现类加

@RestController
@RequestMapping("stock")
public class StockController {

    @Autowired
    private OrderService orderService;

    //秒杀方法
    @GetMapping("kill")
    public String kill(Integer id){
        //根据秒杀商品的id去执行秒杀业务
        try {
            //这里加上悲观锁
            synchronized (this){
                int orderId = orderService.kill(id);
                return "秒杀成功,秒杀ID = "+String.valueOf(orderId);
            }
            
        }catch (Exception e){
            e.printStackTrace();
            return e.getMessage();
        }
    }
}

此时数据变得正常,不会发生超卖现象,但是效率很低

乐观锁解决超卖现象

//改造了service实现类的代码
@Service
@Transactional
public class OrderServiceImpl implements OrderService {

    @Autowired
    private StockDao stockDao;

    @Autowired
    private OrderDao orderDao;

    @Override
    public int kill(Integer id) {
        //根据商品的id去校验库存
        Stock stock = checkStock(id);
        //如果已售的数量和库存数量相等的话,就说明秒杀完毕
        // 校验完毕就扣除库存  给已售+1
        updateSale(stock);
        //创建订单操作
        Integer orderId = creatOrder(stock);
        return orderId;
    }
    //校验库存
    private Stock checkStock(Integer id){
        Stock stock = stockDao.checkStock(id);
        //如果已售的数量和库存数量相等的话,就说明秒杀完毕
        if (stock.getCount().equals(stock.getSale())){
            throw new RuntimeException("该商品已经卖完了");
        }else {
            return stock;
        }
    }
    //扣除库存
    private void updateSale(Stock stock){
        stock.setSale(stock.getSale()+1);
        stockDao.updateSale(stock);
    }
    //创建订单
    private Integer creatOrder(Stock stock){
        Order order = new Order();
        order.setName(stock.getName());
        order.setSid(stock.getId());
        order.setCreate_time(new Date());
        orderDao.createOrder(order);
        return order.getId();
    }
}

首先要给数据库加一个字段名 version

使用乐观锁的方式,实际就是将防止超卖的问题交给数据库来完成,利用数据库定义的version字段以及数据库的事务实现在并发下解决商品超卖的问题

service实现类中更改扣除库存的方法,其他的不变

//扣除库存
    private void updateSale(Stock stock){
        int updateSale = stockDao.updateSale(stock);
        if (updateSale==0){
            //如果等于0 说明其他线程都没有拿到版本号
            throw new RuntimeException("抢购失败,请重试");
        }
    }

更改xml文件中的扣除库存的sql语句,其他的不变

<!--根据商品id扣除库存-->
    <update id="updateSale" parameterType="com.ityang.mstest.entity.Stock">
        update  stock
        set sale = sale+1,
            version = version+1
        where id = #{id}
        and version = #{version}
    </update>

接口的限流

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

  1. 项目中引入依赖

    <!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>29.0-jre</version>
    </dependency>
    <!--google的开源工具类RateLimter,对令牌桶的实现-->
    
  2. controller要引入RateLimiter 然后设置发放的令牌数 参数是发放的令牌数量

   //创建令牌桶的实例  参数是每秒放行多少个请求 
    private RateLimiter rateLimiter = RateLimiter.create(20);
  1. 秒杀接口的改变

    //秒杀方法,使用乐观锁和令牌桶限流
        @GetMapping("kill2")
        public String kill2(Integer id){
            //加入令牌桶的限流  2秒内抢不到令牌的请求就放弃
            if (rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){    
                //就加了这一句的判断,判断请求2秒内是否拿到令牌,拿到了就处理接下来的业务,没有拿到就返回抢购失败
                try {
                    int kill2 = orderService.kill(id);
                    return "秒杀成功,秒杀ID = "+ String.valueOf(kill2);
                }catch (Exception e){
                    e.printStackTrace();
                    return e.getMessage();
                }
            }else {
                return "抢购失败";
            }
        }
    
  2. 限时抢购

    <!-- 整合redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <version>2.3.3.RELEASE</version>
    </dependency>
    
    spring.redis.host=127.0.0.1
    spring.redis.port=6379
    spring.redis.database=0
    
    @Service
    @Transactional
    public class OrderServiceImpl implements OrderService {
    
        @Autowired
        private StockDao stockDao;
    
        @Autowired
        private OrderDao orderDao;
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public int kill(Integer id) {
            //整合Redis,设置限时抢购的时间
    
            //校验redis中秒杀商品是否超时
            Boolean hasKey = stringRedisTemplate.hasKey("kill" + id);
            if (hasKey){
                //根据商品的id去校验库存
                Stock stock = checkStock(id);
                //如果已售的数量和库存数量相等的话,就说明秒杀完毕
                // 校验完毕就扣除库存  给已售+1
                updateSale(stock);
                //创建订单操作
                Integer orderId = creatOrder(stock);
                return orderId;
            }else {
                throw new RuntimeException("当前商品抢购活动已经结束");
            }
        }
        //校验库存
        private Stock checkStock(Integer id){
            //在sql层面完成销量的+1和version的+1,并且根据商品的id和版本号同时查询更新的商品
            Stock stock = stockDao.checkStock(id);
            //如果已售的数量和库存数量相等的话,就说明秒杀完毕
            if (stock.getCount().equals(stock.getSale())){
                throw new RuntimeException("该商品已经卖完了");
            }else {
                return stock;
            }
        }
        //扣除库存
        private void updateSale(Stock stock){
            int updateSale = stockDao.updateSale(stock);
            if (updateSale==0){
                //如果等于0 说明其他线程都没有拿到版本号
                throw new RuntimeException("抢购失败,请重试");
            }
        }
        //创建订单
        private Integer creatOrder(Stock stock){
            Order order = new Order();
            order.setName(stock.getName());
            order.setSid(stock.getId());
            order.setCreate_time(new Date());
            orderDao.createOrder(order);
            return order.getId();
        }
    }
    
  3. 隐藏接口 使用md5加密的方式 在这里插入图片描述

    Controller创建秒杀商品的接口

    	//创建需要秒杀的商品和限购的时间
        @GetMapping("create")
        public String createkill(Integer id,Integer killtime){
            Calendar calendar = Calendar.getInstance();
            //获取当前时间
            calendar.setTime(new Date());
            //创建秒杀任务
            Stock stock = orderService.createkill(id,killtime);
            //获取当前时间 + 设置的时间 之后的时间  设置的时间的单位是 秒
            calendar.add(Calendar.SECOND,killtime);
            String s = "创建" + stock.getName() + "商品的秒杀成功," +
                    "有效时间为:" + new SimpleDateFormat("yyyy-MM-dd HH-mm-ss").format(new Date()) +
                    " 到 " + new SimpleDateFormat("yyyy-MM-dd HH-mm-ss").format(calendar.getTime());
            return s;
        }
    

    Service中的方法

    	@Override
        public Stock createkill(Integer id, Integer killtime) {
            Stock stock = stockDao.checkStock(id);
            stringRedisTemplate.opsForValue().set("kill_"+id,stock.getName(),60,TimeUnit.SECONDS);
            stock.setCreatekill(killtime);	//这里我在stock的实体类中增加了一个属性 
        									//商品的秒杀时间	private Integer createkill;
            return stock;
        }
    

    Controller获取md5加密生成的字符串

    //进行md5加密
        @GetMapping("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;
        }
    

    Service的生成md5的方法

     @Override
        public String getMd5(Integer id, Integer userId) {
            //验证传过来的userid是否存在
            User user = userDao.findById(userId);
            if (user==null){
                throw new RuntimeException("用户名不存在");
            }
            //验证传过来的商品的id,判断该商品是否存在
            Stock stock = stockDao.checkStock(id);
            if (stock==null){
                throw new RuntimeException("商品不存在");
            }
            //两者都存在,生成md5签名,放入redis服务
            String hashkey = "KEY_" +userId +"_" + id;
            //!Q*jS#是一个盐,随机生成的
            String key = DigestUtils.md5DigestAsHex((userId+id+"!Q*jS#").getBytes());
            //将hashkey作为key 生成的md5加密的 key作为value存入redis中  设置的有效时间是 60s
            stringRedisTemplate.opsForValue().set(hashkey, key, 60, TimeUnit.SECONDS);
            log.info("redis写入了:[{}] [{}]",hashkey,key);
            return "加密的数据是:"+key;
        }
    

    改造Controller接口 需要前台传来商品id,用户id和获取的md5字符串

    	//秒杀方法,使用乐观锁和令牌桶限流  加上了md5加密
        @GetMapping("kill3")
        public String kill3(Integer id,Integer userid,String md5){
            //加入令牌桶的限流  2秒内抢不到令牌的请求就放弃
            if (rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
                try {
                    int kill3 = orderService.killmd5(id,userid,md5);
                    return "秒杀成功,秒杀ID = "+ String.valueOf(kill3);
                }catch (Exception e){
                    e.printStackTrace();
                    return e.getMessage();
                }
            }else {
                return "抢购失败";
            }
        }
    

    Service中的方法

    	@Autowired
        private StringRedisTemplate stringRedisTemplate;	//需要整合redis
    
    	@Override
        public int killmd5(Integer id, Integer userid, String md5) {
            //验证是否秒杀商品是否超时  返回的是Boolean值
            Boolean hasKey = stringRedisTemplate.hasKey("kill_" + id);
            //没有超时
            if (hasKey){
                //验证签名
                String hashkey="KEY_" +userid +"_" + id;
                String s = stringRedisTemplate.opsForValue().get(hashkey);
                if (s==null){
                    throw new RuntimeException("没有携带验证体,当前请求不合法");
                }
                if (s.equals(md5)){
                    //校验库存
                    Stock stock = checkStock(id);
                    //更新库存
                    updateSale(stock);
                    //生成订单
                    creatOrder(stock);
                    return id;
                }else {
                    throw new RuntimeException("非法访问");
                }
            }else {
                throw new RuntimeException("商品秒杀活动已经结束了");
            }
        }
    	//校验库存
        private Stock checkStock(Integer id){
            //在sql层面完成销量的+1和version的+1,并且根据商品的id和版本号同时查询更新的商品
            Stock stock = stockDao.checkStock(id);
            //如果已售的数量和库存数量相等的话,就说明秒杀完毕
            if (stock.getCount().equals(stock.getSale())){
                throw new RuntimeException("该商品已经卖完了");
            }else {
                return stock;
            }
        }
        //扣除库存
        private void updateSale(Stock stock){
            int updateSale = stockDao.updateSale(stock);
            if (updateSale==0){
                //如果等于0 说明其他线程都没有拿到版本号
                throw new RuntimeException("抢购失败,请重试");
            }
        }
        //创建订单
        private Integer creatOrder(Stock stock){
            Order order = new Order();
            order.setName(stock.getName());
            order.setSid(stock.getId());
            order.setCreate_time(new Date());
            orderDao.createOrder(order);
            return order.getId();
        }
    
  4. 单用户限制频率

    其他的都没变,只是在要进行业务处理之前增加了一个验证。

    改造Controller中的接口

    	//秒杀方法,使用乐观锁和令牌桶限流  加上了md5加密  再加上单用户限流
        @GetMapping("kill4")
        public String kill4(Integer id,Integer userid,String md5){
            //加入令牌桶的限流  2秒内抢不到令牌的请求就放弃
            if (rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
                try {
                    //查询用户访问该接口的次数
                    int userCount = userService.saveUserCount(userid);
                    log.info(userid+"--用户访问的次数是:[{}]",userCount);
                    //进行调用次数的判断
                    boolean isBanned = userService.getUserCount(userid);
                    if (isBanned){
                        log.info("请求失败,超过了频率限制");
                        return "请求失败,超过了频率限制";
                    }
                    int kill3 = orderService.killmd5(id,userid,md5);
                    return "秒杀成功,秒杀ID = "+ String.valueOf(kill3);
                }catch (Exception e){
                    e.printStackTrace();
                    return e.getMessage();
                }
            }else {
                return "抢购失败";
            }
        }
    

    创建一个UserService接口

    public interface UserService {
        //向redis中写入用户访问的次数
        int saveUserCount(Integer userId);
        //判断单位时间调用的次数
        boolean getUserCount(Integer userId);
    }
    

    UserService实现类

    @Service
    @Slf4j
    public class UserServiceImpl implements UserService {
    
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public int saveUserCount(Integer userId) {
            String userCountKey = "USER_"+userId;
            int limit = 0 ;
            //首先查询redis中有没有这个用户的记录
            String s = stringRedisTemplate.opsForValue().get(userCountKey);
            if (s == null){
                //查询结果是null 说明没有,就创建一个新的key 并且设置访问次数初始是 0  有效时间是 1 小时
                stringRedisTemplate.opsForValue().set(userCountKey,"0",3600,TimeUnit.SECONDS);
                //返回调用次数 0
                return limit;
            }else {
                //如果查询到了  就获取这个用户的访问次数,并且给这个用户的访问次数 +1
                String userCount = stringRedisTemplate.opsForValue().get(userCountKey);
                limit = Integer.parseInt(userCount)+1;
                stringRedisTemplate.opsForValue().set(userCountKey,String.valueOf(limit),3600,TimeUnit.SECONDS);
               //返回调用次数
                return limit;
            }
        }
    
        @Override
        public boolean getUserCount(Integer userId) {
            String userCountKey = "USER_"+userId;
            String userCount = stringRedisTemplate.opsForValue().get(userCountKey);
            if (userCount==null){
                //如果为空,说明该key出现异常
                log.error("该用户没有申请验证记录值,访问异常");
                return true;
            }
            return Integer.parseInt(userCount)>10; //false说明没有超过  true说明超过了
        }
    }
    
后端
springboot
Java

评论