spring-integration-redis中的分布式锁基本使用和源码解析

spring-integration-redis中的分布式锁源码解析

  • 使用
    • 依赖
    • 代码示例
  • 源码解析
    • 获取锁
    • 加锁和锁互斥机制
    • 释放锁和锁可重入机制
  • 总结
    • watch dog机制缺失
    • 加锁的性能太低

使用

依赖

spring-integration-redis中提供了Redis分布式锁的实现,使用spring-integration-redis需要引入以下依赖:

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
dependency>

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-integrationartifactId>
dependency>

<dependency>
  <groupId>org.springframework.integrationgroupId>
  <artifactId>spring-integration-redisartifactId>
dependency>

代码示例

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.integration.redis.util.RedisLockRegistry;

@Configuration
public class RedisLockConfig {
    @Bean
    public RedisLockRegistry redisLockRegistry(RedisConnectionFactory redisConnectionFactory) {
        return new RedisLockRegistry(redisConnectionFactory, "myRegistryKey");
    }
}
@Autowired
private RedisLockRegistry redisLockRegistry;

public void test() {
	// 获取锁对象
	Lock lock = redisLockRegistry.obtain("myLockKey");
	// 加锁
	boolean lockRsp = lock.tryLock(2, TimeUnit.SECONDS);
	try {
		// 业务逻辑
	} catch (Exception ex) {
		ex.printStackTrace();
	} finally {
		// 释放锁
		lock.unlock();
	}
}

源码解析

获取锁

首先看org.springframework.integration.redis.util.RedisLockRegistry类,Redis分布式锁就是由这个类提供,先看obtain方法:

@Override
public Lock obtain(Object lockKey) {
	Assert.isInstanceOf(String.class, lockKey);
	String path = (String) lockKey;
	return this.locks.computeIfAbsent(path, RedisLock::new);
}

这里用到了成员变量locks,locks用于存放RedisLock对象,每个lockKey对应一个RedisLock。computeIfAbsent表示如果有则返回,没有就new一个实例放到map并返回该实例。

private final Map<String, RedisLock> locks = new ConcurrentHashMap<>();

加锁和锁互斥机制

RedisLock是RedisLockRegistry的内部类,实现了java.util.concurrent.locks.Lock接口,对外提供了加锁和释放锁等操作。首先看它的lock方法:

@Override
public void lock() {
	this.localLock.lock();
	while (true) {
		try {
			while (!obtainLock()) {
				Thread.sleep(100); //NOSONAR
			}
			break;
		}
		catch (InterruptedException e) {
			/*
			 * This method must be uninterruptible so catch and ignore
			 * interrupts and only break out of the while loop when
			 * we get the lock.
			 */
		}
		catch (Exception e) {
			this.localLock.unlock();
			rethrowAsLockException(e);
		}
	}
}

这里的核心代码就是obtainLock(),也就是获取锁。获取成功返回true,否则返回false。当返回false时会重试,每次重试之前都会sleep100毫秒,主要是为了防止占用太多的系统资源。但是这也造成了一个弊端,那就是当有多个客户端在竞争锁时,其中一个客户端释放了锁,别的客户端理论上至少要等100毫秒才能拿到锁,所以这种基于重试机制来竞争锁,性能不是很高,但是如果不sleep,会造成CPU占用率过高,所以这两者之间必须得有个权衡。也许通过事件通知机制来告诉其他客户端锁已经释放了会是一个不错的选择。注意到,lock方法通过this.localLock.lock()实现了线程同步,this.localLock是一个可重入锁对象:

private final ReentrantLock localLock = new ReentrantLock();

这里解释一下为什么要在本地实现线程同步,虽然是分布式锁,但是锁的竞争不一定是发生在多个客户端之间,同一个客户端不同的线程竞争锁也是也有可能的,如果没有线程同步,大量线程在下面这段代码自旋,也会使CPU资源耗尽。

while (!obtainLock()) {
	Thread.sleep(100); //NOSONAR
}

还有,在里面获取锁的循环外面为什么还有一个死循环?正如源码中注释所言,应该忽略Thread.sleep抛出的InterruptedException,直到获取到锁才能跳出循环体。所以,在里层循环因为InterruptedException中断时,还会继续获取分布式锁,直到获取成功为止。
我们再看这个obtainLock()方法:

