- 基于Jedis setnx、expire实现分布式锁(存在问题,作为错误示范)
先引入相关依赖(jedis 2.3.0后支持redis集群模式,2.4.2后支持jedisCluster多线程处理,2.9.0之后版本较稳定,这里使用jedis 2.9.0版本,)
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
Jedis单机版连接池:配置jedis连接池获得连接池对象,然后从连接池获得Jedis实例对象(我们一般用集群版的,单机版不过多介绍)
JedisPool jedisPool = null;
Jedis jedis = null;
//设置连接池的配置对象
JedisPoolConfig config = new JedisPoolConfig();
//设置连接池参数
config.setMaxTotal(30);//最大活动对象数
config.setMaxIdle(10);//最小能够保持idel(空闲)状态的对象数
//获取连接池对象
jedisPool = new JedisPool(config,"127.0.0.1", 6379);
//获得jedis实例
jedis=jedisPool.getResource();
//归还连接
jedis.close();
//连接池关闭
jedisPool.close();
Jedis集群版连接池:配置连接池参数
配置文件(yml):
#最大活动对象数
maxTotal: 1000
#最大能够保持idel状态的对象数
maxIdle: 100
#最小能够保持idel状态的对象数
minIdle: 50
#当池内没有返回对象时,最大等待时间
maxWaitMillis: 10000
#当调用borrow Object方法时,是否进行有效性检查
testOnBorrow: true
#当调用return Object方法时,是否进行有效性检查
testOnReturn: true
#“空闲链接”检测线程,检测的周期,毫秒数。如果为负值,表示不运行“检测线程”。默认为-1.
timeBetweenEvictionRunsMillis: 30000
#向调用者输出“链接”对象时,是否检测它的空闲超时;
testWhileIdle: true
# 对于“空闲链接”检测线程而言,每次检测的链接资源的个数。默认为3.
numTestsPerEvictionRun: 50
#redis服务器节点地址(ip:port)
nodes:
- "*.*.*.*:****"
把JedisCluster作为单例类HTJedisClusterClient的一个属性,经过过单例类HTJedisClusterClient初始化,完成JedisCluster的配置,然后只需要通过调用HTJedisClusterClient的getJedisCluster()方法获得JedisCluster实例对象
public class HTJedisClusterClient {
protected final Logger cLogger = Logger.getLogger(getClass());
private static final String LOCK_SUCCESS = "OK";
private static final Long RELEASE_SUCCESS = 1L;
private JedisCluster jedisCluster;
private Set<HostAndPort> nodes = new HashSet();
private static volatile HTJedisClusterClient instance;
private static String cPath="jedis-config.yml";
private HTJedisClusterClient() {
//初始化
this.init();
}
private void init() {
try {
//读取配置文件
Yaml yaml = new Yaml();
// String filePath= SysInfo.cHome+cPath;
String filePath = "classpath:" + cPath;
File file = ResourceUtils.getFile(filePath);
InputStream in=new FileInputStream(file);
Map<String,Object> map = yaml.load(in);
//配置连接池
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxIdle((int)map.get("maxIdle"));
config.setMaxTotal((int)map.get("maxTotal"));
config.setMinIdle((int)map.get("minIdle"));
config.setTestOnBorrow((boolean)map.get("testOnBorrow"));
config.setTimeBetweenEvictionRunsMillis((long)map.get("timeBetweenEvictionRunsMillis"));
//节点信息配置
List<String> hostanportList=(List<String>) map.get("nodes");
for(String hostandport:hostanportList){
HostAndPort hostAndPort=HostAndPort.parseString(hostandport);
this.nodes.add(hostAndPort);
}
//获得jedisCluster对象
this.jedisCluster = new JedisCluster(this.nodes, config);
cLogger.info("jedis cluster init pub/sub pool finish nodes=" + this.nodes);
}catch (Exception e){
e.printStackTrace();
cLogger.info("初始化Jedis连接池异常:"+e.getMessage());
}
}
public static HTJedisClusterClient getInstance() {
if (instance == null) {
synchronized(HTJedisClusterClient.class) {
if (instance == null) {
instance = new HTJedisClusterClient();
}
}
}
return instance;
}
public JedisCluster getJedisCluster(){
return jedisCluster;
}
}
使用方式示例,JedisTest类,实现了Runnable接口,以下是run()方法的内容
@Override
public void run() {
//作为锁 key-value的 key
String jedisKey="jedisKey";
//作为锁 key-value的 value
String requestId =jedisKey+"_"+ UUID.randomUUID().toString()+"_"+Thread.currentThread().getName();
//过期时间,单位秒
int expire=5;
try{
//设置成功,返回 1 ,设置失败,返回 0,value最好为分布式唯一值,这里测试使用线程名,实际上使用不能把线程名作为value,可能重复
if(1l==jedisCluster.setnx(jedisKey,requestId)){
try {
cLogger.info(requestId+"已获取锁");
//设置过期时间为5秒
jedisCluster.expire(jedisKey, expire);
cLogger.info(requestId+"已设置过期时间");
//(业务代码)
Thread.sleep(2000);
}catch (Exception e){
e.printStackTrace();
}finally {
//判断锁是否存在
if(jedisCluster.exists(jedisKey)) {
//判断是否是自己的锁,是则释放
if(jedisCluster.get(jedisKey).equals(requestId)){
jedisCluster.del(jedisKey);
cLogger.info(requestId+"释放锁");
}
}
}
}else{
//若没有获得锁,判断锁有没有设置过期时间,没有则给它设置过期时间,避免死锁
if(jedisCluster.ttl(jedisKey)==-1L){
cLogger.info("检测到锁未设置过期时间,"+requestId+"已重新为其设置过期时间");
jedisCluster.expire(jedisKey,expire);
}
cLogger.info(requestId+"获取锁失败");
}
}catch (Exception e){
e.printStackTrace();
}
}
测试demo(创建10个线程模拟争夺锁的情况)
public static void main(String args[]) throws InterruptedException {
//获取JedisCluster对象
JedisCluster jedisCluster=HTJedisClusterClient.getInstance().getJedisCluster();
for(int i=0;i<10;i++) {
JedisTest2 jedisTest= new JedisTest2(jedisCluster);
Thread thread = new Thread(jedisTest);
thread.start();
Thread.sleep(1000);
}
}
结果
jedisKey_c40274fb-5685-436d-9492-1c247be4974b_Thread-3已获取锁
jedisKey_c40274fb-5685-436d-9492-1c247be4974b_Thread-3已设置过期时间
jedisKey_ec5bb2c2-dedb-4227-89fd-863e1c8747e6_Thread-4获取锁失败
jedisKey_d1625319-7955-41a2-8f59-5205039bbcf4_Thread-5获取锁失败
jedisKey_c40274fb-5685-436d-9492-1c247be4974b_Thread-3释放锁
jedisKey_b179d84b-24c8-4be3-a6a1-d28a9fd46b42_Thread-6已获取锁
jedisKey_b179d84b-24c8-4be3-a6a1-d28a9fd46b42_Thread-6已设置过期时间
jedisKey_b18cbfe5-d511-4405-89e1-4fc963c77546_Thread-7获取锁失败
jedisKey_e5934c4b-7dc3-4c67-89b4-e75b8bb6fad2_Thread-8获取锁失败
jedisKey_b179d84b-24c8-4be3-a6a1-d28a9fd46b42_Thread-6释放锁
jedisKey_d1b4c80a-a411-440a-afd3-5d48162c0573_Thread-9已获取锁
jedisKey_d1b4c80a-a411-440a-afd3-5d48162c0573_Thread-9已设置过期时间
jedisKey_ce8d9370-721d-4c6c-86e7-cfcb0bfdbe06_Thread-10获取锁失败
jedisKey_caf7446a-f58a-4f56-b964-f1d37ebb2aeb_Thread-11获取锁失败
jedisKey_d1b4c80a-a411-440a-afd3-5d48162c0573_Thread-9释放锁
jedisKey_9e6f132d-0580-474a-b402-18b1b406e413_Thread-12已获取锁
jedisKey_9e6f132d-0580-474a-b402-18b1b406e413_Thread-12已设置过期时间
jedisKey_9e6f132d-0580-474a-b402-18b1b406e413_Thread-12释放锁
缺陷:
1)setnx、expire是分两步操作而非一个原子性操作,可能会出现setnx成功,expire失败的情况下,导致锁没有设置过期时间而变成死锁;
2)没有设置等待时间,获取锁失败则不会尝试重新获取锁了。
3)释放锁时,判断锁是否已锁、是不是自己的锁、释放锁也是分了三步操作而非一个原子性操作。可能会出现判断是自己的锁了,即将执行释放锁操作但还未执行时,如果锁在此时超时释放了,别的线程同时获得了这个锁,我们继续执行释放锁的操作会导致释放了别人的锁;
4)没有实现锁的超时续期,导致可能业务还没执行完就过期释放了这个锁,从而出现线程安全问题;
5)上锁失败,就判断是否设置了过期时间,若是没有,则给它设置过期时间;这里虽然可以避免了死锁的问题,但是不够严谨,无法判断别的线程业务代码的执行时间,过期时间设置无法确定合不合理;
带着以上问题,再看看接下来的另一个方案
- 使用Jedis set命令以及Lua脚本方式实现分布式锁(不完美)
自从redis的2.6.12版本起,SET命令已经提供了可选的复合操作符,把上锁和设置过期时间合成了一个原子性的操作。(“resource_name”就是我们上锁 key-value的key;my_random_value是 key-value的value,这个值作为判断锁是否线程自身所持有的标识,必须是唯一的;"NX"的意思是"SET IF NOT EXIST",不存在这个KEY则创建;"PX"意思是要加过期时间,参数为后面的30000毫秒)
SET resource_name my_random_value NX PX 30000
我们可以使用set命令来实现获取锁,但释放锁的多步操作依旧是非原子性的;redis2.6.0之后提供了lua脚本的支持,lua脚本是原子性的操作,它能把释放锁的多步操作合成一条命令提交给redis执行
编写一个JedisCluster的子类(类名也叫JedisCluster),在这个类里编写一个release方法,使用lua脚本实现释放锁。
/**
* 释放分布式锁(lua脚本)
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public boolean release(String lockKey, String requestId) {
//Lua脚本,意思是首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令,确保原子性
Object result = eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
cLogger.info(requestId+"释放锁成功");
return true;
}
return false;
}
既然Lua命令可以把多个操作合成一个原子性操作,基于这个特性,我们实际上也可以自己封装一个获取分布式锁的方法。
除了上面使用jedis的set方法获取分布式锁,还可以使用Lua脚本的方式获取锁(把setnx、expire合为一条lua命令),同样也是可以实现获取分布式锁+设置过期时间的原子性操作。
/**
* 使用Lua脚本方式尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识,加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryLockByLua(JedisCluster jedis, String lockKey, String requestId, int expireTime){
String script ="if redis.call('setnx',KEYS[1],ARGV[1])==1 then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end";
List valueList=new ArrayList();
valueList.add(requestId);//ARGV[1]
valueList.add(String.valueOf(expireTime));//ARGV[2]
//eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令,确保原子性
Object result = jedis.eval(script, Collections.singletonList(lockKey), valueList);
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
然后把HTJedisClusterClient单例类里面的JedisCluster属性替换成刚刚编写的这个它的子类JedisCluster,这样就可以通过HTJedisClusterClient单例类获得这个JedisCluster,调用其相关方法实现redis分布式锁;
实现方式:通过set方法获取锁、release方法释放锁
@Override
public void run() {
//作为锁 key-value的 key
String jedisKey="jedisKey";
//作为锁 key-value的 value
String requestId =jedisKey+"_"+ UUID.randomUUID().toString()+"_"+Thread.currentThread().getName();
//过期时间,单位毫秒
int expire=5000;
try{
// "NX"的意思是"SET IF NOT EXIST",不存在这个KEY则创建 "PX"意思是要加过期时间,参数为expire
if("OK".equals(jedisCluster.set(jedisKey,requestId,"NX","PX",expire))){
try {
cLogger.info(requestId+"已获取锁并设置过期时间");
//(业务代码)
Thread.sleep(3000);
}catch (Exception e){
e.printStackTrace();
}finally {
//释放锁
jedisCluster.release(jedisKey,requestId);
}
}else{
cLogger.info(requestId+"获取锁失败");
}
}catch (Exception e){
e.printStackTrace();
}
}
测试demo
public static void main(String args[]) throws InterruptedException {
//获取JedisCluster对象
JedisCluster jedisCluster= HTJedisClusterClient.getInstance().getJedisCluster();
for(int i=0;i<10;i++) {
JedisTest1 jedisTest= new JedisTest1(jedisCluster);
Thread thread = new Thread(jedisTest);
thread.start();
Thread.sleep(1000);
}
}
结果
jedisKey_26a2d2de-7f50-4614-80d6-245eb68e8b08_Thread-3已获取锁并设置过期时间
jedisKey_b3f549d2-d1c1-4199-b94d-15b478e16dd2_Thread-4获取锁失败
jedisKey_ff57c051-8a0d-41a5-a277-1f6516553437_Thread-5获取锁失败
jedisKey_c6696d0b-8d2b-4d2a-88a8-8474601f8665_Thread-6获取锁失败
jedisKey_26a2d2de-7f50-4614-80d6-245eb68e8b08_Thread-3释放锁成功
jedisKey_7c39d0dd-15b9-448f-969f-d8b0d12e3e0a_Thread-7已获取锁并设置过期时间
jedisKey_fdf73350-55c0-4b5f-9210-afc89a485904_Thread-8获取锁失败
jedisKey_3acc7608-bb3d-49c1-9319-e3885fe4d500_Thread-9获取锁失败
jedisKey_ba73efec-bdb8-4f81-9cc1-6d78225db6ab_Thread-10获取锁失败
jedisKey_7c39d0dd-15b9-448f-969f-d8b0d12e3e0a_Thread-7释放锁成功
jedisKey_82150b2f-7926-4f60-983c-7cb33048562f_Thread-11已获取锁并设置过期时间
jedisKey_7d7fed0b-be86-436d-82ee-9f89417f6cad_Thread-12获取锁失败
jedisKey_82150b2f-7926-4f60-983c-7cb33048562f_Thread-11释放锁成功
缺陷:
1)使用set方法获取分布式锁,没有实现等待时,需要使用者自己实现这个等待时重新获取锁的逻辑;
2)没有实现锁续期功能,一旦过期就释放锁,不管代码是否执行完;
- 使用Redisson+RLock实现分布式锁(tryLock、unLock方法实现原子性的原理也是基于Lua脚本)
引入依赖(这里使用3.12.0版本)
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
配置参数(yml):
#集群配置
clusterServersConfig:
# 连接空闲超时,单位:毫秒,默认值 10000
idleConnectionTimeout: 10000
# ping节点超时,单位:毫秒,默认值 1000
pingTimeout: 1000
# 连接超时,单位:毫秒,默认值 10000
connectTimeout: 10000
# 命令等待超时,单位:毫秒,默认值 3000
timeout: 3000
# 命令失败重试次数,默认值 3
retryAttempts: 3
# 命令重试发送时间间隔,单位:毫秒,默认值 1500
retryInterval: 1500
# 重新连接时间间隔,单位:毫秒,默认值 3000
reconnectionTimeout: 3000
# 执行失败最大次数,默认值 3
failedAttempts: 3
# 密码
password: null
# 单个连接最大订阅数量,默认值 5
subscriptionsPerConnection: 5
# 客户端名称
clientName: null
# 负载均衡算法
loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
# 从节点发布和订阅连接的最小空闲连接数,默认值 1
slaveSubscriptionConnectionMinimumIdleSize: 1
# 从节点发布和订阅连接池大小,默认值 50
slaveSubscriptionConnectionPoolSize: 50
# 从节点最小空闲连接数,默认值 32
slaveConnectionMinimumIdleSize: 32
# 从节点连接池大小,默认值 64
slaveConnectionPoolSize: 64
# 主节点最小空闲连接数,默认值 32
masterConnectionMinimumIdleSize: 32
# 主节点连接池大小,默认值 64
masterConnectionPoolSize: 64
# 读取操作的负载均衡模式,默认值 SLAVE(只在从节点里读取)
readMode: "SLAVE"
# 节点地址
nodeAddresses:
- "redis://*.*.*.*:****"
# 集群扫描间隔时间,默认值 1000
scanInterval: 1000
# 线程池数量,默认值: 当前处理核数量 * 2
#threads: 0
# Netty线程池数量,默认值: 当前处理核数量 * 2
#nettyThreads: 0
# 编码,默认值 org.redisson.codec.JsonJacksonCodec
#codec: !<org.redisson.codec.JsonJacksonCodec> {}
# 传输模式,默认值 NIO
#"transportMode":"NIO"
# 看门狗默认过期时间 默认值 30000 单位:毫秒
lockWatchdogTimeout: 30000
把ReddissonClient作为单例类HTRedissonClient的一个属性,经过过单例类HTRedissonClient初始化,完成ReddissonClient的配置,然后只需要通过调用HTRedissonClient的getRedissonClient()方法,就可以获得ReddissonClient对象进行相应操作
public class HTRedissonClient {
protected final Logger cLogger = Logger.getLogger(getClass());
private static Config config = new Config();
private static RedissonClient redissonClient;
private static volatile HTRedissonClient instance;
private static String cPath="redisson-config.yml";
private HTRedissonClient(){
init();
}
private void init() {
try {
cLogger.info("加载redisson配置文件");
// String filePath= SysInfo.cHome+cPath;
String filePath="classpath:redisson-config.yml";
cLogger.info("配置文件地址:"+filePath);
File file = ResourceUtils.getFile(filePath);
config = Config.fromYAML(file);
cLogger.info("配置成功");
cLogger.info("创建RedissonClient实例");
redissonClient= Redisson.create(config);
cLogger.info("RedissonClient实例创建完成");
}catch (Exception e){
e.printStackTrace();
}
}
public static HTRedissonClient getInstance(){
if(instance==null){
synchronized ((HTRedissonClient.class)){
if(instance==null){
instance = new HTRedissonClient();
}
}
}
return instance;
}
public RedissonClient getRedissonClient(){
return redissonClient;
}
}
使用方式示例,这里要注意一下,Redisson是提供了自动续期的功能的,但是自动续期功能只有在我们不自定义过期时间时才启用;(不自定义过期时间时,默认是30秒过期,启用自动续期功能,默认的过期时间可以通过配置文件参数lockWatchdogTimeout配置)
@Override
public void run() {
String lockKey="lockKey";
RLock rLock = redissonClient.getLock(lockKey);
try {
//尝试加锁,最多等待5秒,默认30秒过期时间,有自动续期功能
if (rLock.tryLock(5, TimeUnit.SECONDS)) {
cLogger.info(Thread.currentThread().getName()+"获取锁成功");
try {
//(业务代码)
Thread.sleep(2000);//模拟业务代码执行时间
}finally {
rLock.unlock();// 释放锁
cLogger.info(Thread.currentThread().getName()+"释放锁成功");
}
}else{
cLogger.info(Thread.currentThread().getName()+"未获取到锁");
}
}catch (Exception e){
e.printStackTrace();
}
}
测试demo
public static void main(String args[]) throws InterruptedException {
//获取HTRedissonClient实例对象
RedissonClient redissonClient = HTRedissonClient.getInstance().getRedissonClient();
for(int i=0;i<10;i++) {
//作为入参传入实现了Runnable接口的RedissonTest的构造函数,获得RedissonTesst对象
RedissonTest redissonTest=new RedissonTest(redissonClient);
//创建线程
Thread thread = new Thread(redissonTest);
//启动线程
thread.start();
}
}
运行结果
Thread-2获取锁成功
Thread-2释放锁成功
Thread-4获取锁成功
Thread-4释放锁成功
Thread-1获取锁成功
Thread-5未获取到锁
Thread-3未获取到锁
Thread-10未获取到锁
Thread-6未获取到锁
Thread-8未获取到锁
Thread-7未获取到锁
Thread-9未获取到锁
Thread-1释放锁成功
tryLock、unLock的底层Lua脚本
**tryLock:**
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"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]);"
**unLock:**
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
优点:
Redisson把获取锁+设置等待时间+设置过期时间、释放锁的各种操作封装成原子操作,并提供了自动续期的功能,此外还提供了可重入锁的功能。
缺陷:
unLock()方法可能会尝试释放别的线程的锁,虽然不会成功,但是会报异常;
- 总结
Jedis提供了和redis命令高度一致的方法,学习成本低,它支持redis的基本数据类型和特性;日常使用相对来说更容易上手,但它提供的分布式锁的封装程度不高,很多逻辑需要我们自己去实现。
Redisson对redis的命令进行了高度封装,提供了许多强大了分布式服务api;不支持字符串操作,不支持redis的一些基本特性。它的方法和我们日常的redis命令有较大差别,上手难度较大,但是它所提供的分布式锁功能较为完善,不需要我们去实现一些比较复杂的逻辑。