浅谈分布式锁--基于缓存(Redis,memcached,tair)实现篇

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/dingjianmin/article/details/82776986

浅谈分布式锁--基于缓存(Redis,memcached,tair)实现篇:

一、Redis分布式锁

1、Redis实现分布式锁的原理:

    1.利用setnx命令,即只有在某个key不存在情况才能set成功该key,这样就达到了多个进程并发去set同一个key,只有一个进程能set成功,如果设置了锁返回1, 已经有值没有设置成功返回0。
    2.死锁问题,仅有一个setnx命令,redis遇到的问题跟数据库锁一样,但是过期时间这一项,redis自带的expire功能可以不需要应用主动去删除锁。
        而且从 Redis 2.6.12 版本开始,redis的set命令直接直接设置NX和EX属性,NX即附带了setnx数据,key存在就无法插入,EX是过期属性,可以设置过期时间。这样一个命令就能原子的完成加锁和设置过期时间。
    3.缓存锁优势是性能出色,劣势就是由于数据在内存中,一旦缓存服务宕机,锁数据就丢失了。像redis自带复制功能,可以对数据可靠性有一定的保证,但是由于复制也是异步完成的,因此依然可能出现master节点写入锁数据而未同步到slave节点的时候宕机,锁数据丢失问题。
    4.针对集群服务器时间不一致问题,可以调用redis的time()获取当前时间。

