• 简单实现
  • RedissonLock
  • lock
  • tryLock
  • unlock
  • 参考

 

简单实现


JDK 中的锁种类繁多、功能齐全,但是有效范围仅仅限于单机 JVM 内,当需要对集群中多个 JVM 中的某个并行的操作全局串行化的时候,就要用到分布式的互斥锁了。当集群中所有节点使用同一份缓存的时候,用缓存实现分布式互斥锁是一种有效的手段。

实现思路

所有 JVM 约定一样的 key,并尝试在缓存中插入数据。若该 key 已存在,表示该 key 代表的锁已被持有,获取失败,如果该 key 不存在,则插入该 key 的 JVM 获取到锁,进行后续操作。

在此情境下,若持有锁的节点挂掉,则锁永远不会被释放,所以在获取锁的时候,需要自行设置锁的过期时间,以免发生此类死锁的情况。

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;

@Service
public class RedisLockService {

    private static final String LOCK_SUCCESS = "OK";
    
    @Autowired
    JedisPool jedisPool;

    /**
     * 尝试加分布式锁
     * @param key 锁的 key
     * @param requestId 标识加锁客户端的 value
     * @return 是否加锁成功
     */
    public boolean lock(String key, String requestId, int expireSeconds) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            SetParams params = new SetParams();
            // 10 秒后过期
            params.ex(expireSeconds);
            params.nx();
            String result = jedis.set(key, requestId, params);
            return LOCK_SUCCESS.equals(result);
        } finally {
            returnToPool(jedis);
        }
    }

    /**
     * 尝试释放分布式锁
     * @param key 锁的 key
     * @param requestId 标识加锁客户端的 value
     * @return 是否释放成功
     */
    public boolean unlock(String key, String requestId) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            // 此段为 Lua 脚本,用来保证原子性:
            // 首先获取锁对应的 value 值,检查是否与 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(key), Collections.singletonList(requestId));
            return LOCK_SUCCESS.equals(result);
        } finally {
            returnToPool(jedis);
        }
    }
}

多线程甚至多个 JVM 竞争同一把锁的时候,只有一个线程能获取到锁,其他线程必须等待或放弃。可以模仿 AQS 中的方式,处理其他线程,也可以采用下面三种简单的方案:

  • 直接抛出异常,通知用户,放弃任务
  • while 循环,sleep 一段时间,重试获取锁
  • 将请求转移到消息队列,过一会再尝试

在笔者的 “限流工具与秒杀系统实现 ” 中,由于单个线程持有锁的时间很短,故采用第二种方案,且不设置 sleep 的时间,仅仅只是自旋。伪代码如下所示:

while (需要执行流程 A) {
    if (获取锁 lock 成功) {
        try {
            if (需要执行流程 A) {
                ---
                执行流程 A
                ---
            } finally {
                ---
                释放锁 unlock
                ---
            }
        }
    }
}

 

RedissonLock

Redisson 封装了分布式锁的接口和实现类,下文以可重入锁 RLock 为例。

RLock 主要有以下几个方法:

//加锁操作,锁的有效期默认30秒,到时间自动释放
    void lock();
    // 可以设置锁有效时间的加锁操作
    // leaseTime 表示有效时间,unit 表示时间单位
    void lock(long leaseTime, TimeUnit unit);
    // 有返回值的加锁操作,成功返回 true,失败(或者锁已被获取)返回 false。
    boolean tryLock();
    // 指定等待时间和锁有效时间的加锁操作
    // waitTime 表示最长等待时间,超过时间就不再等待锁了
    boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
    // 指定等待时间的加锁操作
    boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException;
    // 释放锁
    void unlock();

lock

RedissonLock 实现了 RLock 接口,控制锁的写入和释放的主要有以下全局变量:

// 锁默认释放的时间:30 * 1000,即30秒
protected long internalLockLeaseTime;
// 客户端的唯一标识,只有持有锁的客户端才能释放锁
final String id;
// 订阅者模式,当释放锁的时候,其他客户端能够知道锁已经被释放的消息,并让队列中的第一个消费者获取锁。使用 PUB/SUB 消息机制的目的:减少申请锁时的等待时间、安全、 锁带有超时时间、锁的标识唯一、防止死锁。
protected final LockPubSub pubSub;

lock 方法主要有两个参数,lease 表示最晚释放锁的时间(防止死锁),interruptible 表示等待的线程是否响应中断。