private boolean obtainLock() {
	boolean success = RedisLockRegistry.this.redisTemplate.execute(RedisLockRegistry.this.obtainLockScript,
			Collections.singletonList(this.lockKey), RedisLockRegistry.this.clientId,
			String.valueOf(RedisLockRegistry.this.expireAfter));
	if (success) {
		this.lockedAt = System.currentTimeMillis();
	}
	return success;
}

它通过redisTemplate执行了一段lua脚本:

private static final String OBTAIN_LOCK_SCRIPT =
	"local lockClientId = redis.call('GET', KEYS[1])\n" +
			"if lockClientId == ARGV[1] then\n" +
			"  redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" +
			"  return true\n" +
			"elseif not lockClientId then\n" +
			"  redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])\n" +
			"  return true\n" +
			"end\n" +
			"return false";

调整下格式:

local lockClientId = redis.call('GET', KEYS[1])
if lockClientId == ARGV[1] then
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
  return true
elseif not lockClientId then
  redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
  return true
end 
return false

在Redis中,lua脚本是原子的,中间不会被其他命令插入。这里解释下参数,KEYS[1]就是上面调用RedisLockRegistry的obtain方法传入的那个lockKey,以它作为Redis中的key;ARGV[1]是客户端ID,是key对应的value;ARGV[2]是上述key-value的超时时间。所以上述脚本的意思是:

  1. 通过GET命令获取持有此锁的客户端ID,有可能是nil;
  2. 如果持有此锁的客户端ID等于调用此脚本的客户端ID,则重置当前缓存的超时时间并返回true;
  3. 如果返回的是nil,则设置缓存,同时设置缓存的过期时间,设置成功返回true;
  4. 其他情况,例如持有此锁的客户端ID和调用此脚本的客户端ID不相同,直接返回false。

释放锁和锁可重入机制

再看另外一个很重要的方法unlock:

@Override
public void unlock() {
	if (!this.localLock.isHeldByCurrentThread()) {
		throw new IllegalStateException("You do not own lock at " + this.lockKey);
	}
	if (this.localLock.getHoldCount() > 1) {
		this.localLock.unlock();
		return;
	}
	try {
		if (Thread.currentThread().isInterrupted()) {
			RedisLockRegistry.this.executor.execute(() ->
					RedisLockRegistry.this.redisTemplate.delete(this.lockKey));
		}
		else {
			RedisLockRegistry.this.redisTemplate.delete(this.lockKey);
		}

		if (logger.isDebugEnabled()) {
			logger.debug("Released lock; " + this);
		}
	}
	catch (Exception e) {
		ReflectionUtils.rethrowRuntimeException(e);
	}
	finally {
		this.localLock.unlock();
	}
}

刚才在看lua脚本时我就有些疑问,RedisLock难道不是可重入的?直到看到unlock方法里的这段代码,才解答的我的疑惑:

if (this.localLock.getHoldCount() > 1) {
	this.localLock.unlock();
	return;
}

在当前线程中,localLock每调用一次lock方法,holdCount就+1,每调用一次unlock方法,holdCount就-1,所以如果holdCount>1,说明当前线程还继续持有锁,故直接return,不用去操作redis. 这样就实现了RedisLock的可重入。
最终释放锁时,直接通过redisTemplate删除对应的redis缓存。

总结

watch dog机制缺失

在源码中我并没有找到传说中的watch dog机制相关代码(不清楚是没找到还是真没实现),什么是watch dog机制?在源码中默认的redis key超时时间是60秒,也就是说60秒后锁就自动释放了,一般情况下我们的业务不会执行这么久,但要是真有这种极端情况,那就有问题了。watch dog可以定期检测,如果客户端还持有锁,那就给key续时。

加锁的性能太低

上面源码已经分析过了,竞争锁失败时通过自旋的方式来重试,由于担心CPU占用率过高,每次重试之前sleep 100毫秒,当有多个客户端在竞争锁时,其中一个客户端释放了锁,别的客户端理论上至少要等100毫秒才能拿到锁。

你可能感兴趣的:(分布式系统,redis,spring,spring,boot,lua,java)