• 四、分布式锁之自定义分布式锁


    1、基本原理和实现方式对比

    分布式锁:满足分布式系统或集群模式下多个进程可见并且互斥的锁。分布式锁的核心思想就是多线程都使用同一把锁,实现程序串行执行。
    1653374296906.png
    分布式锁需要具备的条件:
    1653381992018.png

    特性含义
    可见性多个线程都能感知到变化
    互斥性分布式锁的最基本的特性,让程序串行执行
    高可用程序不易崩溃,时刻保证较高的可用性
    高性能要求分布式锁具备较高的加锁和释放锁性能
    安全性要求分布式锁具备一定的安全性

    常见的分布式锁有三种:
    Mysql: mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
    Redis: redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
    Zookeeper: zookeeper也是企业级开发中较好的一个实现分布式锁的方案,这里不过多阐述。
    1653382219377.png

    2、Redis分布式锁实现的核心思路

    实现分布式锁需要实现的两个基本方法:

    • 获取锁
      • 互斥:只能有一个线程成功获取到锁
      • 非阻塞:尝试获取一次,成功返回true,失败返回false
    • 释放锁
      • 手动释放
      • 超时释放:避免服务宕机导致出现死锁

    核心思路:利用redis的setnx特性实现锁的互斥。当第一个线程setnx返回1,代表它获取锁成功,可以执行业务,然后释放锁;其他线程则等待一段时间后进行重试。
    image.png

    3、实现分布式锁 V1.0

    • 锁对象接口
    public interface ILock {
    
        /**
       * 尝试获取锁
       * @param timeoutSec 超时时间(秒)
       * @return
       */
        boolean tryLock(long timeoutSec);
    
        /**
       * 释放锁
       */
        void unlock();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 锁对象实现类
    public class SimpleRedisLock implements ILock {
    
        private StringRedisTemplate stringRedisTemplate;
    
        // 锁的名字(一般与当前业务模块相关)
        private String name;
        private String LOCK_PREFIX = "lock:";
    
        public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
            this.stringRedisTemplate = stringRedisTemplate;
            this.name = name;
        }
        @Override
        public boolean tryLock(long timeoutSec) {
            // value建议设置当前线程的id
            long threadId = Thread.currentThread().getId();
            Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
            // 不要直接返回success,自充拆箱可能会出现空指针异常
            return BooleanUtil.isTrue(success);
        }
    
        @Override
        public void unlock() {
            stringRedisTemplate.delete(LOCK_PREFIX + name);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 业务类-VoucherOrderServiceImpl

    核心代码:

    // 使用分布式锁实现一人一单
    SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
    // 尝试获取锁
    boolean isLock = lock.tryLock(1200);
    if (!isLock) {
        return Result.fail("不允许重复下单");
    }
    try {
        return oneUserAndOrder(voucherId);
    } finally {
        lock.unlock();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    /**
     * 一人一单
     *
     * @param voucherId
     * @return
     */
    @Transactional
    /*
        1、将锁放在方法体上,那么这个方法就是一个同步方法,只有一个线程能够进入,会导致性能问题
     */
    public /*synchronized */Result oneUserAndOrder(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        /*
            2、将锁放在方法体内存在的问题:方法执行完毕后,锁会被释放,但事务是由Spring管理的
            此时,事务还未提交,锁就被释放了,下一个进程进来,仍会出现线程安全问题
         */
    //        synchronized (userId.toString().intern()){
        // 保证一人一单
        Integer count = query().eq("voucher_id", voucherId)
                .eq("user_id", userId).count();
        if (count > 0) {
            return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
        }
    
        // 扣减库存,添加乐观锁
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                // 这种方式反而会增加下单的失败率
    //                .eq("stock", voucher.getStock())
                // 只要我库存还大于0,就允许用户继续下单
                .gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("秒杀券已售罄");
        }
        // 生成订单
        VoucherOrder order = new VoucherOrder();
        long orderID = redisIdWorker.nextId("order");
        order.setId(orderID);
        order.setVoucherId(voucherId);
        order.setUserId(UserHolder.getUser().getId());
        save(order);
        return Result.ok(orderID);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 单元测试

    image.png
    image.png

    可以发现,集群模式下,两个线程同时争抢锁,只有一个线程成功获取到锁,实现了分布式锁的互斥!

    4、分布式锁误删问题

    4.1、误删问题

    现考虑一种在分布式锁情况下仍会导致线程安全问题的极端情况:

    1. 线程1获取锁,获取成功,但因业务阻塞问题,导致分布式锁的TTL过期,锁失效
    2. 线程2获取锁,获取成功。
    3. 线程1执行完业务,释放锁,也就是把线程2的锁给释放掉了。
    4. 线程3获取锁,获取成功。
    5. 线程2执行完业务,释放锁,也就是释放了线程3的锁
    6. 线程3执行完业务,执行释放锁。

    这种情况下,线程2和线程3存在线程安全问题。
    导致该问题出现的本质原因在于线程在去释放锁的时候,不加判断,都不看这锁是不是自己的就给人家释放了。

    4.2、解决方案

    分布式锁会被误删的关键是redis再去删除数据的时候,没有做判断,当前线程没有判断在redis中存储的锁是不是自己的那把锁就直接给删掉了。
    解决方案:给锁添加唯一标识(UUID),删除前做一次查询,判断是不是自己的那把锁,如果是,再做删除操作。

    • 核心代码更新

    获取锁
    image.png
    删除锁
    image.png

    • 测试

    准备两个线程
    image.png
    线程1成功获取锁
    image.png
    image.png
    通过手动删除锁,模拟线程1因业务阻塞导致锁过期被删除
    线程2成功获取锁
    image.png
    线程1执行完业务,删除锁
    image.png
    线程2执行完业务,删除锁
    image.png

    至此,就避免了分布式锁误删的问题!

    5、分布式锁的原子性问题

    5.1、原子性问题

    目前仍存在一种更为极端的情况会导致分布式锁误删问题

    1. 线程1正常获取锁,执行业务逻辑,执行完毕准备删除锁
    2. 经过判断的确是自己的锁,此事发生线程阻塞等意外导致分布式锁TTL到期
    3. 线程2进入,获取到锁
    4. 切回到线程1,由于之前已经判断过是自己的锁了,直接执行释放锁操作

    由此造成了分布式锁的误删问题
    造成该问题出现的本质原因是:释放锁的查询判断和删除操作不具备原子性

    5.2、通过Lua脚本解决原子性问题

    Lua 是一种轻量级的编程语言,具有简洁的语法和强大的功能。它是一种动态类型的语言,支持函数式编程和面向对象编程。Lua 是一种嵌入式脚本语言,可以轻松地集成到其他应用程序中。
    Redis提供了对Lua的支持实现
    Spring提供了调用Lua脚本的API
    基于这些特性,保证分布式锁删除操作原子性的实现思路:

    1. 将锁查询及删除操作写入到Lua脚本;
    2. 通过Spring调用编写好的Lua脚本

    由于在Java中只有调用Lua脚本这一行操作语句,从而保证了原子性

    • unlock.lua
    if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
    
    • 1
    • 释放锁核心代码
    public class SimpleRedisLock implements ILock {
    
        private StringRedisTemplate stringRedisTemplate;
    
        // 锁的名字(一般与当前业务模块相关)
        private String name;
        private String LOCK_PREFIX = "lock:";
        final String uniqueStr = UUID.randomUUID().toString(true) + "-";
    
        private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    
        static {
            UNLOCK_SCRIPT = new DefaultRedisScript<>();
            UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
            UNLOCK_SCRIPT.setResultType(Long.class);
        }
    
        public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
            this.stringRedisTemplate = stringRedisTemplate;
            this.name = name;
        }
    
        @Override
        public boolean tryLock(long timeoutSec) {
            // value建议设置当前线程的id
            long threadId = Thread.currentThread().getId();
            Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, uniqueStr + threadId, timeoutSec, TimeUnit.SECONDS);
            // 不要直接返回success,自充拆箱可能会出现空指针异常
            return BooleanUtil.isTrue(success);
        }
    
        /**
         * 通过Lua脚本释放锁,保证操作的原子性
         */
        @Override
        public void unlock() {
            stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(LOCK_PREFIX + name), uniqueStr + Thread.currentThread().getId());
        }
    
    
    //    @Override
    //    public void unlock() {
    //        // 查询当前线程的锁
    //        String lock = stringRedisTemplate.opsForValue().get(LOCK_PREFIX + name);
    //        // 如果当前线程的锁是自己的,才能删除
    //        if (lock != null && lock.equals(uniqueStr + Thread.currentThread().getId())){
    //            stringRedisTemplate.delete(LOCK_PREFIX + name);
    //        }
    //    }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    至此,解决了因操作原子性而造成的分布式锁误删问题

  • 相关阅读:
    0-1背包问题(回溯法c++详解)
    Android---动态权限适配问题
    人工智能数学课高等数学线性微积分数学教程笔记(7. 最优化)
    技术学习群-第二周内容共享
    C++基础——类的六大特殊成员函数讲解2
    事务回调编程
    搭建大型分布式服务(四十)SpringBoot 整合多个kafka数据源-支持生产者
    嵌入式-面试-八股文
    【性能测试】Jenkins+Ant+Jmeter自动化框架的搭建思路
    Centos7 部署Jenkins
  • 原文地址:https://blog.csdn.net/weixin_45284646/article/details/136780678