分布式锁实践指南:Redis篇

目前越来越多的应用使用负载均衡,以往传统单体应用单机部署的情况下使用的JAVA并发处理资源竞争方式(J.U.C或synchronized等)在集群部署中已经无法保证资源的安全访问。


为什么需要分布式锁

需要考虑以下情况——

  • 只允许一个客户端操作共享资源:这种情况下,对共享资源的操作一般是非幂等性操作。在这种情况下,如果出现多个客户端操作共享资源,就可能意味着数据不一致,数据丢失。
  • 允许多个客户端操作共享资源:对共享资源的操作一定是幂等性操作,无论你操作多少次都不会出现不同结果。在这里使用锁,无外乎就是为了避免重复操作共享资源从而提高效率。

为了解决分布式应用中对资源的安全访问于是便有了分布式锁。

实现分布式锁一般基于Zookeeper或者Redis,前者可靠性高而后者效率高

如果并发量不大追求可靠性选择Zookeeper,反之选择Redis。


Redis实现分布式锁

分布式锁一大特点就是排他性,临界条件下仅有获得锁的调用者才能访问资源。

而Redis是基于内存设计K-V数据库且单线程执行命令。

因此使用Redis作为分布式锁的中间件满足两个重要条件——

  • 基于内部:加锁/解锁效率高
  • 单线程:请求先后顺序执行没有并发冲突,所以任意时刻只有一个调用者能成功获取锁。

而且 Redis支持集群部署(sentinel, cluster)保障了可用性。


加锁

使用SETNX()便可以完成加锁操作。SETNX表示如果Key不存在则写入,如果存在则什么都不做。

setnx key value

这样便只允许一个调用者可以访问。等等似乎少了什么?没错我们没有设置KEY的过期时间,如果此时调用者宕机这把锁就无法释放了。

我们使用EXPIRE加上过期时间,默认单位为秒。

EXPIRE key seconds

但是这样做就完了吗?SETNXEXPIRE是两个独立的操作,即加锁操作不具备原子性。假如加锁调用成功,但是设置过期时间的时候调用服务宕机,依然存在锁无法正常释放的问题。

于是我们还需要把这两条命令合为一条命令。

SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
  • expiration:过期时间,EX表示秒,PX表示毫秒
  • NX:表示如果不存在则写入(显然我们需要这条语句)
  • XX:表示如果存在则写入

目前终于算是完成了原子加锁命令并且锁存在过期时间即使调用者宕机到期之后也会自动释放。


解锁

使用DEL便可以删除锁。

DEL key [key ...]

不过事情真的有这么简单吗?当然不会(套路啊)

  • 假设调用者A加锁成功并且设置锁超时时间为30秒。但是因为某些原因导致调用者A处理业务逻辑所花费的时间超过了30秒。
  • 锁超时自动释放之后调用者B刚好加锁成功,这时调用者A使用DEL语句释放了调用者B的锁。
  • 因为锁被调用者A释放导致后续的调用者可以参与锁竞争,例如调用者C获取到锁。
  • 从而发送在同一个时间内调用者B与调用者C同时运行业务逻辑从而破坏了资源的安全访问。

别绕晕了画张图更直观的感受下——

从图中可以发现A、B有并行,B、C有并行不符合我们的要求。

那么怎样才能让调用者只能释放自己占用的锁呢?这个时候K-V中的V就发挥作用了。如果我们的Value都是唯一(例如:UUID)的并且调用者释放锁需要验证Value便可以避免释放其他调用者占用的锁资源。

SET key uuid EX times NX

那么在释放锁的时候就跟需要分三步——

  1. 根据KEY获取锁
  2. 对比VALUE与预期值是否一致
  3. 如果一致则释放(删除)锁

可是这三个步骤是独立非原子操作,如果在2、3步骤之间锁因为超时自动释放且在高并发情况下别其他调用者加锁成功,在执行步骤3时则仍然存在删除其他调用者的锁。

因此我们仍然需要原子操作。遗憾的是Redis的命令并不提供该操作。

别慌Redis虽然没有提供,但是Redis支持LUA脚本,LUA脚本可以帮助原子性的完成这一系列复合操作。

以下为Redis官方提供脚本——

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

这个脚本即使你不会LUA大概也猜得到是什么意思吧。

  1. 传入的键名参数调用get操作获取到的值与附加参数比较
  2. 如果值一致则调用del操作(删除操作成功返回1)
  3. 如果值不一致则返回0

执行脚本如下——

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

现在释放锁的原子操作也满足了,让我们看看代码实践。


代码示例

笔者这里使用springboot来演示分布式锁。

因为分布式锁的实现可以是多种方式建议抽象一个接口,例如DistributedLock

如果使用Redis作为分布式锁中间件则创建RedisDistributedLock

如果使用Zookeeper作为分布式锁的中间件则创建ZookeeperDistributedLock

