springboot使用Redis+Redisson实现分布式锁Demo&&源码分析

使用redis实现分布式锁,相对于使用数据库锁或者使用ZooKeeper,简单方便,相对可靠,是最常用的方式,本文上一个实现demo。

在写代码之前,先抛出几个常见问题,带着问题去实现代码,逻辑更清晰完整。

一. 经典问题

redis实现分布式锁,几个常见经典问题:

问题一:锁不被释放

就是说一个服务在获取到分布式锁后,在释放锁之前,由于某种原因比如服务挂掉了,导致锁一直不会被释放,那么其他服务自然也就再也拿不到锁了。针对这个问题。解决办法一般都是加锁时,同步设置锁的过期时间。

问题二:服务A释放了服务B的锁,导致问题

比如,服务A在拿到锁之后,设置过期时间1s,但是服务A由于自身某种原因,业务执行了2s才结束;那么,在锁过期后,1.5s的时候,服务B正好来拿锁,并且拿到了,然后执行B的业务1s,那么B业务还没执行结束,A结束了,然后去释放锁,这个时候释放的就是B拿到的锁。

为了避免这个问题,需要为每个服务拿锁的请求进行标记,避免分不清锁是谁的。释放锁的时候,判断此刻redis中的锁是不是自己档时获取到的。

问题三:释放锁过程要保证原子性

针对问题二,说到释放锁的时候,要进行判断是不是自己的锁,这个判断+释放的过程,必须是原子性的,否则同样会产生释放别人锁的问题。

比如,服务A解锁时刚判断锁是自己的,于是下一步就是释放锁,结果释放锁之前,锁正好过期,并且服务B刚好申请到了此锁,那么服务A接下来释放的锁,必然是服务B的。

问题四:多个服务同时获取到了锁

业务中,分布式锁的目的肯定是只希望同时只有一个服务拿到锁,不能多个服务同时拿到锁,不然就失去了锁的意义。

但是,有一种场景,比如A服务拿到了锁,由于A业务执行时间过长,在解锁之前锁早已经被释放,同时又被服务B获取到,这样实际上就是服务A和服务B都获取到了锁并且在执行业务逻辑,这是有问题的。

我们可能会想到,把锁的过期时间设置的足够长,比如1min,保证不少于服务A的业务执行时间,这样的确可以,但是这样又产生了别的问题,比如服务A挂掉了,那么其他服务就需要等1min的时间才能拿到锁,这个等待时间未免太久;

那么,过期时间到底设置多久呢,这个不好设定,只能说设置为服务A业务大多数执行的时长,比如服务A的业务大多数执行时间是200ms,那么就设置为1s,这个应该足够了,但是万一服务A某次业务由于特殊原因,执行了2s呢,还是会有上述问题。

那么,我们会想,既然服务执行时间不是那么稳定,这个锁的过期时间是否能根据业务执行时间动态变化呢?答案是肯定的,本问Demo中,我们使用守护线程来动态延长锁的过期时间。

问题五:redis服务宕机,如何保证锁正常使用

此问题是针对单机版的redis做分布式锁,如果此单机redis服务挂掉,那么redis锁将会不可用。解决方式是使用redis集群,但是,在集群环境下,我们的分布式锁的加锁策略是怎样的呢?

二. 解决方案

2.1 问题1~问题4方案

2.1.1 手写方案

对于问题1到问题4,在下面手写Demo中都有解决,并添加了注释,下面看代码。

2.1.1.1 主线程
public class RedisLockDemo {
    //随便弄个key的名字
    private static final String LOCK_KEY = "distributedLock:key";

	//主线程
    public static void main(String[] args) {
        //获取redis客户端
        RedisClient redisClient = RedisClient.getInstance();
        //开启两个工作线程,模拟分布式服务中的两个服务
        for (int i = 0; i < 1; i++) {
            startAWork(redisClient, String.valueOf(i), 10);
        }
    }

