Redis 分布式锁

566 阅读8分钟

通过可以避免竞争带来的数据不一致的问题。Java中的锁,Synchronized、Lock锁只能解决同一个JVM进程中的线程竞争带来的此类问题。对于集群部署,和分布式部署的便无能为力了。

解决此类问题的思路和解决分布式全局id生成器的思路类似。都需要依靠于数据库生成唯一的标识供给多个服务进程使用。

原理

下面介绍一下分布式锁在实现过程中会遇到哪些问题,Redis又是如何解决这些问题的。

加锁

加锁要解决两个问题,锁不能被失效,通过设置锁的过期时间防止死锁问题

先介绍一下Redis两个命令。

SETNX key value

SETNX 是 SET if Not Exists, 效果是如果key不存在,则set成功返回1,否则返回0.

这个命令解决了锁失效的问题。

SETEX key seconds value

SETEX是 SET Expire的意思。将key设置一个过期时间,并将key的生存时间设置为seconds(以秒为单位)

这个命令解决死锁的问题

另外,还需要保证设置锁和设置锁过期时间是一个原子操作,幸好,Redis的LUA脚本,执行时带有原子性,解决了这个问题。

锁续期

解决完加锁的问题,锁还有可能出现,线程A获取锁,但是还未执行完毕,锁就过期了。因而线程B也成功的获取到了锁。这样就造成了A和B同时拥有了锁。所以需要引入锁续期

具体的实现方式是在线程获取锁的时候,开启一个守护线程,给线程进行锁续期。当执行业务逻辑的线程执行完毕后关掉守护线程。

解锁

解锁的逻辑就比较简单了,就是需要确保,谁加锁,谁解锁,或者被加锁的对象挂了,由过期时间自动解锁。

Redission的实现

上面的锁只是最基础的锁逻辑讨论,其他比较复杂的锁,例如可重入锁(Reentrant Lock)等,实现起来比较复杂。下面来介绍一下Redission是如何实现可重入锁的。

下面先来,应用一下Redission,后面从日志文件到源码一步步分析它是如何实现可重入的分布式锁。

案例

Redission支持redis单例,主从,哨兵,集群模式。不同的模式配置有些许区别。

单机模式

	private static Config getSignalRLock() {
		Config config = new Config();
        // 默认的看门狗时间为30s,为了便于观察设置为6s
		config.setLockWatchdogTimeout(6000L);
		config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(13);
		return config;
	}

其他模式的配置,可以参考Redission文档

