Redis(十四)【Redisson分布式锁基础介绍】

分布式锁 Redisson

一、Redisson 概述


什么是 Redisson

Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。

Redisson 的宗旨是促进使用者对 Redis 的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

一个基于Redis实现的分布式工具,有基本分布式对象和高级又抽象的分布式服务,为每个试图再造分布式轮子的程序员带来了大部分分布式问题的解决办法。

Redisson 和 Jedis、Lettuce区别

Redisson 和它俩的区别就像一个用鼠标操作图形化界面,一个用命令行操作文件。Redisson 是更高层的抽象,Jedis 和 Lettuce 是 Redis 命令的封装

  • Jedis 是 Redis 官方推出的用于通过 Java 连接 Redis 客户端的一个工具包,提供了 Redis 的各种命令支持
  • Lettuce 是一种可扩展的线程安全的 Redis 客户端,通讯框架基于 Netty,支持高级的 Redis 特性,比如哨兵,集群,管道,自动重新连接和 Redis 数据模型。Spring Boot 2.x 开始 Lettuce 已取代 Jedis 成为首选 Redis 的客户端。
  • Redisson 是架构在 Redis 基础上,通讯基于 Netty 的综合的、新型的中间件,企业级开发中使用 Redis 的最佳范本

Jedis 把 Redis 命令封装好,Lettuce 则进一步有了更丰富的 Api,也支持集群等模式。但是两者也都点到为止,只给了操作 Redis 数据库的脚手架,而 Redisson 则是基于 Redis、Lua 和 Netty 建立起了成熟的分布式解决方案,甚至 redis 官方都推荐的一种工具集

二、分布式锁


实现分布式锁

分布式锁是并发业务下的必要,虽然实现五花八门:有根据 ZooKeeper 的 Znode 顺序节点,数据库有表级锁和乐/悲观锁,Redis 有 setNx,但是殊途同归,最终还是要回到互斥上来,本篇介绍 Redisson,那就以 redis 为例。

怎么写一个简单的 Redis 分布式锁?

Spring Data Redis 为例,用 RedisTemplate 来操作 Redis(setIfAbsent 已经是 setNx + expire 的合并命令),如下

@Autowired
private RedisTemplate<String, Object> redisTemplate;

// 加锁
public Boolean tryLock(String key, String value, long timeout, TimeUnit unit) {
    
    
    return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);
}