    /**
     * 开启一个工作线程,模拟分布式中的一个服务,抢分布式锁
     *
     * @param redisClient  redis客户端
     * @param threadName   线程名称
     * @param lengthOfWork 工作时长 秒
     */
    public static void startAWork(RedisClient redisClient, String threadName, int lengthOfWork) {
        new Thread(() -> {
            try {
                //生成并保存 获取分布式锁的 请求id,解决问题二
                String requestId = UUID.randomUUID().toString();
                RedisLockThreadLocalContext.getThreadLocal().set(requestId);

                //获取分布式锁,设置过期时间2s,解决问题一
                boolean result = RedisTool.tryGetDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId, 2000);

                if (result) {//如果成功获取到锁
                    //开一个守护线程延长锁的过期时间
                    Thread thread = new Thread(() -> {
                        while (true) {
                            Jedis jedis = redisClient.getJedis();
                            try {
                                TimeUnit.SECONDS.sleep(1);
                                System.out.println("守护线程延长锁的过期时间1s");
                                jedis.setex(LOCK_KEY, 1, requestId);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            } finally {
                                if (jedis != null) {
                                    jedis.close();
                                }
                            }
                        }
                    });
                    thread.setDaemon(true);
                    thread.start();

                    System.out.println("线程" + threadName + "拿到锁,干点事情");
                    //睡眠一定时间,模拟业务耗时
                    TimeUnit.SECONDS.sleep(lengthOfWork);
                } else {
                    System.out.println("线程" + threadName + "没有拿到锁");
                }
            } catch (Exception e) {
                //
            } finally {
                //释放分布式锁
                String requestId = RedisLockThreadLocalContext.getThreadLocal().get();
                boolean result = RedisTool.releaseDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId);
                if (result) {
                    System.out.println("线程" + threadName + "释放锁");
                } else {
                    System.out.println("线程" + threadName + "释放锁失败");
                }

            }
            System.out.println("线程" + threadName + "结束");
        }).start();
    }
}

主线程说明:

  • 主线程比较简单,只开启了两个工作线程,模拟抢分布式锁的过程;
  • 具体的startAWork()方法中,新建了工作线程,使用睡眠时间来模拟执行业务逻辑的耗时;
  • 在 RedisTool#tryGetDistributedLock()方法中,传入了过期时间参数,方法内容看下问代码。这个参数解决了问题一;
  • 在 RedisTool#tryGetDistributedLock()方法中,传入了requestId参数,这个是一个随机UUID,用来标识每一次加锁的线程,同时这个参数保存在了线程本地变量ThreadLocal中,解决了问题二。
  • 在开启工作线程后,代码中紧接着又开启另外一个线程,并使用thread.setDaemon(true);标识为守护线程;这个守护线程的任务就是死循环延长锁的过期时间;当业务线程执行完毕后,这个守护线程会自动销毁。注意循环的时间间隔要小于锁的过期时间,一般设置为过期时间的一半即可。
2.1.1.2 其他辅助类

添加jedis依赖包:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

使用JedisPool初始化一个Jedis客户端:

/**
 * Description:Redis客户端
 */
public class RedisClient {
    private static final Logger LOGGER = LoggerFactory.getLogger(RedisClient.class);
    private static RedisClient instance = new RedisClient();
    private JedisPool pool;

    private RedisClient() {
        init();
    }

    public static RedisClient getInstance() {
        return instance;
    }

    public Jedis getJedis() {
        return pool.getResource();
    }

    /**
     * 初始化redis连接池
     */
    private void init() {
        int maxTotal = 10;
        String ip = "redis IP";
        String pwd = "redis 密码";
        int port = 6379;

        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(maxTotal);
        jedisPoolConfig.setMaxIdle(20);
        jedisPoolConfig.setMaxWaitMillis(6000);
        pool = new JedisPool(jedisPoolConfig, ip, port, 5000, pwd);
        LOGGER.info("连接池初始化成功 ip={}, port={}, maxTotal={}", ip, port, maxTotal);
    }
}

上述代码初始化了redis连接信息,属于固定代码,没啥好解释的,继续往下看代码。

/**
 * Description:redis分布式锁访问工具类,提供具体的获取锁,释放锁方法
 */
public class RedisTool {

    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";

