Redis分布式锁

概述

所谓分布式锁,应当基本如下几项核心性质:

  • 独占性:对于同一把锁,在同一时刻只能被一个取锁方占有,这是锁最基础的一项特征
  • 健壮性:即不能产生死锁(dead lock). 假如某个占有锁的使用方因为宕机而无法主动执行解锁动作,锁也应该能够被正常传承下去,被其他使用方所延续使用
  • 对称性:加锁和解锁的使用方必须为同一身份. 不允许非法释放他人持有的分布式锁
  • 高可用:当提供分布式锁服务的基础组件中存在少量节点发生故障时,应该不能影响到分布式锁
  • 服务的稳定性

setnx

实现分布式锁时需要实现的两个基本方法:

  • 获取锁:

互斥:确保只能有一个线程获取锁

1
2
3
4
5
6
SETNX lock thread1

EXPIRE lock 10

#添加锁,NX是互斥,EX是设置超时时间
SET lock thread1 NX EX 10
  • 释放锁:

手动释放

超时释放:获取锁时添加的一个超时时间

1
DEL key

基于setnx实现简单的Redis锁

lock接口

1
2
3
4
public interface MyLock{
public boolean tryLock(long timeoutSec);
public void unlock();
}

实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RequiredArgsConstructor
public class SimpleRedisLock implements MyLock{
private String name;
private StringRedisTemplate stringRedisTemplate;

private static final String KEY_PREFIX="lock:";

@Override
public boolean tryLock(long timeoutSec){
long threadId=Thread.currentThread().getId();
Boolean success=stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX+name,threadId+"",timeoutSec,TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}

@Override
public void unlock(){
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}

改进Redis的分布式锁

误删锁

  1. 线程1阻塞,导致Redis超时释放锁
  2. 线程2获取锁,线程1恢复执行删除锁
  3. 线程1删除的是线程2的锁
  4. 线程3此时加锁,加锁成功

方案

  1. 在获取锁时存入线程标识(可以使用UUID表示)
  2. 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识相同
    • 如果一致则释放锁
    • 如果不一致则不释放锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static final String ID_PREFIX=UUID.randomUUID.toString(true)+"-";

@Override
public boolean tryLock(long timeoutSec){
String thread = ID_PREFIX+Thread.currentThread().getId();
long threadId=Thread.currentThread().getId();
Boolean success=stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX+name,threadId+"",timeoutSec,TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock(){
String threadId=ID_PREFIX+Thread.currentThread.getId();
String od=stringRedisTemplate.opsForValue.get(KEY_PREFIX+name);
if(threadId.equals(id)){
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}

判断后阻塞

  1. 线程1准备释放锁,判断标识成功
  2. 线程1阻塞,超时释放锁
  3. 线程2获取锁,线程1恢复执行删除锁
  4. 线程1删除的是线程2的锁
  5. 线程3此时加锁,加锁成功

Redis的Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性

Redis提供的函数调用,语法如下:

1
redis.call('命令名称','key','其他参数',...)

例如,执行set name wereash 则脚本是这样的:

1
redis.call('set','name','wereash')

例如,我们要执行set name wereash,再执行get name,则脚本如下:

1
2
3
redis.call('set','name','wereash')
local name=redis.call('get','name')
return name

写好脚本以后,需要用Redis命令来调用脚本

例如,要执行redis.call(‘set’,’name’,’wereash’)这个脚本,语法如下:

1
EVAL "return redis.call('set','name','wereash')" 0 #0 是脚本需要的key类型的参数个数

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其他参数放入ARGV数组,在脚本中可以从KeYS和ARGV数组获取这些参数:

1
EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name wereash

Lua脚本实现原子性

Lua脚本

1
2
3
4
5
6
--这里的KEYS[1]就是锁的key,这里的ARGV[1]就是当前线程标识
--获取锁中的标识,判断是否与当前线程标识一致
if(redis.call('GET',KEYS[1])==ARGV[1]) then
return redis.call('DEL',KEYS[1])
end
return 0

Redis锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT=new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}

@Override
public void unlock(){
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Colletions.singletonList(KEY_PREFIX+name),
ID_PREFIX+Thread.currentThread().getId()
);
}

缺点

  • 不可重入:同一个线程无法多次获取同一把锁
    • 同一个线程中,方法1获取了锁并调用方法2,方法2就没办法再次获取锁
  • 不可重试:获取锁只尝试一次就返回false,没有重试机制
  • 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致所释放,存在安全隐患
  • 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当在执行setxn获取锁后,还未同步,主节点宕机,新的主节点没有获取之前的锁数据

Redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网络(In-Memory Data-Grid)。它不仅提供了一系列分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种的分布式锁的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException{
//获取可重入锁,指定锁的名称
RLock lock=redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放事件,时间单位
boolean isLock=lock.tryLock(1,10,TimeUnit.SECONDS);
if(isLock){
try{

}finally{
lock.unlock();
}
}
}