Hope is a good thing, and maybe the best thing of all

编程不止是一份工作,还是一种乐趣!!!

基于单Redis节点的分布式锁

首先,Redis客户端为了获取锁,向Redis节点发送如下命令:

SET lock_name random_val NX EX 30
public boolean lock(String lockName, String randomVal, int seconds) {
    Jedis jedis = null;
    try {
        jedis = pool.getResource();
        String rlt = jedis.set(lockName, randomVal, "NX", "EX", seconds);
        return "OK".equalsIgnoreCase(rlt);
    } finally {
        jedis.close();
    }
}


上面的命令如果执行成功,则客户端成功获取到了锁,接下来就可以访问共享资源了;而如果上面的命令执行失败,则说明获取锁失败。

注意,在上面的SET命令中:

  • random_val是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。
  • NX表示只有当lock_name对应的key值不存在的时候才能SET成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • EX 30表示这个锁有一个30秒的自动过期时间。当然,这里30秒只是一个例子,客户端可以选择合适的过期时间。


最后,当客户端完成了对共享资源的操作之后,执行下面的Redis Lua脚本来释放锁:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
public boolean release(String lockName, String randomVal) {
    String program = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then "
            + "return redis.call(\"del\",KEYS[1]) "
            + "else return 0 "
            + "end";

    Jedis jedis = null;
    try {
        jedis = pool.getResource();
        Object rlt = jedis.eval(program, 1, lockName, randomVal);
        return Long.valueOf(1) == rlt;
    } finally {
        jedis.close();
    }
}

这段Lua脚本在执行的时候要把前面的random_val作为ARGV[1]的值传进去,把lock_name作为KEYS[1]的值传进去。至此,基于单Redis节点的分布式锁的算法就描述完了。


这里面有好几个问题需要重点分析一下。

首先第一个问题,这个锁必须要设置一个过期时间。否则的话,当一个客户端获取锁成功之后,假如它崩溃了,或者由于发生了网络分割(network partition)导致它再也无法和Redis节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了。antirez在后面的分析中也特别强调了这一点,而且把这个过期时间称为锁的有效时间(lock validity time)。获得锁的客户端必须在这个时间之内完成对共享资源的访问。


第二个问题,第一步获取锁的操作,网上不少文章把它实现成了两个Redis命令:

SETNX lock_name random_val
EXPIRE lock_name 30

虽然这两个命令和前面算法描述中的一个SET命令执行效果相同,但却不是原子的。如果客户端在执行完SETNX后崩溃了,那么就没有机会执行EXPIRE了,导致它一直持有这个锁。


第三个问题,设置一个随机字符串random_val是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:

  1. 客户端1获取锁成功。
  2. 客户端1在某个操作上阻塞了很长时间。
  3. 过期时间到了,锁自动释放了。
  4. 客户端2获取到了对应同一个资源的锁。
  5. 客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。

之后,客户端2在访问共享资源的时候,就没有锁为它提供保护了。


第四个问题,释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:’GET’、判断和’DEL’,用Lua脚本来实现能保证这三步的原子性。否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:

  1. 客户端1获取锁成功。
  2. 客户端1访问共享资源。
  3. 客户端1为了释放锁,先执行’GET’操作获取随机字符串的值。
  4. 客户端1判断随机字符串的值,与预期的值相等。
  5. 客户端1由于某个原因阻塞住了很长时间。
  6. 过期时间到了,锁自动释放了。
  7. 客户端2获取到了对应同一个资源的锁。
  8. 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。

实际上,在上述第三个问题和第四个问题的分析中,如果不是客户端阻塞住了,而是出现了大的网络延迟,也有可能导致类似的执行序列发生。


前面的四个问题,只要实现分布式锁的时候加以注意,就都能够被正确处理。但除此之外还有一个问题是由failover引起的,却是基于单Redis节点的分布式锁无法解决的。这个问题是这样的。假如Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。考虑下面的执行序列:

  1. 客户端1从Master获取了锁。
  2. Master宕机了,存储锁的key还没有来得及同步到Slave上。
  3. Slave升级为Master。
  4. 客户端2从新的Master获取到了对应同一个资源的锁。

于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。针对这个问题,可以参考一下Redlock。