Redis实现分布式锁

5.4k 词

虽然Java提供了 synchronized之类的方法来实现线程对临界资源的互斥访问,但是这仅仅局限于单台服务器,对于集群服务器并不适用。

Redis分布式锁

于是我们希望通过Redis来实现服务器集群的线程锁,因为Redis对于所有服务器来说是相同的,且具有较好的性能和较高的可用性。

简单实现

具体实现流程如下:

代码实现

先写一个锁的抽象类,因为会做多个版本的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 锁自动释放的市场(秒)
* @return 是否成功获取到锁
*/
abstract public boolean tryLock(long timeoutSec);

/**
* 释放锁
*/
abstract public void unlock();
}

当前版本的最基础实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SimpleRedisLock extends ILock {

StringRedisTemplate redisTemplate;
private final String lockName;
private static final String LOCK_PREFIX = "lock:";

public SimpleRedisLock(String lockName, StringRedisTemplate redisTemplate) {
this.lockName = lockName;
this.redisTemplate = redisTemplate;
}

public boolean tryLock(long timeoutSec) {
String value = String.valueOf(Thread.currentThread().getId());
Boolean success = redisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + lockName, value, timeoutSec, TimeUnit.MINUTES);
return BooleanUtil.isTrue(success);
}


public void unlock() {
redisTemplate.delete(LOCK_PREFIX + lockName);
}
}

存在问题:误删问题

当前的实现存在一个问题,当一个线程在获取锁后,若发生阻塞,在此期间若锁自动失效了,此时另一个线程获取到了锁,那么很可能被第一个线程解锁,从而造成并发安全问题,图示如下:

能够解决误删问题的实现

需要满足需求:

  • 在获取锁时存入线程标识(之前用的是线程id,但是不同JVM中线程id可能相同,所以不能用)
  • 在释放锁时判断是否是当前线程上的锁,若是才释放锁,否则不释放

具体流程如下:

代码实现

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
public class SimpleRedisLock extends ILock {
private static final String LOCK_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString();
private final String lockName;
StringRedisTemplate redisTemplate;

public SimpleRedisLock(String lockName, StringRedisTemplate redisTemplate) {
this.lockName = lockName;
this.redisTemplate = redisTemplate;
}

public boolean tryLock(long timeoutSec) {
//获取线程标识
String threadId = ID_PREFIX + "-" + Thread.currentThread().getId();
//尝试获取锁
Boolean success = redisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + lockName, threadId, timeoutSec, TimeUnit.MINUTES);
return BooleanUtil.isTrue(success);
}


public void unlock() {
String key = LOCK_PREFIX + lockName;
//获取线程标识
String threadId = ID_PREFIX + "-" + Thread.currentThread().getId();
//判断是否是对应的线程
if (threadId.equals(redisTemplate.opsForValue().get(key))) {
redisTemplate.delete(key);
}
}
}

存在问题:原子性问题

当前实现存在一个问题,当一个线程在判断当前锁属于自己线程后,准备释放锁时,发生可阻塞,此时若锁自动释放了,另一个线程获取到锁,则可能被第一个线程释放锁,造成并发安全问题,如图所示:

解决原子性问题的实现

使用Lua脚本,可以使Redis的操作具有原子性。

Lua脚本

1
2
3
4
5
6
-- 比较线程标识与锁中的标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁
return redis.call('del', KEYS[1])
end
return 0

代码实现

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
public class LuaScriptRedisLock extends ILock {
private static final String LOCK_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString();
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("script/unlock.lua"));//脚本相对于class path的路径地址
UNLOCK_SCRIPT.setResultType(Long.class);//指定返回值类型
}
private final String lockName;
StringRedisTemplate redisTemplate;

public LuaScriptRedisLock(String lockName, StringRedisTemplate redisTemplate) {
this.lockName = lockName;
this.redisTemplate = redisTemplate;
}

public boolean tryLock(long timeoutSec) {
//获取线程标识
String threadId = ID_PREFIX + "-" + Thread.currentThread().getId();
//尝试获取锁
Boolean success = redisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + lockName, threadId, timeoutSec, TimeUnit.MINUTES);
return BooleanUtil.isTrue(success);
}


public void unlock() {
redisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(LOCK_PREFIX + lockName), //锁的key
ID_PREFIX + "-" + Thread.currentThread().getId() //锁的线程标识
);
}
}

存在问题

  • 不可重入

    同一个线程无法多次获取同一把锁
  • 不可重试

    获取锁只尝试一次就返回false,不能重试
  • 超时释放

    虽然这么做是为了防止死锁,但如果业务实践较长,则可能提前释放锁,存在安全隐患
  • 主从一致性问题

    主从集群中,如过保存有锁的master节点宕机,其数据并未同步到从节点的话,其他线程也可能获取到锁,从而导致并发安全问题

使用Redisson实现

Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。

Redisson本身就提供了分布式锁的实现,但是为了降低代码的耦合度,所以对Redisson的分布式锁进行了再一次的封装。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class RedissonLock extends ILock {
private static final RedissonClient redissonClient = BeanHelper.getBean(RedissonClient.class);
private RLock lock;

public RedissonLock(String lockName) {
lock = redissonClient.getLock(lockName);
}
@Override
public boolean tryLock(long timeoutSec) {
try {
return lock.tryLock(-1, timeoutSec, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

@Override
public void unlock() {
lock.unlock();
}
}

因为这个类没有交给Spring进行管理,所以无法使用注解来注入RedissonClient,这里使用了自己写的一个工具类来进行注入,详情可以看这里,在非Spring管理的类中注入Bean

Redisson对上述问题的解决

  • 不可重入

    Redisson提供的分布式锁实现是用Hash数据类型进行数据存储的,进行一次访问就对Hash的值加一,释放锁时减一,只有HashValue值为0时才删除Key

  • 不可重试

    利用Redis的消息机制,只有在收到Key被删除的消息后,才会尝试进行获取锁,这样既能降低CPU压力,又能实现重试获取锁

  • 超时释放

    利用WathDog机制,当leaseTime为-1时,触发该机制。获取锁后,设置锁的过期时间史30s(可自行更改),之后每十秒钟检查一次锁是否被释放,若未释放则为这个锁续期10s(初始时间的1/3),否则释放锁。当该服务器在上锁期间发生宕机,续期也无法完成,Redis内的锁就会被释放掉,从而避免了死锁。

  • 主从一致性问题

    使用多个主节点,只有当所有节点中都能够获取锁时,才真正获取到锁。这样即使有一个节点在保存锁时宕机,并且其从节点没有及时完成数据同步,其他线程在尝试获取锁时,只能或者当前节点的锁,其他节点依然无法获取,所以该线程依然无法访问临界资源,保证了并发安全。

    1
    2
    3
    4
    RLock lock1 = redissonClient.getLock("order");
    RLock lock2 = redissonClien2.getLock("order");
    RLock lock3 = redissonClient3.getLock("order");
    RLock lock = redissonClient.getMultiLock(lock1, lock2, lock3);