双重缓存以及分布式锁实现最简单秒杀

双重缓存以及分布式锁实现最简单秒杀

1. 秒杀项目1.0

数据库表设计

  • 订单表
CREATE TABLE `tb_order` (
  `order_id` bigint(20) NOT NULL,
  `product_id` bigint(20) DEFAULT NULL,
  `product_name` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
  • 商品表
CREATE TABLE `tb_product` (
  `product_id` bigint(20) NOT NULL,
  `product_name` varchar(255) DEFAULT NULL,
  `price` decimal(10,2) DEFAULT NULL,
  `stock` int(10) DEFAULT NULL,
  PRIMARY KEY (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

controller

@RestController
@RequestMapping("/seckill")
public class SecKillController {

    @Autowired
    private SecKillService secKillService;

    @PostMapping("/{productId}")
    public ResponseEntity secKill(@PathVariable("productId") Long productId) {
        return secKillService.secKill(productId);
    }
}

秒杀业务具体实现

@Service
@Slf4j
public class SecKillService {

    @Autowired
    private ProductMapper productMapper;

    @Autowired
    private OrderMapper orderMapper;

    @Transactional
    public ResponseEntity secKill(Long productId){
        //先查询出product
        Product product = productMapper.selectByPrimaryKey(productId);
        if (product == null){
            throw new SeckillException(ExceptionEnum.PRODUCT_NOT_FOUND);
        }
        //判断库存是否充足
        if (product.getStock() <= 0){
            throw new SeckillException(ExceptionEnum.STOCK_NOT_ENOUGH);
        }
        //生成订单
        Order order = new Order();
        order.setProductName(product.getProductName());
        order.setOrderId(Long.valueOf(NumberUtils.generateCode(10)));
        order.setProductId(productId);
        try {
            int insert = orderMapper.insert(order);
            if (insert == 0){
                throw new SeckillException(ExceptionEnum.CREATE_ORDER_ERROR);
            }
        }catch (Exception e){
            log.error("【生成订单服务】,生成订单失败,商品id:{}", productId);
            e.printStackTrace();
            throw new SeckillException(ExceptionEnum.CREATE_ORDER_ERROR);
        }
        //库存充足 可以购买 减少库存 这里使用了自定义方法updateStock 利用了数据库的乐观锁
        //避免了多线程竞争 mysql有行锁 使多线程按照一定的顺序(排队)更改数据库
        int resultCount = productMapper.updateStock(productId);
        if (resultCount <= 0){
            throw new SeckillException(ExceptionEnum.PRODUCT_IS_EMPTY);
        }
        return ResponseEntity.ok().body("购买秒杀商品成功");
    }
}

使用数据库乐观锁实现减少库存

乐观锁有效避免了超卖现象

@Repository
public interface ProductMapper extends BaseMapper<Product> {

    @Update("UPDATE tb_product SET stock = stock - 1 WHERE product_id = #{productId} AND stock > 0")
    int updateStock(@Param("productId") Long productId);

}

1.0版本性能测试

吞吐量仅为115.2/sec image.png

2. 秒杀2.0使用Redis缓存

在项目启动前将数据库中的商品数据 全部缓存到redis中 当用户秒杀时先判断redis缓存中的库存是否存在,如果库存已经没了就直接返回。如果存在 则进行下面更新数据库的操作

Controller

在controller中添加初始化redis的方法

    @Autowired
    private StringRedisTemplate redisTemplate;
 /**
     * 方法在项目启动时 自动查询数据库 并且把数据库的商品数据导入到redis中
     */
    @PostConstruct
    public void init(){
        List<Product> productList = secKillService.getProductList();
        for (Product product : productList){
            //将数据库数据导入redis中 保存商品的库存
            redisTemplate.opsForValue().set("product:" + product.getProductId(), product.getStock() + "");
        }
    }

service

@Transactional
public ResponseEntity secKill(Long productId){
    //在修改数据库之前先修改redis中的缓存 如果缓存中库存不足直接返回
    //使用redis的原子操作 对库存减一 stock为减少库存之后剩余的库存量
    Long stock = redisTemplate.opsForValue().decrement("product:" + productId);
    //当库存呢stock<0时商品已经全部卖光 注意stock时减少库存之后的返回值 所以可以等于0
    if (stock < 0){
    //库存量已经小于0了 说明库存 在减少之前就已经没了 所以需要将库存量加一 最后使库存保存为0
    stock = redisTemplate.opsForValue().increment("product:" + productId);
    log.info("====================库存量" + stock);
    throw new SeckillException(ExceptionEnum.STOCK_NOT_ENOUGH);
   }
    ......
    ......
   //生成订单
    ......
    try{
    ...
    }catch(Exception e){
	log.error("【生成订单服务】,生成订单失败,商品id:{}", productId);
        //在更新数据库的库存量之前 生成订单发生异常 我们需要将缓存中的商品库存还原到原来的值
        redisTemplate.opsForValue().increment("product:" + productId);
        throw new SeckillException(ExceptionEnum.CREATE_ORDER_ERROR);
   }
}

测试结果

image.png

3. 使用redis和JVM双缓存

思路

  • 一共发送了1w个请求,其中库存只有1000件,也就是说其中9000个请求是无效请求。我们可以在JVM内存中设置一个标志位,用来标志商品是否已经卖光。当商品卖光后我们将该内存标记位设置为true 同时直接抛出商品已经卖光的异常即可。
  • 使用线程安全的ConcurrentHashMap<Long,Boolean>用来标记商品是否已经卖光。(key为productId, value为商品是否卖光)

service

//定义一个线程安全的hashMap key为商品id value为是否已经卖光
    private static final ConcurrentHashMap<Long, Boolean> PRODUCT_SOLED_OUT_MAP = new ConcurrentHashMap<>();

    private static ConcurrentHashMap<Long, Boolean> getHashMap(){
        return PRODUCT_SOLED_OUT_MAP;
    }


    @Transactional
    public ResponseEntity secKill(Long productId){

        //进入接口先判断内存中库存是否已经被标记为卖光
        //如果已经标记卖光 直接抛出异常
        if (PRODUCT_SOLED_OUT_MAP.get(productId) != null){
            throw new SeckillException(ExceptionEnum.PRODUCT_IS_EMPTY);
        }

        //在修改数据库之前先修改redis中的缓存 如果缓存中库存不足直接返回
        //使用redis的原子操作 对库存减一 stock为减少库存之后剩余的库存量
        Long stock = redisTemplate.opsForValue().decrement("product:" + productId);
        //当库存呢stock<0时商品已经全部卖光 注意stock时减少库存之后的返回值 所以可以等于0
        if (stock < 0){
	    //此时库存已经卖光 我们将标志位设置为true
            PRODUCT_SOLED_OUT_MAP.put(productId, true);
            //库存量已经小于0了 说明库存 在减少之前就已经没了 所以需要将库存量加一 最后使库存保存为0
            stock = redisTemplate.opsForValue().increment("product:" + productId);
            log.info("====================库存量" + stock);
            throw new SeckillException(ExceptionEnum.STOCK_NOT_ENOUGH);
        }
        //**********************......************************
        try {
            //**********************......************************
        }catch (Exception e){
            log.error("【生成订单服务】,生成订单失败,商品id:{}", productId);
            //创建订单失败时 我们将内存中的标志位还原
            if (PRODUCT_SOLED_OUT_MAP.get(productId) != null){
                PRODUCT_SOLED_OUT_MAP.remove(productId);
            }
            //**********************......************************
        }
       
    }

测试结果

image.png

发现问题

在使用双重缓存后,对系统性能提升非常大。在架构为单体应用的情况下,JVM内存缓存不会出现问题。但是在分布式环境下,JVM内存中的缓存就会出现不一致的现象。 比如: 假设只剩最后两件商品 请求1 进入JVM1中 结果在下订单的时候出现了异常 商品售完的标记将会被清除 请求2 进入JVM2中 整个业务流程都没有出现异常 商品售完的标记没有被清除 结果是只剩下最后一件商品,而JVM2因为商品售完的标记没有被清除 导致这台服务器无法完成秒杀业务。 解决这个问题就需要用到分布式锁zookeeper了。

解决方案

在zookeeper下创建一个永久节点,其值为true/false true表示已经卖光,false表示未卖光。当请求进入时先判断节点的值


    //为了防止zookeeper未创建成功就执行操作 需要将创建过程阻塞 待创建连接成功后再进行操作
    private static CountDownLatch countDownLatch = new CountDownLatch(1);


    @Transactional
    public ResponseEntity secKill(Long productId) throws KeeperException, InterruptedException {
        ZooKeeper zooKeeper = null;
        try {
             zooKeeper = new ZooKeeper("192.168.71.128:2181", 5000, new Watcher() {
                @Override
                public void process(WatchedEvent event) {
                    if (event.getState() == Event.KeeperState.SyncConnected){
                        System.out.println(event.getState());
                        countDownLatch.countDown();
                    }
                    //如果节点发生变化 触发watcher机制
                    if (event.getType() == Event.EventType.NodeDataChanged){
                        System.out.println("节点发生变化了,路径为:" + event.getPath());
                    }
                }
            });
            countDownLatch.await();
        } catch (Exception e) {
            log.error("[zookeeper],zk连接失败了");
            e.printStackTrace();
        }

        //****************省略***************
        if (stock < 0){

	    //****************省略***************

            //判断zk下/product节点是否存在 不存在则创建
            if (zooKeeper.exists("/product" + productId, true) == null){
                zooKeeper.create("/product" + productId, "true".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
            //监听/product节点 发生变化时通知
            zooKeeper.exists("/product" + productId, true);

            throw new SeckillException(ExceptionEnum.STOCK_NOT_ENOUGH);
        }
	    //****************省略***************
        try {
            //****************省略***************
        }catch (Exception e){
	     //****************省略***************
            
            //创建订单时发生异常 此时重置zk节点 exists返回的是封装了事务的对象 如果为null则不存在
            if(zooKeeper.exists("/product" + productId, true) != null){
                zooKeeper.setData("/product" + productId, "false".getBytes(), -1);
            }

            throw new SeckillException(ExceptionEnum.CREATE_ORDER_ERROR);
        }
         //****************省略***************
    }

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×