基于Redis的分布式锁 以及 超详细的改进思路

文章目录

  • 基于Redis的分布式锁
    • 需要实现的两个基本方法
    • 实现思路
    • 第一版代码
      • 改进思路
      • 存在的问题
      • 解决方案
        • 新的问题
        • 解决方案
      • 最终方案
    • 第二版代码
      • 新的问题
      • 解决思路
        • Redis的Lua脚本
    • 第三版代码
      • 用Lua写释放锁
      • Java代码如下
    • 总结
      • 引入Redisson
  • 后记

基于Redis的分布式锁

什么是分布式锁

满足分布式系统或集群模式下多进程可见并且互斥的锁

基于Redis的分布式锁 以及 超详细的改进思路_第1张图片

需要实现的两个基本方法

  1. 获取锁

    • 互斥:确保只能有一个线程获取锁,可以利用setnx的互斥特性

    • 非阻塞:尝试一次,成功返回true,失败返回false

  2. 释放锁

    • 手动释放,DEL key

    • 超时释放:获取锁时添加一个超时时间,避免服务宕机引起的死锁,EXPIRE lock 10

组合

SET lock thread1 NX EX 10 # NX是互斥、EX是设置超时时间

实现思路

基于Redis的分布式锁 以及 超详细的改进思路_第2张图片

第一版代码

package cn.sticki.common.redis.utils;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * @author 阿杆
 * @version 1.0
 * @date 2022/6/21 21:57
 */
public class RedisSimpleLock implements ILock {

	private final StringRedisTemplate stringRedisTemplate;

	private final String name;

	private final static String KEY_PREFIX = "lock:";

	public RedisSimpleLock(StringRedisTemplate stringRedisTemplate, String name) {
		this.stringRedisTemplate = stringRedisTemplate;
		this.name = name;
	}

	@Override
	public boolean tryLock(long timeout) {
        // 1. 获取线程标识
        long threadId = Thread.currentThread().getId();
		// 2. 尝试写入redis
		Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeout, TimeUnit.SECONDS);
		// 3. 返回写入情况,成功即获取到锁
		return Boolean.TRUE.equals(success);
	}

	@Override
	public void unlock() {
        // 1. 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
	}

}

改进思路

存在的问题

存在一种情况,按照以下顺序执行:

  1. 线程1获取到锁,开始执行业务,执行过程中发生了阻塞,在阻塞的过程中,锁超时释放了
  2. 线程2获取锁,由于线程1的锁已经超时释放,所以线程2可以成功获取到锁
  3. 线程1业务执行完毕,释放锁,但线程1的锁早就超时释放了,所以释放的是线程2的锁
  4. 线程3获取锁,线程3和线程2并发执行,此时锁并未起到应起的作用。。。

示意图:

基于Redis的分布式锁 以及 超详细的改进思路_第3张图片

解决方案

这个问题,实际上就是释放了不是自己产生的锁,故我们可以通过特定的标识,在释放锁之前判断锁是否是由自己产生的,且只释放自己产生的锁。

可以将线程id存入value,在释放之前判断锁的value是否等于自己的线程id,若等于则说明该锁是当前线程产生的,可以释放。

新的问题

如果我有多个服务器,组成了一个集群,那么不同的服务器有可能出现线程id相同的情况,就会导致value相同,从而错误的释放了别人的锁。

解决方案

让每个启动的服务都有一个不同的标识,再拼接线程id,就可以解决这个问题。

最终方案

  1. 在获取锁时存入线程标识(可以用UUID + 线程id 表示)

  2. 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致

    • 如果一致则释放锁

    • 如果不一致则不释放锁

第二版代码

package cn.sticki.common.redis.utils;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * @author 阿杆
 * @version 1.0
 * @date 2022/6/21 21:57
 */
public class RedisSimpleLock implements ILock {

	private final StringRedisTemplate stringRedisTemplate;

	private final String name;

	private final static String KEY_PREFIX = "lock:";

	private final static String KEY_UUID = UUID.randomUUID() + ":";

	public RedisSimpleLock(StringRedisTemplate stringRedisTemplate, String name) {
		this.stringRedisTemplate = stringRedisTemplate;
		this.name = name;
	}

