【📕分布式锁通关指南 11】源码剖析redisson之读写锁的实现

196 阅读5分钟

引言

本期我们将深入了解 Redisson 提供的另一个常用分布式锁形态——读写锁(ReadWriteLock) 。在实际业务场景中,对于同一关键资源,不同线程可能只需要“读取”或者“写入”,如果能让读操作并行、而写操作互斥,就能大大提升效率。Redisson 为我们提供了一个典型的读写锁实现,让分布式读多写少的场景更具伸缩性。

介绍

在分布式的场景下,有些数据是“读远多于写”的。比如一些基础配置、商品信息等,这类场景往往更适合使用读写锁——对于读操作只要不涉及数据修改,就可以同时进行;一旦需要写操作,就要独占保持一致性。Redisson 在此基础之上封装了分布式的读写锁 API,使得我们可以把单机环境下的读写锁模式,透明地延伸到集群。

它的常见应用包括:

  • 分布式缓存的配置数据:读操作要并发读,而更新操作需要阻塞所有读操作。
  • 某些共享资源的高并发访问场景:比如日志、统计信息等,与写操作互斥而与读操作并行。

接下来让我们一窥源码,看看 RedissonReadWriteLock(以下简称 RWWLock)是如何实现其读写互斥的。

读写锁的实现思路

和 JDK 自带的 java.util.concurrent.locks.ReadWriteLock 类似,Redisson 的读写锁同样提供两种锁:

  1. 读锁(RLock 类型)
  2. 写锁(RLock 类型)

只不过它会分别负责“读 + 读不互斥、写 + 读写互斥”等同步语义,并在底层与 Redis 交互以保证分布式环境的一致性、正确性。

在 Redisson 中,对外暴露的是 RReadWriteLock 接口,后续通过类似来获取读锁或写锁:

RReadWriteLock rwLock = redissonClient.getReadWriteLock("anyRWLock");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();

核心结构

RedissonReadWriteLock 主要包含以下核心逻辑:

  • 通过 Redis 的 Lua 脚本来保证读锁的可重入及与写锁的互斥关系。
  • 每获取一次读锁,会在 Redis 维护一个计数,用于标识该读锁被多少线程持有。
  • 获取写锁时,需要判断 Redis 中是否已有读锁或写锁被占用,若被占用则需要阻塞或返回获取失败。

源码剖析

看看 RedissonReadWriteLock 的部分源码(为了说明简化展示):

public class RedissonReadWriteLock implements RReadWriteLock {

    final CommandAsyncExecutor commandExecutor;
    private final String writeLockName;
    private final String readLockName;

    public RedissonReadWriteLock(CommandAsyncExecutor commandExecutor, String name) {
        this.commandExecutor = commandExecutor;
        // 为读写锁分别构造不同的 key
        this.writeLockName = "redisson_rwlock{" + name + "}:write";
        this.readLockName = "redisson_rwlock{" + name + "}:read";
    }

    @Override
    public RLock readLock() {
        return new RedissonReadLock(commandExecutor, readLockName, writeLockName);
    }

    @Override
    public RLock writeLock() {
        return new RedissonWriteLock(commandExecutor, writeLockName, readLockName);
    }
}

可以看到 RedissonReadWriteLock 会根据同一个业务名 name,分别生成写锁 key 和读锁 key。随后,它会分别返回 RedissonReadLockRedissonWriteLock 来完成具体的分布式加解锁逻辑。接下来,让我们聚焦更细节的 RedissonReadLockRedissonWriteLock 实现。

读锁

RedissonReadLock 内部通过 Lua 脚本来实现加锁等操作。简化版的加锁逻辑如下:

public class RedissonReadLock extends RedissonBaseLock {

    public RedissonReadLock(CommandAsyncExecutor commandExecutor, String lockName, String writeLockName) {
        super(commandExecutor, lockName, LockType.READ);
        this.writeLockName = writeLockName;
    }

    @Override
    public void lock() {
        try {
            tryLock(-1, null);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        // 关键入口
        return tryAcquire(time, unit);
    }

    private boolean tryAcquire(long waitTime, TimeUnit unit) throws InterruptedException {
        // 省略部分细节
        // 核心:通过在Redis执行lua脚本,判断写锁是否被占用、读锁计数等
        // 如果允许加读锁,则对读锁计数加1;否则阻塞或返回false
        return get(tryLockAsync(waitTime, unit));
    }

    // ...
}

tryAcquire 中,会检查当前是否有写锁占用(包括线程自身是否持有写锁),如果没有,则直接对读锁计数 +1 并返回成功;否则需要阻塞或失败退出。

写锁

RedissonWriteLock 的设计也类似,只是检查的逻辑换成——若读锁计数不为 0 或已有其他写锁线程在占用,则无法获取写锁。这些操作同样是通过发送 Lua 脚本到 Redis 来实现原子性的。

public class RedissonWriteLock extends RedissonBaseLock {

    public RedissonWriteLock(CommandAsyncExecutor commandExecutor, String lockName, String readLockName) {
        super(commandExecutor, lockName, LockType.WRITE);
        this.readLockName = readLockName;
    }

    @Override
    public void lock() {
        try {
            tryLock(-1, null);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return tryAcquire(time, unit);
    }
    
    private boolean tryAcquire(long waitTime, TimeUnit unit) throws InterruptedException {
        // 同样省略部分细节
        // 通过Lua脚本判断是否存在读锁计数或被其他写锁占用
        // 如果可加锁,则把写锁 key(val)设为当前线程标识并加1计数
        return get(tryLockAsync(waitTime, unit));
    }

    // ...
}

释放锁

无论是读锁还是写锁,都会在 unlock() 时进行一个 Lua 脚本调用,对计数器进行 -1。如果读锁计数降到 0,说明没人再持有读锁,可以清除对应的 key 同时释放锁资源;写锁同理——当计数回到 0 时,说明可以彻底释放。

如何保证分布式读写锁的一致性?

  1. Lua 脚本原子性
    Redisson 将所有加锁、解锁、自增或自减计数等动作打包到 Lua 脚本中,并在 Redis 服务器端原子执行,避免了“网络传输过程中分步执行可能导致的并发问题”。
  2. 分隔的 key 空间
    读锁与写锁在 Redis 中对应不同的 key,避免互相冲突同时也便于统计读锁计数。但在加锁的 Lua 脚本中,会先检查写锁 key、再检查读锁 key,或反之,以此维持正确关系。
  3. 可重入语义
    当同一线程再次获取读锁或写锁时,Redis 中的计数会递增,继续维持对资源的持有,这对于分布式场景下的可重入需求非常重要。
  4. 自动过期
    和其他 Redisson 锁类似,读写锁也提供 leaseTime 等机制,让锁可以在指定时间后自动释放,防止意外死锁。

小结

RedissonReadWriteLock 在分布式环境下完美复刻了本地读写锁的行为:读锁可并行、写锁需互斥、读写也互斥。通过 Lua 脚本与 Redis 的结合,它保证了加解锁过程的原子性与正确性,同时在可重入、自动续期等方面做了周全考虑。对于多读少写的分布式业务场景,读写锁可以在并发性能与资源互斥之间取得良好平衡,极大地提升系统吞吐量。

阅读源码能让我们更好地理解这套机制背后的原理与设计,也能帮助我们在日常工作中更加灵活地选型和排查问题。希望本文能给大家带来收获,我们下一期再见!