网络上很多关于分布式锁的文章,有些标题起得夸张得要死, 内容却很是通常, 固然也有一些文章总结得至关不错, 让人受益不浅.html
本文比较务实,并无不少高大上的理论, 单纯地想从分布式锁的实现的推演过程当中,探讨一下分布式锁实现,和使用中应该注意哪些问题java
这个主要是利用到了数据库的主键的惟一性, 例如惟一性来实现分布式锁的排他性.git
具体案例的话, 据我所知的, 就是quartz的集群模式中就利用到了innodb来作分布式锁,来避免同一个任务被多个节点重复执行.github
例如数据库主键作分布式锁的主要问题是不够灵活,例如可重入等等特性实现起来比较麻烦, 适合比较简单的场景下使用redis
基于zk的分布式锁通常是用到了其临时顺序节点的特性, id最小的临时节点视为获取到锁, 会话结束时临时节点会被自动删掉,下一个最小id的临时节点获取到锁算法
zk分布式锁存在的问题是,zk的写性能其实很差,毕竟都是写在硬盘上的文件中的, 因此不大适合在高并发环境中数据库
这个主要是利用到了redis是每个命令单个命令都是原子性的特性来实现分布式锁.缓存
简单的来讲就是,须要加锁的时候就调用set, 须要释放锁的时候就调用del, 固然实际上没有那么简单.bash
redis实现分布式锁的最大优势就是性能好.网络
其实每一种分布式锁的实现都有它的优点, 例如说数据库的理解简单, zk的实现可靠性高, redis的实现性能高. 主要仍是要根据具体的业务场景选择合适的实现方式.
因为实际应用中, 仍是redis实现的比较多(印象流), 所以本文选择redis实现来进行分析
首先定义一个最简单的分布式锁的接口,它只有两个方法:
package com.north.lat.dislocklat;
/**
* @author lhh
*/
public interface DisLock {
/**
* 加锁
* @param lockName 锁的名称
* @param lockValue 锁的redis值
* @param expire 锁的超时时间
* @return 加锁成功则返回true, 不然返回false
*/
boolean lock(String lockName,String lockValue,int expire);
/**
* 释放锁
* @param lockName
* @param lockValue
* @return 释放成功则返回true
*/
boolean unlock(String lockName,String lockValue);
}
复制代码
redis官方已经为咱们提供了一个命令
复制代码
SET key value [EX seconds] [PX milliseconds] [NX|XX]
复制代码
这个命令能够在一个key不存在的时候,设置这个KEY的值, 并指定这个key的过时时间, 而且这个命令是原子性的, 因此能够完美地被咱们用来做为加锁的操做
利用这个命令, 咱们能够先实现第一个版本的分布式锁:
package com.north.lat.dislocklat.redisimpl;
import com.north.lat.dislocklat.DisLock;
import redis.clients.jedis.Jedis;
/**
* @author lhh
*/
public class DisLockV1 implements DisLock {
public static final String OK = "OK";
private Jedis jedis = new Jedis ("localhost",6379);
@Override
public boolean lock(String lockName, String lockValue, int expire) {
String ret = jedis.set(lockName, lockValue, "NX", "EX", expire);
return OK.equalsIgnoreCase(ret);
}
@Override
public boolean unlock(String lockName,String lockValue) {
Long c = jedis.del(lockName);
return c > 0;
}
}
复制代码
测试代码以下:
package com.north.lat.dislocklat;
import com.north.lat.dislocklat.redisimpl.DisLockV1;
public class DisLockTest {
public static void main(String[] args) {
String lockName = "test_lock";
String lockValue = "test_value";
DisLock disLock = new DisLockV1();
boolean success = disLock.lock(lockName, lockValue, 10);
if(success){
try {
doSomeThingImportant();
}finally {
disLock.unlock(lockName, lockValue);
}
}
}
public static void doSomeThingImportant(){
}
}
复制代码
这是一个最简单版本的分布式锁
这个分布锁理论上在简单的场景下是没有问题的,然而在doSomeThingImportant()业务比较复杂, 处理时间过长的状况下, 就会出现问题了. 咱们来模拟一下
时刻 | 线程1 | 线程2 | 线程3 | 线程4 |
---|---|---|---|---|
第1秒 | 加锁 | 加锁 | 未开始执行 | 未开始执行 |
第2秒 | 获取到锁 | 没获取到锁 | 未开始执行 | 未开始执行 |
第10秒 | 执行业务逻辑 | 已返回 | 未开始执行 | 未开始执行 |
第11秒 | 执行业务逻辑(锁已超时失效) | - | 加锁 | 未开始执行 |
第12秒 | 释放锁, 这时把线程2的锁也释放了 | - | 执行业务逻辑 | 未开始执行 |
第13秒 | 返回 | - | 执行业务逻辑 | 加锁(获取锁成功) |
第14秒 | - | - | 执行业务逻辑 | 执行业务逻辑 |
第n秒 | - | - | ... | ... |
对照上面的时刻表, 前面的10秒都没有问题, 若是10秒内线程能处理完业务逻辑的话,也不会有问题.
然而, 第11秒的时候线程1尚未处理完它本身的业务逻辑, 恰好线程2又过来加锁, 这时候问题就出现了: 线程1尚未释放锁的时候, 线程2加锁成功了.
问题并不止一个, 到了第12秒的时候,线程1终于处理完本身的业务逻辑,而后就屁颠屁颠地去把锁给释放了.这一释放不单把本身的锁给释放了, 还把线程3的锁也给释放了.
到了第13秒的时候, 线程4过来加锁,有线程1和线程3的锁都被释放了, 所以线程4加锁成功
整个过程当中, 线程1和线程3同时执行过临界区代码, 线程3和线程4也同时执行过临界区代码.分布锁跟本没起一点做用
综上所述, 这个绝对不是一个可用的分布式锁代码. 那么它的问题是什么呢, 主要是下面两点:
1. 超时时间设置不合理, 由于redis key过时致使锁失效
2. 释放锁的问题, 释放锁的时候把其余线程加的锁也给释放了
复制代码
怎么解决呢? 咱们来看第二个版本的分布式锁实现
对于分布式锁的过时时间的值,实际上是一个比较难肯定的东西. 由于咱们永远不知道临界区的业务逻辑到底要执行多长时间, 若是设置过短, 就会出现上面的那种状况, 若是说设置得长点, 那多长算是长呢?
一个简单的办法就是在锁快要失效的时候,若是代码没有执行完,那么就给这个锁的过时时间延长一些.
这个算法思想大概以下:
1. 加锁, 过时时间为N秒
2. 若是加锁成功, 则开启一个定时器
3. 定时器一直在执行, 每过了X(X < N, 通常可配置)秒, 就给这个锁延长Y (Y > X, 通常可配置)秒
4. 释放锁的时候, 把定时器删掉
复制代码
在上面算法中, 只要临界区的代码没有执行完, 定时器会一直给分布式锁"续命", 直到这个分布式锁被应用程序释放掉.
乍一看,若是业务代码一直没有处理完, 那这里岂不是跟没有设置超时时间同样同样的?
但其实仍是有区别:
1. 没有设置超时时间, redis的key是不会失效的.
2. "续命"的这种方式, 只有在应用程序(的临界代码)一直在运行的状况下, redis的key的过时时间会不断地被延长
区别就在于, 锁的失效与否仍是在锁的使用方手上, 而不是在于锁自己
复制代码
另外定时器(具体实现中多是一个守护线程)都是在临界区内生成和销毁的, 也就是每一个时刻最多只会有一个定时器存在, 因此也没必要担忧性能问题
只是要保证加锁释放锁和定时器的生成销毁的事务性, 即加锁成功必需要生成定时器, 释放锁必需要销毁定时器
锁释放的时候,误把其余线程加的锁也释放了. 这个问题其实很容易解决, 就是释放锁的时候, 判断一下这个锁是不是本身加的,是的话才释放锁. 伪代码实现以下:
public boolean unlock(String lockName,String lockValue) {
String val = jedis.get(lockName); // (1)
if(lockValue.equalsIgnoreCase(val)){
jedis.del(lockName); // (2)
}
return true;
}
复制代码
可是上面这段代码明显(1)和(2)不是原子性的, 极可能会带来一些未知的问题.因此真正的实现并非这样的,而是使用lua脚本,把两个命令放在一块儿,原子性地执行, 代码以下:
public boolean unlock(String lockName,String lockValue) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// public Object eval(String script, List<String> keys, List<String> args)
// 第一个参数是脚本, 第二个参数是脚本中涉及到的key的列表, 这里只涉及到lockName一个key, 第三个参数是涉及到的参数的列表, 这里只有一个lockValue参数
// 因此这里实际执行的脚本是: if redis.call('get', lockName) == lockValue then return redis.call('del', lockName) else return 0 end
Object o = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(lockValue));
return "1".equalsIgnoreCase(o.toString());
}
复制代码
在实现咱们的第二个版本的redis分布锁以前, 咱们先来总结一些,针对初版,有哪些优化
1. 每一个线程加锁的时候, redis key的值必须不同,并且惟一.释放锁的时候要传上这个惟一值
2. 加锁的时候,要新建一个定时器, 不断地延长这key的过时时间,直到锁释放
3. 释放锁的时候, 要判断当前锁的redis value是不是当前线程set进入的, 若是不是则不能释放
4. 释放锁的时候要把定时器销毁
复制代码
代码简单实现以下:
package com.north.lat.dislocklat.redisimpl;
import com.north.lat.dislocklat.DisLock;
import redis.clients.jedis.Jedis;
import java.util.Collections;
/**
* @author lhh
*/
public class DisLockV2 implements DisLock {
public static final String OK = "OK";
private Jedis jedis = new Jedis ("localhost",6379);
@Override
public boolean lock(String lockName, String lockValue, int expire) {
String ret = jedis.set(lockName, lockValue, "NX", "EX", expire);
createTimer(lockName, jedis, expire);
return OK.equalsIgnoreCase(ret);
}
@Override
public boolean unlock(String lockName,String lockValue) {
deleteTimer();
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// public Object eval(String script, List<String> keys, List<String> args)
// 第一个参数是脚本, 第二个参数是脚本中涉及到的key的列表, 这里只涉及到lockName一个key, 第三个参数是涉及到的参数的列表, 这里只有一个lockValue参数
// 因此这里实际执行的脚本是: if redis.call('get', lockName) == lockValue then return redis.call('del', lockName) else return 0 end
Object o = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(lockValue));
return "1".equalsIgnoreCase(o.toString());
}
/**
* 建立定时器, 这里暂时省略实现
* @param lockName
* @param jedis
* @param expire
*/
void createTimer(String lockName,Jedis jedis, int expire){
//每过了X(X < expire, 通常可配置)秒,jedis.expire 就给lockName这个锁延长Y (Y > X, 通常可配置)秒
}
/**
*销毁定时器, 这里暂时省略实现
*/
void deleteTimer(){
}
}
复制代码
测试main方法, 惟一变更的是lockValue:
package com.north.lat.dislocklat;
import com.north.lat.dislocklat.redisimpl.DisLockV1;
import java.util.UUID;
public class DisLockTest {
public static void main(String[] args) {
String lockName = "test_lock";
// 用uuid 保持惟一
String lockValue = UUID.randomUUID().toString();
DisLock disLock = new DisLockV1();
boolean success = disLock.lock(lockName, lockValue, 10);
if(success){
try {
doSomeThingImportant();
}finally {
disLock.unlock(lockName, lockValue);
}
}
}
public static void doSomeThingImportant(){
}
}
复制代码
上面这个定时器的思路,实际上是 redission 分布式锁里面的实现细想. 固然redission还实现了可重入,异步等等特性,咱们的跟它的是没法比的这里只是体现一下思想而已.
那么这样实现的分布式锁是否还有问题? 答案是确定的. 让咱们再来推演一下两种异常状况.
你们都知道, 为了提升可用性, 生产环境中的redis通常都不会是单点.解决单点有不少种方案, 可用是客户端分片, 哨兵模式,集群模式等等, 无论是哪一种方式 redis通常都会有一主一从. 正常状况是master提供服务, slave节点保持数据同步, 当master挂了的话, slave节点变成新的master, 来继续提供服务.
在redis只做为缓存服务的时候, 这个模式是比较可靠的. 可是在做为分布锁的状况下, 有时就不可用了.考虑如下的一种场景:
时刻 | 线程1 | 线程2 | redis1 | redis2 | 备注 |
---|---|---|---|---|---|
第1秒 | 获取锁 | - | 做为master | 做为slave | redis1有lock的key, redis2尚未 |
第2秒 | 获取到锁,执行业务逻辑 | 获取锁 | 挂了 | 成为master | 假设因为网络延迟,redis1的lock的key尚未同步到redis2 |
第3秒 | 执行业务逻辑 | 获取到锁,执行业务逻辑 | 挂了 | 做为master | 同时有两个线程在执行临界区代码,分布式锁不起做用 |
第4秒 | 执行业务逻辑 | 执行业务逻辑 | 挂了 | 做为master | |
第n秒 | ... | .. | ... | ... |
从上面第2秒能够看到,因为主从切换的时候, slave节点上面是不必定有master节点的全部的数据的, 这个时候若是有另一个线程来获取锁, 就会出现多个线程同时获取到锁的状况
若是redis是单实例的话, 上面的分布式锁已是可用的了, 只是又必需要面临单redis实例挂掉的风险.
为了解决redis主从切换带来的问题,reddsion的设计者实现一个新的分布式锁, 就是大名鼎鼎的REDLOCK
REDLOCK的设计思想仍是很符合咱们实事求是,具体问题具体分析的方法论的:
1. 主从切换会致使分布式锁失效? ok, 那就用单实例的redis
2. 单实例存在单点故障? ok, 那咱们用多个相互独立的单实例redis
复制代码
总的来讲, REDLOCK的实现思路就是放弃redis的主从结构, 使用N(通常是5)个redis实例来保证可用性
N个redis实例互相独立,分布式锁只有在大多数的实例上成功获取到锁, 才到算获取到锁成功. 为了不多个实例同时挂掉, 通常来讲每一个实例都在不一样的机器上面.
当客户端尝试去获取分布式锁的时候, 须要通过如下几个步骤
1. 计算当前时间戳CUR_T
2. 客户端逐一贯N个redis获取锁.也就是把同一个KEY和VALUE分布写到每一个redis实例中,过时时间为EX_T. 获取锁的时候还须要指一个时间:
此次set命令的响应超时时间RESP_T. 其中RESP_T < EX_T. RESP_T的存在是为了不某个redis实例已经挂了的时候,还在苦等它响应返回.
3. 对于第2步中的任何一个redis实例, 若是RESP_T时间内没有返回, 或者set命令返回false, 则表明获取锁失败, 不然就是获取锁成功. 无论在当前实例获取锁成功仍是失败, 都立马向下一个实例获取锁.
4. N个redis都请求完后,计算总耗时(用加锁完成时间戳-CUR_T) ,知足至少有(N/2+1)个实例能获取到锁,并且总耗时小于锁的失效时间才算获取锁成功.
5. 若是获取锁失败,要算全部的实例unlock释放锁.
复制代码
上面的这个思路, 在这篇译文中描述得很是清楚, 文中REDLOCK的做者大概的论证了这个算法的正确性,并不是常自信地认为该分布锁算法是无懈可击的
可是另一位大神Martin Kleppmann在他的文章内举了很多的例子, 来证实REDLOCK是脆弱的,不可靠的. 其中这里是一篇简单的译文
我试着理解了一下他的其中一个举证
在java应用里面, 当full gc发生的时候, 整个jvm会发生stop the world的停顿, 当停顿发生时, 分布锁的正确性就可能会被打破
来考虑一下下面的一种场景:
时刻 | 进程1 | 进程2 | 进程3 |
---|---|---|---|
第1秒 | 加锁 | 加锁 | 未开始执行 |
第2秒 | 获取到锁 | 没获取到锁 | 未开始执行 |
第3秒 | 执行业务逻辑,发生FULL GC | 已返回 | 未开始执行 |
第4秒 | 执行业务逻辑,FULL GC, STOP THE WORLD中 | 已返回 | 未开始执行 |
第11秒 | FULL GC结束,执行业务逻辑(锁已超时失效) | - | 加锁 |
第12秒 | 执行业务逻辑 | - | 执行业务逻辑 |
第n秒 | .. | ... | ... |
当JVM在stop the world时, 无论是业务逻辑代码, 仍是上面的"续命"定时器代码, 都会中止运行.
当FULL GC的停顿时间过长时, redis中分布式锁的key有可能已通过期了. 倘若FULL GC结束的瞬间有另一个进程过来获取锁的话, 就会发生同时两个进程获取到锁,同时执行临界区代码的状况.
Martin Kleppmann也给出这个状况的解决方案(详细见这篇译文), 并指出redlock处理不了这种状况, 因此redlock是不可靠的.
有趣的是, redlock的做者在另一篇文章回应了Martin Kleppmann的质疑. 内容就没有仔细看了, 质疑的论文和反质疑的论文都是两三年前的了, 在技术突飞猛进的这个时代, 文中的一些观点可能早就过期或者是解决掉了.