Redis分布式锁之惑
Redis毫无疑问是目前业界使用最广泛的缓存方案之一,由于单线程机制以及高性能,许多时候程序员们也会用它来实现分布式锁。
来看一个典型的分布式锁代码(JAVA描述):
package org.owl;
import java.util.ArrayList;
import java.util.List;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
public class RedisLock {
private Jedis client;
private String releaseLockScriptSHA;
private void loadReleaseLockScript() {
StringBuilder script = new StringBuilder();
script.append("if redis.call(\"get\",KEYS[1]) == ARGV[1] then").append("\n");
script.append(" return redis.call(\"del\",KEYS[1])").append("\n");
script.append("else").append("\n");
script.append(" return 0").append("\n");
script.append("end");
releaseLockScriptSHA = client.scriptLoad(script.toString());
}
public RedisLock(String host, int port) {
client = new Jedis(host, port);
loadReleaseLockScript();
}
public boolean acquireLock(String key, String uniqueValue, int timeout) {
SetParams params = new SetParams();
params.ex(timeout);
params.nx();
return "OK".equalsIgnoreCase(client.set(key, uniqueValue, params));
}
public void releaseLock(String key, String uniqueValue) {
List<String> keys = new ArrayList<>();
List<String> argvs = new ArrayList<>();
keys.add(key);
argvs.add(uniqueValue);
client.evalsha(releaseLockScriptSHA, keys, argvs);
}
}
加锁部分代码通过set命令去设置一个键值对,KEY为锁名,VALUE为锁的值,锁的值是个唯一值,用于后续释放锁时避免错误地释放了其他客户端申请的锁,并且指定NX和EX参数来保证只有一个客户端能拿到锁并且在超时后自动解锁防止死锁。
释放锁部分代码则是执行了一段lua代码,这段代码会对比传入的锁值跟Redis中的值是否一致,一致则认为该锁是本客户端所申请的,使用del命令来解锁,否则不处理。
示例图如下
这段代码跟网上流传的Redis锁实现大同小异,在单Redis节点的情况下,这段代码可以说是完全正确的。
但在实际使用中,我们往往不会只使用单个Redis实例,而是多个实例同时运行,使用类似一致性哈希算法来将不同的key散列到不同实例上。
因此情况就会变成了下面这样:
考虑一个情况,如果在Client1拿到锁以后,Instance1挂了,这时候Client2再去申请锁时,由于Client1挂了,所以申请锁的请求就会发送到其他的Redis实例,假设是Instance2吧,因为Instance2上面没有key1,set成功,因此Client2也拿到了锁,这时候就产生了冲突了。如下图所示:
那么,在多个Redis实例的情况下,如何保证锁的正确性呢?
Redis官方给出了一个算法:The Redlock algorithm
假设我们现在有N个Redis实例,这些实例是完全独立的,不会进行数据同步。我们已经知道怎么在单个实例上正确地申请锁和释放锁,那么如何在多个实例上正确地使用锁呢?我们举例目前有5个实例,也就是N=5,那么客户端在拿锁的时候,要执行以下几步操作:
- 获取当前毫秒级时间戳
- 用同一个key和value,按顺序向每一个实例申请锁,客户端在请求实例时的超时时间要小于锁的自然过期时间,比方说锁的自然过期时间是10秒,那么客户端请求实例的超时时间可以设置为5~50毫秒,这是为了避免在请求实例时被堵塞太长时间以至于前面申请的锁自然失效,在请求Redis实例超时或者失败的时候应该尽快向下一个实例发起请求。
- 客户端在申请锁的同时还要统计耗时,也就是发起第一个请求之后的耗时时间,因为我们一共有5个实例,因此某个客户端在(N/2+1)个,即3个实例上成功写入锁值,并且耗时没有超过锁的自然过期时间,就可以认为该客户端拿到了锁。
- 该锁的有效时间为 锁的自然过期时间 减去 请求各个实例的耗时。
- 如果客户端因为某些原因无法获取到锁(比方说没能在N/2+1个实例上获取到锁又或者耗时超过了锁的自然过期时间),那么客户端应该尽快释放掉所有已经申请到的锁。
示例图如下所示:
假设锁的自然过期时间是TTL,发起第一个请求的时间戳是T1,收到最后一个响应的时间戳是T2,如果成功拿到了锁,并且确保 T2 - T1,也就是获取锁的耗时尽可能小于TTL,那么起码在 TTL - (T2 - T1) 这段时间内,这个锁是可用的。
乍一看,这个算法确实像那么回事,如果出现了实例失效的情况,也能保证锁的正常运作,但是该算法也存在不少问题,比如说一旦出现网络分区,即某客户端一直只能访问到一个实例,那么这个客户端就永远拿不到锁了,又例如某个持有锁的值的实例发生了重启,如果该实例做了持久化,那么在restart过程中会产生明显的延迟(如果不持久化,则会导致多个客户端同时拿到锁)。
详细的讨论可以参考Redis官方的专题讨论 -> The Redlock algorithm
看来用Redis来做分布式锁还是要相当谨慎才行啊。