// 没有返回值。
    // leaseTime 表示最晚释放锁的时间,unit 是时间的格式,interruptibly 表示是否响应中断。
    private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
        // 线程 ID
        long threadId = Thread.currentThread().getId();
        // 尝试加锁
        Long ttl = this.tryAcquire(leaseTime, unit, threadId);
        // 如果 ttl 不等于 null ,说明锁已经被获取了
        if (ttl != null) {
            // 订阅锁
            RFuture<RedissonLockEntry> future = this.subscribe(threadId);
            // 相应中断或不响应中断
            if (interruptibly) {
                this.commandExecutor.syncSubscriptionInterrupted(future);
            } else {
                this.commandExecutor.syncSubscription(future);
            }
            
            try {
                // 进入自旋
                while(true) {
                    // 每次自旋都尝试获取锁
                    ttl = this.tryAcquire(leaseTime, unit, threadId);
                    // 获取成功
                    if (ttl == null) {
                        return;
                    }

                    // 等待锁被释放
                    if (ttl >= 0L) {
                        try {
                            ((RedissonLockEntry)future.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                        } catch (InterruptedException var13) {
                            if (interruptibly) {
                                throw var13;
                            }

                            ((RedissonLockEntry)future.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                        }
                    } else if (interruptibly) {
                        ((RedissonLockEntry)future.getNow()).getLatch().acquire();
                    } else {
                        ((RedissonLockEntry)future.getNow()).getLatch().acquireUninterruptibly();
                    }
                }
            } finally {
                // 取消订阅
                this.unsubscribe(future, threadId);
            }
        }
    }

tryAcquire 方法里使用到了 lua 脚本访问 Redis,保证操作的原子性,如下所示:

-- 检查 key 是否被占用,如果没有则设置超时时间和未必表示,初始化 value 等于 1
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; 
-- 如果锁重入,且锁的 key field 都一直的情况下 value 加一
-- 而且要重新设置超时时间
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]);

可以看出大致思路和上一节中简单的实现差不多。使用了发布订阅功能使获取锁更及时,也节约了计算资源;使用 redis 中哈希结构的 value 的值表示重入的次数,使其具有可重入锁的功能。


tryLock

tryLock 也是尝试获取锁的函数,但需要设置等待时间,如果等待时间已经耗尽但是还没有获取到锁,则返回 false,表示失败,反之,返回 true 表示锁获取成功。

public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long time = unit.toMillis(waitTime);
        long current = System.currentTimeMillis();
        long threadId = Thread.currentThread().getId();
        // 尝试获取锁
        Long ttl = this.tryAcquire(leaseTime, unit, threadId);
        // 获取成功,直接返回 true
        if (ttl == null) {
            return true;
        } else {
            // 经过上面的流程之后,计算还剩多长的等待时间
            time -= System.currentTimeMillis() - current;
            // 没有等待时间了,返回 false
            if (time <= 0L) {
                this.acquireFailed(threadId);
                return false;
            } else {
                // 订阅监听 Redis 消息,
                current = System.currentTimeMillis();
                RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
                // 阻塞等待 subscribe 的 future 的结果对象。
                // 如果 await 返回 false,说明 subscribe 方法调用超过了 time,说明已经超过客户端设置的最大等待时间,返回 false, 取消订阅,不再继续申请锁。
                if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
                    if (!subscribeFuture.cancel(false)) {
                        subscribeFuture.onComplete((res, e) -> {
                            if (e == null) {
                                this.unsubscribe(subscribeFuture, threadId);
                            }
                        });
                    }
                    this.acquireFailed(threadId);
                    return false;
                } else {
                    try {
                        // 再次检查是否超过时间
                        time -= System.currentTimeMillis() - current;
                        if (time <= 0L) {
                            this.acquireFailed(threadId);
                            boolean var20 = false;
                            return var20;
                        } else {
                            // 没有超过等待时间,通过 while 一直获取锁,直到成功或失败
                            boolean var16;
                            do {
                                long currentTime = System.currentTimeMillis();
                                ttl = this.tryAcquire(leaseTime, unit, threadId);
                                // 成功
                                if (ttl == null) {
                                    var16 = true;
                                    return var16;
                                }

                                time -= System.currentTimeMillis() - currentTime;
                                // 等待时间耗尽,获取失败
                                if (time <= 0L) {
                                    this.acquireFailed(threadId);
                                    var16 = false;
                                    return var16;
                                }

                                currentTime = System.currentTimeMillis();
                                if (ttl >= 0L && ttl < time) {
                                    ((RedissonLockEntry)subscribeFuture.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                                } else {
                                    ((RedissonLockEntry)subscribeFuture.getNow()).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                                }

                                time -= System.currentTimeMillis() - currentTime;
                            } while(time > 0L);

                            this.acquireFailed(threadId);
                            var16 = false;
                            return var16;
                        }
                    } finally {
                        this.unsubscribe(subscribeFuture, threadId);
                    }
                }
            }
        }
    }

unlock

unlock 的主要逻辑是下面这一段 lua 脚本:

-- 如果锁存在但值不匹配,说明锁不是自己的,无权释放,返回空
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end; 
-- 如果锁存在,且值匹配,将重入次数 -1,如果重入次数大于 0,更新锁的过期时间,不能释放,返回 0 
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; 
-- 删除 key 并释放锁,然后发布锁已释放的消息
else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;

当且仅当释放锁客户端和加锁客户端匹配且重入次数降为 0 时才能释放锁。