基于redis实现分布式锁的几种方案与分析

1、分布式锁的目的

分布式锁能够实现以下两种功能

a、提高效率,避免重复计算。比如多节点同时执行一个批量任务。如果一个节点已经在执行某个任务,其他节点就没必要重复执行这个任务。这时允许存在少量的重复计算,也就是说允许存在偶尔的失败。

b、保证正确性。比如两个客户购买同一件商品,如果一个客户购买了,其他客户就不能购买。这种情况对分布式锁的要求很高,如果重复计算,会对业务的正确性产生影响。也就是不允许失败。

 

使用redis实现分布式锁需要注意以下两点:

a、加锁和解锁的实现,必须保证是同一把锁。常见的解决方案是:给锁设置唯一ID,加锁时生成,解锁时先判断,再解锁。

b、不能让一个资源永久被锁住。解决方案是给锁设置过期时间,如果加锁的节点宕机,在经过了过期时间之后,锁消失,资源自动释放。

 

以下提出了几种redis分布式锁的解决方案:

2、单一redis锁实现

 

引入springboot redis的jar包:

基于redis实现分布式锁的几种方案与分析
1 <dependency>
2     <groupId>org.springframework.boot</groupId>
3     <artifactId>spring-boot-starter-data-redis</artifactId>
4 </dependency>
引入spring-boot-starter-data-redis
配置文件中加入redis相关配置:
基于redis实现分布式锁的几种方案与分析
spring:
  redis:
    host: 192.168.1.110
    database: 2
    port: 6380
    timeout: 2000
    password:
yaml配置

在需要加锁的操作前,使用

Boolean lockResult = stringRedisTemplate.opsForValue().setIfAbsent(key, value, time);

方法试图向redis中存入一对key-value,返回true,表示拿到了锁,就正常处理业务逻辑;返回false,表示没拿到锁,就处理没有拿到资源的业务。

在finally代码块中,要释放这个锁:

if ((driverId + "").equals(stringRedisTemplate.opsForValue().get(lock.intern()))) {
    stringRedisTemplate.delete(lock.intern());
}

释放这个锁时,要先判断这个锁是否是自己的锁,以防止错误的释放了别的服务设置的锁,判断是否是自己的锁的依据是value,每个服务有一个特殊的value,比如:如果是滴滴司机抢一个订单,那这个value可以是司机的id。

 

注意:

a、key必须能够唯一表示某个资源;

b、value必须能够唯一确定资源竞争者,以防止释放锁的时候,释放了别人的锁;(为什么可能会存在释放别人锁的情况呢?当前服务设置了锁之后,在锁的过期时间之内,业务并没有完成,导致锁过期自动释放,下一个服务获取锁之后,业务才完成,此时,可能会释放下一个服务设置的锁)

c、加锁操作和给锁设置过期时间的操作必须保证原子性,以防止加锁成功,设置过期时间失败,导致锁无法释放;

 

缺点:

a、单点问题。单一redis,对redis的可用性要求很高,一旦redis发生宕机,则整个服务不可用;

b、当因为某种异常情况(比如JVM的DC过程,或者网络抖动),导致业务处理时间超过锁的过期时间,会产生业务还未执行完成,锁就释放的情况,如果此时有其他服务来获取这个资源,会导致两个服务同时拥有这个资源的情况,导致业务可能会出现问题。这个问题的解决方案可以是:自己实现一个deamon线程任务,当业务执行时间超过设定的锁过期时间的三分之一的时候,判断业务是否完成,如果还没完成,就延长这个锁的过期时间,延长长度设置为原始的过期时间的长度。(redisson框架就是这样操作的。)

c、即使采用deamon线程的方案,也不能完全保证不出问题。如果上锁之后,服务端与redis服务器失联,导致续期失败,也会出现b的问题。

 

3、单一redisson锁实现

 除了引入redis的jar包,还需要引入redisson的jar包:

基于redis实现分布式锁的几种方案与分析
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.14.0</version>
</dependency>
redisson包

定义redisClient的bean:

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

在业务中先获取redisson锁:

String lock = "redisson_lock_orderid_3";
RLock lock1 = redissonClient.getLock(lock.intern());

在具体业务执行前,先给资源上锁:

lock1.lock();

在finally语句块中,释放锁:

lock1.unlock();

注意:

a、redisson默认的锁过期时间是30s,也可以指定锁的过期时间,但是方法并不是这样:

lock1.lock(10, TimeUnit.SECONDS);

(因为用这个方法,虽然锁的过期时间自定义成了10s,但是redisson将不会自动维护这个锁的TTL。)

而是需要在定义RedissonClient这个Bean的时候,配置lockWatchdogTimeout这个量:

@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort).setDatabase(database);
    config.setLockWatchdogTimeout(8000);
    return Redisson.create(config);
}

这里config.setLockWatchdogTimeout(8000);表示,将lock的过期时间expireTime设置为8s,而且watchDog会每过2s将这个key的TTL重新设置为8s(前提是这个锁还存在的情况下)。

 

redisson框架会自己维护当前锁的TTL,以防止业务执行的时间因为GC或者网络的原因异常增长,超过锁的过期时间。源码在RedissonLock.class中:

    private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
        if (leaseTime != -1L) {
            return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        } else {
            RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
            ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
                if (e == null) {
                    if (ttlRemaining == null) {
                        this.scheduleExpirationRenewal(threadId);
                    }

                }
            });
            return ttlRemainingFuture;
        }
    }

    private void scheduleExpirationRenewal(long threadId) {
        RedissonLock.ExpirationEntry entry = new RedissonLock.ExpirationEntry();
        RedissonLock.ExpirationEntry oldEntry = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry);
        if (oldEntry != null) {
            oldEntry.addThreadId(threadId);
        } else {
            entry.addThreadId(threadId);
            this.renewExpiration();
        }

    }

    private void renewExpiration() {
        RedissonLock.ExpirationEntry ee = (RedissonLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
        if (ee != null) {
            Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
                public void run(Timeout timeout) throws Exception {
                    RedissonLock.ExpirationEntry ent = (RedissonLock.ExpirationEntry)RedissonLock.EXPIRATION_RENEWAL_MAP.get(RedissonLock.this.getEntryName());
                    if (ent != null) {
                        Long threadId = ent.getFirstThreadId();
                        if (threadId != null) {
                            RFuture<Boolean> future = RedissonLock.this.renewExpirationAsync(threadId);
                            future.onComplete((res, e) -> {
                                if (e != null) {
                                    RedissonLock.log.error("Can't update lock " + RedissonLock.this.getName() + " expiration", e);
                                    RedissonLock.EXPIRATION_RENEWAL_MAP.remove(RedissonLock.this.getEntryName());
                                } else {
                                    if (res) {
                                        RedissonLock.this.renewExpiration();
                                    }

                                }
                            });
                        }
                    }
                }
            }, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
            ee.setTimeout(task);
        }
    }

当经过了锁的过期时间的三分之一时,watchDog会检查当前锁是否还存在,如果还存在,就给当前锁的过期时间重新设置为初始值。(默认情况下,redisson锁的过期时间是30s,redis中,这个锁的TTL从30开始倒数。在这个锁的TTL为20s时,watchDog将这个锁的TTL设置为30,继续从30开始倒数。)

 

缺点:

a、单点问题。单一redis,对redis的可用性要求高,一旦redis宕机,则这个服务不可用。

b、无法完全保证不会出现“业务执行时间超过锁TTL的时间”这个问题。假设获取到锁之后,如果与redis失联,锁的TTL无法被延长。

 

4、redisson红锁实现及其分析

 

引入redis和redisson的jar包,同上。