    /**
     * 尝试获取分布式锁
     *
     * @param jedis      Redis客户端
     * @param lockKey    锁的key
     * @param requestId  锁的Value,值是个唯一标识,用来标记加锁的线程请求;可以使用UUID.randomUUID().toString()方法生成
     * @param expireTime 过期时间 ms
     * @return 是否获取成功,成功返回true,否则false
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = null;
        try {
            result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return LOCK_SUCCESS.equals(result);
    }

    /**
     * 释放分布式锁
     *
     * @param jedis     Redis客户端
     * @param lockKey   锁
     * @param requestId 请求标识,锁的Value
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        Object result = null;
        try {
            //使用lua脚本保证原子性
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
        return RELEASE_SUCCESS.equals(result);
    }
}
  • RedisTool工具类,提供了加锁和解锁的两个方法;
  • tryGetDistributedLock()加锁方法设置了过期时间,解决了问题一;
  • releaseDistributedLock()解锁方法中使用了lua脚本,具备原子性,解锁时先判断key的value值,也就是当初加锁保存的requestId是不是和自己线程保存的一致,一致才说明是自己当初加的锁,方可进行解锁;不一致说明自己加锁已经自动过期,无需解锁;这个解决了问题二和问题三。
/**
 * Description:保存redis分布式锁的请求id
 */
public class RedisLockThreadLocalContext {

    private static ThreadLocal<String> threadLocal = new NamedThreadLocal<>("REDIS-LOCK-LOCAL-CONTEXT");

    public static ThreadLocal<String> getThreadLocal() {
        return threadLocal;
    }
}

上述RedisLockThreadLocalContext中创建了一个threadLocal单例,用于保存加锁时设置的requestId。当然在使用线程池时,get完数据要注意清除里面的保存信息,这里就不写那么详细了。

2.1.2 redisson方案

对于问题1到问题4,上面手写的方案,实际Redisson框架已经帮我们实现了,只需要简单的几行代码。

Redisson实现了可重入锁,公平锁等各种java中定义的锁类型,相关资料可参考官方文档:https://github.com/redisson/redisson/wiki/目录

2.1.2.1 redisson原生方式

依赖包:

 <!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
     <groupId>org.redisson</groupId>
     <artifactId>redisson</artifactId>
     <version>3.12.0</version>
 </dependency>

客户端配置:

 @Bean
 public Redisson redisson() {
      Config config = new Config();
      config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
      return (Redisson) Redisson.create(config);
  }

使用:

 public void test() {
    RLock lock = redisson.getLock("keykeykey");
    try {
        boolean b = lock.tryLock(30, TimeUnit.SECONDS);
        if(b){
            //执行业务
        }
        //
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}
2.1.2.2 springboot starter方式

依赖:

 <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.10.6</version>
</dependency>
 <dependency>
      <groupId>org.redisson</groupId>
      <artifactId>redisson</artifactId>
      <version>3.10.6</version>
  </dependency>

使用:
使用redisson-spring-boot-starter更简单,上面的@Bean Redisson 都不用配置,可直接在业务代码中注入RedissonClient:

@Autowired
private RedissonClient redissonClient;

RedissonClient是个接口,它的实现类就是Redisson,因此使用RedissonClient就是使用Redisson:
在这里插入图片描述

 public void test() {
    RLock lock = redissonClient.getLock("keykeykey");
    try {
        boolean b = lock.tryLock(30, TimeUnit.SECONDS);
        if(b){
            //执行业务
        }
        //
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}
2.1.2.3 方法说明

上面两种方式得到的锁,都是RLock 类型,实现类是RedissonLock:

 RLock lock = redissonClient.getLock("keykeykey");
 或者:
 RLock lock = redisson.getLock("keykeykey");

getLock源码如下:
在这里插入图片描述
下面针对RedissonLock中的常用方法,进行一些说明:


public void lock();
public void lock(long leaseTime, TimeUnit unit);
public boolean tryLock();
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException 
    
  1. lock(long leaseTime, TimeUnit unit):阻塞获取锁,拿到锁之前,线程处于阻塞等待状态,拿到锁后,设置leaseTime:
    • leaseTime:持有锁的时间,也就是设置的redis key 过期时间,超过此时间没有主动释放锁的话,会被redis释放;
    • unit :单位;
  2. lock() :阻塞获取锁,拿到锁之前,线程处于阻塞等待状态;拿到锁之后,没有设置过期时间,除非主动释放锁,否则锁不会被释放;
  3. tryLock(long waitTime, long leaseTime, TimeUnit unit):尝试获取锁,最长等待waitTime,等待内拿到锁返回true,否则false;
    • waitTime :阻塞等待获取锁的时间,超过此时间,则不继续等待;
    • leaseTime :拿到锁后,设置redis key的过期时间;
    • unit :时间单位;
  4. tryLock(long waitTime, TimeUnit unit):与第3个一样,不同指出是此方法设置的leaseTime=-1,也就是在线程存活期间,redis key 默认不会被redis释放;
  5. tryLock() :锁可用,就立马返回ture,否则立马返回false;
2.1.2.4 leaseTime特殊说明

上文中提到加锁的参数leaseTime,这里再对其进行进一步阐述,leaseTime的含义是持有锁的时间。

本文一开头提到的第4个问题,Redisson已经帮我们解决,就是子线程会对key的过期时间进行续期,那么是否续期不是必然的,而是通过leaseTime参数控制。

下面我们从lock(long leaseTime, TimeUnit unit)方法作为加锁入口,分析下leaseTime参数的具体作用:
在这里插入图片描述
先看下官方对leaseTime的解释:

* @param leaseTime the maximum time to hold the lock after granting it,
*        before automatically releasing it if it hasn't already been released by invoking <code>unlock</code>.
*        If leaseTime is -1, hold the lock until explicitly unlocked.

其中提到,If leaseTime is -1,如果leaseTime=-1,则锁会被一直持有,直到主动unlock,那么锁是如何一直被持有的?难道真的没有设置锁的过期时间吗?

从上图的调用栈,进入到第2步骤:

  private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, long threadId) {
  //如果leaseTime不是-1,比如设置了30s,那么redis key的过期时间就是30秒
     if (leaseTime != -1) {
          return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
      }
      // 如果leaseTime 等于 -1,代码继续
      //注意tryLockInnerAsync第一个参数,跟进去发现值是lockWatchdogTimeout:
      //private long lockWatchdogTimeout = 30 * 1000;
      //也即是不设置过期时间,默认也是加了过期时间的,默认是30s
      RFuture<Boolean> ttlRemainingFuture = 
      tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
      ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
          if (e != null) {
              return;
          }

          // lock acquired
          if (ttlRemaining) {
          //这里进行锁过期时间续期
              scheduleExpirationRenewal(threadId);
          }
      });
      return ttlRemainingFuture;
  }

