什么是分布式锁

  • 线程锁
  • 进程锁
  • 分布式锁

分布式锁的设计原则

分布式锁的实现方案

  • 基于数据库
  • 基于Redis
    • 单个Redis实例:set NX PX + Lua
    • Redis集群:RedLock
  • 基于zookeeper
  • 基于Consul

基于Redis实现分布式锁

单个Redis实例 - set NX PX + Lua

  • 加锁 set NX PX + 重试 + 重试间隔
  • 解锁 采用lua脚本
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
@Component
public class RedisDistributedLock {

@Autowired
private RedisTemplate<String, String> redisTemplate;

// 锁的过期时间,单位毫秒
private static final long LOCK_EXPIRE_TIME = 30000;

// 释放锁的 Lua 脚本
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"redis.call('del', KEYS[1]); " +
"return true; " +
"else return false; " +
"end";

// 获取分布式锁
public boolean lock(String key, String value) {
// 加锁不用lua表达式
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.SECONDS);
Boolean result = redisTemplate.execute(script, Arrays.asList(keys), args);
return result != null && result;
}

// 释放分布式锁
public boolean unlock(String key, String value) {
String[] keys = {key};
String[] args = {value};
RedisScript<Boolean> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Boolean.class);
Boolean result = redisTemplate.execute(script, Arrays.asList(keys), args);
return result != null && result;
}
}

为什么建议用lua脚本来解锁,而不是直接RedisTemplate.detele(key)
当多个线程同时争抢同一个 Redis 锁时,如果这些线程都是通过使用 RedisTemplate 的 delete() 方法来释放锁的话,可能会出现以下情况:

  1. 线程 A 成功获取了锁,并设置了一个过期时间;
  2. 过了一段时间后,锁的过期时间到了,Redis 自动将锁删除;
  3. 同时,线程 B 也在尝试获取锁,由于此时锁已经被 Redis 删除了,线程 B 成功获取了锁;
  4. 线程 A 在这个时候调用了 RedisTemplate 的 delete() 方法来释放锁,由于此时 Redis 中已经不存在该锁了,所以线程 A 的操作实际上是删除了线程 B 获取到的锁,从而导致线程 B 的锁失效。

因此,使用 RedisTemplate 的 delete() 方法来释放锁的方式可能存在删除其他线程获取的锁的风险。为了避免这种情况的发生,可以使用 Redis 的 Lua 脚本,在 Redis 中执行删除操作,确保只删除对应值的 key-value 对,避免误删其他线程的锁。

Redis集群 - RedLock

假设有两个服务A、B都希望获得锁,有一个包含了5个redis master的Redis Cluster,执行过程大致如下:

  1. 客户端获取当前时间戳,单位: 毫秒
  2. 服务A轮寻每个master节点,尝试创建锁。(这里锁的过期时间比较短,一般就几十毫秒) RedLock算法会尝试在大多数节点上分别创建锁,假如节点总数为n,那么大多数节点指的是n/2+1。
  3. 客户端计算成功建立完锁的时间,如果建锁时间小于超时时间,就可以判定锁创建成功。如果锁创建失败,则依次(遍历master节点)删除锁。
  4. 只要有其它服务创建过分布式锁,那么当前服务就必须轮询尝试获取锁。

Redis的客户端 Redisson

Redis的客户端(Jedis, Redisson, Lettuce等)都是基于上述两类形式来实现分布式锁的,只是两类形式的封装以及一些优化(比如Redisson的watch dog)。

以基于Redisson实现分布式锁为例(支持了 单实例、Redis哨兵、redis cluster、redis master-slave等各种部署架构):

  • 特色
    • redisson所有指令都通过lua脚本执行,保证了操作的原子性
    • redisson设置了watchdog看门狗,“看门狗”的逻辑保证了没有死锁发生
    • redisson支持Redlock的实现方式。
  • 过程
    1. 线程去获取锁,获取成功: 执行lua脚本,保存数据到redis数据库。
    2. 线程去获取锁,获取失败: 订阅了解锁消息,然后再尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。
  • 互斥
    如果这个时候客户端B来尝试加锁,执行了同样的一段lua脚本。第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在。
    接着第二个if判断,判断myLock锁key的hash数据结构中,是否包含客户端B的ID,但明显没有,那么客户端B会获取到pttl myLock返回的一个数字,代表myLock这个锁key的剩余生存时间。
    此时客户端B会进入一个while循环,不停的尝试加锁
  • watch dog自动延时机制
    客户端A加锁的锁key默认生存时间只有30秒,如果超过了30秒,客户端A还想一直持有这把锁,怎么办?其实只要客户端A一旦加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果客户端A还持有锁key,那么就会不断的延长锁key的生存时间。
  • 可重入
    每次lock会调用incrby,每次unlock会减一。

进一步理解

  1. 借助Redis实现分布式锁时,有一个共同的缺陷: 当获取锁被拒绝后,需要不断的循环,重新发送获取锁(创建key)的请求,直到请求成功。这就造成空转,浪费宝贵的CPU资源。
  2. RedLock算法本身有争议,具体看这篇文章How to do distributed locking 以及作者的回复Is Redlock safe?