可以根据@ConditionalOnBean以及spring优先级选择加载那种分布式锁。

  • 分布式锁配置

    spring:
      redis:
        lettuce:
          pool:
            max-active: 100
            max-idle: 50
            min-idle: 30
            max-wait: 2000ms
        # 哨兵模式
        sentinel:
          master: mymaster
          nodes: ${ARGS_REDIS_NODES}
        # 集群模式
        # cluster:
          # nodes: ${ARGS_REDIS_NODES}
        password: ${ARGS_REDIS_PASSWORD}
    
    
    # 分布式配置
    distributed:
      lock:
        expire: 30000
        # connection-timeout-ms: 15000
        # session-timeout-ms: 60000
    
  • 分布式锁接口

    public interface DistributedLock {
    
        /**
         * 获取锁
         *
         * @param key
         * @param value
         * @return
         */
        public boolean tryLock(String key, String value);
    
        /**
         * 释放锁
         *
         * @param key
         * @param value
         * @return
         */
        public boolean releaseLock(String key, String value);
    
    }
    
  • 分布式锁实现

    /**
     * 分布式锁:使用Redis实现
     */
    @AutoConfigureAfter({RedisAutoConfiguration.class})
    @ConditionalOnBean(RedisAutoConfiguration.class)
    @Service
    @Slf4j
    public class RedisDistributedLock implements DistributedLock {
    
        private final static String OK = "OK";
    
        private static final String UNLOCK_LUA;
    
        /**
         * Lua脚本来释放锁
         */
        static {
            StringBuilder sb = new StringBuilder();
            sb.append("if redis.call('get', KEYS[1]) == ARGV[1] ");
            sb.append("then ");
            sb.append("    return redis.call('del', KEYS[1]) ");
            sb.append("else ");
            sb.append("    return 0 ");
            sb.append("end");
            UNLOCK_LUA = sb.toString();
        }
    
        /**
         * 缺省超时时间
         */
        @Value("${distributed.lock.expire}")
        private long expire;
    
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public boolean tryLock(String key, String value) {
            return tryLock(key, value, TimeUnit.MILLISECONDS, expire);
        }
    
        @Override
        public boolean releaseLock(String key, String value) {
            try {
                return stringRedisTemplate.execute(
                  (RedisCallback<Boolean>) connection -> connection.eval(
                        UNLOCK_LUA.getBytes(), ReturnType.BOOLEAN, 1,
                        key.getBytes(Charset.forName("UTF-8")),
                        value.getBytes(Charset.forName("UTF-8"))));
            } catch (Exception ex) {
                log.error("释放redis锁失败", ex);
            }
            return false;
    
        }
    
        /**
         * 获取锁
         *
         * @param key
         * @param value
         * @param timeUnit
         * @param time
         * @return
         */
        private boolean tryLock(String key, String value, 
                                TimeUnit timeUnit, long time) {
            try {
                return stringRedisTemplate.execute(
                  (RedisCallback<Boolean>) connection -> connection.set(
                        key.getBytes(Charset.forName("UTF-8")),
                        value.getBytes(Charset.forName("UTF-8")),
                        Expiration.milliseconds(timeUnit.toMillis(time)),
                        RedisStringCommands.SetOption.SET_IF_ABSENT)
                );
            } catch (Exception ex) {
                log.error("获取redis锁失败", ex);
            }
            return false;
        }
    
    }
    
  • 调用示例

    boolean lock = distributedLock.tryLock(LOCK_NAME, lockValue);
    try {
      if (!lock) {
        log.info("已存在分布式锁:[{}]", LOCK_NAME);
        return;
      }
    } finally {
      if (lock) {
        distributedLock.releaseLock(LOCK_NAME, lockValue);
      }
    }
    

以上就是对分布式锁的代码演示。


分布式锁的疑问

Redis实现分布式锁还有哪些问题呢?

  • 自动续期

    当业务逻辑处理时间超过锁的过期时间需要有监控线程在过期之前进续期。你可以将其称之为Monitor或者Watchdog

  • 可重入

    分布式锁能够支持一个线程对资源的重复加锁吗?

  • 读写锁

    所谓读写锁即:读读不互斥,读写互斥,写写互斥。例如:ReentrantReadWriteLock。能否想使用J.U.C包下面的类一样使用分布式锁呢?

  • 集群

    Redis集群环境是一个复杂的逻辑结构,节点的上线下线、主从复制、从节点的提升导致的数据丢失考虑了吗?

哈哈,是不是感觉头都大了。


好在Redis官方给我们指了一条明路。这就是接下来笔者要介绍的内容了。

Redisson介绍

Redisson正式笔者要介绍的Redis分布式锁终极解决方案。什么你没听说过?没关系跟着笔者进入新世界的大门。


什么是Redisson?

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

关于Redisson项目的详细介绍可以在官方网站找到。每个Redis服务实例都能管理多达1TB的内存。

