设计分布式锁时,先要关注它有哪些特点?
1、排它性(线程并发时,只有一个线程获取锁成功)
2、可重入(获取锁成功的线程,可多次进行获取)
还需要关注如下
1、功能点:获取锁、释放锁。
2、过期时间(保证程序异常后,锁也会正常释放)。
3、未获取到锁的线程,他们这时候在干嘛,应该怎么办?
4、有多个操作步骤时,如何保证它的原子性?(正常开发可能会想到加锁,可我们就是在设计锁,还没有锁呢?--lua脚本?它是原子的?如何保证原子的?感兴趣的小伙伴可以搜lua脚本在redis中使用,怎么就原子了?)
5、线程识别(线程的唯一标识--这里也可以多想想,本文用到的是redis自增和ThreadLocal来保证线程唯一且可获取到它)
下面贴一下获取锁的流程图:
释放的流程图小伙伴们自行脑补,大差不差,有兴趣的同学可以自己画一下。
关键代码:
/**
* 获得锁 lua脚本
* 1个key,两个参数:key、线程标识、超时时间
* 可作为样例
* 步骤:(key和线程标识绑定)
* 1、先获取key的flag(flag是当前线程的标识--通过thredLocal维护的标识--每个线程有一个标识--redis自增保证标识不会重复)
* 2、如果不是字符串类型,且不等于第一个参数。获取锁失败(f第一次进来是nil,type(f)是boolean类型,所以可以往下执行)
* 3、给key的flag存上参数1做标识
* 4、给key设置过期时间参数2
* 5、获取key的次数(可重入)
* 6、如果次数不是字符串或小于0,给次数设置为1
* 7、否则给次数加1
* 8、返回1获取锁成功
*/
public static String LOCK_SCRIPT =
"local f = redis.call('HGET',KEYS[1],'flag');" +
"if type(f) == 'string' and f ~= ARGV[1] then return 0;end " +
"redis.call('HSET',KEYS[1],'flag',ARGV[1]);" +
"redis.call('EXPIRE',KEYS[1],ARGV[2]);" +
"local c = redis.call('HGET',KEYS[1],'count');" +
"if type(c) ~= 'string' or tonumber(c) < 0 then redis.call('HSET',KEYS[1],'count',1);" +
"else redis.call('HSET',KEYS[1],'count',c+1);end " +
"return 1";
/**
* 释放锁 lua脚本
* 两个参数:key、线程标识
* 1、获取key锁被哪个线程占用,取它的标识flag
* 2、如果类型不是string,或者是string但不是当前线程的标识,返回0--不用释放
* 3、进入第3步,说明是当前线程占用的锁,取它的占用次数
* 4、如果次数为空或者次数小于2--代表只占用了一次,直接删除key(这个key是传过来的key加了特殊后缀,当锁的key),返回1
* 5、否则的话代表重入了多次,次数减1,返回2
*
*
*/
public static String UNLOCK_SCRIPT =
"local f = redis.call('HGET',KEYS[1],'flag');" +
"if type(f) ~= 'string' or (type(f) == 'string' and f ~= ARGV[1]) then return 0;end " +
"local c = redis.call('HGET',KEYS[1],'count');" +
"if type(c) ~= 'string' or tonumber(c) < 2 then redis.call('DEL',KEYS[1]);return 1;" +
"else redis.call('HSET',KEYS[1],'count',c-1);return 2;end";
/**
* 获得锁
* 1、如果锁获取成功了,还可以继续获取,可重入
* 2、如果没成功,那么就从锁key的list队列执行blpop操作,如果list有数据(当占用锁的线程释放锁成功时,会往list中加入ok),弹出,夺取资源成功,重新去获得锁
*
*
* @param jedis redis连接
* @param expireSecond 持有锁超时秒数
* @param waitSecond 等待锁超时秒数
* @param flag 线程标识
* @return
*/
private boolean tryLockInner(Jedis jedis, int expireSecond, int waitSecond, String flag) {
// 尝试获得锁 如果自身持有锁则可以再次获得
if ((Long) jedis.eval(LOCK_SCRIPT, 1, redisLockKey, flag, "" + expireSecond) > 0) {
return true;
}
//阻塞等待释放锁通知
List<String> lp = jedis.blpop(waitSecond, redisListKey);
System.err.println("flag:"+flag+",blpop,得到了list弹出的ok,重新获取锁");
if (lp == null || lp.size() < 1) {
//如果超时则返回锁定失败
return false;
}
return tryLockInner(jedis, expireSecond, waitSecond, flag);
}
/**
* 释放锁
* 1、可重入,需要占用的次数减为1时,对应的程序返回1则释放成功,返回2代表次数减1
* 2、释放成功后,往Key的list队列中放入Ok,此时有多个线程开启了blpop操作,会争夺资源,谁弹出成功代表夺取资源成功,可重新去获得锁
*
* @param flag 线程标识
* @return
*/
public boolean tryUnlock(String flag) {
Jedis jedis = jedisPool.getResource();
try {
//删除锁定的key
Long l = (Long) jedis.eval(UNLOCK_SCRIPT, 1, redisLockKey, flag);
if (l < 1) {
return false;
}
// 因为是可重入锁 所以释放成功不一定会释放锁
if (l.intValue() == 2) {
return true;
}
//如果锁释放消息队列里没有值 则释放一个信号
if (l.intValue() == 1 && jedis.llen(redisListKey).intValue() == 0) {
//通知等待的线程可以继续获得锁
System.err.println("flag:"+flag+",放的ok");;
jedis.rpush(redisListKey, "ok");
}
return true;
} finally {
jedis.close();
}
}
原理:使用redis的lua脚本实现 lua脚本可保证原子性,使用BLPOP实现释放锁时的通知,可防止等待线程自旋浪费cpu资源。