2、基于redis实现分布式锁

2.1. 基本实现

借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。同时有多个客户端发送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)。
在这里插入图片描述

  1. 多个客户端同时获取锁(setnx)
  2. 获取成功,执行业务逻辑,执行完成释放锁(del)
  3. 其他客户端等待重试

改造StockService方法:

 /**
     * redis setnx实现分布式锁,最基本的哪一种 !!!
     */
    public void deduct() {
        // 加锁setnx
        Boolean lock = this.redisTemplate.opsForValue().setIfAbsent("lock", "111");
        if (!lock) {
            // 没有获取到锁,进行重试!!
            try {
                Thread.sleep(50);
                this.deduct();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } else {
            try {
                // 1. 查询库存信息
                String stockStr = redisTemplate.opsForValue().get("stock:" + "1001");
                // 2. 判断库存是否充足
                if (stockStr != null && stockStr.length() != 0) {
                    Long stock = Long.parseLong(stockStr);
                    if (stock > 0) {
                        redisTemplate.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
                    }
                }
            } finally {
                // 解锁
                this.redisTemplate.delete("lock");
            }
        }
    }

使用 jmeter 进行压测
在这里插入图片描述
查看库存数量
在这里插入图片描述

上述代码优化,不断重试的过程中一直进行递归,最终导致栈的溢出。

解决


    /**
     *  while循环代替递归,解决不断重试可能导致的栈溢出的问题
     */
    public void deduct() {
        // 加锁setnx
        while (this.redisTemplate.opsForValue().setIfAbsent("lock1", "1")) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            try {
                // 1. 查询库存信息
                String stockStr = redisTemplate.opsForValue().get("stock:" + "1001");
                // 2. 判断库存是否充足
                if (stockStr != null && stockStr.length() != 0) {
                    Long stock = Long.parseLong(stockStr);
                    if (stock > 0) {
                        redisTemplate.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
                    }
                }
            } finally {
                // 解锁
                this.redisTemplate.delete("lock1");
            }
        }
    }

2.2. 防死锁

问题:setnx刚刚获取到锁,当前服务器宕机,导致del释放锁无法执行,进而导致锁无法锁无法释放(死锁)
解决:给锁设置过期时间,自动释放锁。

设置过期时间两种方式:

  • 通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)
  • 使用set指令设置过期时间:set key value ex 3 nx(既达到setnx的效果,又设置了过期时间)

在这里插入图片描述

2.3. 防误删

持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明

解决: setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁
在这里插入图片描述

问题:删除操作缺乏原子性。
场景:

  1. index1执行删除时,查询到的lock值确实和uuid相等
  2. index1执行删除前,lock刚好过期时间已到,被redis自动释放
  3. index2获取了lock
  4. index1执行删除,此时会把index2的lock删除

解决方案:没有一个命令可以同时做到判断 + 删除,所有只能通过其他方式实现(LUA脚本

2.4. redis中的lua脚本

2.4.1 redis 并不能保证一组命令的原子性

redis采用单线程架构,可以保证单个命令的原子性,但是无法保证一组命令在高并发场景下的原子性

如果redis客户端通过lua脚本把3个命令一次性发送给redis服务器,那么这三个指令就不会被其他客户端指令打断。Redis 也保证脚本会以原子性的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。 这和使用 MULTI/ EXEC 包围的事务很类似。

2.4.2 lua介绍

Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

2.4.3. lua基本语法

a = 5               -- 全局变量
local b = 5         -- 局部变量, redis只支持局部变量
a, b = 10, 2*x      -- 等价于       a=10; b=2*x

流程控制:
if( 布尔表达式 1)
then
   --[ 在布尔表达式 1 为 true 时执行该语句块 --]
elseif( 布尔表达式 2)
then
   --[ 在布尔表达式 2 为 true 时执行该语句块 --]
else 
   --[ 如果以上布尔表达式都不为 true 则执行该语句块 --]
end

2.4.4. redis执行lua脚本 - EVAL指令

在redis中需要通过eval命令执行lua脚本。

格式:

EVAL script numkeys key [key ...] arg [arg ...]
script:lua脚本字符串,这段Lua脚本不需要(也不应该)定义函数。
numkeys:lua脚本中KEYS数组的大小
key [key ...]:KEYS数组中的元素
arg [arg ...]:ARGV数组中的元素

案例1:基本案例

EVAL "return 10" 0

输出:(integer) 10


案例2:动态传参
EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 5 10 20 30 40 50 60 70 80 90
# 输出:10 20 60 70

EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 10 20
# 输出:0

EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 20 10
# 输出:1

传入了两个参数10和20,KEYS的长度是1,所以KEYS中有一个元素10,剩余的一个20就是ARGV数组的元素。


案例3:执行redis类库方法
redis.call()中的redis是redis中提供的lua脚本类库,仅在redis环境中可以使用该类库

set aaa 10  -- 设置一个aaa值为10
EVAL "return redis.call('get', 'aaa')" 0
# 通过return把call方法返回给redis客户端,打印:"10"

注意:**脚本里使用的所有键都应该由 KEYS 数组来传递。**但并不是强制性的,代价是这样写出的脚本不能被 Redis 集群所兼容


案例4:给redis类库方法动态传参 ```shell EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 bbb 20 ``` ![在这里插入图片描述](https://img-blog.csdnimg.cn/e796a1bee9f34997a98481f32a019467.png) 学到这里基本可以应付redis分布式锁所需要的脚本知识了。

2.5. 使用lua保证删除原子性

删除LUA脚本:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

在这里插入图片描述

代码实现 StockService:

    /**
     *  解决锁的误删问题
     */
    public void deduct() {
        String uuid = UUID.randomUUID().toString();
        // 加锁setnx
        while (this.redisTemplate.opsForValue().setIfAbsent("lock1", uuid, 20,TimeUnit.SECONDS)) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            try {
                // 1. 查询库存信息
                String stockStr = redisTemplate.opsForValue().get("stock:" + "1001");
                // 2. 判断库存是否充足
                if (stockStr != null && stockStr.length() != 0) {
                    Long stock = Long.parseLong(stockStr);
                    if (stock > 0) {
                        redisTemplate.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
                    }
                }
            } finally {
                // 判断锁是不是被当前线程所持有,是的话,则删除锁
                /*if(uuid.equals(redisTemplate.opsForValue().get("lock1"))) {
                    // 解锁
                    this.redisTemplate.delete("lock1");
                }*/

                // 通过lua脚本来释放锁
                String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                        "then " +
                        "   return redis.call('del', KEYS[1]) " +
                        "else " +
                        "   return 0 " +
                        "end";
                this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList("lock"), uuid);
            }
        }
    }

