在单实例JVM中,常见的处理并发问题的方法有很多,比如synchronized关键字进行访问控制、volatile关键字、ReentrantLock等常用方法。
但是在分布式环境中,上述方法却不能在跨JVM场景中用于处理并发问题,当业务场景需要对分布式环境中的并发问题进行处理时,需要使用分布式锁来实现。
分布式锁,是指在分布式的部署环境下,通过锁机制来让多客户端互斥的对共享资源进行访问。
目前比较常见的分布式锁实现方案有以下几种:
基于数据库,如MySQL
基于缓存,如Redis
基于Zookeeper、etcd等。

使用redis实现分布式锁

使用Redis实现分布式锁最简单的方案是使用命令SETNX(SET if Not eXist)。
只在键key不存在的情况下,将键key的值设置为value,若键key存在,则SETNX不做任何动作。
SETNX在设置成功时返回,设置失败时返回0。当要获取锁时,直接使用SETNX获取锁,当要释放锁时,使用DEL命令删除掉对应的键key即可。
使用SETNX在redis节点发生宕机时,那么获取分布式锁的线程就无法释放锁,一直占用着锁不释放,这种占用发生在进行delete key之前发生了redis的节点宕机,那么为key设置一个超时时间,达到一定的时间获取锁的线程自动释放锁,但是这种通过超时时间控制锁的自动释放还会存在问题,比如SETNX与设置过期时间是两个不同的操作,需要放到一个事务中才能满足以上通过超时时间控制锁的自动释放,即保证SETNX与设置过期时间这两个操作的原子性,因为如果在SETNX与设置过期时间之间发生了异常,还是达不到锁的释放,从而还是达不到预期的结果。

// STEP 1
SETNX key value
// 若在这里(STEP1和STEP2之间)程序突然崩溃,则无法设置过期时间,将有可能无法释放锁
// STEP 2
EXPIRE key expireTime

那么应该使用“SET key value [EX seconds] [PX milliseconds] [NX|XX]”这个命令。
从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改:

EX seconds :将键的过期时间设置为 seconds 秒。执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value 。

PX milliseconds :将键的过期时间设置为 milliseconds 毫秒。执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value 。

NX :只在键不存在时, 才对键进行设置操作。执行 SET key value NX 的效果等同于执行 SETNX key value 。

XX :只在键已经存在时, 才对键进行设置操作。

举例,我们需要创建一个分布式锁,并且设置过期时间为10s,那么可以执行以下命令:

SET lockKey lockValue EX 10 NX
或者
SET lockKey lockValue PX 10000 NX

注意EX和PX不能同时使用,否则会报错:ERR syntax error。
解锁的时候还是使用DEL命令来解锁。
但是上面这种方案还是解决不了实际可能出现的问题,比如:
某线程A获取了锁并且设置了过期时间为10s,然后在执行业务逻辑的时候耗费了15s,此时线程A获取的锁早已被Redis的过期机制自动释放了,在线程A获取锁并经过10s之后,该锁可能已经被其它线程获取到了。当线程A执行完业务逻辑准备解锁(DEL key)的时候,有可能删除掉的是其它线程已经获取到的锁。
解决方式是判断解锁的线程与加锁的线程是否是同一个线程,是同一个线程才能进行解锁操作,比如我们可以在设置key的时候将value设置为一个唯一值uniqueValue(可以是随机值、UUID、或者机器号+线程号的组合、签名等),这种判断比较操作(释放锁的逻辑)也必须保证其原子性,下面我们使用lua脚本来实现这种原子性:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

其中ARGV[1]表示设置key时指定的唯一值。
由于Lua脚本的原子性,在Redis执行该脚本的过程中,其他客户端的命令都需要等待该Lua脚本执行完才能执行。
下面我们使用Jedis来演示一下获取锁和解锁的实现,具体如下:

public boolean lock(String lockKey, String uniqueValue, int seconds){
    SetParams params = new SetParams();
    params.nx().ex(seconds);
    String result = jedis.set(lockKey, uniqueValue, params);
    if ("OK".equals(result)) {
        return true;
    }
    return false;
}

