Redisson

文章目录


Redisson

SETNX实现分布式锁

通过Redis来实现分布式锁,最简单的办法就是通过SETNX命令来实现,SETNX这个命令设置成功会返回1,失败则返回0,借助该命令的特性就可以实现分布式锁。

lua 复制代码
-- 获取锁
SETNX lock 1
-- 释放锁
DEL lock

对应的Java代码实现

获取锁:

  • 锁的KEY为业务标识,而value则是UUID拼接当前线程的ID来区分集群下的不同线程
  • 获取锁的时候设置超时时间

释放锁:

  • 为了避免释放锁的时候误删其它线程的锁,在释放锁之前要进行判断
  • 显然判断锁和释放锁要通过Lua脚本来保证原子性
java 复制代码
public class SimpleRedisLock implements ILock{

    private StringRedisTemplate redisTemplate;
    private static final String ID_PREFIX = UUID.randomUUID().toString();
    private static final String KEY_PREFIX = "lock:";

    private static final DefaultRedisScript UNLOCK_SCRIPT;
    private String name;

    public SimpleRedisLock(String name,StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.name = name;
    }

    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        // 返回值类型
        UNLOCK_SCRIPT.setResultType(Long.class);
        // 读取要执行的脚本
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    }

    @Override
    public boolean tryLock(long time) {
        // 获取当前的线程ID
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 不同的业务
        String key = KEY_PREFIX +this.name;
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key,  threadId, time, TimeUnit.SECONDS);

        return BooleanUtil.isTrue(flag);
    }

    @Override
    public void unLock() {
        // 执行Lua脚本 (脚本对象,参数key,参数value)
        redisTemplate.execute(UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX +this.name),
                ID_PREFIX + Thread.currentThread().getId());
    }

}

lua脚本

lua 复制代码
-- 那当前线程标识和Redis中存储的线程标识做比较
if (redis.call('get',KEYS[1]) == ARGV[1]) then
    -- 释放锁
    redis.call('del',KEYS[1])
    return 1;
end
-- 不是该线程的锁释放失败
return 0

使用SETNX这种方式实现的分布式锁已经是可以正常使用了,但在某些场景还是会出现一些问题,SETNX存在以下问题:

  • 重入问题:重入锁的出现主要是为了避免死锁,比如synchronized就实现了可重入锁,而SETNX实现的分布式锁并不是可重入锁,假设它在一个方法内调用内外一个加锁方法不就死锁了吗
  • 不可重试问题:SETNX这种方式实现的分布式锁,只能尝试获取一次锁,并不能获取锁失败后重新尝试获取锁
  • 超时释放问题:虽然在加锁的时候设置了过期时间,可以一定程度上避免死锁,但是如果该业务的卡顿时间过长,虽然使用了Lua脚本防止误删其它线程的锁,但是如果业务还灭有执行完成的时候锁过期,此时并没有锁,就会有一定的安全隐患
  • 主从一致性问题: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题

引入Redisson

Redisson 是一个功能十分强大的 Redis Java 客户端,它提供了丰富的功能和API,支持同步和异步操作,它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

Redisson使用分布式锁

添加Maven依赖

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

配置Redisson客户端

java 复制代码
@Configuration
public class RedisConfig {
    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 添加单节点Redis地址
        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("123456");
        // 创建客户端
        return Redisson.create(config);
    }
}

使用

java 复制代码
// 创建可重入锁,并指定锁名称
RLock lock = redissonClient.getLock("lock:" + userId);