2、实现思想:

    (1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
    (2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
    (3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。

3、用Redis实现分布式锁原因:

    1.Redis有很高的性能。
    2.Redis命令对此支持较好,实现起来比较方便。

4、使用Redis实现分布式锁的时候,主要用到这三个命令:

    SETNX

    SETNX key val
    当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。

    GETSET
    
    GETSET key value
    将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。
        
    expire

    expire key timeout
    为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。

    delete

    delete key
    删除key

5、实例实现(一种错误的方式):

public class ErrorRedisLock {
	public static long hold_time = 3000;
	public static ThreadLocal<String> expireHolder = new ThreadLocal<>();
	public static Jedis jedis;

	public static void acquire(String lock) {

		// 1.先尝试用setnx命令获取锁,key为参数lock,值为当前时间+要持有锁的时间hold_time
		while (jedis.setnx(lock, String.valueOf(System.currentTimeMillis() + hold_time)) == 0) {
			// 2.如果获取失败,先watch lock key
			jedis.watch(lock);
			// 3.获取当前超时时间
			String expireTime = jedis.get(lock);
			if (expireTime != null && Long.parseLong(expireTime) < System.currentTimeMillis()) {
				// 4.如果超时时间小于当前时间,开事务准备更新lock值
				Transaction transaction = jedis.multi();
				Response<String> response = transaction.getSet(lock,
						String.valueOf(System.currentTimeMillis() + hold_time));
				// 5.步骤2设置了watch,如果lock的值被其他线程修改,不是执行事务中的命令
				if (transaction.exec() != null) {
					String oldExpire = response.get();
					if (oldExpire != null && Long.parseLong(expireTime) < System.currentTimeMillis()) {
						// 6.如果setget命令返回的值依然是过期时间,认为获取锁成功(加了watch之后,这里返回的应该一直是超时时间)
						break;
					}
				}
			} else {
				// 如果key未超时,解除watch
				jedis.unwatch();
			}
		}
		// 设置客户端超时时间
		expireHolder.set(jedis.get(lock));
	}

	public static void release(String lock) {
		// 比较客户端超时时间与lock值,判断是否还由自己持有锁
		if (jedis.get(lock).equals(expireHolder.get())) {
			jedis.del(lock);
		}
		jedis.close();
	}
	
	public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
		// 判断加锁与解锁是不是同一个客户端
		if (requestId.equals(jedis.get(lockKey))) {
			// 若在此时,这把锁突然不是这个客户端的,则会误解锁
			jedis.del(lockKey);
		}
	}
}

正确的实现方式:

import redis.clients.jedis.Jedis;

public class RedisTool {

    private static final Long RELEASE_SUCCESS = 1L;
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        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;

    }
}


5.1、为什么不直接使用expire设置超时时间,而将时间的毫秒数其作为value放在redis中?
    如下面的方式,把超时的交给redis处理:

        if (conn.setnx(lockKey) == 1) {
            conn.expire(lockKey, lockExpire);
        }


    这种方式貌似没什么问题,但是假如在setnx后,redis崩溃了,expire就没有执行,结果就是死锁了。锁永远不会超时。


5.2、多台机器同时向redis发出setnx请求,会不会存在并发问题?

    其实redis本事是不会存在并发问题的,因为他是单进程的,再多的command都是one by one执行的。我们使用的时候,可能会出现的并发问题都是多个命令组才会出现,比如get和set这一对。


5.3、使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间。执行过程:

    通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。

    如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。

6、错误代码实例(获取锁)

	public static void acquire(String lock) {
		// 1.先尝试用setnx命令获取锁,key为参数lock,值为当前时间+要持有锁的时间hold_time
		while (jedis.setnx(lock, String.valueOf(System.currentTimeMillis() + hold_time)) == 0) {
			// 2.如果获取失败,先watch lock key
			jedis.watch(lock);
			// 3.获取当前超时时间
			String expireTime = jedis.get(lock);
			if (expireTime != null && Long.parseLong(expireTime) < System.currentTimeMillis()) {
				// 4.如果超时时间小于当前时间,开事务准备更新lock值
				Transaction transaction = jedis.multi();
				Response<String> response = transaction.getSet(lock,
						String.valueOf(System.currentTimeMillis() + hold_time));
				// 5.步骤2设置了watch,如果lock的值被其他线程修改,不是执行事务中的命令
				if (transaction.exec() != null) {
					String oldExpire = response.get();
					if (oldExpire != null && Long.parseLong(expireTime) < System.currentTimeMillis()) {
						// 6.如果setget命令返回的值依然是过期时间,认为获取锁成功(加了watch之后,这里返回的应该一直是超时时间)
						break;
					}
				}
			} else {
				// 如果key未超时,解除watch
				jedis.unwatch();
			}
		}
		// 设置客户端超时时间
		expireHolder.set(jedis.get(lock));
	}

    这严格来说是一种错误示例而且实现也比较复杂。那么这段代码问题在哪里?


    1.由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。
    2.当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。
    3.锁不具备拥有者标识,即任何客户端都可以解锁。

    可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

    第一个为key,我们使用key来当锁,因为key是唯一的。
    第二个为value,我们传的是requestId,requestId是客户端的唯一标志。
    第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
    第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
    第五个为time,与第四个参数相呼应,代表key的过期时间。


    可以看到上面的set()方法,通过requestId解决了分布式下不同客户端时间不统一问题,通过超期时间解决了多次get,set覆盖问题,通过解锁时判断requestId解决了任何客户端都可以解锁问题。

7、错误代码实例(释放锁)

	public static void release(String lock) {
		// 比较客户端超时时间与lock值,判断是否还由自己持有锁
		if (jedis.get(lock).equals(expireHolder.get())) {
			jedis.del(lock);
		}
		jedis.close();
	}
	
	public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
		// 判断加锁与解锁是不是同一个客户端
		if (requestId.equals(jedis.get(lockKey))) {
			// 若在此时,这把锁突然不是这个客户端的,则会误解锁
			jedis.del(lockKey);
		}
	}


    这种方式的问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,


    比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。

    所以我们应该注意并发的情况下两行代码之间有很高几率出现其它线程乱入的问题。

采用释放锁:

    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        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;

    }

    可以看到,我们解锁只需要两行代码就搞定了!
    第一行代码,我们写了一个简单的Lua脚本代码,第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

    那么这段Lua代码的功能是什么呢?

    首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。

    关于非原子性会带来什么问题,可以参考上面的错误示例,那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:

    简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

8、总结:
    根据上述的分析的两个问题,我们看到了并发过程中,两行代码之间特别容易出现其它线程乱入影响一致性,所以尽量使用一行原子性的操作来保证锁的实现。

参考资料: http://geek.csdn.net/news/detail/246676

