Foutin

Redisson实现分布式可重入锁

希望我是一个让你心动的人,而不是权衡取舍分析利弊后,觉得不错的人。

概述

随着互联网技术快速发展,数据规模增大,分布式系统越来越普及,一个应用往往会部署在多台机器上(多节点),在有些场景中,为了保证数据不重复,要求在同一时刻,同一任务只在一个节点上运行,即保证某一方法同一时刻只能被一个线程执行。在单机环境中,应用是在同一进程下的,只需要保证单进程多线程环境中的线程安全性,通过 JAVA 提供的 volatile、ReentrantLock、synchronized 以及 concurrent 并发包下一些线程安全的类等就可以做到。而在多机部署环境中,不同机器不同进程,就需要在多进程下保证线程的安全性了。因此,分布式锁应运而生。

在某些场景中,多个进程必须以互斥的方式独占共享资源,这时用分布式锁是最直接有效的。

实现分布式锁的三种选择

基于数据库实现分布式锁
基于zookeeper实现分布式锁
基于Redis缓存实现分布式锁

以上三种方式都可以实现分布式锁,其中,从健壮性考虑, 用 zookeeper 会比用 Redis 实现更好,但从性能角度考虑,基于 Redis 实现性能会更好,如何选择,还是取决于业务需求。

下面只介绍Rediss锁。

分布式锁需满足四个条件

首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁。
  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
  • 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。

基于 Redis 实现分布式锁的两种方案

  1. 使用Redis实现分布式锁
  2. 使用 Redisson 实现分布式锁

使用Redis实现分布式锁

通过 set key value px milliseconds nx 命令实现加锁, 通过Lua脚本实现解锁。核心实现命令如下:

1
2
3
4
5
6
7
8
9
//获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000

//释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

这种实现方式主要有以下几个要点:

set 命令要用 set key value px milliseconds nx,替代 setnx + expire需要分两次执行命令的方式,保证了原子性
value要具有唯一性,可以使用UUID.randomUUID().toString()方法生成,用来标识这把锁是属于哪个请求加的,在解锁的时候就可以有依据;
释放锁时要验证 value 值,防止误解锁;
通过 Lua 脚本来避免 Check And Set 模型的并发问题,因为在释放锁的时候因为涉及到多个Redis操作(利用了eval命令执行Lua脚本的原子性);

完整代码实现如下:

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
35
36
37
38
39
40
41
42
43
44
45
public class RedisTool {

private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
private static final Long RELEASE_SUCCESS = 1L;

/**
* 获取分布式锁(加锁代码)
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}

/**
* 释放分布式锁(解锁代码)
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

Object result = jedis.eval(script, Collections.singletonList(lockKey), C ollections.singletonList(requestId));

if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;

}
}

加锁:首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,用来标识这把锁是属于哪个请求加的,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。

解锁:将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。在执行的时候,首先会获取锁对应的value值,检查是否与requestId相等,如果相等则解锁(删除key)。

风险

以上实现在 Redis 正常运行情况下是没问题的,但如果存储锁对应key的那个节点挂了的话,就可能存在丢失锁的风险,导致出现多个客户端持有锁的情况,这样就不能实现资源的独享了。

客户端A从master获取到锁
在master将锁同步到slave之前,master宕掉了(Redis的主从同步通常是异步的)。
主从切换,slave节点被晋级为master节点
客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。导致存在同一时刻存不止一个线程获取到锁的情况。

所以在这种实现之下,不论Redis的部署架构是单机模式、主从模式、哨兵模式还是集群模式,都存在这种风险。因为Redis的主从同步是异步的。

Redisson分布式可重入锁

主要思路

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

设计初衷

公司每个模块服务都有自己一套分布式锁的实现,为了实现各个模块的统一,减少跨模块开发的便捷性,便着手开发一套简单,易用,容易的分布式锁系统。这个任务就交给了我。
调研阶段发现原生Redis写起来并不是那么容易,而且自己能力不一定能够写出能够适应生产环境的分布式可重入Redis锁。所以就使用分装好的Redisson来实现。

要求

  1. 简单易用,复杂度低,可重入
  2. 不侵占业务代码,业务代码无感知
  3. 能够实现多个key的同时加锁

为了显现上述要求,可用如下方式:

  1. 使用Redisson的tryLock方法获取锁,使用unlock解锁
  2. 通过注解的方式实现加锁的目的,利用Spring的AOP技术可以对业务无感知的实现加锁的功能,同时实现通用的工具类
  3. 使用Redisson联锁的功能

以下Redisson的单点模式示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/ 1.构造redisson实现分布式锁必要的Config
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:5379").setPassword("123456").setDatabase(0);
// 2.构造RedissonClient
RedissonClient redissonClient = Redisson.create(config);
// 3.获取锁对象实例(无法保证是按线程的顺序获取到)
RLock rLock = redissonClient.getLock(lockKey);
try {
/**
* 4.尝试获取锁
* waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
* leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
*/
boolean res = rLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS);
if (res) {
//成功获得锁,在这里处理业务
}
} catch (Exception e) {
throw new RuntimeException("aquire lock fail");
}finally{
//无论如何, 最后都要解锁
rLock.unlock();
}