try {
    // 尝试获取锁(获取锁的最大等待时间(期间会重试),锁自动释放的时间,时间单位)
    boolean success = lock.tryLock(1,10,TimeUnit.SECONDS);
    if (!success) {
        return Result.fail("一个用户只能购买一张优惠卷");
    }
    // 执行业务逻辑...
}finally {
    // 释放锁前判断当前线程是否持有锁
    if (lock != null && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

Redisson可重入锁原理

当通过Redisson客户端创建一把锁的,并且尝试获取的锁的时候,如果此时没有人获取,就会创建一把锁。并以锁的名称作为key,而value值是一个Map,map中有一对键值对,KEY为线程的标识,而value为锁的重入次数,如果次数为1,则表示第一次获取锁。

java 复制代码
// 创建可重入锁,并指定锁名称
RLock lock = redissonClient.getLock("lock:" + userId);
// 尝试获取锁
boolean success = lock.tryLock();

来看一下对应获取锁源码,再tryLockInnerAsync方法内有对于获取锁的lua脚本

java 复制代码
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        //......
    }
lua 复制代码
-- KEYS[1] : 锁名称 ARGV[1]:  锁失效时间 
-- ARGV[2]:  id + ":" + threadId; 锁的小key(map对应的Key)
-- 判断该锁是否被创建
if (redis.call('exists', KEYS[1]) == 0) then
    -- 如果没有被创建,就直接创建(hincrby不存在会自动创建)
    redis.call('hincrby', 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 
    -- 如果是则将重入次数+1
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    -- 更新超时时间
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
end; 
-- 返回剩余过期时间
return redis.call('pttl', KEYS[1]);

再来看一下释放锁的源码,再unlockInnerAsync方法内有释放锁的lua脚本

java 复制代码
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    //....
}
lua 复制代码
-- KEYS[1] : 锁名称 ARGV[1]:  锁失效时间 
-- ARGV[2]:  id + ":" + threadId; 锁的小key(map对应的Key)
-- 判断该线程是否持有锁,没有直接return
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) 
    -- 如果重入次数>0直接更新超时时间
    then redis.call('pexpire', KEYS[1], ARGV[2]); 
    return 0; 
    -- 如果重入次数等于0,直接删除锁
else redis.call('del', KEYS[1]); 
    -- 通知等待锁的线程锁已经释放
    redis.call('publish', KEYS[2], ARGV[1]); 
    return 1; 
end; 
return nil;

Redisson可重试原理

可重试

Redisson是支持获取锁失败进行重试的

java 复制代码
RLock lock = redissonClient.getLock("lock:test:");
// 获取锁并指定等待重试时间为2秒
boolean success = lock.tryLock(2, TimeUnit.SECONDS);

查看对应源码

如果没有设置锁的释放时间,会使用默认的看门狗的超时释放时间