二、memcached分布式锁

    1、实现原理:
    memcached带有add函数,利用add函数的特性即可实现分布式锁。
    add和set的区别在于:
    如果多线程并发set,则每个set都会成功,但最后存储的值以最后的set的线程为准。而add的话则相反,add会添加第一个到达的值,并返回true,后续的添加则都会返回false。利用该点即可很轻松地实现分布式锁。

    2、优点
    并发高效。

    3、缺点
    (1)memcached采用列入LRU置换策略,所以如果内存不够,可能导致缓存中的锁信息丢失。
    (2)memcached无法持久化,一旦重启,将导致信息丢失。


三、Tair分布式锁

基于Tair的实现分布式锁其实和Redis类似,其中主要的实现方式是使用TairManager.put方法来实现。

public boolean trylock(String key) {
    ResultCode code = ldbTairManager.put(NAMESPACE, key, "This is a Lock.", 2, 0);
    if (ResultCode.SUCCESS.equals(code))
        return true;
    else
        return false;
}
public boolean unlock(String key) {
    ldbTairManager.invalid(NAMESPACE, key);
}


以上实现方式同样存在几个问题:

    1、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在tair中,其他线程无法再获得到锁。
    2、这把锁只能是非阻塞的,无论成功还是失败都直接返回。
    3、这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在tair中已经存在。无法再执行put操作。

    当然,同样有方式可以解决。

    没有失效时间:tair的put方法支持传入失效时间,到达时间之后数据会自动删除。
    非阻塞:while重复执行。
    非可重入:在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。
    但是,失效时间我设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。
    如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。这个问题使用数据库实现分布式锁同样存在。


四、分布式缓存锁—Redlock

Redlock算法假设有N个redis节点,这些节点互相独立,一般设置为N=5,这N个节点运行在不同的机器上以保持物理层面的独立。

算法的步骤如下:

    1、客户端获取当前时间,以毫秒为单位。
    2、客户端尝试获取N个节点的锁,(每个节点获取锁的方式和前面说的缓存锁一样),N个节点以相同的key和value获取锁。客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是10s,那么接口超时大概设置5-50ms。这样可以在有redis节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。
    3、客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤一获取的时间,只有客户端获得了超过3个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。
    4、客户端获取的锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。
    5、如果客户端获取锁失败了,客户端会依次删除所有的锁。
    使用Redlock算法,可以保证在挂掉最多2个节点的时候,分布式锁服务仍然能工作,这相比之前的数据库锁和缓存锁大大提高了可用性,由于redis的高效性能,分布式缓存锁性能并不比数据库锁差。

    redlock算法相对于单节点redis锁可靠性要更高,但是实现起来条件也较为苛刻。
    (1) 必须部署5个节点才能让Redlock的可靠性更强。
    (2) 需要请求5个节点才能获取到锁,通过Future的方式,先并发向5个节点请求,再一起获得响应结果,能缩短响应时间,不过还是比单节点redis锁要耗费更多时间。
    然后由于必须获取到5个节点中的3个以上,所以可能出现获取锁冲突,即大家都获得了1-2把锁,结果谁也不能获取到锁,这个问题,redis作者借鉴了raft算法的精髓,通过冲突后在随机时间开始,可以大大降低冲突时间,但是这问题并不能很好的避免,特别是在第一次获取锁的时候,所以获取锁的时间成本增加了。
    如果5个节点有2个宕机,此时锁的可用性会极大降低,首先必须等待这两个宕机节点的结果超时才能返回,另外只有3个节点,客户端必须获取到这全部3个节点的锁才能拥有锁,难度也加大了。
    如果出现网络分区,那么可能出现客户端永远也无法获取锁的情况。


总结
可以使用缓存来代替数据库来实现分布式锁,这个可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。
并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如Tair的put方法,redis的setnx方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。

使用缓存实现分布式锁的优点
性能好,实现起来较为方便。

使用缓存实现分布式锁的缺点
通过超时时间来控制锁的失效时间并不是十分的靠谱。

每天努力一点,每天都在进步。

猜你喜欢

转载自blog.csdn.net/dingjianmin/article/details/82776986