能够完美的在云计算环境里使用,并且支持AWS ElastiCache主备版AWS ElastiCache集群版Azure Redis Cache阿里云(Aliyun)的云数据库Redis版

一言以蔽之可以像使用本地J.U.C一样使用Redis分布式锁。


Redisson示例

  • 新增依赖支持

    <dependency>
      <groupId>org.redisson</groupId>
      <artifactId>redisson</artifactId>
      <version>3.13.2</version>
    </dependency>
    
  • YAML文件配置

    redisson:
      client:
        sentinel-address:
          - redis://${redis.senntinel.node1}
          - redis://${redis.senntinel.node2}
          - redis://${redis.senntinel.node3}
        master-name: mymaster
        database: 0
        password: ${redis.password}
    
  • 新增RedissonConfig配置类

    @Configuration
    @ConfigurationProperties(prefix = "redisson.client")
    @Getter
    @Setter
    public class RedissonConfig {
      
      private String masterName;
      
      private int database;
      
      private String password;
      
      private String[] sentinelAddress;
      
      @Bean(destroyMethod = "shutdown")
      public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSentinelServers()
          .setMasterName(masterName)
          .setPassword(password)
          .setDatabase(database)
          .setCheckSentinelsList(false)
          .addSentinelAddress(sentinelAddress);
        return Redisson.create(config);
      }
    
    }
    
  • 测试方法

    @GetMapping(value = "/api/lock")
    public String testRedisson() throws InterruptedException {
      // 锁对象
      RLock rLock = redissonClient.getLock("lock");
      // rLock.tryLock();
      // 阻塞式获取锁
      rLock.lock();
      try {
        log.info("the lock for {}-{}", Thread.currentThread().getName(), Thread.currentThread().getId());
        // 超过默认的30秒锁过期时间
        Thread.sleep(1000 * 120);
      } finally {
        rLock.unlock();
        log.info("release the lock for {}-{}", Thread.currentThread().getName(), 
                 Thread.currentThread().getId());
      }
      return "";
    }
    

    验证结果如下所示——

    由此可以Redisson帮我们自动续期。抓取一下Redis的请求信息看看——

    "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:97"
    
    "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
    

Redisson代码

观察代码发现使用LUA脚本续期,Redisson中大量使用LUA脚本保证其命令具有原子性。

1594739476.909029 [0 101.88.95.75:25242] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:97"
1594739476.909135 [0 lua] "exists" "lock"
1594739476.909158 [0 lua] "hincrby" "lock" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:97" "1"
1594739476.909176 [0 lua] "pexpire" "lock" "30000"
1594739476.909201 [0 101.88.95.75:25242] "WAIT" "2" "1000"
1594739477.166176 [0 10.10.10.101:36452] "PING"
1594739477.452893 [0 10.10.10.101:36468] "PING"
1594739477.619219 [0 10.10.10.101:36468] "PUBLISH" "__sentinel__:hello" "172.17.0.3,26380,2d8a2463c5242b26b0fc58a07b444ed28fca45c6,0,mymaster,10.10.10.101,6379,0"
1594739477.766886 [0 10.10.10.101:36484] "PING"
1594739477.870853 [0 10.10.10.101:36484] "PUBLISH" "__sentinel__:hello" "172.17.0.3,26381,037ccf65d8178a1aaac14bbb40fc28b2cebd4eac,0,mymaster,10.10.10.101,6379,0"
1594739478.017627 [0 101.88.95.75:25247] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
1594739478.017727 [0 lua] "exists" "lock"
1594739478.017738 [0 lua] "hexists" "lock" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
1594739478.017747 [0 lua] "pttl" "lock"
1594739478.017758 [0 101.88.95.75:25247] "WAIT" "2" "1000"
1594739478.047182 [0 101.88.95.75:25263] "SUBSCRIBE" "redisson_lock__channel:{lock}"
1594739478.065068 [0 101.88.95.75:25251] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
1594739478.065226 [0 lua] "exists" "lock"
1594739478.065241 [0 lua] "hexists" "lock" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
1594739478.065256 [0 lua] "pttl" "lock"
1594739478.065270 [0 101.88.95.75:25251] "WAIT" "2" "1000"

MONITOR日志中也证实了Redisson操作使用了大量的LUA脚本。

Redisson的锁集成了J.U.C下的Lock接口,提供分布式的重入锁读写锁等等,读者完全可以放心大胆的使用Redisson实现分布式锁。

Redisson的时候场景非常多,由于篇幅现在这里笔者就点到为止。建议去Redisson官网去进一步学习。


尾声

一个合格的Redis分布式锁考虑到多少问题。在实际使用中还需要考虑到KEY的设计以及锁的粒度问题。粒度越低影响越少,只锁定访问共享数据的代码尽可能的降低锁的粒度。

Redis分布式锁就介绍到这里,看到这里的你是否有所收获呢?

猜你喜欢

转载自blog.csdn.net/emperor220/article/details/107547733