基于Redis的分布式锁实现及思考

目前,几乎所有的大型网站及应用都是分布式部署的。分布式环境下的数据一致性是一个非常重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了,也就是说单纯的Java Api并不能提供分布式锁的能力。那么怎么实现分布式锁呢?

这里我们讲述基于Redis的分布式锁实现,并对实现中存在的问题进行说明和优化。

最开始,我是这么设计分布式锁的,大家可以看下是怎么实现的。

public class SyncLockManager {
    
    private static VARedis redis = VARedis.getInstance();
	
	private static long lockTimeout = 5000;
	
	private static int expireTimeout = 5000;
	
	/**尝试获取某个关键字代表的锁
	 * @param key
	 * @return
	 * @throws InterruptedException 
	 */
	public static boolean tryLock(String key) throws InterruptedException{
		boolean getLock = false;
		long waitTotalTime = lockTimeout;
		while(waitTotalTime>0){
			long now = System.currentTimeMillis();
			long lockReleaseTime = now + lockTimeout + 1;
			long flag = redis.setNx(key, String.valueOf(lockReleaseTime));
			if(flag==1){//成功设置成功,获取到锁
				getLock = true;
				break;
			}
			String currentLockReleaseTime = redis.get(key);
			if(currentLockReleaseTime!=null&&Long.parseLong(currentLockReleaseTime)<System.currentTimeMillis()){
				String oldValue = redis.getSet(key, String.valueOf(lockReleaseTime));
				if(oldValue!=null&&oldValue.equals(currentLockReleaseTime)){
					getLock = true;
					break;
				}
			}
			Thread.sleep(100);
			waitTotalTime = waitTotalTime - 100;
			
		}
		return getLock;
	}
	
	
	/**
	 * 释放锁
	 * @param key
	 */
	public static void releaseLock(String key){
		long now = System.currentTimeMillis();
		String currentLockReleaseTime = redis.get(key);
		if(currentLockReleaseTime!=null&&now<Long.parseLong(currentLockReleaseTime)){
			redis.delete(key);
		}
	}
}

上面的实现思路主要可概括为:(1)尝试获取锁的过程中设计为超时时间内反复尝试获取锁,获取到锁就返回,获取不到就继续等待直到获取到锁返回成功或者达到超时时间返回失败;(2)为避免线程加锁后无法释放锁,导致后续线程永远无法获取到锁,约定线程将锁的过期时间作为redis的值保存,后续其他线程如果发现当前时间已经超过了锁的过期时间,则有权重新设置锁或者释放锁;

大家先想想这种实现分布式锁有没有问题?

说实话,最开始我就是这么实现的,当时也考虑到各种因素,也进行过相应的分布式压力测试,都没有发现问题,但是后来发现这种实现也是存在一定问题的,虽然造成问题的原因基本上在现网不会出现。

是什么问题呢?不卖关子了,那就是系统时间。直观点说,那就是如果分布式部署情况下,如果系统时间不同步,则这个分布式锁就会出现较大的问题,线程在获取锁的时候由于在比较之前其他分布式应用线程加的锁的过期时间和当前分布式应用中的系统时间时都会失败,从而导致永远无法加锁成功进而无法获取到锁。当然在现网环境下系统时间不一致或者差距较大还是基本不回出现的,如果可以保证,那么这种实现未免不能成为一种实现方案。

但是,我们不能满足于此,我们要实现不受各方面因素影响的分布式锁该怎么修改呢?

为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

我们先直接看修改之后的基于Redis的分布式锁实现方案。

扫描二维码关注公众号,回复: 184872 查看本文章
public class SyncLockManager {
	
	private static final String LOCK_SUCCESS = "OK";
	private static final Long RELEASE_SUCCESS = 1L;
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    
    private static VARedis redis = VARedis.getInstance();
	
	private static long lockTimeout = 5000;
	
	private static int expireTimeout = 5000;
	
	
	/**
     * 尝试获取分布式锁并失败重试
     * 备注:主要用于解决高并发情况下接口因拿不到锁而导致的接口失败率高问题,可以延迟等待并重试,从而确保接口成功成功率。
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLockWithRetry(String lockKey, String requestId) {
    	long waitTotalTime = lockTimeout;
    	boolean getLockSuccess = false;
    	while(waitTotalTime>0){
    		if(tryGetDistributedLock(lockKey, requestId, expireTimeout)){
    			return true;
    		}
    		try {
				Thread.sleep(100);//等待重试
			} catch (InterruptedException e) {
				//不做处理
			}
			waitTotalTime = waitTotalTime - 100;
    	}
    	return getLockSuccess;
    }
	

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

        String result = redis.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(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 = redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

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

    }

}

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

  • 第一个为key,我们使用key来当锁,因为key是唯一的。

  • 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。

  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;

  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。

  • 第五个为time,与第四个参数相呼应,代表key的过期时间。

总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。

心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。

对于解锁代码,可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。

如果你的项目中Redis是多机部署的,那么可以尝试使用Redisson实现分布式锁,这是Redis官方提供的Java组件。

感谢大家的阅读,如果有对Java编程、中间件、数据库、及各种开源框架感兴趣,欢迎关注我的博客和头条号(源码帝国),博客和头条号后期将定期提供一些相关技术文章供大家一起讨论学习,谢谢。

如果觉得文章对您有帮助,欢迎给我打赏,谢谢。






猜你喜欢

转载自blog.csdn.net/andamajing/article/details/79534139