Redisson分布式重入锁实现

分布式锁代码结构

如下图所示:
Redisson代码结构

  • LockAspectAdvice类:Aop实现类,在方法前后加锁解锁
  • RedissonLockImpl类:redisson实现分布式锁实现类
    • tryLock(String key, Long waitTimeMillis, Long expirationMillis, TimeUnit unit):获取锁方法
      • key:锁key
      • waitTimeMillis:获取锁等待时间,单位毫秒
      • expirationMillis:锁key的过期时间,单位毫秒
      • unit:时间单位
    • unlock(String key):解锁方法
      • key:锁key
    • tryMultiLock(List keys, Long waitTime, Long expireTime, TimeUnit timeUnit)
      • keys: 锁key集合
      • waitTime:等待时间
      • expireTime:过期时间
      • timeUnit:时间单位
  • LockKey注解:参数注解,注解的参数将作为锁key
  • CustomReentrantLock注解:自定义方法注解,对方法加锁
    • long waitTimeMillis() default 1000: 等待获取锁时间,等待时间内一直尝试获取锁,单位毫秒ms,默认1秒
    • long expireMillis() default 15000:锁的过期时间,单位毫秒ms,默认15秒

Spring集群模式使用说明

环境配置
Spring与Redisson简单整合配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<context:property-placeholder location="classpath:spring-config.properties" ignore-unresolvable="true" />

<!--单台redis机器配置-->
<!--<redisson:client id="redissonClient">-->
<!--<redisson:single-server address="192.168.6.21:6382" connection-pool-size="30" />-->
<!--</redisson:client>-->

<!-- redis集群配置 -->
<redisson:client id="redissonClient">
<!--//scan-interval:集群状态扫描间隔时间,单位是毫秒 -->
<redisson:cluster-servers scan-interval="10000" >
<redisson:node-address value="redis://${redis.cluster1.hostname}:${redis.cluster1.port}"/>
<redisson:node-address value="redis://${redis.cluster2.hostname}:${redis.cluster2.port}"/>
<redisson:node-address value="redis://${redis.cluster3.hostname}:${redis.cluster3.port}"/>
<redisson:node-address value="redis://${redis.cluster4.hostname}:${redis.cluster4.port}"/>
<redisson:node-address value="redis://${redis.cluster5.hostname}:${redis.cluster5.port}"/>
<redisson:node-address value="redis://${redis.cluster6.hostname}:${redis.cluster6.port}"/>
</redisson:cluster-servers>

</redisson:client>

这里没有设置复杂的参数,因为默认的方式已经足够实现平常业务需求,如果你自己需要更加优异的分布式锁,可以具体设置Redisson具体参数

具体的参数设置请参考:Redisson和Spring框架整合

比如如下示例,可以设置其中部分参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