定义多个redissonClient:

    @Bean("redissonClient1")
    @Primary
    public RedissonClient redissonClient1() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6388").setDatabase(0);
        return Redisson.create(config);
    }

    @Bean("redissonClient2")
    public RedissonClient redissonClient2() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6399").setDatabase(0);
        return Redisson.create(config);
    }

    @Bean("redissonClient3")
    public RedissonClient redissonClient3() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6380").setDatabase(2);
        return Redisson.create(config);
    }

这里定义了三个redissonClient,注意第一个需要加上@Primary注解,如果不加,项目运行报错。不明白为啥,不重要。

在业务代码中,先获取红锁:

RLock lock1 = redissonClient1.getLock(lock.intern());
RLock lock2 = redissonClient2.getLock(lock.intern());
RLock lock3 = redissonClient3.getLock(lock.intern());
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);

然后执行业务前先上锁:

redLock.lock();

业务完成,在finally代码块中释放锁:

redLock.unlock();

 

红锁RedLock的工作流程:

a、获取当前时间;

b、依次获取N个节点的锁。每个节点获取锁的方法和单一redisson的方法一样,但这里有个细节,在每个节点获取锁的时候,设置的过期时间都不同,需要减去之前获取锁操作所花费的时间:

  • 例如设置锁的过期时间是500ms;

  • 第一个节点,设置锁的过期时间是500ms,操作时间1ms;

  • 第二个节点,设置锁的过期时间是499ms,操作时间2ms;

  • 第三个节点,设置锁的过期时间是497ms······依次类推;

  • 如果在某个节点,锁的过期时间小于等于0了,说明获取锁的操作已经超时了,整个加锁操作失败。

c、判断上锁是否成功:如果超过N/2 + 1个节点上锁成功,并且每个节点的锁过期时间都大于0,就说明成功获取到了锁,否则,获取锁失败。获取锁失败时,释放锁。

d、释放锁。对所有节点发出释放锁的指令,每个节点释放锁的逻辑和上边单一redisson的逻辑一致。为什么不仅仅对于加锁成功的节点发释放锁的指令,而是对所有节点都发?因为在某个节点上锁失败,不一点表示该节点上锁失败,有可能是因为网络延时导致操作超时,实际上锁成功了。

 

上边是红锁RedLock的运行流程,但是依然可能出现一些问题,尤其是在高并发情况下:

a、性能问题。分两方面:

  • 一方面,如果节点比较多,挨个加锁,耗时可能会比较长,影响性能。解决办法是:每个节点加锁的操作可以是异步操作,可以同时向多个节点获取锁。

  • 另一方面,被加锁的资源太大。加锁操作本身就是为了保证正确性而牺牲了并发,牺牲和资源大小成正比,这时可以考虑对资源进行拆分。

b、重试问题。当多个client共同竞争一个资源时,每个client都获取了部分锁,但是没有一个超过半数。这时候需要重试。且重试的时间要保证随机,以便让client重新获取锁的操作错开。虽然无法根治,但是可以有效缓解这个问题。

c、节点宕机问题。对于红锁RedLock,如果redis节点不做持久化,某个节点宕机重启了,可能导致多个client重复上锁问题:比如,有A、B、C、D、E五个节点,client1从A、B、C三个节点获取到了锁,这时C宕机重启,client2从C、D、E获取到了锁,这时就出现了两个client同时获取到锁的情况。解决方案三种:

  • 持久化。让所有节点都支持持久化,但是持久化对性能影响很大,一般不采用这种方式。

  • 延时启动。让运维配合,当redis节点宕机需要重启时,设置延时启动,延时的时长要大于所有锁的TTL。

  • 增加redis节点的数量。某一两个节点宕机不至于影响锁的归属。但是这样会增加成本。这就需要在成本和服务正确性稳定性之间取一个平衡。

总结,无。。。

 

参考信息:

https://blog.csdn.net/lpd_tech/article/details/104773257/

https://redis.io/topics/distlock

 

上一篇:Java中间件 - Redisson简介


下一篇:redis 分布式锁-简易版与 redisson 实验