分布式锁

控制分布式系统不同进程共同访问共享资源的一种锁实现的方式,如果不同的系统或者同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性

特征:

  1. 互斥性:任意时刻,只有一个客户端能持有锁
  2. 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁
  3. 可重入性:可重入锁,也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。 说白了就是同一个线程再次进入同样代码时,可以再次拿到该锁。 (作用:防止在同一线程中多次获取锁导致死锁发生)
  4. 高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效(高性能:查询快,高可用:节点故障时,服务仍然能正常运行或进行降级后提供部分服务)
  5. 安全性:锁只能被持有的用户删除,不能被其他客户端删除

实现方式(1)

SETNX + EXPIRE

SETNX KEY_NAME VALUE //设置成功,返回 1 。 设置失败,返回 0 。

Expire KEY_NAME TIME_IN_SECONDS //设置成功返回 1 。 当 key 不存在或者不能为 key 设置过期时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的过期时间)返回 0

使用这个方案要注意 setnx 与 expire 之间的原子性操作,如果在执行完 setnx 之后服务器 crash 或重启了导致加的这个锁没有设置过期时间,就会导致死锁的情况(别的线程就永远获取不到锁了)

实现方式(2)

SETNX + value 值(系统时间 + 过期时间)

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//系统时间+设置的过期时间
long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);

// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
return true;
}
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);

// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {

// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
String oldValueStr = jedis.getSet(key_resource_id, expiresStr);

if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
return true;
}
}

//其他情况,均返回加锁失败
return false;

优点: 移除了 expire 单独设置过期时间的操作,把过期时间放到 setnx 的 value 值里面来,解决了所得不到释放的问题。
缺点:

  1. 过期时间是客户端自己生成的(System.currentTimeMillis() 是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。
  2. 如果所过期的时候,并发多个客户端同时请求过来,都执行 jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被其他的客户端覆盖。
  3. 该锁没有保存持有者的唯一标识,可能被别的客户端释放 / 解锁。

实现方式(3)

使用 Lua 脚本(包含 SETNX + EXPIPE 两条指令)

lua脚本

1
2
3
4
5
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end;

加锁代码

1
2
3
4
5
6
String lua_scripts = "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(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values));
//判断是否成功
return result.equals(1L);

实现方式(4)

SET 的扩展命令(SET EX PX NX)

set key value EX seconds PX milliseconds NX|XX
EX seconds:设置失效时长,单位秒
PX milliseconds:设置失效时长,单位毫秒
NX:key不存在时设置value,成功返回OK,失败返回(nil)
XX:key存在时设置value,成功返回OK,失败返回(nil)

示例代码

1
2
3
4
5
6
7
8
9
if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
jedis.del(key_resource_id); //释放锁
}
}

隐患:
「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。

实现方式(5)

SET EX PX NX + 校验唯一随机值,再删除

给value值设置一个标记当前线程唯一的随机数,在删除的时候,进行校验

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
//判断是不是当前线程加的锁,是才释放
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); //释放锁
}
}
}

判断是不是当前线程加的锁释放锁不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。

判断成功后,锁释放前锁超时,其他线程获取锁成功,此时将会释放其他线程的锁

lua脚本解决

1
2
3
4
5
if redis.call('get',KEYS[1]) == ARGV[1] then 
return redis.call('del',KEYS[1])
else
return 0
end;

实现方式(6)

Redisson框架

只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。

实现方式(7)

多机实现的分布式锁Redlock+Redisson

如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁,锁的安全性就没了。

为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。

假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例

RedLock的实现步骤如下:
1. 获取当前时间,以毫秒为单位。
2. 按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
3. 客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
4. 如果取到了锁,key的真正有效时间改变,需要减去获取锁所使用的时间。
5. 如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)
简洁版
1. 按顺序向5个master节点请求加锁
2. 根据设置的超时时间来判断,是不是要跳过该master节点
3. 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功。
4. 如果获取锁失败,解锁

看门狗

概述

看门狗机制是Redission提供的一种自动延期机制,这个机制使得Redission提供的分布式锁是可以自动续期的

1
private long lockWatchdogTimeout = 30 * 1000;

看门狗机制提供的默认超时时间是30*1000毫秒,也就是30秒

如果一个线程获取锁后,运行程序到释放锁所花费的时间大于锁自动释放时间(也就是看门狗机制提供的超时时间30s),那么Redission会自动给redis中的目标锁延长超时时间。

在Redission中想要启动看门狗机制,那么我们就不用获取锁的时候自己定义leaseTime(锁自动释放时间)

如果自己定义了锁自动释放时间的话,无论是通过lock还是tryLock方法,都无法启用看门狗机制。
但是,如果传入的leaseTime为-1,也是会开启看门狗机制的。