java 复制代码
/**
waitTime 等待时间
*/
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
    return this.tryLock(waitTime, -1L, unit);
}
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    // 锁自动释放时间为默认的-1,所以会走else
    if (leaseTime != -1L) {
        return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        // 锁的释放时间使用看门狗的默认超时时间 30秒
        RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(waitTime,     this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            if (e == null) {
                if (ttlRemaining == null) {
                    this.scheduleExpirationRenewal(threadId);
                }

            }
        });
        return ttlRemainingFuture;
    }
}
java 复制代码
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    // 将等待时间转换为毫秒
    long time = unit.toMillis(waitTime);
    // 获取当前时间戳
    long current = System.currentTimeMillis();
    // 获取当前线程的ID
    long threadId = Thread.currentThread().getId();
    // 尝试获取锁,返回锁的剩余过期时间(对应获取锁的lua脚本的返回值)
    Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
    // 如果锁被成功获取,返回true
    if (ttl == null) {
        return true;
    } else {
        // 减去获取锁的消耗的时间
        time -= System.currentTimeMillis() - current;
        // 如果等待时间已经用完,则获取锁失败,返回false
        if (time <= 0L) {
            this.acquireFailed(waitTime, unit, threadId);
            return false;
        } else {
            current = System.currentTimeMillis();
            // 订阅锁的释放事件(对应释放锁的lua脚本的发送锁释放的消息)
            RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
            // 等待一段时间,看是否能获取到锁的释放事件
            if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
                // 如果等待超时,则取消订阅并获取锁失败,返回false
                if (!subscribeFuture.cancel(false)) {
                    subscribeFuture.onComplete((res, e) -> {
                        if (e == null) {
                            this.unsubscribe(subscribeFuture, threadId);
                        }
                    });
                }
                this.acquireFailed(waitTime, unit, threadId);
                return false;
            } else {
                try {
                    // 减去已经消耗的时间
                    time -= System.currentTimeMillis() - current;
                    // 如果还有剩余等待时间
                    if (time > 0L) {
                        do {
                            // 获取当前时间戳
                            long currentTime = System.currentTimeMillis();
                            // 再次尝试获取锁,返回锁的剩余过期时间
                            ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
                            // 如果锁成功获取
                            if (ttl == null) {
                                return true;
                            }
                            // 减去已经消耗的时间
                            time -= System.currentTimeMillis() - currentTime;
                            // 如果等待时间已经用完,则获取锁失败,返回false
                            if (time <= 0L) {
                                this.acquireFailed(waitTime, unit, threadId);
                                return false;
                            }
                            currentTime = System.currentTimeMillis();
                            // 根据剩余时间尝试获取锁,ttl是别的线程tll毫秒后释放锁
                            if (ttl >= 0L && ttl < time) {
                                // 通过信号量机制来等待别的线程释放锁,在等待期间就已经释放了锁
                                // 等待别的线程释放锁的时长tll
                                // 这里采用信号量机制,等待释放锁的线程释放锁
                           		((RedissonLockEntry)subscribeFuture.getNow()).
                               getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                            } else {
                                // 如果这个ttl时间比当前线程尝试获取锁的时间还长
                                // 那么就直接等待尝试获取锁的时间
                                ((RedissonLockEntry)subscribeFuture.getNow()).
                                    getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                            }
                            // 减去已经消耗的时间
                            time -= System.currentTimeMillis() - currentTime;
                        } while(time > 0L); // 如果还有剩余等待时间,则继续尝试获取锁
                        // 如果等待时间已经用完,则获取锁失败,返回false
                        this.acquireFailed(waitTime, unit, threadId);
                        return false;
                    }
                    // 如果等待时间已经用完,则获取锁失败,返回false
                    this.acquireFailed(waitTime, unit, threadId);
                    return false;
                } finally {
                    // 释放订阅
                    this.unsubscribe(subscribeFuture, threadId);
                }
            }
        }
    }
}
定时更新有效期

那么此时还有一个问题,如果当前持有锁的线程业务阻塞了,TTL到期了别其它线程获取到了锁,那么此时就会有安全问题了

而Redisson是通过看门狗来解决这个问题的

java 复制代码
/**
 * 尝试以异步方式获取锁的剩余过期时间。
 * @param waitTime 等待时间
 * @param leaseTime 锁的租期时间
 * @param unit 时间单位
 * @param threadId 当前线程ID
 * @return 表示剩余过期时间的Future对象
 */
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    // 如果租期时间不为-1,即锁具有过期时间
    if (leaseTime != -1L) {
        // 调用tryLockInnerAsync方法尝试获取锁
        return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        // 如果租期时间为-1,即锁没有过期时间,则获取锁的过期时间为锁守护超时时间
        // 调用tryLockInnerAsync方法尝试获取锁的剩余过期时间
        RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(
            waitTime, 
            this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), 
            TimeUnit.MILLISECONDS, 
            threadId, 
            RedisCommands.EVAL_LONG
        );
        // 在获取剩余过期时间的异步结果完成后,执行回调
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            if (e == null) { // 如果没有发生异常
               // 如果剩余过期时间为null,表示锁成功获取,但是没有获取到剩余过期时间
                // 对应尝试获取锁的Lua脚本返回值
                if (ttlRemaining == null) { 
                    // 更新锁的有效期(继续往下看文章)
                    this.scheduleExpirationRenewal(threadId);
                }
            }
        });
        return ttlRemainingFuture; // 返回表示剩余过期时间的Future对象
    }
}

更新锁的过期时间的

java 复制代码
/**
 *更新锁的有效期
 * @param threadId 当前线程ID
 */