直接看代码上的注释,
也就是不设置过期时间,默认也是加了过期时间的,默认是30s。然后通过 scheduleExpirationRenewal(threadId);方法进行锁过期时间的续期:

 private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    //需要续期的key和线程信息,放到map中,后面有单独的线程从此map中获取并进行续期
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId);
    } else {
        entry.addThreadId(threadId);
        //执行续期
        renewExpiration();
    }
}

点renewExpiration()进去跟进:

//更新过期时间
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }
    
    //使用定时任务进行更新,这里是一个新的线程,使用的是netty的定时工具HashedWheelTimer
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            
            //执行续期动作
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                    return;
                }
                
                if (res) {
                    // reschedule itself
                    renewExpiration();
                }
            });
        }
        //定时任务执行时间internalLockLeaseTime / 3,internalLockLeaseTime默认是30s
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}

renewExpirationAsync(threadId);执行了续期动作,跟进去:

protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                "return 1; " +
            "end; " +
            "return 0;",
        Collections.<Object>singletonList(getName()), 
        //ARGV[1])是internalLockLeaseTime=30s
        internalLockLeaseTime, getLockName(threadId));
}

总结:设置releaseTime=-1时,Redisson并不是不设置锁的持有时间,而是默认设置了30s,然后通过netty的定时任务每10s就去进行续期,续期长度是30s。 当然,如果拿到锁的主线程挂了,挂了分两种情况:

  • 线程抛出异常:这种情况,我们会在try finaly中进行解锁处理;
  • 整个机器挂了:那么续期任务的子线程自然也没了,也就不会对锁进行续期,锁等30s也就被redis释放了,不会产生锁不被释放的问题。

2.2 问题5方案

对于宕机的问题,redis作者已经给出了方案,那就是RedLock算法,原理是对redis集群的每个节点都加锁,然后判断超过半数的节点返回true,表示加锁成功。Redisson框架实现了RedLock算法,具体使用如下。

Demo:

RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();

关键是使用了红锁:RedissonRedLock

2.3 redisson分布式锁失效场景

1.应用运行过程中发生了fullgc,导致系统长时间停滞,redisson锁守护线程无法自动进行锁的续期,导致锁过期被释放。
这种目前没有好的方案,只能从业务上来规避,或者建立完善的告警机制,及时发现问题。

可参考文章:https://www.jianshu.com/p/dd66bdd18a56

以上就是本文全部内容,特别要注意本文开头的那几个问题。

  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值