分布式锁是不能设置永不过期的,这是为了避免在分布式的情况下,一个节点获取锁之后宕机从而出现死锁的情况,所以需要个分布式锁设置一个过期时间。但是这样会导致一个线程拿到锁后,在锁的过期时间到达的时候程序还没运行完,导致锁超时释放了,那么其他线程就能获取锁进来,从而出现问题。
所以,看门狗机制的自动续期,就很好地解决了这一个问题。


总结
watch dog 在当前节点存活时每10s给分布式锁的key续期 30s;
watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
要使 watchLog机制生效 ,lock时 不要设置过期时间
watchlog的延时时间 可以由 lockWatchdogTimeout指定默认延时时间,但是不要设置太小。如100
watchdog 会每 lockWatchdogTimeout/3时间,去延时。
watchdog 通过 类似netty的 Future功能来实现异步延时
watchdog 最终还是通过 lua脚本来进行延时

使用案例

原始代码

1
2
3
4
5
6
7
8
9
String lockKey = "myLock";  
//重新设置key对应的值,如果存在返回false,否则返回true
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhen"); //加锁
if (!result) {
throw new Exception("error!"); //抛出异常
}
//执行业务
System.out.println("执行业务");
stringRedisTemplate.delete(lockKey); //释放锁

问题:
上述代码抛出异常执行,锁无法释放,出现死锁

改进一:try/finally语句

1
2
3
4
5
6
7
8
9
10
11
12
String lockKey = "myLock";  
Boolean result;
try {
result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhen");
if (!result) {
throw new Exception("error!");
}
//执行业务
System.out.println("执行业务");
} finally {
stringRedisTemplate.delete(lockKey); //释放锁
}

问题
finally 块执行前,程序部署机器死机,仍会出现上述问题

改进二:设置过期时间

1
2
3
4
5
6
7
8
9
10
11
12
13
String lockKey = "myLock";  
Boolean result;
try {
result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhen");//加锁
stringRedisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); //设置有效期
if (!result) {
throw new Exception("error!");
}
//执行业务
System.out.println("执行业务");
} finally {
stringRedisTemplate.delete(lockKey);
}

问题:
设置过期时间语句未执行,程序部署机器死机,发生死锁
改进:
同时设置value&时间

1
>result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhen",10,TimeUnit.SECONDS);

问题:
高并发情况下,线程1获取锁,锁有效期10s,业务代码执行15s,线程2同样获取该锁,有效期10s,业务代码执行15s
当前情况意味着,线程1业务完成后,锁已经被线程2持有,因此线程1的锁释放操作,释放的是线程2的锁,并且线程2尚未执行完成

改进三:value随机值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String lockKey = "myLock";  
Boolean result;
String requestId = UUID.randomUUID().toString(); //生成随机值
try {
result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
if (!result) {
throw new Exception("error!");
}
//执行业务
System.out.println("执行业务");
} finally {
if (requestId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}

问题:
功能完备,但是时间设置无法判断,如果锁失效时间设置为5s,可能太少锁不住,但如果设置为30s,也可能不够,无法确定业务执行时间
时间太长,会导致其他线程获取锁的等待时间拉长,影响程序运行效率
改进:
新建一个分支线程,设置一个定时任务,比如每10s判断一下线程还活着没,如果这个线程存在,就把expire再设置成30s,重置锁的失效时间

改进四:直接采用redisson

1
2
3
4
5
6
7
8
9
String lockKey = "myLock";  
RLock lock = redisson.getLock(lockKey); //1 获得锁对象
try {
lock.lock(); //2 加锁(默认设置了有效时间,并开启分支线程定时续命)
//业务
System.out.println("业务");
}finally {
lock.unlock(); //3 解锁
}

注解实现分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
//锁的名称
String lockName();
//锁的失效时间
long leaseTime() default 3;
//是否开启看门狗,默认开启,开启时锁的失效时间不执行。任务未完成时会自动续期锁时间
//使用看门狗,锁默认redis失效时间未30秒。失效时间剩余1/3时进行续期判断,是否需要续期
boolean watchdog() default true;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class RedisLockAspect {
@Autowired
private RedissonClient redissonClient;

private static final String REDIS_PREFIX = "redisson_lock:";

@Around("@annotation(redisLock)")
public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
String lockName = redisLock.lockName();

RLock rLock = redissonClient.getLock(REDIS_PREFIX + lockName);

Object result = null;
boolean isLock;
if(redisLock.watchdog()){
isLock =rLock.tryLock(0, TimeUnit.SECONDS);
}else {
isLock =rLock.tryLock(0,redisLock.leaseTime(), TimeUnit.SECONDS);
}
if(isLock){
try {
//执行方法
result = joinPoint.proceed();
} finally {
if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}else {
log.warn("The lock has been taken:{}",REDIS_PREFIX + lockName);
}
return result;
}
}

使用

1
2
3
4
5
6
7
8
9
10
//使用注解进行加锁
@RedisLock(lockName = "npa_lock_test",watchdog = true)
public void redisLockTest() {
System.out.println("get lock and perform a task");
try {
Thread.sleep(20000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}