private void scheduleExpirationRenewal(long threadId) {
    // 这个entry里主要存储了两个东西,一个是更新锁释放时间的定时任务,还有一个就是线程ID
    ExpirationEntry entry = new ExpirationEntry();
    // 将entry添加到ConcurrentHashMap中,如果是第一次添加则会返回null
    // 保证每次重入拿到的是同一个extry
    ExpirationEntry oldEntry = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry);
    if (oldEntry != null) {
        // 将当前线程ID添加到旧的entry中
        oldEntry.addThreadId(threadId);
    } else { 
        // 如果旧的条目为null,说明是第一次添加该条目
        // 将当前线程ID添加到map中
        entry.addThreadId(threadId);
        // 第一次来,就需要创建的更新释放时间的定时任务
        this.renewExpiration();
    }
}

更新过期时间代码

java 复制代码
/**
 * 续约锁的过期时间。
 */
private void renewExpiration() {
    // 获取锁的过期续约条目
    ExpirationEntry ee = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
    // 如果续约条目不为null
    if (ee != null) {
        // 创建一个定时任务,用于定时执行续约操作
        Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            public void run(Timeout timeout) throws Exception {
                // 获取锁的过期续约条目
                ExpirationEntry ent = (ExpirationEntry)RedissonLock.EXPIRATION_RENEWAL_MAP.get(RedissonLock.this.getEntryName());
                // 如果续约条目不为null
                if (ent != null) {
                    // 获取第一个等待续约的线程ID
                    Long threadId = ent.getFirstThreadId();
                    // 如果线程ID不为null
                    if (threadId != null) {
                        // 异步执行续约操作
                        RFuture<Boolean> future = RedissonLock.this.renewExpirationAsync(threadId);
                        // 在续约操作完成后执行回调
                        future.onComplete((res, e) -> {
                            if (e != null) {
                                // 如果续约操作出现异常,则记录日志
                                RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", e);
                            } else {
                                // 如果更新超时时间成功,继续递归更新超时时间
                                if (res) {
                                    RedissonLock.this.renewExpiration();
                                }
                            }
                        });
                    }
                }
            }
        }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS); // 更新间隔为看门狗时间的1/3也就是30秒
        // 将定时任务设置到续约条目中
        ee.setTimeout(task);
    }
}

更新锁释放时间的lua源码在renewExpirationAsync方法里

java 复制代码
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    //...
}
lua 复制代码
-- 判断该锁是否当前线程持有
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 更新锁释放时间为看门狗的默认时间30
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1; 
end; 
return 0;

再来看一下释放锁的源码

java 复制代码
/**
 * 异步释放锁。
 * @param threadId 当前线程ID
 * @return 表示释放结果的Future对象
 */
public RFuture<Void> unlockAsync(long threadId) {
    // 创建一个Promise对象,用于表示释放结果
    RPromise<Void> result = new RedissonPromise();
    // 异步执行内部的解锁操作
    RFuture<Boolean> future = this.unlockInnerAsync(threadId);
    // 在解锁操作完成后执行回调
    future.onComplete((opStatus, e) -> {
        // 取消锁的自动更新释放时间
        this.cancelExpirationRenewal(threadId);
        if (e != null) { // 如果解锁操作出现异常
            // 设置Promise为失败状态,并将异常作为失败原因
            result.tryFailure(e);
        } else if (opStatus == null) { // 如果操作状态为null,说明锁未被当前线程持有
            // 设置Promise为失败状态,并抛出IllegalMonitorStateException异常
            IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: " + this.id + " thread-id: " + threadId);
            result.tryFailure(cause);
        } else { // 否则,解锁成功
            // 设置Promise为成功状态
            result.trySuccess(null);
        }
    });
    // 返回表示释放结果的Future对象
    return result;
}

取消自动更新锁的释放时间方法

EXPIRATION_RENEWAL_MAP对应上面添加更新任务,这个Map里存的是一个个ExpirationEntry,ExpirationEntry里主要包含定时更新锁释放时间的任务和线程ID

