基于spring-data-redis的RedisTemplate 和 lua 脚本的 redis 分布式锁的实现

基于 RedisTemplate 和 lua 脚本的 redis 分布式锁的实现

分布式锁一般的实现方法有三种:基于数据库锁。(行级锁如唯一约束或乐观锁的版本号方式),基于redis 的分布式锁
和基于zookeeper 的分布式锁。网上有很多关于 redis 分布式锁的实现,本文介绍的是基于 redis 锁的一种简单易用实现方式:基于 spring-data-redis 的RedisTemplate 的实现方式。
实现分布式锁要满足的几个条件:

  1. 锁的特性:在任一时刻,只能有一个线程持有锁。
  2. 不能死锁:即使持锁线程崩溃没有主动释放锁也不会影响其他线程持续阻塞。基于redis 过期时间能很好的避免这个问题。
  3. 只有加锁的线程才能解锁,其他线程不能强制抢占锁,即:强制把别的线程的锁释放。
  4. 能容错:部分redis节点挂了,不会造成所有任务中止。
  5. 加锁操作必须是原子操作(网上有些基于java 的实现先set再del是不严谨的)

代码实现

  1. 首先,自定义一个RedisTemplate, 替换 spring boot 默认装载的 redisTemplate , 因为默认的redisTemplate 序列化用的是 Jackson2JsonRedisSerializer,序列化与反序列化过程中会出现格式问题)
