分布式锁2:基于redis实现分布式锁 setnx+lua脚本

目录

一 redis实现分布式锁

1.1 原理

1.2 Lua脚本+hash结构实现

1.2.1 加锁命令

1.2.2 lua脚本实现加锁

1.2.3 lua脚本实现解锁

二 redis使用senx命令实现分布式锁

2.1 操作案例

2.2 测试

2.2.1.启动zk

2.2.2.启动redis

2.2.3.启动nginx

2.2.4.启动服务

2.2.5.jemterces

2.2.6.查看数据库

2.3 存在问题

2.3.1 死锁问题

2.3.2 防止误删除

2.3.3 加锁和解锁无法保证原子性

2.3.4 可重入性

2.3.5 自动续期

2.4 redis防止误删除专题

2.4.1 场景描述&解决办法

2.5 redis原子性专题 

2.5.1 描述

2.6 lua脚本专题

2.6.1 lua脚本

2.6.2 redis为何要使用lua脚本

2.6.3 lua脚本的操作案例

 2.7 可重入专题

2.7.1 可重入性描述

2.7.2 redis可重入性

2.7.3 redis的Hash数据结构和命令

2.7.4 redis使用lua脚本+hash结构

2.8 自动续期

2.8.1 问题场景描述

三 redis使用senx+lua脚本实现分布式锁

3.1 逻辑流程

3.2  操作步骤

3.3  测试

 3.2.1.启动zk

3.2.2.启动redis

3.2.3.启动nginx

3.2.4.启动服务

3.2.5.jemterces

3.2.6.查看数据库


一 redis实现分布式锁

1.1 原理

利用redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。同时有多个客户端发送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)。

1.2 Lua脚本+hash结构实现

1.2.1 加锁命令

1.2.2 lua脚本实现加锁

1.2.3 lua脚本实现解锁

二 redis使用senx命令实现分布式锁

2.1 操作案例

1.核心代码

 public void deductByRedis() {
         // 加锁setnx
        // 加锁,获取锁失败重试
        while (!this.stringRedisTemplate.opsForValue().setIfAbsent("ljf-lock", "111")){
            try {
                Thread.sleep(40);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //加锁成功
            try {
                // 先查询库存是否充足
                Stock stock = this.stockMapper.selectById(1L);
                // 再减库存
                if (stock != null && stock.getCount() > 0){
                    stock.setCount(stock.getCount() - 1);
                    this.stockMapper.updateById(stock);
                }
                else{
                    System.out.println("库存不足..........."+new Date());
                }
            } finally {
        // 解锁
                this.stringRedisTemplate.delete("ljf-lock");
            }
        }

2.controller修改调用service的方法

2.2 测试

2.2.1.启动zk

[root@localhost ]  cd  /root/export/apache-zookeeper-3.7.0-bin/bin

[root@localhost bin]# ./zkServer.sh start
ZooKeeper JMX enabled by default
Using config: /root/export/apache-zookeeper-3.7.0-bin/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED
[root@localhost bin]# ./zkCli.sh

2.2.2.启动redis

[root@localhost ~]# redis-server /myredis/redis.conf
[root@localhost ~]# redis-cli -a 123456 -p 6379
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.

2.2.3.启动nginx

2.2.4.启动服务

2.2.5.jemterces

1.配置

2.执行 

2.2.6.查看数据库

1.初始

2.测试后

2.3 存在问题

2.3.1 死锁问题

1.存在死锁问题:

redis客户端程序执行setnx命令获取到锁之后,客户端立马宕机导致del释放锁无法执行,进而导致锁无法释放(死锁)采用给锁添加过期时间:

===》解决办法:给锁设置过期时间,自动释放锁。

设置过期时间两种方式:

  1. 通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)

  2. 使用set指令设置过期时间:set key value ex 3 nx(既达到setnx的效果,又设置了过期时间)

  3. set key value ex 3 nx解释:只有在key不存在,进行设置值并设置超时时间。

2.3.2 防止误删除

防误删:可能会释放其它客户端请求的锁。  

场景:如果业务逻辑的执行时间是7s。执行流程如下

  1. index1业务逻辑没执行完,3秒后锁被自动释放。

  2. index2获取到锁,执行业务逻辑,3秒后锁被自动释放。

  3. index3获取到锁,执行业务逻辑

  4. index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁,导致index3的业务只执行1s就被别人释放。

  5. 最终等于没锁的情况。

===》解决办法:setnx获取锁时,设置一个指定的唯一值(例如:uuid+线程id);释放前获取这个值,判断是否自己的锁

2.3.3 加锁和解锁无法保证原子性

场景:

  1. index1执行删除时,查询到的lock值确实和uuid相等

  2. index1执行删除前,lock刚好过期时间已到,被redis自动释放

  3. index2获取了lock

  4. index1执行删除,此时会把index2的lock删除。

===》解决方案:lua脚本解决删除操作缺乏原子性问题。没有一个命令可以同时做到判断+删除,所有只能通过其他方式实现(LUA脚本Lua脚本保证释放锁,删除时保证【判断+删除的原子性。

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

2.3.4 可重入性

Redis 提供了 Hash (哈希表)这种可以存储键值对数据结构。所以我们可以使用 Redis Hash 存储的锁的重入次数,然后利用 lua 脚本判断逻辑。  

a)判断锁是否被占用(exists),如果没有被占用则直接获取锁(hset/hincrby)并设置过期时间(expire)

b)如果锁被占用,则判断是否当前线程占用的(hexists),如果是则重入(hincrby)并重置过期时间(expire)