// 解锁,防止多线程情况下删错别人的锁,以uuid为value校验是否自己的锁
public void unlock(String lockName, String uuid) {
    
    
    if(uuid.equals(redisTemplate.opsForValue().get(lockName)){
    
            
        redisTemplate.opsForValue().del(lockName);
    }
}

// 结构
if(tryLock) {
    
    
    // todo
}finally {
    
    
    unlock;
}

问题】简单1.0版本完成,一眼看出,这是锁没错,但 get 和 del 操作并非原子性,并发一旦大了,无法保证进程安全。于是决定使用 Lua 脚本

2.1 Lua 脚本是什么?

Lua 脚本是 redis 已经内置的一种轻量小巧语言,其执行是通过 redis 的 eval /evalsha 命令来运行,把操作封装成一个 Lua 脚本,如论如何都是一次执行的原子操作

  • 于是2.0版本通过Lua脚本删除,脚本如下
-- 操作的键
local redisKey = KEYS[1];
-- 操作的值
local redisValue = ARGV[1];
if redis.call('get', redisKey) == redisValue 
    then 
 -- 执行删除操作
        return redis.call('del', redisKey) 
    else 
 -- 不成功,返回0
        return 0 
end
  • 使用 Java 执行删除脚本
// 解锁脚本
DefaultRedisScript<Object> unlockScript = new DefaultRedisScript();
unlockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockDel.lua")));

// 执行lua脚本解锁
redisTemplate.execute(unlockScript, Collections.singletonList(keyName), value);

问题】2.0似乎更像一把锁,但好像又缺少了什么,在Java当中 synchronized 和 ReentrantLock 有两个共同的点,就是他们都是可重入锁,一个线程多次拿锁也不会死锁,需要设计可重入的功能

2.2 可重入锁

如何保证可重入?

可重入就是,同一个线程多次获取同一把锁是允许的,不会造成死锁,这一点 synchronized 偏向锁提供了很好的思路,synchronized 的实现重入是在 JVM 层面,Java 对象头 Mark Word 域中便藏有线程ID和计数器来对当前线程做重入判断,避免每次CAS

偏向锁

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单测试一下对象头的Mark Word域里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁标志是否设置成1:没有则CAS竞争;设置了,则CAS将对象头偏向锁指向当前线程

再维护一个计数器,同个线程进入则自增1,离开再减1,直到为0才能释放

Redisson 可重入锁的设计

了解原理之后,需要对Lua脚本进行改造,过程如下

1. 需要存储 锁名称lockName、获得该锁的线程id和对应线程的进入次数count

2. 加锁
- 每次线程获取锁时,判断是否已存在该锁
- 不存在
 - 设置hash的key为线程id,value初始化为1
 - 设置过期时间
 - 返回获取锁成功true
- 存在
 - 继续判断是否存在当前线程id的hash key
  - 存在,线程key的value + 1,重入次数增加1,设置过期时间
  - 不存在,返回加锁失败
3. 解锁
- 每次线程来解锁时,判断是否已存在该锁
- 存在
 - 是否有该线程的id的hash key,有则减1,无则返回解锁失败
 - 减1后,判断剩余count是否为0,为0则说明不再需要这把锁,执行del命令删除

使用的数据结构—Hash

为了方便维护这个对象,我们用Hash结构来存储这些字段。Redis 的 Hash 类似Java的 HashMap,适合存储对象,redis操作命令如下

# 在redis中的hash数据结构当中添加一个以theadId为key,1为value的值
hset lockname threadId 1

# 获取lockname的threadId的值
hget lockname threadId

# 存储结构为
lockname 锁名称
    key1:   threadId   唯一键,线程id
    value1:  count     计数器,记录该线程获取锁的次数

计数器的加减

当同一个线程获取同一把锁时,我们需要对对应线程的计数器count做加减,判断一个redis key是否存在,可以用exists,而判断一个 hash 的 key 是否存在,可以用hexists,而 redis 也有 hash 自增的命令hincrby

# 每次自增1时
hincrby lockname threadId 1
# 自减1时 
hincrby lockname threadId -1

解锁的判断

当一把锁不再被需要了,每次解锁一次,count减1,直到为0时,执行删除

综合上述的存储结构和判断流程,加锁和解锁Lua脚本如下

  • 加锁 lock.lua
local key = KEYS[1];
local threadId = ARGV[1];
local releaseTime = ARGV[2];

-- lockname不存在
if(redis.call('exists', key) == 0) then
    redis.call('hset', key, threadId, '1');
    redis.call('expire', key, releaseTime);
    return 1;
end;

-- 当前线程已id存在
if(redis.call('hexists', key, threadId) == 1) then
    redis.call('hincrby', key, threadId, 1);
    redis.call('expire', key, releaseTime);
    return 1;
end;
return 0;
  • 解锁 unlock.lua
local key = KEYS[1];
local threadId = ARGV[1];

-- lockname、threadId不存在
if (redis.call('hexists', key, threadId) == 0) then
    return nil;
end;

-- 计数器-1
local count = redis.call('hincrby', key, threadId, -1);

-- 计数器为0,删除lock
if (count == 0) then
    redis.call('del', key);
    return nil;
end;
  • 使用 Java 操作加锁和解锁脚本
/**
 * @description 原生redis实现分布式锁
 **/
@Getter
@Setter
public class RedisLock {
    
    

    private RedisTemplate redisTemplate;
    private DefaultRedisScript<Long> lockScript;
    private DefaultRedisScript<Object> unlockScript;

    public RedisLock(RedisTemplate redisTemplate) {
    
    
        this.redisTemplate = redisTemplate;
        // 加载加锁的脚本(对应resources目录下)
        lockScript = new DefaultRedisScript<>();
        this.lockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock.lua")));
        this.lockScript.setResultType(Long.class);
        // 加载释放锁的脚本(对应resources目录下)
        unlockScript = new DefaultRedisScript<>();
        this.unlockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));
    }

    /**
     * 获取锁
     */
    public String tryLock(String lockName, long releaseTime) {
    
    
        // 存入的线程信息的前缀
        String key = UUID.randomUUID().toString();

        // 执行脚本
        Long result = (Long) redisTemplate.execute(
                lockScript,
                Collections.singletonList(lockName),
                key + Thread.currentThread().getId(),
                releaseTime);

        if (result != null && result.intValue() == 1) {
    
    
            return key;
        } else {
    
    
            return null;
        }
    }

    /**
     * 解锁
     * @param lockName
     * @param key
     */
    public void unlock(String lockName, String key) {
    
    
        redisTemplate.execute(unlockScript,
                Collections.singletonList(lockName),
                key + Thread.currentThread().getId()
                );
    }
}