public boolean unlock(String lockKey, String uniqueValue){
    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(uniqueValue));
    if (result.equals(1)) {
        return true;
    }
    return false;
}
上面的这种实现还是会有问题,在我们的系统架构里存在一个单点故障,如果Redis的master节点宕机了怎么办呢?

有人可能会说:加一个slave节点!在master宕机时用slave就行了!
但是其实这个方案明显是不可行的,因为Redis的复制是异步的。
举例来说:

1、线程A在master节点拿到了锁。
2、master节点在把A创建的key写入slave之前宕机了。
3、slave变成了master节点。
4、线程B也得到了和A还持有的相同的锁。(因为原来的slave里面还没有A持有锁的信息)

使用Redlock算法可以解决上述的问题。

当然,在某些场景下这个方案没有什么问题,比如业务模型允许同时持有锁的情况,那么使用这种方案也未尝不可。
举例说明,某个服务有2个服务实例A和B,初始情况下A获取了锁然后对资源进行操作(可以假设这个操作很耗费资源),B没有获取到锁而不执行任何操作,此时B可以看做是A的热备。

当A出现异常时,B可以“转正”,当锁出现异常时,比如Redis master宕机,那么B可能会同时持有锁并且对资源进行操作,如果操作的结果是幂等的(或者其它情况),那么也可以使用这种方案。

Redlock算法的主要思想是:假设我们有N个Redis master节点,这些节点都是完全独立的,我们可以运用前面的方案来对前面单个的Redis master节点来获取锁和解锁

如果我们总体上能在合理的范围内或者N/2+1个锁,那么我们就可以认为成功获得了锁,反之则没有获取锁

Redlock算法

假设redis的部署模式是redis cluster,总共有5个master节点,通过以下步骤获取一把锁:
获取当前时间戳,单位是毫秒
轮流尝试在每个master节点上创建锁,过期时间设置较短,一般就几十毫秒
尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2 +1)
客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了
要是锁建立失败了,那么就依次删除这个锁。
只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。
但是这种RedLock算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确,下面使用基于企业级开源的redis-client的Redisson。

基于企业级开源的redis-client的Redisson

此外,实现Redis的分布式锁,除了自己基于redis client原生api来实现之外,还可以使用开源框架:Redission。
Redisson是一个企业级的开源Redis Client,也提供了分布式锁的支持。我也非常推荐大家使用,为什么呢?
如果自己写代码来通过redis设置一个值,是通过下面这个命令设置的。

SET anyLock unique_value NX PX 30000

这里设置的超时时间是30s,假如我超过30s都还没有完成业务逻辑的情况下,key会过期,其他线程有可能会获取到锁。这样一来的话,第一个线程还没执行完业务逻辑,第二个线程进来了也会出现线程安全问题。所以我们还需要额外的去维护这个过期时间,太麻烦了~我们来看看redisson是怎么实现的?先感受一下使用redission的爽:

Config config = new Config();
config.useClusterServers()
    .addNodeAddress("redis://192.168.31.101:7001")
    .addNodeAddress("redis://192.168.31.101:7002")
    .addNodeAddress("redis://192.168.31.101:7003")
    .addNodeAddress("redis://192.168.31.102:7001")
    .addNodeAddress("redis://192.168.31.102:7002")
    .addNodeAddress("redis://192.168.31.102:7003");

RedissonClient redisson = Redisson.create(config);


RLock lock = redisson.getLock("anyLock");
lock.lock();
lock.unlock();

就是这么简单,我们只需要通过它的api中的lock和unlock即可完成分布式锁,他帮我们考虑了很多细节:

1、redisson所有指令都通过lua脚本执行,redis支持lua脚本原子性执行
2、redisson设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s怎么办?
3、redisson中有一个watchdog的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s
4、这样的话,就算一直持有锁也不会出现key过期了,其他线程获取到锁的问题了。
5、redisson的“看门狗”逻辑保证了没有死锁发生。
(如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期了,其他线程可以获取到锁)

redisson 分布式任务 redis实现分布式锁最好方案_Redis

另外,redisson还提供了对redlock算法的支持。

具体的代码实现请参考分布式锁的实现,结合demo的实现