为什么需要分布式锁?
大前提:在分布式架构中, 分布式锁可以保证在多节点场景下,保证一个任务同时只有一个节点在执行。
主要目的:
- 节省资源,提高性能
- 处理共享资源的竞争问题
基于Redis的实现方案:
- redis setnx
- redison
- redlock
Redis SetNX 方案
方案方案可以分为2中,在redis2.6.1之前,使用两条命令setnx + expire,为了保证原子性,使用lua脚本进行执行。 在2.6.1之后,setnx命令中添加了expire参数,使用起来简单一些,下面代码是2.6.1之后的版本:
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.UUID;
public class RedisLock {
private StringRedisTemplate redisTemplate; // redis操作类
private String lockKey; // 锁的键
private String lockValue; // 锁的值,用于标识锁的持有者
private long expireTime; // 锁的过期时间,单位毫秒
// 构造方法,初始化相关参数
public RedisLock(StringRedisTemplate redisTemplate, String lockKey, long expireTime) {
this.redisTemplate = redisTemplate;
this.lockKey = lockKey;
this.expireTime = expireTime;
this.lockValue = UUID.randomUUID().toString(); // 生成随机的锁值
}
// 获取锁,返回是否成功
public boolean lock() {
// 调用setIfAbsent方法,传入键值和过期时间,执行setnx和expire命令
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireTime, TimeUnit.MILLISECONDS);
// 判断返回结果,如果是true,表示加锁成功,如果是false,表示加锁失败
return result != null && result;
}
// 释放锁
public void unlock() {
// 创建一个RedisScript对象,用于执行lua脚本
RedisScript<Long> unlockScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
// 调用execute方法,传入RedisScript对象和键值参数,执行lua脚本
redisTemplate.execute(unlockScript, Collections.singletonList(lockKey), lockValue);
}
// 解锁的lua脚本,判断锁的值是否匹配,保证原子性
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then\n" +
" return redis.call('del', KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
}
setnx方案的问题
总结有一下2类问题:
- 过期时间
- 非重入锁
过期时间
在lock
的时候,为了防止服务异常无法解锁的情况,通常会设置锁的过期时间,这个时间的设置依赖历史任务平均耗时得出,过期时间要大于任务执行需要的时间。这个时间的设置有一定学问,设置得太大,当节点宕机时,意味着会阻塞更久;短了又会容易提早释放锁,导致多节点同时拿到锁情况的发生。 解决这个问题的一个方案是"续约机制",也可以叫"看门狗'。大致的原理是设置一个守护线程执行定时任务,当发现锁快过期时,对锁的过期时间进行延长。
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class RedisLock {
private StringRedisTemplate redisTemplate; // redis操作类
private String lockKey; // 锁的键
private String lockValue; // 锁的值,用于标识锁的持有者
private long expireTime; // 锁的过期时间,单位毫秒
private long heartbeatInterval; // 心跳间隔时间,单位毫秒
private ScheduledExecutorService heartbeatExecutor; // 心跳线程池
// 加锁的lua脚本,使用setnx和pexpire命令,保证原子性
private static final String LOCK_SCRIPT =
"if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then\n" +
" return redis.call('pexpire', KEYS[1], ARGV[2])\n" +
"else\n" +
" return 0\n" +
"end";
// 解锁的lua脚本,判断锁的值是否匹配,保证原子性
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then\n" +
" return redis.call('del', KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
// 心跳的lua脚本,判断锁的值是否匹配,如果匹配则更新过期时间
private static final String HEARTBEAT_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then\n" +
" return redis.call('pexpire', KEYS[1], ARGV[2])\n" +
"else\n" +
" return 0\n" +
"end";
// 构造方法,初始化相关参数
public RedisLock(StringRedisTemplate redisTemplate, String lockKey, long expireTime, long heartbeatInterval) {
this.redisTemplate = redisTemplate;
this.lockKey = lockKey;
this.expireTime = expireTime;
this.heartbeatInterval = heartbeatInterval;
this.lockValue = UUID.randomUUID().toString(); // 生成随机的锁值
this.heartbeatExecutor = new ScheduledThreadPoolExecutor(1); // 创建一个单线程的心跳线程池
}
// 获取锁,返回是否成功
public boolean lock() {
// 创建一个RedisScript对象,用于执行lua脚本
RedisScript<Long> lockScript = new DefaultRedisScript<>(LOCK_SCRIPT, Long.class);
// 调用execute方法,传入RedisScript对象和键值参数,执行lua脚本
Long result = redisTemplate.execute(lockScript, Collections.singletonList(lockKey), lockValue, String.valueOf(expireTime));
// 判断返回结果,如果是1,表示加锁成功,如果是0,表示加锁失败
if (result != null && result == 1L) {
// 启动一个定时任务,每隔一定时间发送心跳信号,更新锁的过期时间
heartbeatExecutor.scheduleAtFixedRate(this::heartbeat, heartbeatInterval, heartbeatInterval, TimeUnit.MILLISECONDS);
return true;
} else {
return false;
}
}
// 释放锁
public void unlock() {
// 创建一个RedisScript对象,用于执行lua脚本
RedisScript<Long> unlockScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
// 调用execute方法,传入RedisScript对象和键值参数,执行lua脚本
redisTemplate.execute(unlockScript, Collections.singletonList(lockKey), lockValue);
// 停止心跳任务
heartbeatExecutor.shutdownNow();
}
// 心跳方法,发送心跳信号,更新锁的过期时间
private void heartbeat() {
// 创建一个RedisScript对象,用于执行lua脚本
RedisScript<Long> heartbeatScript = new DefaultRedisScript<>(HEARTBEAT_SCRIPT, Long.class);
// 调用execute方法,传入RedisScript对象和键值参数,执行lua脚本
Long result = redisTemplate.execute(heartbeatScript, Collections.singletonList(lockKey), lockValue, String.valueOf(expireTime));
// 判断返回结果,如果是0,表示锁已经失效,停止心跳任务
if (result == null || result == 0L) {
heartbeatExecutor.shutdownNow();
}
}
}
非重入锁
顾名思义,客户端A拿到锁后,无法重复获取锁,这可能会导致死锁的发生。 为了解决问题,可以在锁中记录获取的次数,可以使用redis的hash类型,其中包含两个字段,客户端唯一表示uid和获取次数count。 这里的代码可以自行修改,下面重点看看Redisson是如何解决这几个问题的。
Redisson 方案
Redission是一个基于Redis的分布式锁和事务框架,它提供了一些高级的功能,比如超时、重入、公平性等。 其中一个重要功能就是看门门狗机制,它可以在Redisson实例被关闭前,不断地延长锁的有效期,防止锁被其他线程或进程占用。
Redisson 看门狗(锁续约)实现方案
redission内部维护了一个时间轮(执行轮询任务,使用Timeout类实现),每隔一定时间(默认为30秒),就会检查当前线程对锁的持有情况,并根据一定的规则(默认为internalLockLeaseTime/3),更新锁的过期时间。这样就可以保证锁在Redisson实例关闭前不会被释放。
下面这个文章给出了比较详细的分析过程,包括代码部分,有兴趣的可以阅读
Redisson 可重入性实现
和上面我们提到的自己实现方案一样,value使用hash结构,有字段保存了上锁次数。
如果同一个机器同一个线程再次来请求,hexists
判断的结果会是1,然后执行hincrby
对字段+1,然后继续设置过期时间。
同理,一个线程重入后,解锁时value - 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]);"
Redisson 主从不一致的问题
为了提高redis的高可用和性能,通常会启用redis的主从功能,主库提供写,保证数据一致性,从库备份主库的数据,并提供读,缓解主库的压力,以及当主库出问题时,可以升级为主库,保证集群的持续运行。 而主从同步的问题主要发生在数据复制的时刻,可以这样描述:
如何解决主从问题? redis的作者给了答案,这就是著名的Redlock(红锁)。
RedLock 方案
主从问题的核心是只有一个master,如何解决?让数据存在很多个节点上,这样就可以降低问题的风险,这是软件系统设计里的一个通用思想。 首先,redlock是一个解决方案,并不是一个框架,这个方案核心思想是这样,它要求你的架构里有多个redis集群,
获取锁的流程:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从N个Master实例使用相同的key和随机值获取锁(假设这个key是LOCK_KEY)。当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。
如何使用redlock
redis官方这里有一份清单,列举了所有已经支持redlock的框架,其中我看到了熟悉的 redisson , 简单说明一下redisson中如何使用redlock。
```text
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://192.168.0.1:6379")
.setPassword("afeiblog").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://192.168.0.2:6379")
.setPassword("afeiblog").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://192.168.0.3:6379")
.setPassword("afeiblog").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
String resourceName = "LOCK_KEY";
RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);
// 向3个redis实例尝试加锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
// isLock = redLock.tryLock();
// 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
System.out.println("isLock = "+isLock);
if (isLock) {
//TODO if get lock success, do something;
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
}
Redlock一定安全吗?
Redlock要求N/2+1个节点上锁成功,这个方案确实可以极大提高锁的安全性,但是一定吗?这和redis的持久化有关。 现在假设架构中有5个Master节点a,b,c,d,e ,以及服务端s1,s2 ,持久化使用AOF,1秒刷盘1次:
- s1请求所有节点,从a,b,c节点中获取到了锁,超过半数,s1成功获取到锁。
- c节点在数据持久化之前宕机了,不久后,c节点恢复
- s2请求所有节点,从c,d,e中获取到了锁,超过半数,s2也获取到了锁。 这种情况下s1,s2还是同时获取到了锁。解决这个问题的办法也有,例如提出的控制redis重启时间 > 锁的过期时间,这样可以保证锁过期后才进行下一轮的锁竞争。或者从数据持久化方式入手,AOF使用appendfsync这种刷盘的方式,但是会影响redis的性能。
总结
我们到底该使用哪一种分布式方案呢? 这是一个开放性问题,我的观点是取决于你觉得你用分布式锁的目的,通常来说有两种原因:
- 使用分布式锁减少不必要的资源消耗,而非数据一致性问题。 例如我们有一个定时任务,凌晨2点将本地文件上传到云端,这个任务其实只需要执行一次就可以了,如果所有实例都执行一次,没什么大问题,就是增加了不必要的资源消耗。
- 使用分布式锁解决共享数据一致性问题,对于这个情况,考虑到业务场景的一致性要求高不高,一般场景下没有那么高的要求,我们使用单机方案足矣,如果是金融等场景,我更推荐使用zookeeper 、etcd等基于CP的分布式组件,redlock对架构的要求太麻烦了。