Redis分布式锁

发布时间 2023-12-25 19:22:14作者: _mcj

1.分布式锁的方案

分类 方案 原理 优点 缺点
基于数据库 mysql数据库表的唯一索引 1.表创建唯一索引
2.加锁:执行insert语句,成功则加锁成功,失败则加锁失败
3.解锁:执行delete语句
完全利用DB实现,实现简单 1.锁无超时自动失效机制,有死锁风险
2.不支持锁冲入,不支持阻塞等待
3.操作数据库开销比较大,性能不高
MongoDB的findAndModify原子操作 1加锁:执行findAndModify原子命令查找document,不存在则新增
2.解锁:删除document
实现比较容易,比mysql的方式性能要高 锁无超时自动失效机制
基于分布式协调系统 基于Zookeeper 1.加锁:在/lock目录下创建临时有序节点,判断创建的节点序号是否最小。若是,则表示获取到锁;不是则看/lock目录下需要比自身小的前一个节点
2.解锁:删除节点
1.由zk保障系统高可用
2.Curator框架已原生支持系列分布式锁命令,使用简单
需要单独维护一套zk集群,维护成本高
基于缓存 基于redis 1.加锁:执行setnx与expire命令加锁设置过期时间。
解锁:执行delete命令
2.通过执行Lua脚本
3.开源框架:Redisson
4.RedLock
相比上面几种,这种性能最好 1.第一种setnx与expire非原子操作,可能会出现死锁,delete命令存在误删的可能
2.第一,二种不支持阻塞等待,不可重入。

2.靠谱的分布式锁有的特征

  • 互斥性:任意时刻,只有一个客户端能持有锁
  • 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁
  • 可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁
  • 高性能和高可用:加锁和解锁需要的开销尽可能低,同时也要保证高可用,避免分布式锁失效。
  • 安全性:锁只能被持有的客户端删除,不能被其它客户端删除

3.redis分布式锁的方案

3.1 setnx+expire

这种方式是先通过setnx操作进行加锁,加锁成功后通过expire操作进行设置过期时间。但是由于两个命令是分开的,不是原子操作。因此可能会出现在加锁成功后,还没设置过期时间的时候进程重启了,那么这个锁就会一直存在了。具体的伪代码如下:

if (jedis.setnx(id, value) == 1) { //加锁成功
	expire(id, 100); //设置锁过期时间
	try {
		//业务操作
	} catch() {
		//业务处理异常
	} finally {
		jedis.del(id); //释放锁
	}
}

3.2 setnx+value值是(系统时间+过期时间)

这种就是一个setnx命令,在设值的时候把value值设为系统时间加过期时间,如果加锁成功,则过期时间就为value的值,加锁失败,则需要校验一下value的值是否已过期,重新设置过期时间,返回加锁成功。伪代码如下:

{
	String expiresStr = String.valueOf(System.currentTimeMillis() + 60000); //当前时间+过期时间
	//当前锁不存在,则返回加锁成功
	if (jedis.setnx(id, expiresStr) == 1) {
		return true;
	}
	//如果锁已存在,获取锁的过期时间
	String expireTime = jedis.get(id);
	//判断锁是否已过期
	if (StringUtils.isNotBlank(expireTime) && Long.parseLong(expireTime) < System.currentTimeMillis()) {
		//锁已过期,获取旧的过期时间设置新的过期时间
		String oldExpireTime = jedis.getSet(id, expiresStr);
		//防止多线程并发,只有一个线程的设置值与当前值相同,才可以加锁
		if (StringUtils.isNotBlank(oldExpireTime) && oldExpireTime.equals(expireTime)) {
			return true;
		}
	}
	return false;
}

这种方式可以解决setnx与expire操作不是原子操作的问题。但该方案同样有缺点:

  • 过期时间是客户端生成的,因此每个客户端的时间必须同步
  • 如果有多个线索执行jedis.getSet(),最终只有一个加锁成功,但是可能会导致其它线程覆盖加锁线程的锁过期时间。
  • 锁可能会被别的线程解锁。

3.3 使用Lua脚本

使用Lua脚本能够保证setnx+expire原子性

String luaLock = "if redis.call('setnx', KEYS[1],ARGV[1]) == 1 then redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end";
Object result = jedis.eval(luaLock, Collections.singletonList(id), Colections.singletonList(valuse));
return result.equals(1L);

这种方式也是有缺点的,学习成本比较高,使用lua脚本应该要掌握一定的lua语法。无法进行代码复用。

3.4 set的扩展命令

另外还可以通过set的扩展参数*(SET key value [EX seconds] [PX milliseconds] [NX|XX])来实现,其也是原子性的。

  • NX:表示key不存在的时候,才能set成功。
  • EX seconds:设定key的过期时间,时间单位是秒
  • PX milliseconds:设定key的过期时间,单位为毫秒
  • XX:仅当key存在时设置值
    伪代码如下:
if (jedis.set(id, value, "NX", "EX", 100s) == 1) {
	try {
		//业务逻辑
	} catch() {
		//异常时的操作
	} finally {
		jedis.del(id);
	}
}

这种方式也会存在一些问题:
线程A持有锁过期释放掉了,但是业务还没有处理完成,此时有另一个线程B获取到锁,执行业务逻辑,但是此时线索A执行完业务逻辑之后会释放掉线程B持有的锁。

3.5 set的扩展命令+校验唯一的随机值,再删除

为了保证只会删除自己的锁而不会误删其它线程的锁,我们可以为value设置一个唯一的值,在删除之前进行校验一下是不是当前线程的锁就行了。这里为了保证校验是原子性的,可以使用lua脚本的方式。伪代码如下:

if (jedis.set(id, value, "NX", "EX", 100s) == 1) {
	try {
		//业务逻辑
	} catch() {
		//异常时的操作
	} finally {
		deleteLock(id, value);
	}
}

public boolean deleteLock(String id, String value) {
	String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
	return jedis.eval(lua, Collections.singletonList(id), Collections.singletonList(value)).equals(1L);
}

3.6 Redisson框架

上面的方式也会存在当设置的锁的已经过期释放了,但是业务代码还没执行完的问题。那么此时就可以通过Redisson框架实现,其会启动一个watchDog用来给锁进行延时。需要注意的是使用此方法通过tryLock方法进行加锁时不能设置过期时间,设置的话就不会启动watchDog。伪代码如下:

public void testLock(String key) {
    RLock lock = null;
    try {
        // 读取配置文件
        Config config = Config.fromYAML(new File("redis-file.yaml"));
        RedissonClient redissonClient = Redisson.create(config);
        lock = redissonClient.getLock(key);
        // 不设置锁的过期时间,启动watchDog机制
        boolean b = lock.tryLock(10, TimeUnit.SECONDS);
        if (b) {
            // 加锁成功后的业务逻辑
        }
    } catch (Exception e) {
        log.error("加锁失败");
    } finally {
        if (Objects.nonNull(lock)) {
            lock.unlock();
        }
    }
}

redisson框架的底层是通过lua脚本进行加锁与解锁的,保证了其加锁时的原子性。

3.7 RedLock

对于集群的情况,如果线索成A在主节点拿到锁,但是加锁的key还没有同步到从节点,此时主节点发生了宕机,一个从节点升为主节点。线程B可以再次获取同个key的锁。这样就会导致锁不安全了。
而为了解决这个问题则可以使用redisson的redlock算法。其核心思想是部署多个Redis主节点,这些节点完全互相独立,不存在主从复制或者其它集群协调机制;依次尝试从N个Master实例使用相同的key和随机值获取锁,当至少有N/2 + 1个redis实例加锁成功后,才是加锁成功。
其实现步骤如下:

  • 获取当前时间,以毫秒为单位
  • 按顺序向所有的mater节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间)。如果超时,跳过该master节点,尽快取尝试下一个master节点。
  • 客户端使用当前时间减去开始获取锁时间,得到获取锁使用的时间。当且仅当超过一半的Redis master节点都获得锁,并且使用的时间(所有节点获取锁所用时间与超时时间和)小于锁失效时间时,锁才算获取成功。
  • 如果获取到了锁,key的真正有效时间就变了,需要减去获取锁所使用的时间。
  • 如果获取锁失败,需要在所有的master节点上解锁。

伪代码如下:

public void testLock(String key) {
    RedissonRedLock redLock = null;
    try {
        // 读取配置文件
        Config config1 = Config.fromYAML(new File("redis-file1.yaml"));
        RedissonClient redissonClient1 = Redisson.create(config1);
        RLock lock1 = redissonClient1.getLock(key);
        Config config2 = Config.fromYAML(new File("redis-file2.yaml"));
        RedissonClient redissonClient2 = Redisson.create(config2);
        RLock lock2 = redissonClient2.getLock(key);
        Config config3 = Config.fromYAML(new File("redis-file3.yaml"));
        RedissonClient redissonClient3 = Redisson.create(config3);
        RLock lock3 = redissonClient3.getLock(key);
        redLock = new RedissonRedLock(lock1, lock2, lock3);
        // 不设置锁的过期时间,启动watchDog机制
        boolean b = redLock.tryLock(10, TimeUnit.SECONDS);
        if (b) {
            // 加锁成功后的业务逻辑
        }
    } catch (Exception e) {
        log.error("加锁失败");
    } finally {
        if (Objects.nonNull(redLock)) {
            redLock.unlock();
        }
    }
}