c)否则获取锁失败,后面尝试进行重试。

2.3.5 自动续期

===》解决办法:Timer定时器 + lua脚本,判断锁是否自己的锁(hexists == 1),如果是自己的锁则执行expire重置过期时间。luna脚本:

2.对应方法

2.4 redis防止误删除专题

2.4.1 场景描述&解决办法

1.问题描述:可能会释放其他服务器的锁。

进程A加锁之后,扣减库存的时间超过设置的超时时间,这里设置的锁是10秒

在第10秒的时候由于时间到期了所以进程A设置的锁被Redis释放了(T5)    

刚好进程B请求进来了,加锁成功(T6)

进程A操作完成(扣减库存)之后,把进程B设置的锁给释放了

刚好进程C请求进来了,加锁成功

进程B操作完成之后,也把进程C设置的锁给释放了

以此类推…

2.解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁

2.5 redis原子性专题 

2.5.1 描述

1.问题:删除操作缺乏原子性。

场景:

index1执行删除时,查询到的lock值确实和uuid相等

index1执行删除前,lock刚好过期时间已到,被redis自动释放

index2获取了lock

index1执行删除,此时会把index2的lock删除。

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

1.可重入性:hash + lua脚本 保证删除的原子性。

2..自动续期:Timer定时器 + lua脚本

解锁:先判断再删除同时保证原子性:lua脚本

2.6 lua脚本专题

2.6.1 lua脚本

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

Redis支持Lua脚本执行机制,即Redis客户端可以使用Lua语言编写脚本,由Redis服务端解析并执行,支持多线程、锁机制、运行效率高等优势。

2.6.2 redis为何要使用lua脚本

Redis中的Lua脚本可以用来完成复杂的数据处理、业务逻辑、批量操作等操作。与普通的Redis命令相比,Lua脚本具有以下几个方面的优势:

1. 原子性:Lua脚本使用Redis的单线程模型执行,可以保证脚本中的多个操作在同一时间点执行,保证了原子性。

2. 批处理和事务:Redis支持将多个命令通过Lua脚本打包成一个事务,以便保证这些命令的原子性执行。与Redis事务相比,Lua脚本的批处理和事务更加灵活和高效

3. 自定义命令:Lua脚本可以通过定义自己的函数来实现自定义命令,可以将一些复杂的操作封装成一条简单的命令,便于使用和维护。