2.6. 可重入锁

由于上述加锁命令使用了 SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加锁,将会加锁失败。

用一段 Java 代码解释可重入:

public synchronized void a() {
    b();
}

public synchronized void b() {
    // pass
}

假设 X 线程在 a 方法获取锁之后,继续执行 b 方法,如果此时不可重入,线程就必须等待锁释放,再次争抢锁。
锁明明是被 X 线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己~

可重入性就可以解决这个尴尬的问题,当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法辑。退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。

2.6.1. 加锁脚本

参照 ReentrantLock 中的非公平可重入锁实现分布式可重入锁: hash + lua脚本

加锁:
1、判断锁是否存在(exists), 不存在则直接获取锁,hset key filed value
2、如果锁存在则判断是否是自己的锁(hexists)
如果是自己的锁则重入:hincrby key field increment
如果不是自己的锁,则重试

// 当锁不存在 或者 持有锁的线程是当前线程时
if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) 
then
    redis.call('hincrby', KEYS[1], ARGV[1], 1);
    redis.call('expire', KEYS[1], ARGV[2]);
    return 1;
else
	return 0;
end

2.6.2. 解锁脚本

解锁
1、判断自己的锁是否存在,不存在则返回nil
2、如果自己的锁存在,则减1(hincrby -1 ),判断减1后的值是否为0
为0则del key并且返回1,表示解锁成功
不为0,则返回0表示解锁失败

if(redis.call('hexists', KEYS[1], ARGV[1]) == 0) then 
    return nil; 
elseif(redis.call('hincrby', KEYS[1], ARGV[1], -1) > 0) then 
    return 0; 
else 
    redis.call('del', KEYS[1]); 
    return 1; 
end;

在这里插入图片描述

2.6.3. 代码实现

由于加解锁代码量相对较多,这里可以封装成一个工具类:
在这里插入图片描述

// 生成锁的工厂
@Component
public class DistributedLockClient {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private String uuid;

    public DistributedLockClient() {
        this.uuid = UUID.randomUUID().toString();
    }

    public DistributedRedisLock getRedisLock(String lockName){
        return new DistributedRedisLock(redisTemplate, lockName, uuid);
    }
}

// 锁具体实现
public class DistributedRedisLock implements Lock {

    private StringRedisTemplate redisTemplate;

    private String lockName;

    private String uuid;

    private long expire = 30;

    public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid = uuid;
    }

    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        try {
            return this.tryLock(-1L, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit timeUnit) throws InterruptedException {
        if (time != -1){
            this.expire = timeUnit.toSeconds(time);
        }
        String script = "if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) " +
                "then" +
                "    redis.call('hincrby', KEYS[1], ARGV[1], 1);" +
                "    redis.call('expire', KEYS[1], ARGV[2]);" +
                "    return 1;" +
                "else" +
                "    return 0;" +
                "end";
        while (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), getId(), String.valueOf(expire))) {
            Thread.sleep(50);
        }
        return true;
    }

    @Override
    public void unlock() {
        String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
                "then " +
                "   return nil " +
                "elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
                "then " +
                "   return redis.call('del', KEYS[1]) " +
                "else " +
                "   return 0 " +
                "end";
        Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), getId());
        if (flag == null) {
            throw new IllegalMonitorStateException("this lock doesn't belong to you!");
        }
    }

    @Override
    public Condition newCondition() {
        return null;
    }


    /**
     * 给线程拼接唯一标识
     *
     * @return
     */
    String getId() {
        return uuid + ":" + Thread.currentThread().getId();
    }
}