// 执行的redisTemplate调用的函数如下
@Override
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
    
    
    return scriptExecutor.execute(script, keys, args);
}

至此已经完成了一把分布式锁,拥有互斥、可重入、防死锁的基本特点

问题】A进程在获取到锁的时候,因业务操作时间太长,锁释放了但是业务还在执行,而此刻B进程又可以正常拿到锁做业务操作,两个进程操作就会存在依旧有共享资源的问题,而且如果负责储存这个分布式锁的Redis节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态

所以希望在这种情况时,可以延长锁的releaseTime延迟释放锁来直到完成业务期望结果,这种不断延长锁过期时间来保证业务执行完成的操作就是锁续约

读写分离也是常见,一个读多写少的业务为了性能,常常是有读锁和写锁的

而此刻的扩展已经超出了一把简单轮子的复杂程度,光是处理续约,就给业务带来了难题,何况在性能(锁的最大等待时间)、优雅(无效锁申请)、重试(失败重试机制)等方面还要下功夫研究

三、Redisson 分布式锁


  • 依赖引入
<!-- 原生,本章使用-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.15.0</version>
</dependency>

<!-- 另一种Spring集成starter,本章未使用 -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.15.0</version>
</dependency>
  • 配置客户端连接
@Configuration
public class RedissionConfig {
    
    
    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.password}")
    private String password;
    
    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedissonClient getRedisson() {
    
    
        Config config = new Config();
        config.useSingleServer().
                setAddress("redis://" + redisHost + ":" + port).
                setPassword(password);
        config.setCodec(new JsonJacksonCodec());
        return Redisson.create(config);
    }
}
  • 启用分布式锁
@Resource
private RedissonClient redissonClient;

RLock rLock = redissonClient.getLock(lockName);
try {
    
    
    boolean isLocked = rLock.tryLock(expireTime, TimeUnit.MILLISECONDS);
    if (isLocked) {
    
    
        // TODO
    }
} catch (Exception e) {
    
    
    rLock.unlock();
}

简洁明了,只需要一个 RLock,既然推荐 Redisson,就往里面看看他是怎么实现的

3.1 RLock 锁

RLock 是 Redisson 分布式锁的最核心接口,继承了 concurrent 包的 Lock 接口和自己的 RLockAsync 接口,RLockAsync 的返回值都是 RFuture,是 Redisson 执行异步实现的核心逻辑,也是 Netty 发挥的主要阵地

RLock 如何加锁?

从 RLock 进入,找到 RedissonLock 类,找到 tryLock 方法再推进到主要的 tryAcquireOnceAsync 方法,这是加锁的主要代码(版本不一此处实现有差别,和最新3.15.x有一定出入,但是核心逻辑依然未变。此处以3.15.0为例)

    private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    
    
        if (leaseTime != -1L) {
    
    
            return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        } else {
    
    
            RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
            ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    
    
                if (e == null) {
    
    
                    if (ttlRemaining == null) {
    
    
                        this.scheduleExpirationRenewal(threadId);
                    }

                }
            });
            return ttlRemainingFuture;
        }
    }

此处出现leaseTime时间判断的2个分支,实际上就是加锁时是否设置过期时间,未设置过期时间(-1)时则会有 watchDog锁续约 (下文),一个注册了加锁事件的续约任务。先来看有过期时间 tryLockInnerAsync 部分

  • evalWriteAsync 是 eval 命令执行lua脚本的入口

在这里插入图片描述

-- 如果不存在key时
if (redis.call('exists', KEYS[1]) == 0) then
    -- 新增该锁并且hash中该线程id对应的count置1
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    -- 设置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil; 
end;
-- 存在该key并且hash中线程id的key也存在
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]);

