[Redis]Redis实现分布式锁

分布式锁

为了防止分布式系统中的多个线程之间相互干扰,需要一种分布式协调技术来对这些进程进行调度,这个技术的核心就是分布式锁。比如在如下场景中,就需要用到分布式锁,现有某个服务有ABC三个实例,部署在三台服务器上,成员变量var在三个实例中都存在,此时三个请求经过nginx同时对var操作,显然结果不是对的,而倘若不同时对A进行操作,而A是不共享的,也不具有可见性,所以处理的结果也是不对的。这时候就需要分布式锁了。

分布式锁的条件

  • 分布式环境下,一个方法同一时间只能有一个机器的线程执行。
  • 高可用地获取和释放
  • 高性能地获取和释放
  • 可重入
  • 具有锁失效机制,防止死锁
  • 具有非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

用Redis实现分布式锁

加锁

加锁过程简单拆分为两步,第一步是检查key是否存在,也就是检查目前有没有别的客户端已经上锁了,如果有人上锁了,那就直接返回失败。第二步就是如果没有上锁,也就是key不存在,那么就设置key,那么就设置一个过期时间,之所以设置过期时间,是因为万一客户端发生意外没有来解锁,redis也可以自己来解,代码比较简单。

   /**
     * 尝试获取分布式锁
     * @param lockKey 键
     * @param reqId 值,此处设置为requestID可以在解锁的时候有依据知道是哪个请求加的锁
     * @param expire 过期时间
     * */
    public static boolean tryGetLock(Jedis jedis,String lockKey,String reqId,int expire){
        //SET_IF_NOT_EXIST 不存在的时候设置,存在的时候不操作
        //SET_WITH_EXPIRE_TIME 设置过期时间
        String result = jedis.set(lockKey,
                reqId,
                SET_IF_NOT_EXIST,
                SET_WITH_EXPIRE_TIME,
                expire);
        if(LOCK_SUCCESS.equals(result)){
            return true;
        }
        return false;
    }

那么问题来了,如果将这两个步骤分开可以不呢,例如像下面这样:

    public static boolean tryGetLock(Jedis jedis,String lockKey,String reqId,int expire){
        long result = jedis.setnx(lockKey,reqId);
        if(result==1){
        	jedis.expire(lockKey,expire);
        }
    }

自然是不行的,因为这段代码不能保证原子性,倘若某一个客户端执行完setnx之后就因为某些原因没有继续往下面执行了,那么这个key就一直设置在这里而不能自动过期,这个时候就没有别的客户端能够获取到这个锁了。

第二个问题是我在网上看到的,我初一看还没想明白为什么是错误的加锁代码:

代码来源

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {

    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);
    // 如果当前锁不存在,返回加锁成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }

    // 如果锁存在,获取锁的过期时间
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        }
    }
      
    // 其他情况,一律返回加锁失败
    return false;
}

这段问题在于,1,使用System.currentTimeMillis()这个系统函数,那么就可能存在多个客户端上时间并不一致的问题。2,多线程的情况下,设置过期时间还是不线程安全。3,俗话说,解铃还须系铃人,这段代码缺少一个能表示客户端的值来用作key的值。所以无论是哪个客户端都能来解锁。

释放锁(lua脚本)

看过不少网上博客的会发现在使用Redis实现分布式锁的时候使用了lua脚本,那么为什么需要lua脚本呢,难道就不能手动通过Redis的原生API实现么,如果用了会有什么问题呢,首先看lua脚本是怎么写的。

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

这段代码作用挺好理解,就是获取锁对应的值,如果和传来的ARGV[1]即RequestID相等,那么就删除,这是解锁中用到的,现在抛开这个lua脚本不谈,用jedis的api来解锁。

第一种:

public static void releaseLock(Jedis jedis,String key){
	jedis.del(key);
}

第二种:

public static void releaseLock(Jedis jedis,String key,String reqId){
	if(jedis.get(key).equals(reqId)){
		jedis.del(key);
	}
}

第一种的问题在于,当一个线程到达解锁方法的时候,没有判别是否这个线程就是给这个key上锁的线程,也就是锁不认主人了,谁都能解开。当然在某些场景下是允许这样的,但是在分布式锁中这样自然不行。而第二个问题看似滴水不漏实际上这是两条命令,而两个命令无法保证原子性,也就是说,判断if之后执行del之前,中间的是有可能其他线程再上锁的,但是这个时候被当前线程解锁了。说到这里,lua脚本的作用自然就出来了,请出官方对于jedis,eval()的解释:

Redis.eval()

然后中间有一段这个:

Atomicity of scripts
Redis uses the same Lua interpreter to run all the commands. Also Redis guarantees that a script is executed in an atomic way: no other script or Redis command will be executed while a script is being executed. This semantic is similar to the one of MULTI / EXEC. From the point of view of all the other clients the effects of a script are either still not visible or already completed.
......

Atomicity,也就是说,使用lua脚本,能够保证脚本的执行具有原子性,不会因为多个进程竞争而被细分为更小的过程。

实测

写一个简单的Controller,然后用JMeter来模拟多个客户端进行请求的情景。

@Slf4j
@RestController
public class RedisController {
    

    @Autowired
    private JedisPool jedisPool;

    @GetMapping("/lock")
    public String lock(){

        String reqId = UUID.randomUUID().toString();

        boolean locked = false;

        Jedis jedis = jedisPool.getResource();

        try {
            locked = RedisUtil.tryGetLock(jedis,"lock1",reqId,2000);
            if (locked){
                log.info(reqId+"获取成功
");
            }else{

                log.info(reqId+"获取失败
");
            }
            Thread.sleep(1000);
        }catch (Exception e){
            e.printStackTrace();
            //回收jedis实例,不回收jedis实例会pool中的jedis资源越来越少,从而导致获取不到可以用的jedis实例,报异常。
            if(jedis != null ) {
                jedisPool.returnResource(jedis);
            }
        }finally {
            if(locked) {
                boolean released = RedisUtil.releaseLock(jedis, "lock1",reqId);
                if(released){
                    log.info(reqId+"释放成功
");
                }else{
                    log.info(reqId+"释放失败
");
                }
            }
            if(jedis != null ) {
                jedisPool.returnResource(jedis);
            }
        }
        return "ok";

    }

}

逻辑很简单,设置key,这里设置过期时间为两秒,实际上这里也可以通过请求传入过期时间,这样每个客户端都可以设置自己的时间,然后用sleep(1000)来模拟业务处理。最后释放锁。

  • JMeter测试

用JMeter开启一个线程组,设置五个线程,循环五次,然后用http请求去访问这个接口。

avatar

avatar

  • 测试结果

上面五次中,每一个都应该是一个线程获取成功,其他四个获取失败。下面查看一下控制台,截取两次的日志进行查看,可以发现符合预期。

2020-10-25 13:32:39.274  INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController         : a230e2cb-c87c-4abf-a17e-19ea8d3affc5获取成功

2020-10-25 13:32:39.274  INFO 13012 --- [nio-9001-exec-1] c.i.r.controller.RedisController         : 77ce1001-9621-42bb-bd2a-15bce0f83e25获取失败

2020-10-25 13:32:39.684  INFO 13012 --- [nio-9001-exec-3] c.i.r.controller.RedisController         : d9a3d30b-8aa1-4481-b82e-d1a3b347267e获取失败

2020-10-25 13:32:39.684  INFO 13012 --- [nio-9001-exec-4] c.i.r.controller.RedisController         : 48709ff6-5b84-42ca-8b00-83ee64ef756a获取失败

2020-10-25 13:32:39.783  INFO 13012 --- [nio-9001-exec-5] c.i.r.controller.RedisController         : e54a38a1-3c3f-4e3b-99a9-6766b04bafa5获取失败

2020-10-25 13:32:40.296  INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController         : a230e2cb-c87c-4abf-a17e-19ea8d3affc5释放成功

2020-10-25 13:32:40.393  INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController         : 740adf9e-bb28-445f-82e4-cf7e8f1bda99获取成功

2020-10-25 13:32:40.394  INFO 13012 --- [nio-9001-exec-1] c.i.r.controller.RedisController         : f5bbe368-e016-4fed-ba05-32700e908404获取失败

2020-10-25 13:32:40.698  INFO 13012 --- [nio-9001-exec-4] c.i.r.controller.RedisController         : cf393325-868f-4137-806e-c52f6d5e2968获取失败

2020-10-25 13:32:40.698  INFO 13012 --- [nio-9001-exec-3] c.i.r.controller.RedisController         : d914d620-2ce6-46df-a002-c92b546978c1获取失败

2020-10-25 13:32:40.792  INFO 13012 --- [nio-9001-exec-6] c.i.r.controller.RedisController         : 7356a83c-30a8-492f-b7a0-179d4ecd27a2获取失败

2020-10-25 13:32:41.396  INFO 13012 --- [nio-9001-exec-2] c.i.r.controller.RedisController         : 740adf9e-bb28-445f-82e4-cf7e8f1bda99释放成功
原文地址:https://www.cnblogs.com/Yintianhao/p/13873176.html