测试代码

	public static final String REDIS_LOCK_FLAG = "redis_lock_flag";

	public static final Integer NUM = 2;

	public static final Logger logger = LoggerFactory.getLogger(Demo.class);
	
	public static void main(String[] args) {
        // 获取锁对象
		Config signalRLock = getSignalRLock();
		RedissonClient redissonClient = Redisson.create(signalRLock);
		final RLock lock = redissonClient.getLock(REDIS_LOCK_FLAG);
        // 创立线程池
		ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(NUM, NUM, 10L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
        // 防止子线程还未执行完毕, 主线程就退出了, 导致看不到日志
		final CountDownLatch countDownLatch = new CountDownLatch(NUM);
		for (int i = 0; i < NUM; i ++) {
 			threadPoolExecutor.execute(new Runnable() {
				@Override
				public void run() {
					// 加锁
					logger.info(Thread.currentThread().getName() + " is ready to get lock");
                    lock.lock();
					logger.info(Thread.currentThread().getName() + " get lock");
					try {
						Thread.sleep(3000L);
					}catch (Exception e){
						e.printStackTrace();
					}
                    // 解释
					lock.unlock();
					logger.info(Thread.currentThread().getName() + " release lock");
					countDownLatch.countDown();
				}
			});
		}
		try {
			countDownLatch.await();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}

代码的执行日志如下(简化信息):

// 线程2 和 线程1 出现了竞争锁的关系
15:55:53.518 [pool-2-thread-2] INFO com.springboot.redission.Demo - pool-2-thread-2 is ready to get lock
15:55:53.518 [pool-2-thread-1] INFO com.springboot.redission.Demo - pool-2-thread-1 is ready to get lock
// 线程1 尝试获取锁
15:55:53.540 [pool-2-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('exists', KEYS[1]) == 0)...
// 线程2 也尝试获取锁    
15:55:53.540 [pool-2-thread-2] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('exists', KEYS[1]) == 0)...
// 线程2 获取到了锁
15:55:53.560 [pool-2-thread-2] INFO com.springboot.redission.Demo - pool-2-thread-2 get lock
15:55:53.603 [pool-2-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command 
// 看门狗线程 执行锁续期   [注意该日志时间 与 线程2 获取锁的时间 相差了2s.]    
15:55:55.634 [pool-1-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)...
// 线程2任务执行完毕 释放锁
15:55:56.561 [pool-2-thread-2] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)...
15:55:56.577 [pool-2-thread-2] INFO com.springboot.redission.Demo - pool-2-thread-2 release lock

 // 线程1 再次尝试获取锁
15:55:56.584 [pool-2-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('exists', KEYS[1]) == 0) ...
15:55:56.614 [pool-2-thread-1] INFO com.springboot.redission.Demo - pool-2-thread-1 get lock
 // 看门狗线程 执行锁续期    
15:55:58.635 [pool-1-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) ...
 // 线程1任务执行完毕 释放锁   
15:55:59.619 [pool-2-thread-1] DEBUG org.redisson.command.RedisExecutor - acquired connection for command (EVAL) and params [if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)...
15:55:59.624 [pool-2-thread-1] INFO com.springboot.redission.Demo - pool-2-thread-1 release lock

可以看到Redission锁发挥了作用。

加锁

先来看看,lock方法的执行源码

    private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
        // 尝试获取锁
        // ttl 为Time to live 存活时间的意思
        long threadId = Thread.currentThread().getId();
        Long ttl = tryAcquire(leaseTime, unit, threadId);
        // 获取成功返,则返回
        if (ttl == null) {
            return;
        }
		// 获取失败,则订阅到对应这个锁的channel
        RFuture<RedissonLockEntry> future = subscribe(threadId);
        commandExecutor.syncSubscription(future);
	
        try {
            while (true) {
                // 再次尝试获取锁
                ttl = tryAcquire(leaseTime, unit, threadId);
                // 获取成功 返回
                if (ttl == null) {
                    break;
                }

                // ttl大于0 则等待ttl时间后继续尝试获取
                if (ttl >= 0) {
                    try {
                        getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    } catch (InterruptedException e) {
                        if (interruptibly) {
                            throw e;
                        }
                        getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    }
                } 
                // ttl小于0 说明 锁已经过期了,尝试获取锁
                else {
                    if (interruptibly) {
                        getEntry(threadId).getLatch().acquire();
                    } else {
                        getEntry(threadId).getLatch().acquireUninterruptibly();
                    }
                }
            }
        } finally {
            unsubscribe(future, threadId);
        }
    }

接下来,看看tryAcquire方法,到底干了啥。逐步步深入到tryAcquireAsync方法

    private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
        // 有参方法调用
        if (leaseTime != -1) {
            return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        }
        // 无参方法调用
        // 默认的过期时间为30s
        RFuture<Long> ttlRemainingFuture = 			     tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        // 锁续期 相关代码
		...
    }

接下来,看看tryLockInnerAsync的源码

    <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        // 转换过期时间
        internalLockLeaseTime = unit.toMillis(leaseTime);
		
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  // 如果锁不存在,则通过hset命令设置线程id作为它的值,并且设置过期时间                            
                  "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; " +
                                              
                  // 如果锁已存在,并且为持有锁的为当前线程,则通过hincrby 将当前值+1(可重入)                     
                  "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]);",
                    Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
    }

下面,画个流程图总结一下整个加锁的过程

需要注意的是,传入leaseTime的方法,不会开启锁续期的线程,所以可能会出现锁被多个线程同时持有的情况

锁续期

上文中提到了锁续期的情况,现在来看看锁续期是如何实现的。目光再次回到加锁的方法tryAcquireAsync.

    private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
      	// 加锁相关方法
        ... 
        // 锁续期相关代码    
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            // e表示异常
            if (e != null) {
                return;
            }
            // ttlRemaining 为null 表示加锁成功
            if (ttlRemaining == null) {
                scheduleExpirationRenewal(threadId);
            }
        });
        return ttlRemainingFuture;
    }

可以看到scheduleExpirationRenewal方法是精华所在,继续往下走,看看这个方法做了啥。

	private void scheduleExpirationRenewal(long threadId) {
    	// 此处是EXPIRATION_RENEWAL_MAP 结构为 ConcurrentMap<String, ExpirationEntry>
        // String 为当前RedissionLock 的EnteryName
        ExpirationEntry entry = new ExpirationEntry();
        ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
        // 如果oldEntery 不为空, 说明当前锁已被当前线程占有(锁重入了)。不需要额外开启守护线程
        if (oldEntry != null) {
            oldEntry.addThreadId(threadId);
        }
        // 如果oldEntery不存在,需要开启守护线程
        else {
            entry.addThreadId(threadId);
            renewExpiration();
        }
    }