和前面写自定义的分布式锁的脚本几乎一致,看来redisson也是一样的实现,具体参数分析

// keyName
KEYS[1] = Collections.singletonList(this.getName())
// leaseTime
ARGV[1] = this.internalLockLeaseTime
// uuid + threadId组合的唯一值
ARGV[2] = this.getLockName(threadId)

总共3个参数完成了一段逻辑

判断该锁是否已经有对应hash表存在,

• 没有对应的hash表: 则set该hash表中一个entry的key为锁名称,value为1,之后设置该hash表失效时间为leaseTime

• 存在对应的hash表: 则将该lockName的value执行+1操作,也就是计算进入次数,再设置失效时间leaseTime

• 最后返回这把锁的ttl剩余时间

RLock 如何解锁?

  • 对应的,有key的对应hashKey当中的value+1,就有key的对应hashKey当中的value-1,代码如下

在这里插入图片描述

-- 不存在key
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;
end;
-- 计数器 -1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
    -- 过期时间重设
    redis.call('pexpire', KEYS[1], ARGV[2]); 
    return 0; 
else
    -- 删除并发布解锁消息
    redis.call('del', KEYS[1]);
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1;
end; 
return nil;

该 lua KEYS有2个Arrays.asList(getName(), getChannelName()),ARGV变量有三个LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId)

// 锁名称
KEYS[1] = this.getName()
// 用于pub/sub发布消息的channel名称
KEYS[2] = this.getChannelName()
// channel发送消息的类别,此处解锁为0
ARGV[1] = LockPubSub.UNLOCK_MESSAGE
// watchDog配置的超时时间,默认为30s
ARGV[2] = this.internalLockLeaseTime
// 这里的lockName指的是uuid和threadId组合的唯一值
ARGV[3] = this.getLockName(threadId)

释放锁过程如下

1.如果该锁不存在则返回nil

2.如果该锁存在则将其线程的hash key计数器-1

3.计数器counter>0,重置下失效时间,返回0;否则,删除该锁,发布解锁消息unlockMessage,返回1

其中unLock的时候使用到了Redis发布订阅Pub / Sub完成消息通知

而订阅的步骤就在RedissonLock的加锁入口的tryLock方法里

Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
        if (ttl == null) {
    
    
            return true;
        } else {
    
    
            time -= System.currentTimeMillis() - current;
            if (time <= 0L) {
    
    
                this.acquireFailed(waitTime, unit, threadId);
                return false;
            } else {
    
    
                current = System.currentTimeMillis();
                RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
                if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
    
    
                    if (!subscribeFuture.cancel(false)) {
    
    
                        subscribeFuture.onComplete((res, e) -> {
    
    
                            if (e == null) {
    
    
                                this.unsubscribe(subscribeFuture, threadId);
                            }

                        });
                    }
                    // 省略...

当锁被其他线程占用时,通过监听锁的释放通知(在其他线程通过RedissonLock释放锁时,会通过发布订阅pub/sub功能发起通知),等待锁被其他线程释放,也是为了避免自旋的一种常用效率手段

3.2 消息发布与订阅

发布消息

一探究竟通知了什么,通知后又做了什么,进入LockPubSub

org.redisson.pubsub.PublishSubscribe#subscribe方法下

在这里插入图片描述

org.redisson.pubsub.PublishSubscribe#createListener

在这里插入图片描述

    @Override
    protected void onMessage(RedissonLockEntry value, Long message) {
    
    
        // 解锁消息 
        if (message.equals(UNLOCK_MESSAGE)) {
    
    
            // 从监听器队列取监听线程执行监听回调
            Runnable runnableToExecute = value.getListeners().poll();
            if (runnableToExecute != null) {
    
    
                runnableToExecute.run();
            }
            // getLatch()返回的是Semaphore,信号量,此处是释放信号量
            // 释放信号量后会唤醒等待的entry.getLatch().tryAcquire去再次尝试申请锁
            value.getLatch().release();
        } else if (message.equals(READ_UNLOCK_MESSAGE)) {
    
    	// 读锁解锁消息
            while (true) {
    
    
                Runnable runnableToExecute = value.getListeners().poll();
                if (runnableToExecute == null) {
    
    
                    break;
                }
                runnableToExecute.run();
            }

            value.getLatch().release(value.getLatch().getQueueLength());
        }
    }

发现一个是默认解锁消息 ,一个是读锁解锁消息 ,因为redisson是有提供读写锁的,而读写锁读读情况和读写、写写情况互斥情况不同,我们只看上面的默认解锁消息unlockMessage分支

LockPubSub 监听最终执行了2件事

  • runnableToExecute.run() 执行监听回调
  • value.getLatch().release() 释放信号量

Redisson通过 LockPubSub 监听解锁消息,执行监听回调和释放信号量通知等待线程可以重新抢锁

3.3 Watch Dog(看门狗机制)

这时再回来看tryAcquireOnceAsync另一分支

在这里插入图片描述

可以看到,无超时时间时,在执行加锁操作后,还执行了一段费解的逻辑

RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                                                     TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    
    
    if (e != null) {
    
    
        return;
    }

    // lock acquired
    if (ttlRemaining == null) {
    
    
        scheduleExpirationRenewal(threadId);
    }
});

