一. 对于分布式的应用,一定程度上会增加处理的速度。但是也会带来一些分布式上的麻烦,比如有个需求:后台程序部署在多台服务器上,client向该后台程序发送参数为 用户账号和 账号类型 的rpc请求,后台程序需要返回该账号对应的身份信息(逻辑很简单,先判断库中有没有该账号信息,有就返回,没有就新生成一个新的身份信息 返回)。设想如果多个client 同时发送多个一样的账号和账号类型 到后台程序,由于同时查库没有该账号信息,这样岂不是要新生成多个不同的身份信息。解决该问题也许可以不通过分布式锁(项目中不是用的分布式锁,但不属于本文的内容的范畴,故省略),下面介绍用redis 实现分布式锁。
二 .不安全的做法
使用jedis.setnx(key,value) . 伪代码如下
if(jedis.setnx(key,value) ==1 ){ //get the lock
jedis.expire(key,timeout) ; //设置锁超时时间
try{
do something ..
}finally{
jedis.del(key) ; //删除(释放) 锁
}
}
使用该做法有3点不安全的隐患
隐患1: setnx 和 expire 不是同步的,如果刚setnx完成,还没来得及 expire key ,就宕机了,那该锁就 是“长生不老的”
隐患2: del 可能是删除 “别人”设置的锁,由于自己执行任务时间的比较长,设置的锁因为超时已经过期了。这个时候别的线程已经拿到该锁,那删除的时候,锁的别的线程设置的。
隐患3: 可能出现 多个线程并存的情况。如果 thread1 拿到锁,设置的锁已经过期了,但是还没有执行完成 。 thread2 访问拿到了锁 。这样thread1 和 thread2 同时存在。
上述隐患解决方案:
隐患1解决方案 : 如果向要setnx 和expire 是原子操作,可以使用jedis.set(key,value,nxxx,expx,time).)(redis2.6以上) 。具体的用法可以自己查询baidu/google
隐患2解决方案 : 将set(key,value.nxxx,expx,time)中的value 设置为 Thread.currentThread.getId().即根据该value判断锁是否是自己设置的。如果是自己设置才删除
伪代码:
if(Thead.currentThread.getId().equal(jedis.get(key))){ //步骤1
del(key) ; //步骤2
}
有人可能会说,执行步骤1 判断的时候锁过期了,“别人”拿到了锁,那删除的时候也删除了别人的锁了,即要保持 步骤1 判断和步骤2 删除也是原子性的,这个可以做到吗?答案是肯定的。lua脚本如下:
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(luaScript, Collections.singletonList(key),Collections.singletonList(Thread.currentThead.getId()));
代码的大致意思和伪代码差不多,只不过该过程是原子的。
隐患3解决方案:出现隐患3的根本原因是,执行时间过长,超出了过期时间。如果我们再开启一个守护线程,在某个拿到锁的线程快要过期的时候时候延长 过期时间, 保证锁不会由于过期时间而删除,是要由执行del 命令删除的(宕机情况例外)
三 . 代码部分
public boolean getLock(String value){
Jedis jedis = null;
boolean isGetLock = false;
try {
jedis = redisPoolServer.getJedis();
String result = jedis.set("key",value,"nx","ex",20);
if(result != null){
isGetLock = true;
}
return isGetLock;
}catch(Exception e ){
e.printStackTrace();
return false;
}finally {
if(jedis != null){
jedis.close();
}
}
}
private void delLock(String threadId){
Jedis jedis = null;
try{
jedis = redisPoolServer.getJedis();
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(luaScript, Collections.singletonList("key"),Collections.singletonList(threadId));
}catch(Exception e){
e.printStackTrace();
}finally {
if(jedis != null){
jedis.close();
}
}
}
//守护线程 获取存储的value值
private String getLockValue(){
Jedis jedis = null;
try{
jedis = redisPoolServer.getJedis();
jedis.select(15);
return jedis.get("key");
}catch(Exception e){
e.printStackTrace();
return null;
}finally {
if(jedis != null){
jedis.close();
}
}
}
//守护线程 获取key的剩余时间
private Long getTtl(){
Jedis jedis = null;
try{
jedis = redisPoolServer.getJedis();
return jedis.ttl("key");
}catch(Exception e){
e.printStackTrace();
return null;
}finally {
if(jedis != null){
jedis.close();
}
}
}
//守护线程发现快要过期,延长锁的过期时间
private void spanExpireTime(int seconde){
Jedis jedis = null;
try{
jedis = redisPoolServer.getJedis();
jedis.expire("key",seconde);
}catch(Exception e){
e.printStackTrace();
}finally {
if(jedis != null){
jedis.close();
}
}
}
private class SubThread implements Runnable{
public void run() {
//如果拿到锁,执行了较长的时间,超出过期时间,那么别的线程会得到该锁,那么这个时候就同时有多个线程同时访问。为了防止这种情况当达到过期时间的90%时 ,延长过期时间
System.out.println("subThread:"+Thread.currentThread().getName());
Thread daemonThead = new Thread(new DaemonThred(Thread.currentThread()));
daemonThead.setDaemon(true);
daemonThead.start();
try {
while (true) {
boolean isGetLock = getLock(new Long(Thread.currentThread().getId()).toString());
if(isGetLock){
System.out.println(Thread.currentThread().getName()+new Long(Thread.currentThread().getId()).toString()+"get the redis lock ,doing something");
Thread.sleep(5000); //do somethings
//判断和删除同步
delLock(new Long(Thread.currentThread().getId()).toString());
Thread.sleep(1000);
}else{
System.out.println(Thread.currentThread().getName()+new Long(Thread.currentThread().getId()).toString()+"do not get the redis lock.sleep 1s");
Thread.sleep(1000);
}
}
}catch(Exception e ){
e.printStackTrace();
}
}
}
private class DaemonThred implements Runnable{
private Thread userThead;
public DaemonThred(Thread userThead){
this.userThead = userThead;
}
public void run() {
while(true && userThead.isAlive()){
Long ttl = getTtl();
if((ttl != null) && (ttl+2 !=0) && (ttl < 20*0.1) && new Long(userThead.getId()).toString().equals(getLockValue()) ){
//设置的ttl值剩下不到 10% 了 ,延迟该key的时间
//log info : 一般情况是不会达到过期时间的,可以打印日志,便于分析情况
spanExpireTime(10);
}else{
try {
Thread.sleep(100);
}catch(Exception e){
e.printStackTrace();
}
}
}
}
}
四 . 后续
4.1 该代码不是正式线上的代码,只是写的一个demo,由好的实现方式或者问题,欢迎吐槽。
4.2 大家可能会问,守护线程 的判断剩余时间、获取value值 和 设置延长过期时间 不是原子操作,会不会存在和隐患2中 判断是否自己设置的锁 和后续 删除锁一样的问题 --不是原子操作的问题 ? 我这里设置的锁 过期时间是20s,还有2s的时候我就延长过期时间,也就是 守护线程 有足够的时间 执行剩余时间判断 和 执行延长过期时间的操作 。一般来说,如果使用分布式锁, thread访问很快。不会等到后台守护线程去延长过期时间 。如果线程 很多时候 get 锁 do somthings 需要花费很长时间。这个时候需要考虑下是不是需要分布式锁,或者架构上的设计是否有改善的地方。