可以看到该方法主要是为了区分,当前占有锁的线程是否为可重入占有,如果是重入占有,则不需要额外开启锁续约线程。因为之前已经存在了锁续约线程。接下来看看renewExpiration方法具体做了啥。

  private void renewExpiration() {
        // 为null直接返回
        ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
        if (ee == null) {
            return;
        }
        //     Timeout newTimeout(TimerTask task, long delay, TimeUnit unit);
        //    任务延时执行,延时时间 为 设定的 锁过期时间的 1/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;
                }
                // 锁续约LUA代码
                RFuture<Boolean> future = renewExpirationAsync(threadId);
                // res -- 续约结果 , e续约过程中的异常
                future.onComplete((res, e) -> {
                    if (e != null) {
                        log.error("Can't update lock " + getName() + " expiration", e);
                        return;
                    }
                    // 如果为true重复调用自身
                    if (res) {
                        // reschedule itself
                        renewExpiration();
                    }
                });
            }
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
        
        ee.setTimeout(task);
    }

	// 续期LUA 方法
    // 如果当前线程占有锁,并且还未过期,给锁重新设置过期时间 并且返回true
    // 其他情况,返回false
	protected RFuture<Boolean> renewExpirationAsync(long threadId) {
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                "end; " +
                "return 0;",
            Collections.<Object>singletonList(getName()), 
            internalLockLeaseTime, getLockName(threadId));
    }

到这里,总算明白了,锁续期是如何生效的了。renewExpiration会开启一个延时 1/3 过期时间的续期任务。续期任务续期成功,则重复调用自身。相当于又开启了一个延时的续期任务。续期失败,续约线程则方法执行完毕,自动失效。

另外。这个延时 1/3 过期时间的续期任务,解释了为什么案例中线程每2s进行一次锁续约。

再画一个锁续期的流程图。

解锁

解锁部分调用的方法为unlock,具体的实现方法为unlockAsync,看看它是怎么实现解锁的

    public RFuture<Void> unlockAsync(long threadId) {
        RPromise<Void> result = new RedissonPromise<Void>();
        // 调用LUA进行解锁,待会深入了解一下
        RFuture<Boolean> future = unlockInnerAsync(threadId);
		
        future.onComplete((opStatus, e) -> {
            // 如果解锁异常
            if (e != null) {
                cancelExpirationRenewal(threadId);
                result.tryFailure(e);
                return;
            }
			// 如果解锁状态返回为null
            if (opStatus == null) {
                IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                        + id + " thread-id: " + threadId);
                result.tryFailure(cause);
                return;
            }
            // 取消续约线程方法
            // 具体的做法是 获取 threadId对应的Entry 的值减1
            // 如果减1后的值为0, 则删除该Entry
            cancelExpirationRenewal(threadId);
            result.trySuccess(null);
        });

        return result;
    }

可以,看到解锁的主要方法为unlockInnerAsync, 余下的部分为对其返回参数进行处理的代码。看看unlockInnerAsync到底做了什么。

    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                // 如果释放锁的线程和已存在锁的线程不是同一个线程,返回null                             
                "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                "end; " +
                // 获取锁值减1后的值,释放一次锁                              
                "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                // 如果值大于0, 说明释放后,仍然由当前线程持有锁,返回false                              
                "if (counter > 0) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                    "return 0; " +
                // 值等于0,说明锁释放成功。发布锁释放的消息,并且返回true                              
                "else " +
                    "redis.call('del', KEYS[1]); " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; "+
                "end; " +
                // 其他情况返回null                              
                "return nil;",
                Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    }

可以看到,锁的释放,主要进行的操作就是调用unlockInnerAsync进行原子操作进行解锁。另外加上取消续约线程方法对threadId对应的ExpirationEntry进行减1或者删除操作。

再次画个流程图

总结

整个源码流程看下来,学习到了Redission实现了通过与AQS类似的思想,实现了可重入锁。另外值得关注的就是实现锁续期的代码,主要就是循环调用自身的延时任务,当锁被释放时或者ExpirationEntry不存在时,便不再续约。

另外调用有参的tryLock方法时,不会起开续约任务,所以可能会导致多个线程同时占用锁的问题。

而且使用多个Redis节点时,还可能存在在一个节点上加了锁,还没有同步到其他节点,该节点就宕机了,又有另外一个线程拿着同一把锁进来也可以加锁成功的情况。这也是 Redis 作为分布式锁的一个痛点。Redis 集群之间的同步是异步的,是 AP 模型,并不能保证完全的数据一致性。但是 Redis 的作者使用 Red Lock 来解决这个问题。

引用

本文参考自

个人博主基于Redis的分布式锁之ReentrantLock的文章

掘金用户分布式锁之Redis实现的文章

Redission官方文档