此处涉及到Netty的 Future/Promise-Listener 模型,Redisson 中几乎全部以这种方式通信(所以说 Redisson 是基于 Netty 通信机制实现的),理解这段逻辑可以试着先理解下面这些内容

在 Java 的 Future 中,业务逻辑为一个 Callable 或 Runnable 实现类,该类的 call()或 run()执行完毕意味着业务逻辑的完结,在 Promise 机制中,可以在业务逻辑中人工设置业务逻辑的成功与失败,这样更加方便的监控自己的业务逻辑

这块代码的表面意义就是,在执行异步加锁的操作后,加锁成功则根据加锁完成返回的 ttl 是否过期来确认是否执行一段定时任务

这段定时任务的就是 watchDog 的核心

3.4 锁续约

查看org.redisson.RedissonBaseLock#scheduleExpirationRenewal(long threadId)

protected void scheduleExpirationRenewal(long threadId) {
    
    
    ExpirationEntry entry = new ExpirationEntry();
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    // 重入加锁
    if (oldEntry != null) {
    
    
        oldEntry.addThreadId(threadId);
    } else {
    
    
        // 第一次加锁,触发定时任务
        entry.addThreadId(threadId);
        renewExpiration();
    }
}

private void renewExpiration() {
    
    
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
    
    
        return;
    }
	// 开启一个定时任务、每30 / 3秒执行一次
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
    
    
        @Override
        public void run(Timeout timeout) throws Exception {
    
    
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
    
    
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
    
    
                return;
            }

            RFuture<Boolean> future = renewExpirationAsync(threadId);	// 异步续锁时间
            future.onComplete((res, e) -> {
    
    
                if (e != null) {
    
    
                    log.error("Can't update lock " + getName() + " expiration", e);
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }

                if (res) {
    
    
                    // reschedule itself
                    renewExpiration();	// 回调定时任务
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

    ee.setTimeout(task);
}

拆分来看,这段连续嵌套且冗长的代码实际上做了几步

• 添加一个netty的Timeout回调任务,每(internalLockLeaseTime / 3)毫秒执行一次,执行的方法是renewExpirationAsync(long threadId)

• renewExpirationAsync重置了锁超时时间,又注册一个监听器,监听回调又执行了renewExpiration

renewExpirationAsync 的 lua 如下

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

KEYS[1] = Collections.singletonList(getName());
ARGV[1] = internalLockLeaseTime;	// 默认是30s
ARGV[2] = getLockName(threadId);

重新设置了超时时间

Redisson加这段逻辑的目的是什么?

目的是为了某种场景下保证业务不影响,如任务执行超时但未结束,锁已经释放的问题

当一个线程持有了一把锁,由于并未设置超时时间 leaseTime,Redisson 默认配置了30S,开启 watchDog,每10S对该锁进行一次续约,维持30S的超时时间,直到任务完成再删除锁

在这里插入图片描述

这就是Redisson的锁续约 ,也就是WatchDog 实现的基本思路

流程概括

- A、B线程争抢一把锁,A获取到后,B阻塞
- B线程阻塞时并非主动CAS,而是PubSub方式订阅该锁的广播消息
- A操作完成释放了锁,B线程收到订阅消息通知
- B被唤醒开始继续抢锁,拿到锁

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/Wei_Naijia/article/details/129693379