甲乙小朋友的房子

甲乙小朋友很笨,但甲乙小朋友不会放弃

0%

分布式存储-Redis分布式锁

引言

在多台机器上操作同一个数据时,需要分布式锁。(比如11.11的场景)

两大类分布式锁:

  • 类CAS自旋式——只能靠自己不断询问的方式,不断尝试加锁 (IO太高),例如mysql,redis
  • event事件式—— 靠event事件通知我后续锁的变化,轮询向外的过程,例如zookeeper,etcd

redis是用类CAS自旋式锁。

redis的worker是单线程串行的,处理clients的请求时,是串行的!比如c1来了,抢到了锁。c2来的时候,没抢到,被拒了。接下来可能会发生:

  1. 如果c1运行一段,挂掉了,但没释放锁。死锁了。
  2. 此时c2就一直自旋询问(网络IO)worker,好了没好了没好了没。worker给他一个timeout时间,等会再来问。

实现

Redis 锁主要利用 Redis 的 setnx 命令。伪代码:

1
2
3
4
5
6
7
8
9
10
11
if (setnx(key, 1) == 1) // 获取锁。KEY 是锁的唯一标识,一般按业务来决定命名
{
expire(key, 30) // set 锁timeout = 30,避免资源被永远锁住。
try
{
//TODO 业务逻辑
} finally
{
del(key)
}
}

上述锁存在一些问题:

死锁

如果 SETNX 成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间变成死锁。 很多开源代码来解决这个问题,比如使用 lua 脚本。示例

1
2
3
4
5
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;

锁已解除

如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。 解决方案:加锁时,将锁value设为UUID(唯一标识码)。释放锁之前,先再次通过 get 命令获取加锁 key 的 value 值,然后判断 value 跟加锁时设置的 value 是否一致,这就看出 UUID 的重要性了,如果一致,就执行 delete() 方法释放锁,否则不执行。

1
2
3
4
5
6
7
8
// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30 // 将value设为uuid
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
then return redis.call('del', KEYS[1])
else return 0
end

超时解锁导致并发

如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。

A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:

  • 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。
  • 为获取锁的线程增加守护线程,让守护线程在一段时间后,重新去设置这个锁的LockTime。

抢不到锁

如果锁被C1占了,同时C2也想加锁,怎么办?两种解决办法:

  1. 客户端轮询。非常消耗服务器资源。
  2. 用Redis发布订阅功能。当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。

参考文献

  1. redis底层原理实现
  2. 分布式锁的实现之 redis 篇