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锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

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


相关推荐
ketil277 分钟前
Ubuntu 安装 redis
redis
王佑辉1 小时前
【redis】redis缓存和数据库保证一致性的方案
redis·面试
Karoku0662 小时前
【企业级分布式系统】Zabbix监控系统与部署安装
运维·服务器·数据库·redis·mysql·zabbix
gorgor在码农2 小时前
Redis 热key总结
java·redis·热key
想进大厂的小王2 小时前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构
Java 第一深情2 小时前
高性能分布式缓存Redis-数据管理与性能提升之道
redis·分布式·缓存
minihuabei7 小时前
linux centos 安装redis
linux·redis·centos
monkey_meng10 小时前
【Rust中多线程同步机制】
开发语言·redis·后端·rust
hlsd#10 小时前
go 集成go-redis 缓存操作
redis·缓存·golang
奶糖趣多多12 小时前
Redis知识点
数据库·redis·缓存