Redis高级(八)、全网最详细的分布式锁原理+实战+WatchDog机制源码解读

478 阅读10分钟

觉得对你有益的小伙伴记得点个赞+关注

后续完整内容持续更新中

希望一起交流的欢迎发邮件至javalyhn@163.com

1. Distributed locks with Redis官网说明

image.png

2. 使用场景

多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)。

Redis分布式锁比较正确的姿势是采用redisson这个客户端工具。

3. RedLock理念

首先搞明白RedLock与Redisson的关系 Redisson是RedLock的实现

image.png

官方:redis.io/topics/dist…

中文版:redis.cn/topics/dist…

Redisson相关文献请参照下面三个网址

redisson.org/ (redisson之官网)

github.com/redisson/re… (redisson之Github)

github.com/redisson/re… (redisson之解决分布式锁)

4. 单机案例说明

单机案例的三个重要元素

  1. 加锁

加锁实际上就是在redis中,设置一个带有过期时间的key。加过期时间是为了避免死锁。

  1. 解锁

将key键删除,但也不能乱删,不能张冠李戴。

同时为了保证删除的原子性,常见的就是用lua脚本实现,先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。

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

  1. 超时

锁key要注意过期时间,不能长期占用

加锁关键逻辑

public static boolean tryLock(String key, String uniqueId, int seconds) {
    return "OK".equals(jedis.set(key, uniqueId, "NX", "EX", seconds));
}

解锁关键逻辑

public static boolean releaseLock(String key, String uniqueId) {
    String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "return redis.call('del', KEYS[1]) else return 0 end";
    
    return jedis.eval(
            luaScript,
            Collections.singletonList(key),
            Collections.singletonList(uniqueId)
    ).equals(1L);
}

单机模式中,一般都是用set/setnx+lua脚本搞定,想想它的缺点是什么?

image.png

  1. 客户A通过Redis的setnx命令建立分布式锁成功并且成功占有锁
  2. 正常情况下主从机都会持有这个锁
  3. 突然出现了故障,master还没有来得及同步数据给slave,此时slave机上还没有对应的锁的信息
  4. 从机slave上位,变成了新的master
  5. 客户B此时同样的去建立锁获取锁成功,一锁被多建多用,此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据。

CAP里面的CP遭到了破坏,而且无论单机、主从、哨兵都有这样的风险

由于我们加的事排他独占锁,同一时间只能有一个建Redis锁成功并且持有锁,严禁出现2个以上的请求线程拿到锁

5. Redis之父提出RedLock解决这个问题

Redis也提供了Redlock算法,用来实现基于多个实例的分布式锁。 锁变量由多个实例维护,即使有实例发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。

Redlock算法是实现高可靠分布式锁的一种有效解决方案,可以在实际开发中使用。

6. RedLock设计理念

该方案也是基于(set 加锁、Lua 脚本解锁)进行改良的,所以Redis之父antirez 只描述了差异的地方,大致方案如下。

假设我们有N个Redis主节点,例如 N = 5这些节点是完全独立的,我们不使用复制或任何其他隐式协调系统,为了取到锁客户端执行以下操作:

序号步骤
1获取当前时间,以毫秒为单位;
2依次尝试从5个实例,使用相同的 key 和随机值(例如 UUID)获取锁。当向Redis 请求获取锁时,客户端应该设置一个超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以防止客户端在试图与一个宕机的 Redis 节点对话时长时间处于阻塞状态。如果一个实例不可用,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁;
3客户端通过当前时间减去步骤 1 记录的时间来计算获取锁使用的时间。当且仅当从大多数(N/2+1,这里是 3 个节点)的 Redis 节点都取到锁,并且获取锁使用的时间小于锁失效时间时,锁才算获取成功;
4如果取到了锁,其真正有效时间等于初始有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
5如果由于某些原因未能获得锁(无法在至少 N/2 + 1 个 Redis 实例获取锁、或获取锁的时间超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

该方案为了解决数据不一致的问题,直接舍弃了异步复制只使用 master 节点,同时由于舍弃了 slave,为了保证可用性,引入了 N 个节点,官方建议是 5。

7. N=2X+1(N是最终部署机器数,X是容错机器数)

由该公式可知,最终选择部署的Redis机器数为奇数(肯定也可以是偶数,不限定)

首先我们要知道什么是容错

容错就是失败了多少个机器实例后我还是可以容忍的,所谓的容忍就是数据一致性还是可以Ok的,CP数据一致性还是可以满足。

加入在集群环境中,redis失败1台,可接受。2X+1 = 2 * 1+1 =3,部署3台,死了1个剩下2个可以正常工作,那就部署3台。

加入在集群环境中,redis失败2台,可接受。2X+1 = 2 * 2+1 =5,部署5台,死了2个剩下3个可以正常工作,那就部署5台。

那为什么为奇数个呢?

最少的机器,最多的产出效果

为什么redis推荐奇数个节点,其主要原因还是从成本上考虑的,因为奇数个节点和偶数个节点允许宕机的节点数是一样的,比如3个节点和4个节点都只允许宕机一台,那么为什么要搞4个节点去浪费服务资源呢?

那么话又说回来了,为什么三个节点和四个节点都只允许宕机一个节点呢?这是因为redis规定集群中,半数以上节点认为主节点故障了,才会选举新的节点。

8. 多机案例演示

此处演示采用三台redis的master机器

8.1 本次设置三台master各自独立没有从属关系

docker run -p 6381:6379 --name redis-master-1 -d redis:6.0.7
 
docker run -p 6382:6379 --name redis-master-2 -d redis:6.0.7
 
docker run -p 6383:6379 --name redis-master-3 -d redis:6.0.7

image.png

8.2 进入上一步刚启动的redis容器实例

docker exec -it redis-master-1 /bin/bash
 
docker exec -it redis-master-2 /bin/bash
 
docker exec -it redis-master-3 /bin/bash

8.3 建module redis_redLock

8.4 改pom

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <!--<version>3.12.0</version>-->
            <version>3.13.4</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.8</version>
        </dependency>
        <!--swagger-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!--swagger-ui-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.4</version>
            <scope>compile</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

8.5 写配置文件

spring.application.name=spring-boot-redis
server.port=9090

spring.swagger2.enabled=true


spring.redis.database=0
spring.redis.password=
spring.redis.timeout=3000
#sentinel/cluster/single
spring.redis.mode=single

spring.redis.pool.conn-timeout=3000
spring.redis.pool.so-timeout=3000
spring.redis.pool.size=10

spring.redis.single.address1=XXX
spring.redis.single.address2=XXX
spring.redis.single.address3=XXX

8.6 主启动

@SpringBootApplication
public class RedisRedlockApplication
{

    public static void main(String[] args)
    {
        SpringApplication.run(RedisRedlockApplication.class, args);
    }

}

8.7 RedisSingleProperties

@Data
public class RedisSingleProperties {
   private  String address1;
   private  String address2;
   private  String address3;
}

8.8 RedisProperties

@ConfigurationProperties(prefix = "spring.redis", ignoreUnknownFields = false)
@Data
public class RedisProperties {

   private int database;

   /**
    * 等待节点回复命令的时间。该时间从命令发送成功时开始计时
    */
   private int timeout;

   private String password;

   private String mode;

   /**
    * 池配置
    */
   private RedisPoolProperties pool;

   /**
    * 单机信息配置
    */
   private RedisSingleProperties single;


}

8.9 CacheConfiguration

@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class CacheConfiguration {

   @Autowired
   RedisProperties redisProperties;

   @Bean
   RedissonClient redissonClient1() {
       Config config = new Config();
       String node = redisProperties.getSingle().getAddress1();
       node = node.startsWith("redis://") ? node : "redis://" + node;
       SingleServerConfig serverConfig = config.useSingleServer()
               .setAddress(node)
               .setTimeout(redisProperties.getPool().getConnTimeout())
               .setConnectionPoolSize(redisProperties.getPool().getSize())
               .setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
       if (StringUtils.isNotBlank(redisProperties.getPassword())) {
           serverConfig.setPassword(redisProperties.getPassword());
       }
       return Redisson.create(config);
   }

   @Bean
   RedissonClient redissonClient2() {
       Config config = new Config();
       String node = redisProperties.getSingle().getAddress2();
       node = node.startsWith("redis://") ? node : "redis://" + node;
       SingleServerConfig serverConfig = config.useSingleServer()
               .setAddress(node)
               .setTimeout(redisProperties.getPool().getConnTimeout())
               .setConnectionPoolSize(redisProperties.getPool().getSize())
               .setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
       if (StringUtils.isNotBlank(redisProperties.getPassword())) {
           serverConfig.setPassword(redisProperties.getPassword());
       }
       return Redisson.create(config);
   }

   @Bean
   RedissonClient redissonClient3() {
       Config config = new Config();
       String node = redisProperties.getSingle().getAddress3();
       node = node.startsWith("redis://") ? node : "redis://" + node;
       SingleServerConfig serverConfig = config.useSingleServer()
               .setAddress(node)
               .setTimeout(redisProperties.getPool().getConnTimeout())
               .setConnectionPoolSize(redisProperties.getPool().getSize())
               .setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
       if (StringUtils.isNotBlank(redisProperties.getPassword())) {
           serverConfig.setPassword(redisProperties.getPassword());
       }
       return Redisson.create(config);
   }


   /**
    * 单机
    * @return
    */
   /*@Bean
   public Redisson redisson()
   {
       Config config = new Config();

       config.useSingleServer().setAddress("redis://192.168.111.147:6379").setDatabase(0);

       return (Redisson) Redisson.create(config);
   }*/

}

8.10 RedisPoolProperties

@Data
public class RedisPoolProperties {

    private int maxIdle;

    private int minIdle;

    private int maxActive;

    private int maxWait;

    private int connTimeout;

    private int soTimeout;

    /**
     * 池大小
     */
    private  int size;

}

8.11 controller

@RestController
@Slf4j
public class RedLockController {

    public static final String CACHE_KEY_REDLOCK = "JavaLyHn_REDLOCK";

    @Autowired
    RedissonClient redissonClient1;

    @Autowired
    RedissonClient redissonClient2;

    @Autowired
    RedissonClient redissonClient3;

    @GetMapping(value = "/redlock")
    public void getlock() {
        //CACHE_KEY_REDLOCK为redis 分布式锁的key
        RLock lock1 = redissonClient1.getLock(CACHE_KEY_REDLOCK);
        RLock lock2 = redissonClient2.getLock(CACHE_KEY_REDLOCK);
        RLock lock3 = redissonClient3.getLock(CACHE_KEY_REDLOCK);

        RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
        boolean isLock;

        try {

            //waitTime 锁的等待时间处理,正常情况下 等5s
            //leaseTime就是redis key的过期时间,正常情况下等5分钟。
            isLock = redLock.tryLock(5, 300, TimeUnit.SECONDS);
            log.info("线程{},是否拿到锁:{} ",Thread.currentThread().getName(),isLock);
            if (isLock) {
                //TODO if get lock success, do something;
                //暂停20秒钟线程
                try { TimeUnit.SECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
            }
        } catch (Exception e) {
            log.error("redlock exception ",e);
        } finally {
            // 无论如何, 最后都要解锁
            redLock.unlock();
            System.out.println(Thread.currentThread().getName()+"\t"+"redLock.unlock()");
        }
    }
}

最终三个Redis实例都成功建锁并且持有锁

9. WatchDog机制

9.1 demo起手

public class WatchDogDemo
{
    public static final String LOCKKEY = "AAA";

    private static Config config;
    private static Redisson redisson;

    static {
        config = new Config();
        config.useSingleServer().setAddress("redis://"+"XXX"+":6379").setDatabase(0);
        redisson = (Redisson)Redisson.create(config);
    }

    public static void main(String[] args)
    {
        RLock redissonLock = redisson.getLock(LOCKKEY);

        redissonLock.lock();
        try
        {
            System.out.println("1111");
            //暂停几秒钟线程
            try { TimeUnit.SECONDS.sleep(25); } catch (InterruptedException e) { e.printStackTrace(); }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
           redissonLock.unlock();
        }

        System.out.println(Thread.currentThread().getName() + " main ------ ends.");

        //暂停几秒钟线程
        try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
        redisson.shutdown();
    }
}

9.2 还记得之前说过的缓存续命吗?

看过前一篇文章的小伙伴肯定对这个问题留有疑问,也就是说Redis分布式锁过期了,但业务还在进行,这该怎么办?

9.3 守护线程“续命”(看门狗机制)

额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。

Redisson 里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间

9.4 你觉得Reids之父写的这个方案还有什么bug吗?

什么,你在质疑???

我肯定不会质疑,Reids之父真的太厉害了,我这辈子恐怕都不会有他的强大思维能力o(╥﹏╥)o

但是有一个bug是 系统时钟影响,这是分布式难以避免的

也就是说:如果线程 1 从 3 个实例获取到了锁。但是这 3 个实例中的某个实例的系统时间走的稍微快一点,则它持有的锁会提前过期被释放,当他释放后,此时又有 3 个实例是空闲的,则线程2也可以获取到锁,则可能出现两个线程同时持有锁了。

分布式的知识真的是太多了!!!!!!!要想完全搞懂真的不简单

9.5 WatchDog源码详解

在获取锁成功后,给锁加一个 watchdog,watchdog 会起一个定时任务,在锁没有被释放且快要过期的时候会续期

image.png

image.png

9.5.1 通过redisson新建出来的锁key,默认是30秒

image.png

image.png

9.5.2 tryAcqiureAsyn

image.png

加锁的逻辑会进入到org.redisson.RedissonLock#tryAcqiureAsyn中,在获取锁成功后,会进入scheduleExpirationRenewal(threadId);

image.png

9.5.3 scheduleExpirationRenewal里面初始了一个定时器

image.png

9.5.4 WatchDog自动延期机制

dely 的时间是 internalLockLeaseTime/3。

在 Redisson 中,internalLockLeaseTime 是 30s,也就是每隔 10s 续期一次,每次 30s。

image.png

总结就是客户端A加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端A还持有锁key,那么就会不断的延长锁key的生存时间,默认每次续命又从30秒新开始

9.5.5 tryLockInnerAsync

image.png

我们step into看一下

image.png

有关这里lua脚本的参数再次说明一下

image.png

流程解释

graph TD
通过exists判断,如果所不存在,就设置过期时间,加锁成功 --> 通过hexists判断,如果锁已存在,并且锁已存在,并且锁的是当前线程,则证明是可重入锁,加锁成功 --> 如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁,返回当前锁的过期时间,加锁失败

9.5.6 加锁查看

加锁成功后,在redis的内存数据中,就有一条hash结构的数据。

Key为锁的名称;field为随机字符串+线程ID;值为1。见下

image.png

如果同一线程多次调用lock方法,值递增1。----------可重入锁见后

9.5.7 重入锁查看

image.png

9.5.8 ttl续命的演示

这个很简单,小伙伴们自己加大业务逻辑处理时间,看超过10秒钟后,redisson的续命加时

9.5.9 解锁源码分析

image.png

9.6 bug演示与解决

image.png

image.png

image.png

image.png

解决方法就是在最终finally块解锁时加上一个判断

finally {
            if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread())
            {
                redissonLock.unlock();
            }
        }

那么至此,分布式锁原理+实战+WatchDog机制源码解读就已讲解完毕,如果对小伙伴有帮助的,麻烦点赞+关注!!