2.6.4. 使用及测试

在业务代码中进行使用:

    public void deduct() {
        DistributedRedisLock lock = distributedLockClient.getRedisLock("lock");
        try {
            lock.lock();
            // 1. 查询库存信息
            String stockStr = redisTemplate.opsForValue().get("stock:" + "1001");
            // 2. 判断库存是否充足
            if (stockStr != null && stockStr.length() != 0) {
                Long stock = Long.parseLong(stockStr);
                if (stock > 0) {
                    redisTemplate.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
                }
            }
        } finally {
            lock.unlock();
        }
    }

测试可重入性:

  public void deduct() {
        DistributedRedisLock lock = distributedLockClient.getRedisLock("lock");
        try {
            lock.lock();
            // 1. 查询库存信息
            String stockStr = redisTemplate.opsForValue().get("stock:" + "1001");
            // 2. 判断库存是否充足
            if (stockStr != null && stockStr.length() != 0) {
                Long stock = Long.parseLong(stockStr);
                if (stock > 0) {
                    redisTemplate.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
                }
            }
            this.test();
        } finally {
            lock.unlock();
        }
    }

    private void test() {
        DistributedRedisLock lock = distributedLockClient.getRedisLock("lock");
        lock.lock();
        System.out.println("获取到可重入锁");
        System.out.println("执行其他业务代码......");
        lock.unlock();
    }

在这里插入图片描述

2.7. 自动续期

自动续期: 定时任务 (Time 定时器 ) + Lua脚本

判断自己的锁是否存在(hexists),如果存在则重置过期时间

if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then 
    redis.call('expire', KEYS[1], ARGV[2]); 
    return 1; 
else 
    return 0; 
end

DistributedRedisLock 实现

public class DistributedRedisLock implements Lock {

    private StringRedisTemplate redisTemplate;

    private String lockName;

    private String uuid;

    private long expire = 30;

    public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid = uuid + ":" + Thread.currentThread().getId();
    }

    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        try {
            return this.tryLock(-1L, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit timeUnit) throws InterruptedException {
        if (time != -1) {
            this.expire = timeUnit.toSeconds(time);
        }
        String script = "if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) " +
                "then" +
                "    redis.call('hincrby', KEYS[1], ARGV[1], 1);" +
                "    redis.call('expire', KEYS[1], ARGV[2]);" +
                "    return 1;" +
                "else" +
                "    return 0;" +
                "end";
        while (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expire))) {
            Thread.sleep(50);
        }
        // 加锁成功返回之前,开启自动续期
        this.renewExpire();
        return true;
    }

    @Override
    public void unlock() {
        String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
                "then " +
                "   return nil " +
                "elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
                "then " +
                "   return redis.call('del', KEYS[1]) " +
                "else " +
                "   return 0 " +
                "end";
        Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), uuid);
        if (flag == null) {
            throw new IllegalMonitorStateException("this lock doesn't belong to you!");
        }
    }

    @Override
    public Condition newCondition() {
        return null;
    }



    private void renewExpire() {
        String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "   return redis.call('expire', KEYS[1], ARGV[2]) " +
                "else " +
                "   return 0 " +
                "end";
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                if (redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expire))) {
                    // 当续期成功了,重新开启一个定时任务
                    renewExpire();
                }
            }
        }, this.expire * 1000 / 3);
    }
}

测试 ,改造stockService

    public void deduct() {
        DistributedRedisLock lock = distributedLockClient.getRedisLock("lock");
        try {
            lock.lock();
            // 1. 查询库存信息
            try {
                Thread.sleep(300000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            String stockStr = redisTemplate.opsForValue().get("stock:" + "1001");
            // 2. 判断库存是否充足
            if (stockStr != null && stockStr.length() != 0) {
                Long stock = Long.parseLong(stockStr);
                if (stock > 0) {
                    redisTemplate.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
                }
            }
            this.test();
        } finally {
            lock.unlock();
        }
    }

可以看到加锁 10s后,锁过期时间重置为30s
在这里插入图片描述
【注意】
锁的自动续期和锁的过期释放是不是冲突的?

锁的自动续期和锁的过期释放并不冲突,它们是为了解决不同场景下的问题而设计的。

锁的自动续期:这个机制主要用于处理持有锁的客户端在执行任务时可能因为某些原因导致任务执行时间超过预设值。通过自动续期,可以确保在客户端仍在执行任务时,其他客户端无法获取到该锁。这样就避免了多个客户端同时操作共享资源造成数据混乱或者错误。

锁的过期释放:这个机制主要用于处理持有锁的客户端异常退出(比如崩溃、断电等)导致无法正常释放锁。通过设置一个合理的过期时间,在发生异常情况后一段时间内其他客户端依然可以获取到该锁,并且进行相应操作。

总之,两者都是为了确保分布式系统中对共享资源访问互斥性以及容错性而设计,并不存在冲突关系。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值