4.redis使用Lua脚本可以提高Redis的性能和可扩展性,提供用户更加灵活方便的编程接口和安全保障。

https://www.dbs724.com/303512.html

2.6.3 lua脚本的操作案例

在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   ==》 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

案例3:EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 10 20  

====》 0

案例4:

redis.call()中的redis是redis中提供的lua脚本类库,仅在redis环境中可以使用该类库。

set ky 10  -- 设置一个ky值为10

EVAL "return redis.call('get', 'ky')" 0    ====》10

案例5:

案例6:

 当call() 在执行命令的过程中发生错误时,脚本会停止执行,并返回一个脚本错误,输出错误信息

pcall函数不影响后续指令的执行

注意案例中:set方法写成了sets,肯定会报错。

 2.7 可重入专题

2.7.1 可重入性描述

假设X线程在a方法获取锁之后,继续执行b方法,如果此时不可重入,线程就必须等待锁释放,再次争抢锁。

锁明明是被X线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪

public synchronized void a() {

    b();

}

public synchronized void b() {

    // pass

}

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

可以看到可重入锁最大特性就是计数,计算加锁的次数。所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。

2.7.2 redis可重入性

由于上述加锁命令使用了SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加锁,将会加锁失败。当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的子任务代码,可重入性就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。

可重入:在一个synchronized修饰的同步方法和同步代码块调用本类的其他synchronized修饰的同步方法,代码块时,是永远可以得到锁的。

2.7.3 redis的Hash数据结构和命令

1.Redis的hash表结构,创建并设置值。

2.hexists只用来判断是否存在参数所指定的hash字段(集合中某个字段),只可以带一个key参数,返回值只有1(存在)和0(不存在)两种情况。例如mylock为hash的表名,uuid2为key的名。

127.0.0.1:6379> hset mylock  uuid 2

(integer) 1

127.0.0.1:6379> hexists mylock uuid2

(integer) 1

127.0.0.1:6379> hexists mylock uuname

(integer) 0

3.exists用来判断key是否存在,只有1组参数时用法和hexists一样,时间复杂度也一样,所以效率没区别。Redis3.0.3之后支持多组参数,返回存在的key的数量。

127.0.0.1:6379> set   yourlock   'pa'

OK

127.0.0.1:6379> exists yourlock

(integer) 1

127.0.0.1:6379> exists  yourklocks

(integer) 0

4.Redis Hincrby 命令用于为哈希表中的字段值加上指定增量值。

a.这里两步到位:

redis> HSET myhash field 5

(integer) 1

redis> HINCRBY myhash field 1

(integer) 6

b.这里1步到位: 直接创建了hash表lock,并实现了累加统计操作为100,经过第3步,累加后为101。

127.0.0.1:6379> hincrby lock uuid 100

(integer) 100

127.0.0.1:6379> hexists lock uuid

(integer) 1

127.0.0.1:6379> hincrby lock uuid 1

(integer) 101

127.0.0.1:6379>

2.7.4 redis使用lua脚本+hash结构

redis 提供了 Hash(哈希表)这种可以存储键值对数据结构。所以我们可以使用 Redis Hash 存储的锁的重入次数,然后利用lua脚本判断逻辑

1.加锁逻辑:

假设值为:KEYS:[lock], ARGV[uuid, expire] 如果锁不存在或者这是自己的锁,就通过hincrby(不存在就新增并加1,存在就加1)获取锁或者锁次数加1。同时设置过期时间。

2.解锁逻辑:

1.如果自己的锁不存在,则返回nill

2.如果自己的锁存在,且可重入数-1还大于0,则还没有释放锁,返回0;

3.如果自己的锁存在,且可重入数<=0,则释放锁,删除锁,返回1

3.使用代码

2.8 自动续期

2.8.1 问题场景描述

1.问题描述:可能会释放其他服务器的锁。

进程A加锁之后,扣减库存的时间超过设置的超时时间,这里设置的锁是10秒

