基于Redis实现分布式锁

一、背景

随着业务越来越复杂,应用服务都会朝着分布式、集群方向部署,而分布式CAP原则告诉我们,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。很多场景中,需要使用分布式事务、分布式锁等技术来保证数据最终一致性。很多时候,我们需要保证某一方法同一时刻只能被一个线程执行,否则就会出现售票超卖、多次扣款、多次充值等严重生产BUG出现。

在单机(单进程)环境中,JAVA提供了很多并发相关API,像JVM层面的加锁(synchronizedreentrantLock),但在分布式环境中就无能为力了。

对于分布式锁,最好能够满足以下几点:

  • 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行;
  • 这把锁要是一把可重入锁(避免死锁);
  • 这把锁最好是一把阻塞锁;
  • 有高可用的获取锁和释放锁功能;
  • 获取锁和释放锁的性能要好。

因此业界常用的解决方案通常是借助于一个第三方组件并利用它自身的排他性来达到多进程的互斥,如:

  • 基于 DB 的唯一索引;
  • 基于 Zookeeper 的临时有序节点;
  • 基于 Redis 的 NX EX 参数。

二、分布式锁实现思路

锁的实现主要基于redis的SETNX命令:

SETNX key value
当且仅当 key 不存在时,将 key 的值设为 value;
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
返回值:
设置成功,返回 1 。
设置失败,返回 0 。

基于Redis实现分布式锁_第1张图片
实现思路:

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

三、分布式锁实现代码

1. 错误实现

网上有很多错误实现方法,都考虑的不够全面,像如下代码:

  • 错误的加锁方法
public String lock(Jedis jedis, String lockKey, Long acquireTimeout, Long timeOut) {
    try {
        // 定义 redis 对应key 的value值(uuid) 作用 释放锁 随机生成value,根据项目情况修改
        String value = UUID.randomUUID().toString();
        // 定义在获取锁之后的超时时间
        int expireLock = (int) (timeOut / 1000);// 以秒为单位
        // 定义在获取锁之前的超时时间
        //使用循环机制 如果没有获取到锁,要在规定acquireTimeout时间 保证重复进行尝试获取锁
        // 使用循环方式重试的获取锁
        Long endTime = System.currentTimeMillis() + acquireTimeout;
        while (System.currentTimeMillis() < endTime) {
            // 获取锁
            // 使用setnx命令插入对应的redislockKey ,如果返回为1 成功获取锁
            if (jedis.setnx(lockKey, value) == 1) {
                // 设置对应key的有效期
                jedis.expire(lockKey, expireLock);
                return value;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}    

乍一看好像没什么问题,然而由于setnxexpire是两条Redis命令,不具有原子性。如果程序在执行完setnx()之后突然异常或崩溃,导致锁没有设置过期时间,那么就会发生死锁的情况。像部署微服务Jar包的某台机器挂掉了,程序无法调用解锁方法,所以设置过期时间是必须的。

  • 改进之后的加锁方法,基本上和原来的逻辑类似,只是将setnx和expire的操作合并为一步,改为使用新的set多参的方法。
public String lock(Jedis jedis, String lockKey, Long acquireTimeout, Long timeOut) {
    try {
        String value = UUID.randomUUID().toString();
        Long endTime = System.currentTimeMillis() + acquireTimeout;
        while (System.currentTimeMillis() < endTime) {
            // 获取锁
            if ("OK".equals(jedis.set(lockKey, lockKey, "NX", "PX", timeOut))) {
                return value;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    } 
    return null;
}
  • 错误的解锁方法
// 释放redis锁
public void unLock(Jedis jedis, String lockKey, String value) {
    try {
        // 如果该锁的id 等于value,是同一把锁情况才可以删除
        if (jedis.get(lockKey).equals(value)) {
            jedis.del(lockKey);
        }
    } catch (Exception e){
        e.printStackTrace();
    }
}

这里也犯了上面加锁方法一样的错误,getdel是两个命令,不具有原子性。此时就可能会出现一种情况,线程A执行完get命令后,此时它的锁正好过期,但是还没来得及执行del命令。于此同时,线程B正好又抢到了锁,线程A执行del命令就会把线程B的锁给删掉,就可能会造成生产事故。而解锁的话,没有类似像加锁那样的命令既能加锁也能设置时间,那么解锁要如何保证原子性呢?有两种方法:一种是使用Lua脚本,一种是使用Redis事务,官方和生产都推荐使用Lua脚本。

  • 改进之后的解锁方法
public void unLock(Jedis jedis, String lockKey, String value) {
    try {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return   redis.call('del', KEYS[1]) else return 0 end";
        Long result = (Long) jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(value));
        //1释放锁成功,0失败
        if (1 == result) {
            System.out.println(result+"释放锁成功");
        } 
    } catch (Exception e){
        e.printStackTrace();
    }
}   

那么为什么执行eval()方法可以确保原子性?源于Redis的特性,因为Redis是单线程,在eval命令执行Lua代码时,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。


2. 总结注意事项

  • 加锁时要设置锁过期时间,且要保证加锁操作和设置过期时间操作为原子性操作;
  • 解锁时要判断是否是自己的锁,且要保证获取锁内容操作和删除锁操作为原子性操作。

三、基于redisson实现分布式锁

上面的锁,其实还是有不少瑕疵的,像锁一般都是需要可重入的,上面的线程都是执行完锁就释放了,无法再次进入了,进去也是重新加锁了,对于一个锁的设计来说肯定不是很合理的。还有对于过期时间的设置来讲,当业务在某一时间段内执行的特别慢的话,业务没执行完锁就过期了,没有一种自动续期的机制,不够完美。所以,我们就要学习基于Redisson实现分布式锁。

Redisson是Redis官方推荐的Java版的Redis客户端,它提供的功能非常多,也非常强大,它的底层都给我们封装好了,我们只要使用即可。像Redisson的看门狗自动延期机制,有兴趣可以去了解一下。

相关Redisson文章:

  • Redis集群环境下的-RedLock(真分布式锁) 实践
  • 阿里云专访Redisson作者Rui Gu:构建开源企业级Redis客户端之路

你可能感兴趣的:(微服务,分布式锁,Redis,Redisson)