Redis分布式锁的实现与应用

3,168 阅读8分钟

利用Watch实现Redis乐观锁

乐观锁基于CAS(Compare And Swap)思想(比较并替换),是不具有互斥性,不会产生锁等待而消 耗资源,但是需要反复的重试,但也是因为重试的机制,能比较快的响应。因此我们可以利用redis来实 现乐观锁。具体思路如下:

1、利用redis的watch功能,监控这个redisKey的状态值
2、获取redisKey的值
3、创建redis事务
4、给这个key的值+1
5、然后去执行这个事务,如果key的值被修改过则回滚,key不加1(watch的特性,watch监听的key如果被改动了,则事物不会提交)

Redis乐观锁实现秒杀

public class Second {
    public static void main(String[] arg) {
        String redisKey = "lock";
        ExecutorService executorService = Executors.newFixedThreadPool(20);
        try {
            Jedis jedis = new Jedis("127.0.0.1", 6378); // 初始值
            jedis.set(redisKey, "0");
            jedis.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
        for (int i = 0; i < 1000; i++) {
            executorService.execute(() -> {
                Jedis jedis1 = new Jedis("127.0.0.1", 6378);
                try {
                    jedis1.watch(redisKey);
                    String redisValue = jedis1.get(redisKey);
                    int valInteger = Integer.valueOf(redisValue);
                    String userInfo = UUID.randomUUID().toString();
                    // 没有秒完
                    if (valInteger < 20) {
                        Transaction tx = jedis1.multi(); tx.incr(redisKey);
                        List list = tx.exec();
                        // 秒成功 失败返回空list而不是空
                        if (list != null && list.size() > 0) {
                            System.out.println("用户:" + userInfo + ",秒杀成功! 当前成功人数:" + (valInteger + 1));
                        }
                        // 版本变化,被别人抢了。 
                        else {
                            System.out.println("用户:" + userInfo + "秒杀失败")
                        }
                    }
                    // 秒完了 
                    else {
                        System.out.println("已经有20人秒杀成功,秒杀结束");
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    jedis1.close();
                }
            }); }
        executorService.shutdown();
    }
}

setnx实现分布式锁

获取锁
方式1 推荐使用

    /**
     * 使用redis的set命令实现获取分布式锁
     * @param lockKey 就是锁
     * @param requestId 请求ID,保证同一性 uuid+threadID
     * @param expireTime 过期时间,避免死锁
     * @return
     */
    public boolean getLock(String lockKey,String requestId,int expireTime) {
        //NX:保证互斥性
        // hset 原子性操作 只要lockKey有效 则说明有进程在使用分布式锁
        String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime); 
        if("OK".equals(result)) {
            return true;
        }
        return false;
    }

方式2 不推荐使用 高并发情况下存在问题

public  boolean getLock(String lockKey,String requestId,int expireTime) {
    Long result = jedis.setnx(lockKey, requestId);
    if(result == 1) {
        //成功设置 进程down 永久有效 别的进程就无法获得锁 
        jedis.expire(lockKey, expireTime);
        return true;
    }
    return false;
}

释放锁
方式1 不推荐使用 并发下存在问题

/**
 * 释放分布式锁
 * @param lockKey * @param requestId
 */
public static void releaseLock(String lockKey,String requestId) {
    // 获取锁和释放锁不在同一个事物执行
    if (requestId.equals(jedis.get(lockKey))) {
        jedis.del(lockKey);
    }
}

问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?

答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行 jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。

方式2 (redis+lua脚本实现) 推荐使用
reids执行lua脚本会将命令放到一个事物里执行

public static boolean releaseLock(String lockKey, String requestId) {
    // 获取锁和释放锁在同一事物里执行
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
    redis.call('del', KEYS[1]) else return 0 end";
    Object result = jedis.eval(script, Collections.singletonList(lockKey),
            Collections.singletonList(requestId));
    if (result.equals(1L)) {
        return true;
    }
    return false;
}

上面方式实现Redis分布式锁存在的问题

1、主从的情况下,主机宕机时,可能造成锁重复被不同的客户端获得

image.png 上图表示,client1已经获得了锁,但是还没来得及同步到从服务器,主服务器就宕机了,这时候从服务器变成主服务器,但是服务器上是没有key的数据的,所以client2也能够加锁成功。

2、超过expireTime后,不能继续使用

那么如何解决了?
使用Redisson即可

Redisson分布式锁的使用

加入jar包的依赖

<dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
         <version>2.7.0</version>
 </dependency>

配置Redisson

    public class RedissonManager {
        private static Config config = new Config(); //声明redisso对象
        private static Redisson redisson = null;
        //实例化redisson
        static{
         config.useClusterServers()
                 // 集群状态扫描间隔时间,单位是毫秒
                 .setScanInterval(2000)
                 //cluster方式至少6个节点(3主3从,3主做sharding,3从用来保证主宕机后可以高可用)
                 .addNodeAddress("redis://127.0.0.1:6379" )
                 .addNodeAddress("redis://127.0.0.1:6380")
                 .addNodeAddress("redis://127.0.0.1:6381")
                 .addNodeAddress("redis://127.0.0.1:6382")
                 .addNodeAddress("redis://127.0.0.1:6383")
                 .addNodeAddress("redis://127.0.0.1:6384");
         //得到redisson对象
        redisson =(Redisson)Redisson.create(config);
    }