<redisson:client id="cluster" codec-ref="stringCodec">
<redisson:cluster-servers slaveConnectionPoolSize="500" 
                               masterConnectionPoolSize="500" 
                               idle-connection-timeout="10000"  
                               connect-timeout="10000"  
                               timeout="3000"  
                               ping-timeout="1000"  
                               reconnection-timeout="3000"  
                               database="0">  
<redisson:node-address value="redis://127.0.0.1:6379" />
<redisson:node-address value="redis://127.0.0.1:6380" />
<redisson:node-address value="redis://127.0.0.1:6381" />
<redisson:node-address value="redis://127.0.0.1:6382" />
<redisson:node-address value="redis://127.0.0.1:6383" />
<redisson:node-address value="redis://127.0.0.1:6384" />
</redisson:cluster-servers>
</redisson:client>

其中具体的参数含义请看:Redisson的基本配置

使用AOP对方法加锁

AOP实现加锁配置文件,注入需要的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

<import resource="spring-redisson.xml"/>

<!--注入redisson类对象属性值-->
<bean id="redissonLockUtils" class="com.foutin.redisson.lock.cluster.impl.RedissonLockImpl">
<constructor-arg name="redissonClient" ref="redissonClient"/>
</bean>
<bean id="lockAspectAdvice" class="com.foutin.redisson.lock.cluster.annotation.LockAspectAdvice">
<constructor-arg name="distributedLock" ref="redissonLockUtils"/>
</bean>

<!--redisson锁-->
<aop:config>
<aop:pointcut id="pointcut" expression="@annotation(com.foutin.redisson.lock.cluster.annotation.CustomReentrantLock)"/>
<aop:aspect ref="lockAspectAdvice">
<aop:around method="around" pointcut-ref="pointcut" />
</aop:aspect>
</aop:config>

然后把spring-config-aop.xml导入spring-config.xml配置文件中即可。

使用示例

方式一:使用注解

通过方法注解CustomReentrantLock和参数注解LockKey一起使用。
注解使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

@Service
public class DemoLockService {

@CustomReentrantLock(expireMillis = 10000)
public void demoDiffLock(String name, @LockKey Long id) {

System.out.println("fanxingkai-demoLockService:" + id);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

方式二:使用加单个锁方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

@Autowired
private RedissonLockImpl redissonLock;

public void demoUtils(String name) {
Boolean locked = redissonLock.tryLock(name, 2L, 10000L, TimeUnit.MILLISECONDS);
if (locked) {
try {
System.out.println("fanxingkai--llll:" + name);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redissonLock.unlock(name);
}
}
}

方式三:使用联锁

1
2
3
4
5
6
7
8
9
10
public void demoMultiLock(List<String> name) {
RedissonMultiLock multiLock = redissonLock.tryMultiLock(name, 2000L, 120000L);
try {
System.out.println("fanxingkai--llll:" + name);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
multiLock.unlock();
}

联锁使用场景:需要对多个字符串同时加锁。

注意项

  1. redisson需要依赖netty包,不然会报错
  2. 使用注解的方式,注解的方法必须有参数,无参数无法使用LockKey注解,无法获取锁key。

源码地址

Redisson实现分布式可重入锁

总结

这次的实践让我对Redis分布式锁有了一定的了解。万变不离其宗,加锁:通过向Redis保存有指定时间的key-value值,每次在执行代码前都会查询指定key是否在Redis中存在,是否过期,不存在或者已经过期的值,可以重新保存,保存成功便相当于获取了Redis锁。解锁: 通过lua来实现解锁的整个过程,保证原子性操作,在解锁的时候必须判断加锁和解锁是否是同一把锁,也就是在Redis中是否是同一个key。这样便实现了Redis的锁的基本功能。
如果要实现可重入的功能,每次获取相同的锁之前必须判断是否属于同一个线程的操作。同一个线程可以获取同一把锁,不为同一个线程设置其他的锁。每个线程可以通过记录线程和UUID值来组成一个唯一的值,并记录下来。同一把锁在重入的时候需要刷新这把锁的过期时间。
以上便是我对Redis锁的理解。下次我将尝试zookeeper分布式锁的实现。

坚持原创技术分享,您的支持将鼓励我继续创作!
-------------本文结束感谢您的阅读-------------