zoukankan      html  css  js  c++  java
  • 分布式锁的三种实现方式

    一、基本概念

    1、引入
            传统的锁都是有JDK官方提供的锁的解决方案,也就是说这些锁只能在一个JVM进程内有效,我们把这种锁叫做单体应用锁。但是,在互联网高速发展的今天,单体应用锁能够满足我们的需求吗?
    新的阅读体验:http://www.zhouhong.icu/post/143

    本篇文章所有代码:https://github.com/Tom-shushu/Distributed-system-learning-notes/

    2、互联网系统架构的演进
            在互联网系统发展之初,系统比较简单,消耗资源小,用户访问量也比较少,我们只部署一个Tomcat应用就可以满足需求。系统架构图如下:
            一个Tomcat可以看作是一个JVM进程,当大量的请求并发到达系统时,所有的请求都落在这唯一的一个Tomcat上,如果某些请求方法是需要加锁的,比如:秒杀扣减库存,是可以满足需求的,这和我们前面章节所讲的内容是一样的。但是随着访问量的增加,导致一个Tomcat难以支撑,这时我们就要集群部署Tomcat,使用多个Tomcat共同支撑整个系统
            上图中,我们部署了两个Tomcat,共同支撑系统。当一个请求到达系统时,首先会经过Nginx,Nginx主要是做负载转发的,它会根据自己配置的负载均衡策略将请求转发到其中的一个Tomcat中。当大量的请求并发访问时,两个Tomcat共同承担所有的访问量,这时,我们同样在秒杀扣减库存的场景中,使用单体应用锁,还能够满足要求吗?
    3、单体应用锁的局限性
            如上图所示,在整个系统架构中,存在两个Tomcat,每个Tomcat是一个JVM。在进行秒杀业务的时候,由于大家都在抢购秒杀商品,大量的请求同时到达系统,通过Nginx分发到两个Tomcat上。我们通过一个极端的案例场景,可以更好地理解单体应用锁的局限性。假如,秒杀商品的数量只有1个,这时,这些大量的请求当中,只有一个请求可以成功的抢到这个商品,这就需要在扣减库存的方法上加锁,扣减库存的动作只能一个一个去执行,而不能同时去执行,如果同时执行,这1个商品可能同时被多个人抢到,从而产生超卖现象。加锁之后,扣减库存的动作一个一个去执行,凡是将库存扣减为负数的,都抛出异常,提示该用户没有抢到商品。通过加锁看似解决了秒杀的问题,但是事实上真的是这样吗?
            我们看到系统中存在两个Tomcat,我们加的锁是JDK提供的锁,这种锁只能在一个JVM下起作用,也就是             在一个Tomcat内是没有问题的。当存在两个或两个以上的Tomcat时,大量的并发请求分散到不同的Tomcat上,在每一个Tomcat中都可以防止并发的产生,但是在多个Tomcat之间,每个Tomcat中获得锁的这个请求,又产生了并发,从而产生超卖现象。这也就是单体应用锁的局限性,它只能在一个JVM内加锁,而不能从这个应用层面去加锁。
    那么这个问题如何解决呢?这就需要使用分布式锁了,在整个应用层面去加锁。什么是分布式锁呢?我们怎么去使用分布式锁呢?
    4、什么是分布式锁
            在说分布式锁之前,我们看一看单体应用锁的特点,单体应用锁是在一个JVM进程内有效,无法跨JVM、跨进程。那么分布式锁的定义就出来了,分布式锁就是可以跨越多个JVM、跨越多个进程的锁,这种锁就叫做分布式锁。
    5、分布式锁的设计思路
            在上图中,由于Tomcat是由Java启动的,所以每个Tomcat可以看成一个JVM,JVM内部的锁是无法跨越多个进程的。所以,我们要实现分布式锁,我们只能在这些JVM之外去寻找,通过其他的组件来实现分布式锁。系统的架构如图所示:

            两个Tomcat通过第三方的组件实现跨JVM、跨进程的分布式锁。这就是分布式锁的解决思路,找到所有JVM可以共同访问的第三方组件,通过第三方组件实现分布式锁。
    6、目前存在的分布式的方案
    分布式锁都是通过第三方组件来实现的,目前比较流行的分布式锁的解决方案有:
    • 数据库,通过数据库可以实现分布式锁,但是在高并发的情况下对数据库压力较大,所以很少使用。
    • Redis,借助Redis也可以实现分布式锁,而且Redis的Java客户端种类很多,使用的方法也不尽相同。
    • Zookeeper,Zookeeper也可以实现分布式锁,同样Zookeeper也存在多个Java客户端,使用方法也不相同。

    二、电商平台中针对超卖的解决思路

    ① 单体架构下针对超卖的解决方案

    1、超卖现象一
    什么是超卖:某件商品库存数量10件,结果卖出了15件。

     

    A和B同时下单,同时读到库存,同时减库存,同时更新数据库,这是库存减1,结果却下单了两件。
    解决办法:
    扣减库存不在程序中进行,同时通过数据库解决;向数据库传递库存增量,扣减1个库存,增量为-1;在数据库update语句计算库存,通过update行锁解决并发问题
    2、超卖现象二
    使用上述通过数据库行锁解决,会出现下面的第二种现象:数据库的库存会减为负数。

     

    解决方案一
    更新库存成功后,再次检索商品库存,如果商品为负数,抛出异常。
    解决方案二
    添加锁、将数据库库存校验和数据库库存更新绑定到一起加上锁,每次只能有一个得到这个锁,从而避免库存被减为负数(-1)的情况。

    3、具体代码实现

    • 创建数据库表
    CREATE DATABASE /*!32312 IF NOT EXISTS*/`distribute` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
    USE `distribute`;
    DROP TABLE IF EXISTS `order`;
    CREATE TABLE `order` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `order_status` int(1) NOT NULL,
      `receiver_name` varchar(255) NOT NULL,
      `receiver_mobile` varchar(11) NOT NULL,
      `order_amount` decimal(11,0) NOT NULL,
      `create_time` time NOT NULL,
      `create_user` varchar(255) NOT NULL,
      `update_time` time NOT NULL,
      `update_user` varchar(255) NOT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4;
    DROP TABLE IF EXISTS `order_item`;
    CREATE TABLE `order_item` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `order_id` int(11) NOT NULL,
      `product_id` int(11) NOT NULL,
      `purchase_price` decimal(11,0) NOT NULL,
      `purchase_num` int(3) NOT NULL,
      `create_time` time NOT NULL,
      `create_user` varchar(255) NOT NULL,
      `update_time` time NOT NULL,
      `update_user` varchar(255) NOT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4;
    DROP TABLE IF EXISTS `product`;
    CREATE TABLE `product` (
      `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'id',
      `product_name` varchar(255) NOT NULL COMMENT '商品名称',
      `price` decimal(11,0) NOT NULL COMMENT '价格',
      `count` int(5) NOT NULL COMMENT '库存',
      `product_desc` varchar(255) NOT NULL COMMENT '描述',
      `create_time` time NOT NULL COMMENT '创建时间',
      `create_user` varchar(255) NOT NULL COMMENT '创建人',
      `update_time` time NOT NULL COMMENT '更新时间',
      `update_user` varchar(255) NOT NULL COMMENT '更新人',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=100101 DEFAULT CHARSET=utf8mb4;
    insert  into `product`(`id`,`product_name`,`price`,`count`,`product_desc`,`create_time`,`create_user`,`update_time`,`update_user`) values (100100,'测试商品','1',1,'测试商品','18:06:00','周红','19:19:21','xxx');
    /**后续分布式锁需要用到**/
    DROP TABLE IF EXISTS `distribute_lock`;
    CREATE TABLE `distribute_lock` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `business_code` varchar(255) NOT NULL COMMENT '根据业务代码区分,不同业务使用不同锁',
      `business_name` varchar(255) NOT NULL COMMENT '注释,标记编码用途',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
    insert  into `distribute_lock`(`id`,`business_code`,`business_name`) values (1,'demo','test');
    • 订单创建,库存减1等主要逻辑代码(这里使用 ReentrantLock 当然,也可以使用其他锁 )
    // 注意:这边不能使用注解的方式回滚,不然会在事务提交前下一个线程会进来
    //    @Transactional(rollbackFor = Exception.class)
        public Integer createOrder() throws Exception{
            Product product = null;
            lock.lock();
            try {
                // 开启事务
                TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);
                // 查询到所要购买的商品
                product = productMapper.selectByPrimaryKey(purchaseProductId);
                if (product==null){
                    platformTransactionManager.rollback(transaction1);
                    throw new Exception("购买商品:"+purchaseProductId+"不存在");
                }
                // 获取商品当前库存
                Integer currentCount = product.getCount();
                System.out.println(Thread.currentThread().getName()+"库存数:"+currentCount);
                // 校验库存 (购买商品数量大于库存数量,抛出异常)
                if (purchaseProductNum > currentCount){
                    platformTransactionManager.rollback(transaction1);
                    throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");
                }
                productMapper.updateProductCount(purchaseProductNum,"xxx",new Date(),product.getId());
                platformTransactionManager.commit(transaction1);
            }finally {
                lock.unlock();
            }
            // 创建订单
            TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition);
            Order order = new Order();
            order.setOrderAmount(product.getPrice().multiply(new BigDecimal(purchaseProductNum)));
            order.setOrderStatus(1);//待处理
            order.setReceiverName("xxx");
            order.setReceiverMobile("15287653421");
            order.setCreateTime(new Date());
            order.setCreateUser("不不不不");
            order.setUpdateTime(new Date());
            order.setUpdateUser("哈哈哈哈");
            orderMapper.insertSelective(order);
            // 创建订单明细
            OrderItem orderItem = new OrderItem();
            orderItem.setOrderId(order.getId());
            orderItem.setProductId(product.getId());
            orderItem.setPurchasePrice(product.getPrice());
            orderItem.setPurchaseNum(purchaseProductNum);
            orderItem.setCreateUser("不不不");
            orderItem.setCreateTime(new Date());
            orderItem.setUpdateTime(new Date());
            orderItem.setUpdateUser("哈哈哈哈");
            orderItemMapper.insertSelective(orderItem);
            // 事务提交
            platformTransactionManager.commit(transaction);
            return order.getId();
        }
    • 测试(使用五个线程同时并发的下单)
    @Test
    public void concurrentOrder() throws InterruptedException {
        Thread.sleep(60000);
        CountDownLatch cdl = new CountDownLatch(5);
        CyclicBarrier cyclicBarrier = new CyclicBarrier(5);
        // 创建5个线程执行下订单操作
        ExecutorService es = Executors.newFixedThreadPool(5);
        for (int i =0;i<5;i++){
            es.execute(()->{
                try {
                    // 等5个线程同时达到 await()时再执行创建订单服务,这时候5个线程会堆积到同一时间执行
                    cyclicBarrier.await();
                    Integer orderId = orderService.createOrder();
                    System.out.println("订单id:"+orderId);
                } catch (Exception e) {
                    e.printStackTrace();
                }finally {
                    // 每个线程执行完成之后会减一
                    cdl.countDown();
                }
            });
        }
        cdl.await();
        es.shutdown();
    }

    ② 分布式架构下分布式锁的实现

    一、基于数据库实现分布式锁

    多个进程、多个线程访问共同组件---数据库
    通过select...for update 访问同一条数据、for update 锁定数据
    • 在mapper.xml 里面加入如下自定义的SQL
    <select id="selectDistributeLock" resultType="com.example.distributelock.model.DistributeLock">
      select * from distribute_lock
      where business_code = #{businessCode,jdbcType=VARCHAR}
      for update
    </select>
    • 主要的逻辑实现
    @RequestMapping("singleLock")
    /**
     * 没有添加 Transactional 注解前,查询分布式锁和sleep二者不是原子操作,在获取到分布式锁后自动提交事务,
     * 故不会阻止第二个请求获取锁。添加了注解后,在sleep结束前,事务一直未提交,故会等待sleep结束后再行提交事务,
     * 此时第二个请求才能从数据库中获取分布式锁
     */
    @Transactional(rollbackFor = Exception.class)
    public String singleLock() throws Exception {
        log.info("我进入了方法!");
        DistributeLock distributeLock = distributeLockMapper.selectDistributeLock("demo");
        if (distributeLock==null) throw new Exception("分布式锁找不到");
        log.info("我进入了锁!");
        try {
            Thread.sleep(20000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "我已经执行完成!";
    }
    • 另一个项目和这个相同,只需要更改端口号即可
    优点:
    • 简单方便,易于理解,易于操作
    缺点:
    • 并发量大,对数据库压力较大
    建议:
    • 作为锁的数据库与业务数据库分开

    二、基于Redis的SetNX实现分布式锁

    ① 获取锁的Redis命令
    • Set resource_name my_random_value NX PX 30000
    • resource_name:资源名称,可根据不同的业务区分不同的锁
    • my_random_value:随机值,每个线程的随机值都不相同,用于释放锁时的校验
    • NX: key不存在是设置成功,key存在则设置不成功
    • PX:自动失效时间,出现异常情况,锁可以过期失效
    ② 实现原理
    • 利用NX的原子性,多个线程并发时,只有一个线程可以设置成功
    • 设置成功即获得锁,可以执行后续的业务处理
    • 如果出现异常,过了锁的有效期,锁自动释放。
    • 释放锁采用了Redis的delete命令
    • 释放锁时校验值钱设置的随机数,相同才能释放
    • 释放锁的LUA脚本
    if redis.call("get",KEYS[1])==argv[1] then 
        return redis.call("del",KEYS[1])
    else
        return 0
    end
    ③ 为什么要添加LUA脚本校验:
    没有校验可能导致锁的混乱,如上图所示:A可能释放掉了B的锁,会出现问题。
    ④ Redis分布式锁关键代码封装
    package com.example.distributelock.lock;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.data.redis.connection.RedisStringCommands;
    import org.springframework.data.redis.core.RedisCallback;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.data.redis.core.script.RedisScript;
    import org.springframework.data.redis.core.types.Expiration;
    import java.util.Arrays;
    import java.util.List;
    import java.util.UUID;
    @Slf4j
    public class RedisLock implements AutoCloseable {
        private RedisTemplate redisTemplate;
        private String key;
        private String value;
        // 过期时间 单位:秒
        private int expireTime;
        public RedisLock(RedisTemplate redisTemplate,String key,int expireTime){
            this.redisTemplate = redisTemplate;
            this.key = key;
            this.expireTime=expireTime;
            this.value = UUID.randomUUID().toString();
        }
        /**
         * 获取分布式锁
         * @return
         */
        public boolean getLock(){
            RedisCallback<Boolean> redisCallback = connection -> {
                //设置NX
                RedisStringCommands.SetOption setOption = RedisStringCommands.SetOption.ifAbsent();
                //设置过期时间
                Expiration expiration = Expiration.seconds(expireTime);
                //序列化key
                byte[] redisKey = redisTemplate.getKeySerializer().serialize(key);
                //序列化value
                byte[] redisValue = redisTemplate.getValueSerializer().serialize(value);
                //执行setnx操作
                Boolean result = connection.set(redisKey, redisValue, expiration, setOption);
                return result;
            };
            //获取分布式锁
            Boolean lock = (Boolean)redisTemplate.execute(redisCallback);
            return lock;
        }
        // 释放锁
        public boolean unLock() {
            String script = "if redis.call("get",KEYS[1]) == ARGV[1] then
    " +
                    "    return redis.call("del",KEYS[1])
    " +
                    "else
    " +
                    "    return 0
    " +
                    "end";
            RedisScript<Boolean> redisScript = RedisScript.of(script,Boolean.class);
            List<String> keys = Arrays.asList(key);
    
            Boolean result = (Boolean)redisTemplate.execute(redisScript, keys, value);
            log.info("释放锁的结果:"+result);
            return result;
        }
        @Override
        public void close() throws Exception {
            unLock();
        }
    }
    ⑤ 测试
    @RequestMapping("redisLock")
    public String redisLock(){
        log.info("我进入了方法!");
        try (RedisLock redisLock = new RedisLock(redisTemplate,"redisKey",30)){
            if (redisLock.getLock()) {
                log.info("我进入了锁!!");
                Thread.sleep(15000);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        }
        log.info("方法执行完成");
        return "方法执行完成";
    }
    ⑥ 在项目中使用ESJOB定时任务没问题,但是如果项目中使用了SpringTask来定时,那么在集群中可能会出现任务重复执行的情况。
    解决办法:使用分布式锁在定时到达后,在执行任务之前,哪个节点获取到了锁,哪个节点就来执行这个任务。
    ⑦ 代码实现
    public class SchedulerService {
        @Autowired
        private RedisTemplate redisTemplate;
        @Scheduled(cron = "0/5 * * * * ?")
        public void sendSms(){
            try(RedisLock redisLock = new RedisLock(redisTemplate,"autoSms",30)) {
                if (redisLock.getLock()){
                    log.info("每五秒执行这个程序!");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    三、基于Zookeeper实现分布式锁

    zookeeper的观察器
    1. 可设置观察器的3个方法:getData();getChildren();exists();
    2. 节点数据发生变化,发送给客户端;
    3. 观察器只能监控一次,再监控需重新设置;
    实现原理

    1. 利用zookeeper的瞬时有序节点的特性;
    2. 多线程并发创建瞬时节点时,得到有序的序列;
    3. 序列号最小的线程获得锁;
    4. 其他线程监听自己序号的前一个序号;
    5. 前一个线程执行完成,删除自己序号节点;
    6. 下一个序号的线程得到通知,继续执行;
    7. 以此类推,创建节点时,已经确定了线程的执行顺序;
    代码实现:
    package com.example.distributelock.lock;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.zookeeper.*;
    import org.apache.zookeeper.data.Stat;
    
    import java.io.IOException;
    import java.util.Collections;
    import java.util.List;
    @Slf4j
    public class ZkLock implements Watcher,AutoCloseable {
        private ZooKeeper zooKeeper;
        private String businessName;
        private String znode;
        public ZkLock(String connectString,String businessName) throws IOException {
            this.zooKeeper = new ZooKeeper(connectString,30000,this);
            this.businessName = businessName;
        }
        public boolean getLock() throws KeeperException, InterruptedException {
            Stat existsNode = zooKeeper.exists("/" + businessName, false);
            if (existsNode == null){
                zooKeeper.create("/" + businessName,businessName.getBytes(),
                        ZooDefs.Ids.OPEN_ACL_UNSAFE,
                        CreateMode.PERSISTENT);
            }
            znode = zooKeeper.create("/" + businessName + "/" + businessName + "_", businessName.getBytes(),
                    ZooDefs.Ids.OPEN_ACL_UNSAFE,
                    CreateMode.EPHEMERAL_SEQUENTIAL);
            znode = znode.substring(znode.lastIndexOf("/")+1);
            List<String> childrenNodes = zooKeeper.getChildren("/" + businessName, false);
            Collections.sort(childrenNodes);
            String firstNode = childrenNodes.get(0);
            if (!firstNode.equals(znode)){
                String lastNode = firstNode;
                for (String node:childrenNodes){
                    if (!znode.equals(node)){
                        lastNode = node;
                    }else {
                        zooKeeper.exists("/"+businessName+"/"+lastNode,true);
                        break;
                    }
                }
                synchronized (this){
                    wait();
                }
            }
            return true;
        }
        @Override
        public void process(WatchedEvent watchedEvent) {
            if (watchedEvent.getType() == Event.EventType.NodeDeleted){
                synchronized (this){
                    notify();
                }
            }
        }
        @Override
        public void close() throws Exception {
            zooKeeper.delete("/"+businessName+"/"+znode,-1);
            zooKeeper.close();
            log.info("我释放了锁");
        }
    }
    测试:
    @RequestMapping("zkLock")
        public String zkLock(){
            log.info("我进入了方法!");
            try (ZkLock zkLock = new ZkLock("localhost:2181","order")){
                if (zkLock.getLock()) {
                    log.info("我进入了锁!!");
                    Thread.sleep(15000);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (Exception e) {
                e.printStackTrace();
            }
            log.info("方法执行完成");
            return "方法执行完成";
        }
     

    输了不可怕,大不了从头再来,我们还年轻---周红

  • 相关阅读:
    eclipse导入web项目报错 Cannot find the class file for javax.servlet.ServletContext.
    项目检出JRE问题(Unbound classpath container: 'JRE System Library [JavaSE-1.7]' in project 'idweb')
    eclipse添加web项目报错“Target runtime Apache Tomcat v7.0 is not defined.”
    Mysql(Navicat for Mysql)怎么添加数据库
    Jmeter线程ramp-up period (in seconds)如何取值
    JMeter基础使用方法
    loadrunner中回放log看不到参数替代后具体数值
    Luogu P2490「JSOI2016」黑白棋
    LOJ #2026「JLOI / SHOI2016」成绩比较
    LOJ#2127「HAOI2015」按位或
  • 原文地址:https://www.cnblogs.com/Tom-shushu/p/14547047.html
Copyright © 2011-2022 走看看