@Bean
public RedisTemplate<String, Serializable> redisTemplateDemo(RedisConnectionFactory redisConnectionFactory) {
	RedisTemplate<String, Serializable> template = new RedisTemplate<>();
	template.setConnectionFactory(redisConnectionFactory);
	// 使用 Jackson2JsonRedisSerializer 替换默认序列化
	Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
	ObjectMapper om = new ObjectMapper();
	om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
	om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
	jackson2JsonRedisSerializer.setObjectMapper(om);
	// 字符串序列化器
	StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
	// 普通Key设置为字符串序列化器
	template.setKeySerializer(stringRedisSerializer);
	// Hash结构的key设置为字符串序列化器
	template.setHashKeySerializer(stringRedisSerializer);
	// 普通值和hash的值都设置为jackson序列化器
	template.setValueSerializer(jackson2JsonRedisSerializer);
	template.setHashValueSerializer(jackson2JsonRedisSerializer);
	template.afterPropertiesSet();
	return template;
}
  1. 初始化装载 lua 脚本(classpath:resources/script/*.lua)
	@SuppressWarnings("unchecked")
	@PostConstruct
	public void init() {
		getLockRedisScript = new DefaultRedisScript<String>();
		getLockRedisScript.setResultType(String.class);
		releaseLockRedisScript = new DefaultRedisScript<String>();
		releaseLockRedisScript.setResultType(String.class);

		//初始化装载 lua 脚本
		getLockRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/getLock.lua")));
		releaseLockRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/releaseLock.lua")));

	}
  1. 获得锁脚本 getLock.lua

这里用的是 set 命令加{NX|PX参数}, 有同学喜欢用 setnx,但setnx 不支持过期时间,需要与 expire一起用。命令详见:redis doc(很奇怪setnx命令为什么不支持过期时间 ?)

脚本调用set命令返回OK,加锁成功,若此时再有线程用相同的 key调用 set ,Redis 会返回为空,确保了锁只能被一个线程占有。用request id 作为 value 值,可用UUID.randomUUID()作为线程锁的标识,这样就能保证只有加锁的线程才能解锁。

if redis.call('set',KEYS[1],ARGV[1],'NX','PX',ARGV[2]) then
    return '1'
else
    return '0'
end	

参数解释:
KEYS【1】:key值是为要加的锁定义的字符串常量

ARGV【1】:value值是 request id, 用来防止解除了不该解除的锁. 可用 UUID

ARGV【2】: 过期时间,锁占用时间一般不会太长,业务处理占用锁时间太长会造成其他线程阻塞太久。

注:在 Redis 2.6.12 版本以前,SET 命令总是返回 OK。我用的是redis 版本是 3.0的。

  1. 释放锁脚本 releaseLock.lua
if redis.call('get',KEYS[1]) == ARGV[1] then
    return tostring(redis.call('del',KEYS[1]))
else
    return '0'
end
  1. 加锁 & 解锁

这里 redisTemplateDemo.execute()方法用到的是RedisConnection 的 eval()方法, eval方法可以确保原子性。源于 redis 的特性:eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

	/**
	 * 加锁操作
	 * @param key Redis 锁的 key 值
	 * @param requestId 请求id,防止解了不该由自己解的锁 (随机生成)
	 * @param expireTime 锁的超时时间(毫秒)
	 * @param retryTimes 获取锁的重试次数
	 * @return true or false
	 */

	@SuppressWarnings("unchecked")
	public boolean lock(String key, String requestId, String expireTime, int retryTimes) {
		if (retryTimes <= 0)
			retryTimes = 1;

		try {
			int count = 0;
			while (true) {
				Object result = redisTemplateDemo.execute(getLockRedisScript, stringSerializer, stringSerializer, Collections.singletonList(key),
						requestId, expireTime);
				if (EXEC_RESULT.equals(String.valueOf(result))) {
					return true;
				} else {
					count++;
					if (retryTimes == count) {
						log.warn("has tried {} times , failed to acquire lock for key:{},requestId:{}", count, key, requestId);
						return false;
					} else {
						log.warn("try to acquire lock {} times for key:{},requestId:{}", count, key, requestId);
						Thread.sleep(100);
						continue;
					}
				}
			}
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		return false;
	}
	/**
	 * 解锁操作  
	 * @param key Redis 锁的 key 值
	 * @param requestId 请求 id, 防止解了不该由自己解的锁  (随机生成)
	 * @return true or false
	 */
	@SuppressWarnings("unchecked")
	public boolean unLock(String key, String requestId) {
		Object result = redisTemplateDemo.execute(releaseLockRedisScript, stringSerializer, stringSerializer, Collections.singletonList(key),
				requestId);
		if (EXEC_RESULT.equals(String.valueOf(result))) {
			return true;
		}
		return false;
	}
  1. 测试代码如下

测试场景:100 个线程同时修改一个静态常量值 NUM(初始值为0),每个线程加1,如果不加锁会看到打印结果不都是100。
每个线死循环确保一定获取锁且执行+1操作。这样的锁竞争是比较大了。

	/**
	 * 测试多线程竞争锁场景
	 * 100 个线程同时修改一个静态常量值 NUM(初始值为0)
	 * 每个线程加1,最终结果 100 
	 * (如果不加锁不能确定结果是100)
	 */
	@Override
	public void testLockAndUnlock() {
		String key = "prefix_key123456"; // redis 锁的 key 值
		String expireTime = "5000";// 锁的超时时间(毫秒),评估任务时间,建议任务的时间不要太长
		int retryTimes = 3;// 获取锁的重试次数

		NUM = 0;// 共享变量
		int threasCount = 100;// 线程任务个数

		List<Thread> list = new ArrayList<Thread>();
		for (int i = 0; i < threasCount; i++) {
			list.add(new Thread(new Runnable() {
				@Override
				public void run() {

					// request id, 防止解了不该由自己解的锁。
					String requestId = UUID.randomUUID().toString();
					while (true) { //这里循环操作,以确保该线程一定获得锁并执行线程任务
						
						if (redisUtil.lock(key, requestId, expireTime, retryTimes)) {
							try {
								// 调用业务逻辑
								doSomething();
							} finally {
								redisUtil.unLock(key, requestId);
							}
							break;
						}
					}

				}
			}));
		}

		//启动所有任务线程
		for (Thread t : list) {
			t.start();
		}

		//轮询状态,等待所有子线程完成
		while (true) {
			int aliveThreadCount = 0;
			for (Thread t : list) {
				if (t.isAlive()) {
					++aliveThreadCount;
				}
			}

			if (aliveThreadCount == 0) {
				log.debug("All Threads are completed!");
				break;
			} else {
				log.debug("Threads have not yet completed, sleep 5s!");
				try {
					Thread.sleep(5000);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}

		//打印最终结果值: NUM
		log.info("Completed! The final value of NUM is : {}", NUM);
	}

运行结果:测试多次结果可以看到都是等于 100。

另注:代码只在单机redis环境测试过,多机redis 及集群部署环境没有测试过,欢迎大家提供测试结果和解决方案,有兴趣的同学可以看看Redisson实现。

GitHub源码地址: demo-redis

参考资料:
https://blog.csdn.net/u014495560/article/details/82531046
https://www.cnblogs.com/linjiqin/p/8003838.html
http://redisdoc.com
http://www.redis.cn/topics/distlock.html

你可能感兴趣的:(基于spring-data-redis的RedisTemplate 和 lua 脚本的 redis 分布式锁的实现)