Springboot RedisTemplate 分布式锁

  • 引言
  • 测试环境
  • RedisTemplate.execute
  • RedisTemplate.executePipelined
  • 实现锁
  • 测试锁
  • 锁可能失效的原因


引言

其中有一些幂等性的细节。

测试环境

  • springboot 2.2.6

RedisTemplate.execute

execute 有很多参数
常用的有execute(RedisCallback)execute(SessionCallback) 通常都是使用SessionCallback,因为封装的更好用,不需要自己转byteexecute中,你可以在回调中随意获取值,这和executePipelined有很大区别。

看看两者的区别

redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                  operations.watch("监控key");
                  if ("蛇皮".equals(operations.opsForValue().get("监控key"))){
                      operations.multi();
                      operations.delete("监控key");
                      List<Object> exec = operations.exec();
                      //这里就可以看到结果
                      System.out.println(exec);
                      return exec;
                  }
                  operations.unwatch();
                  return null;
            }
        });

RedisTemplate.executePipelined

它和execute差不多,但是使用了Pipeline,也就是说传入的SessionCallback,里面exec的代码你是无法真实的看到返回内容,有点绕口,并且你不能在回调中返回非null的值,它会被管道覆盖,看个例子:

redisTemplate.executePipelined(new SessionCallback<Object>() {
          @Override
          public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                 operations.multi();
                 operations.delete("删掉这个");
                 List<Object> exec = operations.exec();
                 //这里你无法看到任何内容
                 //事实上 executePipelined中,不让你返回除了null的任何东西
                 System.out.println(exec);
                 //如果这里需要判断exec.get(0) xxxx是不行的
                 return null;//这里你不返回null会出错,你不能返回exec
            }
      });
  • 正确的方式
List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() {
          @Override
          public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
                 operations.multi();
                 operations.delete("删掉这个");
                 operations.exec();
                 return null;
            }
      });
      //在这里可以使用results了
      System.out.println(results);

executePipelined 只适合串行操作,并且中途不需要判断,比如批量的set操作,或者get的值,你最后拿出来只是读,并不需要它在中途去操作事务。

实现锁

  • 清楚了上面了executePipelinedexecute 后,为了保证幂等性,锁的实现我们就需要使用到excute

一个简单的例子

@Component
public class MyLock {
    @Autowired
    private RedisTemplate<String, Object> objectRedisTemplate;
    public String acquire(String lockName, Duration expire){
        String identifier = UUID.randomUUID().toString();
        //尝试获取锁的等待时间,如果需要,这里可以改为while(true),看实际情况
        long timeOut = System.currentTimeMillis() + 5000;
        while(timeOut > System.currentTimeMillis()) {
            //这里使用了setIfAbsent,其实就是SET key value [EX seconds] [PX milliseconds] NX
            Boolean setBoolean = objectRedisTemplate.opsForValue().setIfAbsent(lockName, identifier, expire);
            if (setBoolean)
                return identifier;
        }
        return null;
    }

    public String tryLock(String lockName, Duration expire){
        String identifier = UUID.randomUUID().toString();
        int retryLimit = 0;
        long delay = 10;
        while(retryLimit<10) {
            try {
                //每次多尝试一次,就增加延迟时间
                Thread.sleep(delay << retryLimit);
            } catch (InterruptedException e) {
                //nothing to do
            }
            
			retryLimit++;
            Boolean setBoolean = objectRedisTemplate.opsForValue().setIfAbsent(lockName, identifier, expire);
            if (setBoolean)
                return identifier;
        }
        return null;
    }

   public Object release(String lockName, String identifier){
        assert lockName!=null && identifier!=null;
        return objectRedisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
              List<Object> results = null;
              //如果在watch中被改,results是个长度为0的list,并不是null
              //result为null的情况是锁刚好自动到期,并被其他线程获取到了锁
              //标识符不同了,没有执行到operations.multi();我们unwatch返回就行了
              while(results==null||results.size()==0){
                  operations.watch(lockName);
                  if (identifier.equals(operations.opsForValue().get(lockName))){
                      operations.multi();
                      operations.delete(lockName);
                      results = operations.exec();
                  }else{
                      operations.unwatch();
                      return null;
                  }
              }
              return results;
            }
        });
    }
}

测试锁

@Test
    public void testLock() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(3);
        
        new Thread(()->{
            String identifier = myLock.acquire("Lock007", Duration.ofSeconds(5));
            if (StringUtils.isNotBlank(identifier)){
                System.out.println(Thread.currentThread().getName()+"  拿到了锁");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                myLock.release("Lock007",identifier);
            }
            else
                System.out.println(Thread.currentThread().getName()+"  没拿到锁");

            latch.countDown();
        },"一号线程").start();

        new Thread(()->{
            String identifier = myLock.acquire("Lock007", Duration.ofMillis(5500));
            if (StringUtils.isNotBlank(identifier)){
                System.out.println(Thread.currentThread().getName()+"  拿到了锁");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                myLock.release("Lock007",identifier);
            }
            else
                System.out.println(Thread.currentThread().getName()+"  没拿到锁");

            latch.countDown();
        },"二号线程").start();

        new Thread(()->{
            String identifier = myLock.acquire("Lock007", Duration.ofMillis(5500));
            if (StringUtils.isNotBlank(identifier)){
                System.out.println(Thread.currentThread().getName()+"  拿到了锁");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                myLock.release("Lock007",identifier);
            }
            else
                System.out.println(Thread.currentThread().getName()+"  没拿到锁");

            latch.countDown();
        },"三号线程").start();

       
        latch.await();
    }
  • 细说一下 在watch中,如果被监视的key被篡改,那么exec一定会返回一个长度为0的list,并不是一个null,注意这点。我看好多文章都写的是个null,事实上并不是
  1. 在watch中,无论如何都会返回list,不会是个null,只是如果list中没有元素,则表明中途被人篡改。
  2. 在watch中 如果自动到期,那么执行delete后exec,返回值list一定包含0(del 返回0 表示删除失败),不影响事务。
  3. 在watch中 如果另外一个线程手动删除key, 那么返回的一定是个[](长度为0的list),而不是返回删除失败。
  4. 自动到期和人为删除watch的表现是不同的。

锁可能失效的原因

当对redis集群使用上面的获取锁代码后,如果从服务器同步数据过程中主服务器挂了,此时从服务器变为master但从服务器会丢失这个锁的KEY其他worker当前worker没有释放锁的情况下,仍然可以获取到一把锁,这类问题解决 要么用rediswait命令,要么读写分离后,setnx到锁后用get再读一次,直到从服务器有这条数据为止。