在第10秒的时候由于时间到期了所以进程A设置的锁被Redis释放了(T5)    

刚好进程B请求进来了,加锁成功(T6)

进程A操作完成(扣减库存)之后,把进程B设置的锁给释放了

刚好进程C请求进来了,加锁成功

进程B操作完成之后,也把进程C设置的锁给释放了

以此类推…

2.解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁

3.这种方式还是有问题;线程A还没有执行完业务,进行释放锁动作,是锁自动过期释放锁的;线程B此时获取锁,也进行业务执行,

问题1:线程A和线程B重复执行。

问题2:线程A进行释放锁时,将线程B的锁给释放了

2.8.2 解决办法

问题1:线程A和线程B重复执行。

问题2:线程A进行释放锁时,将线程B的锁给释放了。

使用锁续期的watch dog守护程序就能解决。续锁核心代码

2.解锁核心代码

三 redis使用senx+lua脚本实现分布式锁

3.1 逻辑流程

加锁:

  1. setnx:独占排他 死锁、不可重入、原子性

     set k v ex 30 nx:独占排他、死锁 不可重入

  2. hash + lua脚本:可重入锁

    1. 判断锁是否被占用(exists),如果没有被占用则直接获取锁(hset/hincrby)并设置过期时间(expire)

    2. 如果锁被占用,则判断是否当前线程占用的(hexists),如果是则重入(hincrby)并重置过期时间(expire)

    3. 否则获取锁失败,将来代码中重试

  3. Timer定时器 + lua脚本:实现锁的自动续期

    判断锁是否自己的锁(hexists == 1),如果是自己的锁则执行expire重置过期时间

解锁

  1. hash + lua脚本:可重入

    1. 判断当前线程的锁是否存在,不存在则返回nil,将来抛出异常

    2. 存在则直接减1(hincrby -1),判断减1后的值是否为0,为0则释放锁(del),并返回1

    3. 不为0,则返回0

重试:递归 循环

3.2  操作步骤

1.客户端

@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);
    }
}

2.分布锁

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;
    }

    /**
     * 加锁方法
     * @param time
     * @param unit
     * @return
     * @throws InterruptedException
     */
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time != -1){
            this.expire = unit.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;
    }

    // String getId(){
    //     return this.uuid + ":" + Thread.currentThread().getId();
    // }

    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);
    }

}

3.调用

 @Autowired
    private DistributedLockClient distributedLockClient;
    public void deductByRedisLua() {
        DistributedRedisLock redisLock = this.distributedLockClient.getRedisLock("lua-lock");
        redisLock.lock();
        //加锁成功
        try {
            // 先查询库存是否充足
            Stock stock = this.stockMapper.selectById(1L);
            // 再减库存
            if (stock != null && stock.getCount() > 0){
                stock.setCount(stock.getCount() - 1);
                this.stockMapper.updateById(stock);
            }
            else{
                System.out.println("库存不足..........."+new Date());
            }
            //重入锁
            test();
        } finally {
            // 解锁
            redisLock.unlock();
        }
    }
    public void test(){
        DistributedRedisLock redisLock = this.distributedLockClient.getRedisLock("lua-lock");
        redisLock.lock();;
        System.out.println("可重入锁.....");
        redisLock.unlock();
    }

4.controller

3.3  测试

 3.3.1.启动zk

[root@localhost ]  cd  /root/export/apache-zookeeper-3.7.0-bin/bin

[root@localhost bin]# ./zkServer.sh start
ZooKeeper JMX enabled by default
Using config: /root/export/apache-zookeeper-3.7.0-bin/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED
[root@localhost bin]# ./zkCli.sh

3.3.2.启动redis

[root@localhost ~]# redis-server /myredis/redis.conf
[root@localhost ~]# redis-cli -a 123456 -p 6379
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.

3.3.3.启动nginx

3.3.4.启动服务

3.3.5.jemterces

1.配置

2.执行 

3.3.6.查看数据库

1.初始

2.测试后

查看

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值