分布式锁概念

分布式锁其实就是,控制分布式系统的不同进程共同访问共享资源的一种锁的实现。如果不同系统或同一个系统的不同主机去访问一个共享的临界资源,往往需要互斥来防止彼此干扰,以保证一致性。
分布式锁应该具备以下条件:

  1. 互斥性:任意时刻,只允许一个客户端访问。
  2. 锁超时:持有锁超时,可以释放,避免资源浪费,也可以防止死锁。
  3. 可重入性:一个线程获取锁之后,还可以再次请求加锁。
  4. 高可用和高性能:加锁和释放锁的开销要尽可能的低,同时保证高可用,防止分布式锁失效。
  5. 安全性:锁只能被持有的客户端删除,不能被其他客户端删除。

分布式锁实现方式

1,基于数据库锁
2,基于Redis锁
3,基于Zookeeper

Redis分布式锁实现方案

本文以基于Redis来实现分布式锁,描述多个实现方案,并分析利弊。

方案一:SETNX + EXPIRE

Redis中有一个 SETNX 命令,命令格式是 SETNX key value,如果key不存在,则SETNX返回1,如果已存在,则返回0。
那么可以使用 SETNX + EXPIRE命令,即SETNX抢到锁,在用EXPIRE给锁一个过期时间,防止锁忘记释放或者服务奔溃而没有释放锁。
伪代码如下:

if(jedis.setnx(key_resource_id,lock_value) == 1){ //加锁
    expire(key_resource_id,100); //设置过期时间
    try {
        do something  //业务请求
    }catch(){
  }
  finally {
       jedis.del(key_resource_id); //释放锁
    }
}

这个方案的问题是,setnx和expire两个命令操作不是原子性的,那么有可能在setnx加完锁之后,进程发生异常等原因,设置过期时间还没有执行,那么就会导致这个锁一直得不到释放,其他请求就会一直在等待,造成死锁问题。

方案二:使用lua脚本(包含SETNX + EXPIRE两条指令)

针对方案一的问题,那么可以使用lua脚本来保证原子性(包含SETNXX + EXPIRE)。
lua脚本如下:

if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
   redis.call('expire',KEYS[1],ARGV[2])
else
   return 0
end;

加锁代码如下:

String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
            " redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";   
Object result = jedis.eval(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values));
//判断是否成功
return result.equals(1L);

方案三:SET的扩展命令(SET NX EX PX)

我们可以使用lua脚本来保证SETNX + EXPIRE两条命令的原子性,也可以使用Redis的SET指令扩展参数来实现。

语法:SET key value NX|XX EX|PX  expire_time
参数:
NX:只有健key不存在的时候,才会去设置健key的值
PX:只有健key存在的时候,才会去设置健key的值
EX:设置健key的过期时间,单位为秒
PX:设置健key的过期时间,单位为毫秒
expire_time:过期时间,整数类型

伪代码demo如下:

if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁
    try {
        do something  //业务处理
    }catch(){
  }
  finally {
       jedis.del(key_resource_id); //释放锁
    }
}

方案二和方案三的还可能存在问题:
问题1,锁过期释放了,业务还没有执行完。比如a线程拿到锁并执行业务代码,但是过期时间到了,业务代码还没有执行完成,那么b线程进来,就能拿到锁,此时就不是同步串行执行的。
问题2,锁被别的线程误删。比如a线程拿到锁,然后准备去释放锁,但有可能过期时间到了,b线程进来拿到锁,此时a线程去释放锁,就会把b线程的锁给释放带,但b线程的业务代码还没有执行完成,后续又有新线程来拿锁,导致问题的发生。

方案四:SET NX EX|PX + 校验唯一随机值,再删除

既然锁可能被别的线程误删除,那么给value值设置一个标记当前线程的唯一随机值,在删除的时候校验一下。

加锁和释放锁的参考代码如下:

/**
 * 尝试获取分布式锁
 * @param lockKey 锁
 * @param requestId 请求标识,唯一ID, 可以使用UUID.randomUUID().toString();
 * @param expireTime 超期时间,毫秒
 * @return 是否获取成功
 */
public  boolean redisLockByKey(String lockKey, String requestId, int expireTime) {
    Jedis jedis = jedisPool.getResource();
    try {
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    } finally {
        jedis.close();
    }
}

/**
 * 释放分布式锁
 * @param lockKey 锁
 * @param requestId 请求标识
 * @return 是否释放成功
 */
public  boolean redisUnlockByKey(String lockKey, String requestId) {
    Jedis jedis = jedisPool.getResource();
    try {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    } finally {
        jedis.close();
    }

}

方案四解决了误删除的问题,但还是没有解决【锁过期释放,业务代码还没有执行完】的问题。

方案五:Redisson框架

其实以上的方案,还只是基于单机版的Redis来讨论的,但生产环境大多都是集群模式部署的。

如果线程a在Redis的master节点上拿到锁,但是加锁的key还没有同步到slave节点。恰好此时,master节点发生故障,一个slave节点会升级为master,这时候线程b就能拿到同个key的锁,而线程a也同样拿到锁,这样就不能保证安全性了。

针对这个问题,Redis作者antirez提出一个高级的分布式锁算法,Redlock。
Redlock核心思想是这样的:

搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。

Redlock的实现步骤如下:

1.获取当前时间,以毫秒为单位。

2.按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。

3.客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)

4,如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。

5,如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。

Redisson框架实现了Redlock版本的锁。

使用Redisson框架就可以解决以上方案的问题,并且是基于集群模式的,是一个目前较完美的分布式锁解决方案。