    //获取redisson对象的方法
    public static Redisson getRedisson() {
        return redisson;
    }
}

锁的获取和释放

public class DistributedRedisLock { 
    //从配置类中获取redisson对象
    private static Redisson redisson = RedissonManager.getRedisson();
    private static final String LOCK_TITLE = "redisLock_"; 
    
    //加锁
    public static boolean acquire(String lockName) { 
        //声明key对象
        String key = LOCK_TITLE + lockName; 
        //获取锁对象
        RLock mylock = redisson.getLock(key); 
        //加锁,并且设置锁过期时间3秒,防止死锁的产生 uuid+threadId
        mylock.lock(2, 3, TimeUtil.SECOND); 
        //加锁成功
        return true;
    }

    //锁的释放
    public static void release(String lockName) {
        //必须是和加锁时的同一个key
        String key = LOCK_TITLE + lockName;
        //获取所对象
        RLock mylock = redisson.getLock(key);
        //释放锁(解锁) 
        // mylock.unlock();
    }
}

使用分布式锁

public String discount() throws IOException{
    String key = "lock001";
    //加锁 
    DistributedRedisLock.acquire(key); 
    // 执行具体业务逻辑
    // ...
    //释放锁 
    DistributedRedisLock.release(key);
    //返回结果 具体情况具体自己指定
    return "";
}

Redisson分布式锁实现原理

image.png 下面分析一下上图中的几种机制

加锁机制

如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。 发送lua脚本到redis服务器上,脚本如下:

"if (redis.call('exists',KEYS[1])==0) then "+ --看有没有锁 
    "redis.call('hset',KEYS[1],ARGV[2],1) ; "+ --无锁 加锁 
    "redis.call('pexpire',KEYS[1],ARGV[1]) ; "+ 
    "return nil; end ;" + 
"if (redis.call('hexists',KEYS[1],ARGV[2]) ==1 ) then "+ --我加的锁 
    "redis.call('hincrby',KEYS[1],ARGV[2],1) ; "+ --重入锁 
    "redis.call('pexpire',KEYS[1],ARGV[1]) ; "+ 
    "return nil; end ;" + 
"return redis.call('pttl',KEYS[1]) ;" --不能加锁,返回锁的时间

lua的作用:保证这段复杂业务逻辑执行的原子性。
lua的解释:
KEYS[1] : 加锁的key
ARGV[1] : key的生存时间,默认为30秒
ARGV[2] : 加锁的客户端ID (UUID.randomUUID() + ":" + threadId)

第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。如何加锁呢?很简单,用下面的命令:

hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1

通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:

myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1 }

上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。

接着会执行pexpire myLock 30000命令,设置myLock这个锁key的生存时间是30秒。

锁互斥机制

如果客户端1已经加锁,客户端2来尝试加锁,执行了同样的一段lua脚本,会怎样呢?

第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。
接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。
此时客户端2会进入一个while循环,不停的尝试加锁。

自动延时机制

只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一 下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。

可重入锁机制

第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。
第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是

“8743c9c0-0795-4907-87fd-6c719a6b4586:1”

此时就会执行可重入加锁的逻辑,他会用:

incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1

通过这个命令,对客户端1的加锁次数,累加1。数据结构会变成:

myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":2 }

释放锁机制

锁释放的时候执行lua脚本如下:

#如果key已经不存在,说明已经被解锁,直接发布(publish)redis消息 
     "if (redis.call('exists', KEYS[1]) == 0) then " + 
        "redis.call('publish', KEYS[2], ARGV[1]); " +
        "return 1; " + 
     "end;" + 
#key和field不匹配,说明当前客户端线程没有持有锁,不能主动解锁。 不是我加的锁 不能解锁      "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + 
         "return nil;" + 
     "end; " + 
# 将value减1 
     "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + 
# 如果counter>0说明锁在重入,不能删除key 
     "if (counter > 0) then " + 
         "redis.call('pexpire', KEYS[1], ARGV[2]); " + 
         "return 0; " + 
# 删除key并且publish 解锁消息 
     "else " + 
        "redis.call('del', KEYS[1]); " + #删除锁 
        "redis.call('publish', KEYS[2], ARGV[1]); " + 
        "return 1; "+ 
        "end; " + 
        "return nil;",

– KEYS[1] :需要加锁的key,这里需要是字符串类型。
– KEYS[2] :redis消息的ChannelName,一个分布式锁对应唯一的一个channelName:“redisson_lockchannel{” + getName() + “}”
– ARGV[1] :reids消息体,这里只需要一个字节的标记就可以,主要标记redis的key已经解锁,再结合redis的Subscribe,能唤醒其他订阅解锁消息的客户端线程申请锁。 – ARGV[2] :锁的超时时间,防止死锁 – ARGV[3] :锁的唯一标识,也就是刚才介绍的 id(UUID.randomUUID()) + “:” + threadId

如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。 其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。 如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:del myLock命令,从redis里删除这个key。

然后呢,另外的客户端2就可以尝试完成加锁了。