常见的分布式锁实现方式

  • 数据库乐观锁;
  • 基于Redis的分布式锁;
  • 基于ZooKeeper的分布式锁。

redis 分布式锁满足条件

  • 互斥性。在任意时刻,只有一个客户端能持有锁
  • 不发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  • 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

加锁

setnx是SET if Not eXists的缩写,当key不存在的情况下设置key和value,返回1,当key存在时,不做任何操作,返回0。可以用锁的争抢,设置key,意为获得锁。
expire是对已存在的key设置过期时间,到达过期时间后,自动删除。这个命令有更新时间的含义,已有过期时间,新指令会替代旧指令的时间,并重新计时。重新设置key,也会重新计时。
但是分布式锁的实现话,不要用setnx+expire两次命令实现,需要保证原子性。所以用一条指令去实现加锁,set key value px milliseconds nx。

  • set中加入nx参数,实现效果与setnx是一样的,满足了互斥性。
  • 设置过期时间,是为了防止死锁,持有锁的线程异常崩溃,无法正常的释放锁。
  • 这里还有考虑内存淘汰策略。极限情况下,丢锁了。。。

redis 淘汰策略

解锁

先获取key里的值,进行判断,看是否是自己加的锁。一致后删除。
eval执行lua脚本可以保证原子性。整个流程有多次操作(获取,比较,删除(获取+删除))。

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

存在问题

redis存在宕机的危险,如果存储锁的节点宕机了,锁可能会丢失(除了主从复制的集群,都不能保证集群下数据的强一致性,单机挂了就没了,虽然有持久化,但是也不能保证数据完全不丢失)。导致多个客户端持有锁。

redlock算法

假设有5个redis master-slave,那么就尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点 n / 2 + 1。
客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了;
要是锁建立失败了,那么就依次之前建立过的锁删除;
只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。

redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。
Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

Redisson 分布式重入锁用法

Redisson 支持单点模式、主从模式、哨兵模式、集群模式,这里以单点模式为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1.构造redisson实现分布式锁必要的Config
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:5379").setPassword("123456").setDatabase(0);
// 2.构造RedissonClient
RedissonClient redissonClient = Redisson.create(config);
// 3.获取锁对象实例(无法保证是按线程的顺序获取到)
RLock rLock = redissonClient.getLock(lockKey);
try {
/**
* 4.尝试获取锁
* waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
* leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
*/
boolean res = rLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS);
if (res) {
//成功获得锁,在这里处理业务
}
} catch (Exception e) {
throw new RuntimeException("aquire lock fail");
}finally{
//无论如何, 最后都要解锁
rLock.unlock();
}

加锁流程图

解锁流程图

我们可以看到,RedissonLock是可重入的,并且考虑了失败重试,可以设置锁的最大等待时间, 在实现上也做了一些优化,减少了无效的锁申请,提升了资源的利用率。
需要特别注意的是,RedissonLock 同样没有解决 节点挂掉的时候,存在丢失锁的风险的问题。而现实情况是有一些场景无法容忍的,所以 Redisson 提供了实现了redlock算法的 RedissonRedLock,RedissonRedLock 真正解决了单点失败的问题,代价是需要额外的为 RedissonRedLock 搭建Redis环境。
所以,如果业务场景可以容忍这种小概率的错误,则推荐使用 RedissonLock, 如果无法容忍,则推荐使用 RedissonRedLock。