	@Override
	public boolean tryLock(long timeout) {
		// 1. 生成key,通过拼接前缀和业务名
		String key = KEY_PREFIX + name;
		// 2. 生成value,用于判断该锁是不是当前线程生成的。使用随机的UUID+当前线程id,防止集群时value碰撞。
		String value = KEY_UUID + Thread.currentThread().getId();
		// 3. 尝试写入redis
		Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeout, TimeUnit.SECONDS);
		// 4. 返回写入情况,成功即获取到锁
		return Boolean.TRUE.equals(success);
	}

	@Override
	public void unlock() {
		String key = KEY_PREFIX + name;
		String value = KEY_UUID + Thread.currentThread().getId();
		// 1. 获取锁的值
		String lockValue = stringRedisTemplate.opsForValue().get(key);
		if (value.equals(lockValue)) {
			// 2. 若值相同,则当前锁是由当前线程创建的,可以删除
			stringRedisTemplate.delete(key);
		}
	}

}

新的问题

如果,我是说如果,在上面的unlock()代码中,获取到锁的值之后,删除key之前,发生了阻塞(GC阻塞),等阻塞完成后,当前线程创建的锁已经被释放了,然后发生了和上面类似的问题,也一样会导致锁的失效。

可能描述的不太清楚,看看示意图吧:

基于Redis的分布式锁 以及 超详细的改进思路_第4张图片

解决思路

产生这个问题的原因主要在于判断锁标识和释放锁是分别执行的两个操作,解决这个问题,可以通过Lua脚本将两个操作绑定在一起。

Redis的Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法可以参考网站:https://www.runoob.com/lua/lua-tutorial.html

这里重点介绍Redis提供的调用函数,语法如下:

# 执行redis命令
redis.call('命令名称', 'key', '其它参数', ...)

例如,我们要执行set name jack,则脚本是这样:

# 执行 set name jack
redis.call('set', 'name', 'jack')

例如,我们要先执行set name Rose,再执行get name,则脚本如下:

# 先执行 set name jack
redis.call('set', 'name', 'jack')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name

第三版代码

用Lua写释放锁

释放锁的业务流程是这样的:

  1. 获取锁中的线程标示
  2. 判断是否与指定的标示(当前线程标示)一致
  3. 如果一致则释放锁(删除)
  4. 如果不一致则什么都不做

unlock.lua(这个文件放在mian/resource下面)

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

Java代码如下

package cn.sticki.common.redis.utils;

import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * @author 阿杆
 * @version 1.0
 * @date 2022/6/21 21:57
 */
public class RedisSimpleLock implements ILock {

	private final static String KEY_PREFIX = "lock:";

	private final static String KEY_UUID = UUID.randomUUID() + ":";

	private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

	static {
		// 加载lua文件
		UNLOCK_SCRIPT = new DefaultRedisScript<>();
		UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
		UNLOCK_SCRIPT.setResultType(Long.class);
	}

	private final StringRedisTemplate stringRedisTemplate;

	private final String name;

	public RedisSimpleLock(StringRedisTemplate stringRedisTemplate, String name) {
		this.stringRedisTemplate = stringRedisTemplate;
		this.name = name;
	}

	@Override
	public boolean tryLock(long timeout) {
		// 1. 生成key,通过拼接前缀和业务名
		String key = KEY_PREFIX + name;
		// 2. 生成value,用于判断该锁是不是当前线程生成的。使用随机的UUID+当前线程id,防止集群时value碰撞。
		String value = KEY_UUID + Thread.currentThread().getId();
		// 3. 尝试写入redis
		Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeout, TimeUnit.SECONDS);
		// 4. 返回写入情况,成功即获取到锁
		return Boolean.TRUE.equals(success);
	}

	@Override
	public void unlock() {
		String key = KEY_PREFIX + name;
		String value = KEY_UUID + Thread.currentThread().getId();
		// 调用lua脚本
		stringRedisTemplate.execute(
				UNLOCK_SCRIPT,
				Collections.singletonList(key),
				value);
	}

}

总结

实现思路:

  1. 利用set nx ex获取锁,并设置过期时间,保存线程标示
  2. 释放锁时先判断线程标示是否与自己一致,一致则删除锁
  3. 利用set nx满足互斥性
  4. 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
  5. 利用Redis集群保证高可用和高并发特性

可改进点:

  1. 不可重入:同一个线程无法多次获取同一把锁
  2. 不可重试:获取锁只尝试一次就返回false,没有重试机制
  3. 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
  4. 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁失效

引入Redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

基于Redis的分布式锁 以及 超详细的改进思路_第5张图片

官网地址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson

后记

这篇文章我是学习自黑马程序员的视频课程的时候做的总结和笔记,有兴趣的同学可以自行观看视频:https://www.bilibili.com/video/BV1cr4y1671t?p=56
基于Redis的分布式锁 以及 超详细的改进思路_第6张图片

你可能感兴趣的:(Redis,学习笔记,Java,redis,分布式,数据库)