java 复制代码
/**
 * 取消锁的过期续约。
 * @param threadId 要取消续约的线程ID,如果为null,则表示取消所有线程的续约
 */
void cancelExpirationRenewal(Long threadId) {
    // 获取锁的过期续约任务
    ExpirationEntry task = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
    // 如果过期续约任务不为null
    if (task != null) {
        // 如果线程ID不为null,则移除指定线程的续约
        if (threadId != null) {
            task.removeThreadId(threadId);
        }
        // 如果线程ID为null,或者任务已经没有任何线程在续约
        if (threadId == null || task.hasNoThreads()) {
            // 获取任务的定时器任务
            Timeout timeout = task.getTimeout();
            // 如果定时器任务不为null,则取消定时器任务
            if (timeout != null) {
                timeout.cancel();
            }
            // 从过期续约映射中移除该任务
            EXPIRATION_RENEWAL_MAP.remove(this.getEntryName());
        }
    }
}
小结

尝试获取锁时可以指定3个参数

java 复制代码
// 尝试获取锁(获取锁的最大等待时间(期间会重试),锁自动释放的时间,时间单位)
    boolean success = lock.tryLock(1,10,TimeUnit.SECONDS);
  • 首先线程来获尝试获取锁(调用Lua脚本),判断Lua脚本的返回值TTL是否为NULL(为剩余释放时间说明获取失败)
  • ttl为空说明获取成功,判断锁的释放时间是否设置,为-1表示未设置,则开启看门狗(看门狗的释放锁时间为30秒)
  • 如果自己设置了锁的释放时间则不会启用开门狗,也就是是不会自动更新释放时间
  • 如果获取TTL不为null说明锁被其他线程给占用了,被占用就去判断当前线程的剩余等待时间是否大于0
  • 剩余等待时间不大于0则说明等待超时直接返回false表示获取锁失败
  • 剩余等待时间大于0则订阅等待锁的释放信号,等待别的线程的剩余释放时间
  • 如果别的线程的释放时间大于等于当前获取锁线程的最大等待时间,则当前线程直接等待最大等待时间
  • 等待完毕后判断等待时间是否超时,是则返回false获取锁失败
  • 没有超时则继续尝试获取锁

释放锁流程

  • 线程尝试获取锁,判断是否获取成功
  • 如果获取 成功,则发生释放锁消息给其他订阅线程,并取消看门狗
  • 获取失败则记录异常

Redisson分布式锁原理

  • 可重入:利用hash结构记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用看门狗机制,每隔一段时间 (releaseTime/3),重置超时时间

Redisson分布式锁主从一致性问题

如果采用Redis主从来实现分布式锁,当主节点宕机了,而锁的信息还没有同步过去,就会导致锁的信息丢失。

为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

通过这种联锁的方式就可以解决锁信息丢失的问题。


相关推荐
fpcc7 小时前
redis6.0之后的多线程版本的问题
c++·redis
刘九灵7 小时前
Redis ⽀持哪⼏种数据类型?适⽤场景,底层结构
redis·缓存
登云时刻10 小时前
Kubernetes集群外连接redis集群和使用redis-shake工具迁移数据(一)
redis·kubernetes·bootstrap
煎饼小狗15 小时前
Redis五大基本类型——Zset有序集合命令详解(命令用法详解+思维导图详解)
数据库·redis·缓存
秋意钟17 小时前
缓存雪崩、缓存穿透【Redis】
redis
简 洁 冬冬17 小时前
046 购物车
redis·购物车
soulteary18 小时前
突破内存限制:Mac Mini M2 服务器化实践指南
运维·服务器·redis·macos·arm·pika
wkj00119 小时前
php操作redis
开发语言·redis·php
菠萝咕噜肉i19 小时前
超详细:Redis分布式锁
数据库·redis·分布式·缓存·分布式锁
登云时刻21 小时前
Kubernetes集群外连接redis集群和使用redis-shake工具迁移数据(二)
redis·容器·kubernetes