在单节点状况下,实现线程安全须要靠同步状态来控制。而在分布式应用中,使程序正确执行不被并发问题影响,就须要分布式锁来控制。html
在单节点中,须要用一个并发线程都能访问到的资源的状态变化来控制同步。在分布式应用中,使用应用全部节点都能访问到的 Redis
中的某个 key
来控制并发问题。java
setnx
setnx
指令会在 key
不存在的状况下放入 redis
,若是存在则不会设置。redis
>setnx lock:distributed true
OK
...
other code
...
>del lock:distributed
复制代码
这种方式的问题在于,执行到 other code 时,程序出现异常,致使 del
指令不会被执行,key
没有被释放,这样会陷入死锁。算法
setnx then expire
为了解决死锁,乍一看可使用 expire
来给 key
设置超时时间。安全
>setnx lock:distributed true
OK
>expire lock:distributed 5
...
other code
...
>del lock:distributed
复制代码
这种处理其实仍然有问题,由于 setnx
与 expire
不是原子操做, 执行 expire
语句以前可能发生异常。死锁仍然会出现。bash
set and expire
为了解决非原子性操做被中断的问题,在 Redis 2.8
中加入了 setnx
与 expire
组合在一块儿的原子指令。并发
>set lock:distributed true ex 5 nx
OK
...
other code
...
>del lock:distributed
复制代码
这种方式保证了加锁并设置有效时间操做的原子性,可是依然有问题。dom
假设咱们在加锁与释放锁之间的业务代码执行时间超过了设置的有效时间,此时锁会由于超时被释放。会致使两种状况:异步
由于在加锁时,各个节点使用的同一个 key
,因此会存在超时节点释放了当前加锁节点的锁的状况。这种状况下,能够给加锁的 key
设置一个随机值,删除的时候须要判断 key
当前的 value
是否是等于随机值。分布式
val = Random.nextInt();
if( redis.set(key,val,true,5) ){
...
other code
...
value = redis.get(key);
if(val == value){
redis.delete(key);
}
}
复制代码
上述代码实现了根据随机值删除的逻辑,可是获取 value
直到 delete
指令并不是是原子指令,仍然可能有并发问题。这时候须要使用 lua
脚本处理,由于 lua
脚本能够保证连续多个指令原子执行。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
复制代码
这种方式能够避免锁被其余线程释放的问题。
临界区代码出现并发问题的本质是业务代码执行时间大于锁过时时间。
咱们能够定时刷新加锁时间,保证业务代码在锁过时时间内执行完成。
private volatile boolean isFlushExpiration = true;
while(redis.set(lock, val, NOT_EXIST, SECONDS, 20)){
Thread thread = new Thread(new FlushExpirationTherad());
thread.setDeamon(true);
thread.start();
...
other code
...
}
isFlushExpiration = false;
String deleteScript = "if redis.call("get",KEYS[1]) == ARGV[1] then"
+ "return redis.call("del",KEYS[1])"
+ "else return 0 end";
redis.eval(deleteScript,1,key,val);
private class FlushExpirationTherad implements Runnable{
@Override
public void run(){
while(isFlushExpiration){
String checkAndExpireScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('expire',KEYS[1],ARGV[2]) " +
"else return 0 end";
redis.eval(checkAndExpireScript,1,key,val,"20");
// 每隔十秒检查是否完成
Thread.sleep(10);
}
}
}
复制代码
这种实现是用一个线程按期监控客户端是否执行完成。也能够由服务端实现心跳检测机制来保证业务完成(Zookeeper
)。
因此实现单节点 Redis
分布式锁要关注三个关键问题:
Redis2.8
开始已支持)key
设置随机值)lua
脚本实现)为了保证项目的高可用性,项目通常都配置了 Redis
集群,以防在单节点 Redis
宕机以后,全部客户端都没法得到锁。
在集群环境下,Redis
存在 failover
机制。当 Master
节点宕机以后,会开始异步的主从复制(replication
),这个过程可能会出现如下状况:
Master
节点的锁。Master
节点宕机了,存储锁的 key
暂未同步到 Slave
上。Slave
节点升级为 Master
节点。Master
节点上获取到了同一资源的锁。在这种状况下,锁的安全性就会被打破,Redis
做者 antirez
针对此问题设计了 Redlock
算法。
Redlock
算法获取锁时客户端执行步骤:
Redis
节点请求锁。请求锁的方式与从单节点 Redis
获取锁的方式一致。为了保证在某个 Redis
节点不可用时该算法可以继续运行,获取锁的操做都须要设置超时时间,须要保证该超时时间远小于锁的有效时间。这样才能保证客户端在向某个 Redis
节点获取锁失败以后,能够马上尝试下一个节点。Redis
节点(>= N/2 + 1) 成功获取锁,而且获取锁总时长没有超过锁的有效时间,这种状况下,客户端会认为获取锁成功,不然,获取锁失败。consumeTime
。Redis
节点发起释放锁的请求。在释放锁时,须要向全部 Redis
节点发起释放锁的操做,无论节点是否获取锁成功。由于可能存在客户端向 Redis
节点获取锁时成功,但节点通知客户端时通讯失败,客户端会认为该节点加锁失败。
Redlock
算法实现了更高的可用性,也不会出现 failover
时失效的问题。可是若是有节点崩溃重启,仍然对锁的安全性有影响。假设共有 5 个 Redis
节点 A、B、C、D、E:
在这种状况下,客户端 A 与 B 都获取了访问同一资源的锁。
这里第 2 步中节点 C 锁丢失的问题可能由多种缘由引发。默认状况下,
Redis
的AOF
持久化方式是每秒写一次磁盘(fsync),这状况下就有可能丢失 1 秒的数据。咱们也能够设置每次操做都触发fsync
,这会影响性能,不过即便这样设置,也有可能因为操做系统的问题致使操做写入失败。
为了解决节点重启致使的锁失效问题,antirez
提出了延迟重启的概念,即当一个节点崩溃以后并不当即重启,而是等待与分布式锁相关的 key
的有效时间都过时以后再重启,这样在该节点重启后也不会对现有的锁形成影响。
关于 Redlock
的安全性问题,在分布式系统专家 Martin Kleppmann 和 Redis
的做者 antirez 之间发生过一场争论,这个问题引起了激烈的讨论。关于这场争论的内容能够关注 基于Redis的分布式锁到底安全吗 这篇文章。 最后得出的结论是 Redlock
在效率要求的应用中是合理的,因此在 Java
项目中可使用 Redlock
的 Java
版本 Redission
来控制多节点访问共享资源。可是仍有极端状况会形成 Redlock
的不安全,咱们应该知道它在安全性上有哪些不足以及会形成什么后果。若是须要进一步的追求正确性,可使用 Zookeeper
分布式锁。