本文将在RedisTemplate中使用Redis的String类型或Hash类型来迭代式实现一个分布式锁的基本功能(互斥、防误删)和附加功能(可重入、自动续期)。
目录
1.0原始版本
本版本中我们将介绍一个非分布式的锁的功能。我们将假设多个线程将抢占一个锁,并对一个Redis中的String类型进行互斥更改。
@RestController
@RequestMapping("/lock")
public class Lock_String {
@Autowired
StringRedisTemplate stringRedisTemplate;
private static final String KEY = "Number1";
private static final Lock lock = new ReentrantLock();
@GetMapping("/inc")
public String addOne() {
//上锁
lock.lock();
String result1 = null;
String result2 = null;
try {
//1 访问redis中资源
result1 = stringRedisTemplate.opsForValue().get(KEY);
//2 更改数值
stringRedisTemplate.opsForValue().set(KEY, String.valueOf(Integer.parseInt(result1) + 1));
//3 检测资源是否更改
result2 = stringRedisTemplate.opsForValue().get(KEY);
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
//解锁
lock.unlock();
}
return "数值从" + result1 + "变更为" + result2;
}
}
2.0Setnx自旋锁版本
实现功能:理想情况下互斥
@RestController
@RequestMapping("/lock")
public class lock2_0 {
@Autowired
StringRedisTemplate stringRedisTemplate;
private static final String KEY_RES = "Number1";
private static final String KEY_LOCK = "Lock";
private final String VALUE_LOCK = UUID.randomUUID() + ":" + Thread.currentThread().getId();
@GetMapping("/inc")
public String addOne() {
//加锁
lock();
String result1 = null;
String result2 = null;
try {
//1 访问redis中资源
result1 = stringRedisTemplate.opsForValue().get(KEY_RES);
//2 增加该资源
stringRedisTemplate.opsForValue().set(KEY_RES, String.valueOf(Integer.parseInt(result1) + 1));
//3 检测资源是否更改
result2 = stringRedisTemplate.opsForValue().get(KEY_RES);
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
//解锁
unlock();
}
return "数值从" + result1 + "变更为" + result2;
}
private void lock() {
System.out.println(VALUE_LOCK);
while (!stringRedisTemplate.opsForValue().setIfAbsent(KEY_LOCK, VALUE_LOCK)) {
//暂停20毫秒,自旋
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void unlock() {
stringRedisTemplate.delete(KEY_LOCK);
}
}
目前存在问题:由于是分布式架构,stringRedisTemplate.delete(KEY_LOCK);语句向redis发送的命令可能会由于网络问题丢失,也可能是java微服务崩溃,进而导致该锁始终无法解除。
改进方案:为锁设置过期时间。
3.0Setnx自旋+过期锁版本
目前实现功能:到期锁自动过期。
private static final int expiredTime = 30;
private void lock() {
System.out.println(VALUE_LOCK);
while (!stringRedisTemplate.opsForValue().setIfAbsent(KEY_LOCK, VALUE_LOCK)) {
stringRedisTemplate.expire(key,expiredTime,TimeUnit.SECONDS);//添加过期时间
//暂停20毫秒,自旋
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
存在问题:
- 更改过期时间问题:可能expire语句和del语句一样由于网络问题丢失,导致该锁还是无法解除。
- 改进思路:加锁时原子性设置过期时间。
3.1Setnx自旋+过期plus锁版本
private void lock() {
System.out.println(VALUE_LOCK);
while (!stringRedisTemplate.opsForValue().setIfAbsent(KEY_LOCK, VALUE_LOCK, expiredTime, TimeUnit.SECONDS)) {//原子性添加过期时间
//暂停20毫秒,自旋
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
存在问题:
- 误删问题:如果业务处理过慢导致del语句在到期后再执行,会导致删除其他线程的锁。
- 改进思路:删除时使用Lua脚本添加判断逻辑,同时保证原子性(如果不保证原子性,会导致和上面一样的问题)。
4.0Setnx自旋+防误删+过期锁版本
private void unlock() {
String LuaScript =
"if (redis.call('get',KEYS[1]) == ARGV[1]) then " +//如果KEY_LOCK的value就是当前线程的uuid
"return redis.call('del',KEYS[1]) " +//说明就是当前线程的锁,进行删除
"else " +//否则不删除
"return 0 " +
"end";
stringRedisTemplate.execute(new DefaultRedisScript<>(LuaScript, Boolean.class), Arrays.asList(KEY_LOCK), VALUE_LOCK);
}
存在问题:
- 正常情况下锁过期:可能业务处理过慢,导致业务没处理完锁就过期了。
- 改进思路:另起一个线程,定期将锁的时间延长。(保证在未宕机的情况下锁不会过期,并且依旧不会让锁无法释放)
5.0Setnx自旋+防误删+过期+续期锁版本
private void lock() {
System.out.println(VALUE_LOCK);
while (!stringRedisTemplate.opsForValue().setIfAbsent(KEY_LOCK, VALUE_LOCK, expiredTime, TimeUnit.SECONDS)) {
//暂停20毫秒,自旋
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
renewal();
}
private void renewal() {
String script =
"if (redis.call('get',KEYS[1]) == ARGV[1]) then " +//如果KEY_LOCK的value就是当前线程的uuid
"return redis.call('expire',KEYS[1],ARGV[2]) " +说明就是当前线程的锁,进行续期
"else " +
"return 0 " +//否则返回0并退出
"end";
new Thread(() -> {
while (Boolean.TRUE.equals(stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(KEY_LOCK), VALUE_LOCK, String.valueOf(expiredTime)))) {
System.out.println("重续");
try {
Thread.sleep(expiredTime*1000/3);//执行成功就睡1/3个过期时间,不成功说明已经被解锁或已经过期了,就停止该线程。
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
存在问题:并不存在什么大问题了,只是不支持可重入锁,其实不怎么必需(go就不支持重入锁),但是为了让这个锁更加方便使用,还是实现一下可重入功能。
改进思路:在原先Lock-Key、Lock-Value的基础上,添加一个int值用于记录重入次数,并且使用Lua脚本重新实现lock、unlock、renewal函数的对应逻辑。
6.0Hset自旋+防误删+过期+续期+可重入锁版本
在Hset中存储:Lock-Key:Lock-Value:Lock-time s。
@RestController
@RequestMapping("/lock2")
public class Lock_Hash {
@Autowired
StringRedisTemplate stringRedisTemplate;
private static final String KEY_RES = "Number1";
private static final String KEY_LOCK = "Lock";
private final String VALUE_LOCK = UUID.randomUUID() + ":" + Thread.currentThread().getId();
private static final int expiredTime = 30;
@GetMapping("/inc")
public String addOne() {
//加锁
lock();
String result1 = null;
String result2 = null;
try {
//1 访问redis中资源
result1 = stringRedisTemplate.opsForValue().get(KEY_RES);
//2 增加该资源
stringRedisTemplate.opsForValue().set(KEY_RES, String.valueOf(Integer.parseInt(result1) + 1));
//3 检测资源是否更改
result2 = stringRedisTemplate.opsForValue().get(KEY_RES);
//4 测试重入方法
reEnter();
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
//解锁
unlock();
}
return "数值从" + result1 + "变更为" + result2;
}
private void reEnter() {
//加锁
lock();
String result1 = null;
try {
//1 访问redis中资源
result1 = stringRedisTemplate.opsForValue().get(KEY_RES);
//2 偷偷再+1
stringRedisTemplate.opsForValue().set(KEY_RES, String.valueOf(Integer.parseInt(result1) + 1));
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
//解锁
unlock();
}
}
private void lock() {
String LuaScript =
"if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +//不存在说明锁未被抢占;存在则判断锁是否是自己的锁
"redis.call('hincrby',KEYS[1],ARGV[1],1) " +//自己的锁或未抢占锁则重入次数加1,并重置或设置锁过期时间
"redis.call('expire',KEYS[1],ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
while (!stringRedisTemplate.execute(new DefaultRedisScript<>(LuaScript,Boolean.class), Arrays.asList(KEY_LOCK),VALUE_LOCK,String.valueOf(expiredTime))) {
//暂停20毫秒,自旋
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
renewal();
}
private void unlock() {
String LuaScript =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +//锁不存在说明已过期
" return nil " +
"elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +//否则将重入值-1,如果重入值为到达0,说明释放锁,将锁删除
" return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(LuaScript, Long.class), Arrays.asList(KEY_LOCK),VALUE_LOCK,String.valueOf(expiredTime));
if(flag == null)
{
throw new RuntimeException("This lock doesn't EXIST");
}
}
private void renewal() {
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +//如果KEY_LOCK的value就是当前线程的uuid
"return redis.call('expire',KEYS[1],ARGV[2]) " +说明就是当前线程的锁,进行续期
"else " +
"return 0 " +//否则返回0并退出
"end";
new Thread(() -> {
while (Boolean.TRUE.equals(stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(KEY_LOCK), VALUE_LOCK, String.valueOf(expiredTime)))) {
try {
Thread.sleep(expiredTime * 1000 / 3);//执行成功就睡1/3个过期时间,不成功说明已经被解锁或已经过期了,就停止该线程。
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
其他改进点:如实现JUC的Lock接口;使用简单工厂模式等,这里就不做赘述了。
到这分布式锁已经写得差不多了,不过还是有一个绕不过的点,就是都是单实例的锁,要求redis的master不会宕机。接下来就需要使用Redlock来改进这个问题,具体请查看后续文章。