【Redis实战篇】基于Redis的分布式锁的原理及实现

温馨提示:建议在PC端浏览~

  • 分布式锁

    • 过程分析

    • 分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

    • 分布式锁的特点

    • 分布式锁的实现

      • 分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:
    • 基于Redis的分布式锁

      • 实现分布式锁时需要实现的两个基本方法:

        • 1、获取锁

          • 互斥:确保只能有一个线程获取锁
          • 非阻塞:尝试一次,成功返回true,失败返回false
            • 但是上图将获取锁与给锁设置过期时间分成了两步操作,这样还是存在获取锁后无法释放的隐患。例如,当执行第一条setnx语句成功获取锁后,Redis服务器刚好宕机了,此时还未来得及给锁设置过期时间,锁就一直无法被释放。为了解决这个问题,我们可以将获取锁和给锁设置过期时间合并成一步操作:
        • 2、释放锁

          • 手动释放
          • 超时释放:获取锁时添加一个超时时间
      • 流程梳理

      • 基于Redis实现分布式锁初级版本

        • 需求:定义一个类,实现下面接口,利用Redis实现分布式锁功能。

        • 代码示例:

          java 复制代码
            //ILock:
            public interface ILock {
                /**
                 * 尝试获取锁
                 * @param timeoutSec 锁的过期时间
                 * @return
                 */
                boolean tryLock(Long timeoutSec);
            
                /**
                 * 释放锁
                 */
                void unLock();
            }
            
            //RedisSimpleLock:
            public class RedisSimpleLock implements ILock{
                private final StringRedisTemplate stringRedisTemplate;
                private final String name;
                private static final String KEY_PREFIX = "lock:";
            
                public RedisSimpleLock(StringRedisTemplate stringRedisTemplate, String name) {
                    this.stringRedisTemplate = stringRedisTemplate;
                    this.name = name;
                }
            
                /**
                 * 尝试获取锁
                 * @param timeoutSec 锁的过期时间
                 * @return
                 */
                @Override
                public boolean tryLock(Long timeoutSec) {
                    //获取线程标识
                    long threadId = Thread.currentThread().getId();
                    Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, String.valueOf(threadId), timeoutSec, TimeUnit.SECONDS);
                    //防止自动拆箱时发生空指针异常
                    return Boolean.TRUE.equals(success);
                }
            
                /**
                 * 释放锁
                 */
                @Override
                public void unLock() {
                    stringRedisTemplate.delete(KEY_PREFIX + name);
                }
            }
      • 分布式锁初级版本存在的并发问题(锁超时释放导致误删)

        • 并发问题说明:线程1成功获取锁后去执行业务,但是在执行业务的过程中因为某些原因导致业务阻塞直到锁超时被自动释放,这时候线程2来了并成功获取到锁,所以线程2就去执行自己的业务了,这时候,线程1又醒来并执行完业务,然后直接去释放锁,就导致线程2的锁被释放了,其他线程过来也可以成功获取锁,这就造成了并发问题。如下图所示:

        • 并发问题解决办法:并发问题产生的根本原因是线程1误删了线程2的锁,所以我们可以在释放锁之前根据线程标识判断一下这个锁是不是当前线程的,是的话才能释放锁。如下图所示:

          • 流程图
      • 改进Redis的分布式锁

        • 需求:修改之前的分布式锁实现,满足:

          • 1、在获取锁时存入线程标示(可以用"UUID-线程ID"表示)
          • 2、在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
            • 如果一致则释放锁
            • 如果不一致则不释放锁
        • 代码示例:

          java 复制代码
            public class RedisSimpleLock implements ILock{
                private final StringRedisTemplate stringRedisTemplate;
                private final String name;
                private static final String KEY_PREFIX = "lock:";
                private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
            
                public RedisSimpleLock(StringRedisTemplate stringRedisTemplate, String name) {
                    this.stringRedisTemplate = stringRedisTemplate;
                    this.name = name;
                }
            
                /**
                 * 尝试获取锁
                 * @param timeoutSec 锁的过期时间
                 * @return
                 */
                @Override
                public boolean tryLock(Long timeoutSec) {
                    //获取线程标识
                    String threadId = ID_PREFIX + Thread.currentThread().getId();
                    Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
                    //防止自动拆箱时发生空指针异常
                    return Boolean.TRUE.equals(success);
                }
            
                /**
                 * 释放锁
                 */
                @Override
                public void unLock() {
                    //获取线程标识
                    String threadId = ID_PREFIX + Thread.currentThread().getId();
                    //获取锁中的标识
                    String lockId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
                    //判断标识是否一致
                    if(threadId.equals(lockId)){
                        //标识一致,释放锁
                        stringRedisTemplate.delete(KEY_PREFIX + name);
                    }
                }
            }
        • 改进后的Redis分布式锁存在的问题

          • 如下图所示,极端情况下,线程1在执行业务直至判断线程标识与锁中标识是否一致都未发生阻塞,但是在释放锁时却发生了阻塞(虽然这中间没有代码要执行,但是JVM本身的垃圾回收机制或者其他情况也会导致线程阻塞),在阻塞的这段时间内,线程1的锁超时失效了,这时候线程2过来并成功获取锁,然后去执行它的业务,但在线程2执行业务的期间,线程1醒过来并继续执行释放锁的操作,仍旧导致了锁的误删问题。
          • 造成这个问题的原因,是因为判断锁和释放锁是分开的两个操作,在这两个操作之间,就很容易发生阻塞从而产生并发问题。所以我们要想解决这个问题,就要确保判断锁和释放锁成一个原子性的操作(一起执行,不能出现间隔)。
      • Redis的Lua脚本

        • Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法可以参考网站:https://www.runoob.com/lua/lua-tutorial.html

        • 这里重点介绍Redis提供的调用函数,语法如下:

          lua 复制代码
            -- 执行redis命令
            redis.call('命令名称', 'key', '其他参数', ...)
            
            -- 例如,我们要执行set name jack,则脚本是这样:
            redis.call('set', 'name', 'jack')
            
            -- 例如,我们要先执行set name Rose,再执行get name,最后返回name,则脚本如下:
            redis.call('set', 'name', 'Rose')
            local name = redis.call('get', 'name')
            return name
        • 写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:

          • 例如,我们要执行redis.call('set', 'name', 'jack')这个脚本,语法如下:
          • 如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
              • 注意:Lua语言中数组的索引从1开始。
        • 释放锁的业务流程是这样的:

          • 1、获取锁中的线程标示
          • 2、判断是否与指定的标示(当前线程标示)一致
          • 3、如果一致则释放锁(删除)
          • 4、如果不一致则什么都不做
        • 如果用Lua脚本来表示则是这样的:

          lua 复制代码
            -- 判断锁中的线程标识是否与当前线程标识一致
            if(redis.call('GET', KEYS[1]) == ARGV[1]) then
              -- 标识一致,释放锁
              return redis.call('DEL', KEYS[1])
            end
            -- 不一致,则直接返回
            return 0
      • 再次改进Redis的分布式锁

        • 需求:基于Lua脚本实现分布式锁的释放锁逻辑。

        • 提示:RedisTemplate调用Lua脚本的API如下:

        • 基于Lua脚本改进Redis的分布式锁的步骤

          • 1、在resource下新建Lua文件,并写入之前的Lua脚本。(注意:创建Lua脚本需要先下载EmmyLua插件。)

          • 2、在RedisSimpleLock的unlock方法中通过调用StringRedisTemplate的execute方法执行Lua脚本:

            java 复制代码
              //RedisSimpleLock:
              private static final DefaultRedisScript<Long> unlockScript;
              // 初始化lua脚本,静态代码块,只会在类加载时执行一次
              static{
                  unlockScript = new DefaultRedisScript<>();
                  unlockScript.setLocation(new ClassPathResource("unlock.lua"));
                  unlockScript.setResultType(Long.class);
              }
              /**
               * 释放锁
               */
              @Override
              public void unLock() {
                  //调用Lua脚本
                  stringRedisTemplate.execute(unlockScript, Collections.singletonList(KEY_PREFIX + name), 
                                              ID_PREFIX + Thread.currentThread().getId());
              }
      • 小结

        • 基于Redis的分布式锁实现思路:

          • 利用set nx ex获取锁,并设置过期时间,保存线程标示。
          • 释放锁时先判断线程标示是否与自己一致,一致则删除锁。
        • 特性

          • 利用set nx满足互斥性。
          • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性。
          • 利用Redis集群保证高可用和高并发特性。
    • 基于Redis的分布式锁优化(Redisson)

      • 基于setnx实现的分布式锁存在下面的问题:

        • 1、不可重入:同一个线程无法多次获取同一把锁。
          • 举例:假如方法A获取了一把锁,然后去调用方法B,但是方法B也需要获取同一把锁才能继续执行,由于基于setnx实现的分布式锁的不可重入性,就导致B等待锁释放后获得锁才能继续执行,而A又需要等待B执行完才能继续执行从而释放锁,这就形成了A和B互相等待的局面,造成死锁问题。
        • 2、不可重试:获取锁只尝试一次就返回false,没有重试机制。
        • 3、超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
        • 4、主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现。
      • Redisson

        • Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

        • 官网地址:https://redisson.org

        • GitHub地址:https://github.com/redisson/redisson

        • Redisson入门

          • 1、引入依赖:

            java 复制代码
              <dependency>
                  <groupId>org.redisson</groupId>
                  <artifactId>redisson</artifactId>
                  <version>3.13.6</version>
              </dependency>
          • 2、配置Redisson客户端:

            java 复制代码
              @Configuration
              public class RedisConfig {
                  @Bean
                  public RedissonClient redissonClient() {
                      // 配置类
                      Config config = new Config();
                      // 添加redis地址,这里添加了单节点的地址,也可以使用config.useClusterServers()添加集群地址
                      config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassword("123321");
                      // 创建客户端
                      return Redisson.create(config);
                  }
              }
          • 3、使用Redisson的分布式锁

            java 复制代码
              @Resource
              private RedissonClient redissonClient;
              
              @Test
              void testRedisson() throws InterruptedException {
                  // 获取锁(可重入),指定锁的名称
                  RLock lock = redissonClient.getLock("anyLock");
                  // 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
                  boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
                  // 判断释放获取成功
                  if(isLock){
                      try {
                          System.out.println("执行业务");
                      }finally {
                          // 释放锁
                          lock.unlock();
                      }
                  }
              }
    • Redisson可重入锁原理

      • 之前我们自己设计的分布式锁,使用的是String类型来记录锁,其value为线程标识。但是为了实现锁的可重入性,我们不仅要记录线程的标识,还要记录重入次数,所以最终选择使用Hash结构来记录锁的线程标识和重入次数。Hash结构中value的field记录线程标识、value记录重入次数,如下图:

      • 可重入锁实现原理的流程图:

          • 补充:为了确保获取锁与释放锁操作的原子性,需要使用Lua脚本来执行上图这些步骤。
      • 获取锁的Lua脚本:

        lua 复制代码
          local key = KEYS[1]; -- 锁的key
          local threadId = ARGV[1]; -- 线程唯一标识
          local releaseTime = ARGV[2]; -- 锁的自动释放时间
          -- 判断是否存在
          if(redis.call('exists', key) == 0) then
              -- 不存在,获取锁
              redis.call('hset', key, threadId, 1);
              -- 设置有效期
              redis.call('expire', key, releaseTime);
              return 1; -- 返回结果
          end;
          -- 锁已经存在,判断threadId是否是自己
          if(redis.call('hexists', key, threadId) == 1) then
              -- threadId是自己,获取锁,重入次数+1
              redis.call('hincrby', key, threadId, 1);
              -- 重置有效期
              redis.call('expire', key, releaseTime);
              return 1; -- 返回结果
          end;
          return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
      • 释放锁的Lua脚本:

        lua 复制代码
          local key = KEYS[1]; -- 锁的key
          local threadId = ARGV[1]; -- 线程唯一标识
          local releaseTime = ARGV[2]; -- 锁的自动释放时间
          -- 判断当前锁是否还是被自己持有
          if (redis.call('HEXISTS', key, threadId) == 0) then
              return nil; -- 如果已经不是自己,则直接返回
          end;
          -- 是自己的锁,则重入次数-1
          local count = redis.call('HINCRBY', key, threadId, -1);
          -- 判断是否重入次数是否已经为0
          if (count > 0) then
              -- 大于0说明不能释放锁,重置有效期然后返回
              redis.call('EXPIRE', key, releaseTime);
              return nil;
          else -- 等于0说明可以释放锁,直接删除
              redis.call('DEL', key);
              return nil;
          end;
    • Redisson分布式锁原理

      • 小结:Redisson分布式锁原理
        • 可重入:利用hash结构记录线程id和重入次数。
        • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制。
        • 超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间。
    • Redisson分布式锁主从一致性问题(multiLock)

      • 问题产生原因分析:一开始,Java应用向Master发起一个请求,并成功获取锁,但是在Master的数据还未同步至Slave时Master宕机了,这时,Redis的哨兵会从Slave中选取一个当作新的Master,但是由于之前的Master数据未能同步,所以之前的锁就失效了,此时若有其他线程来获取锁,也是可以成功的,就出现了并发安全问题。
      • 问题解决办法:我们可以用多个独立的Redis节点构建联合锁(以下图中3个独立的Redis节点为例)。当Java应用发起请求获取锁时,需要从三个独立的Redis节点都获取一把锁组成联合锁,只有当三个Redis节点的锁都获取成功,才算成功获取锁,成功获取锁后,只要还有一个节点是在正常运行的,那么这个锁就是生效的。就算其中一个Redis节点宕机,且未来得及同步数据,其他线程想要乘虚而入,也只能成功获取到新节点的这一把锁,而无法成功获取联合锁。这样就不会产生并发安全问题,且由于这三个Redis节点都是相互独立的,也就没有主从一致性问题。
    • 小结

      • 不可重入Redis分布式锁:
        • 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示。
        • 缺陷:不可重入、无法重试、锁超时失效。
      • 可重入的Redis分布式锁:
        • 原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待。
        • 缺陷:redis宕机引起锁失效问题。
      • Redisson的multiLock:
        • 原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。
        • 缺陷:运维成本高、实现复杂。
相关推荐
兔子宇航员03011 小时前
HiveSQL 中 NULL 与空字符串的区别与注意事项
数据库·数据仓库·sql
fpcc1 小时前
C++编程实践——提高缓存的命中
c++·缓存
杨云龙UP2 小时前
Oracle CDB巡检脚本使用SOP:从HTML原始报告到Word正式交付_2026-05-29
运维·服务器·数据库·oracle·架构·html·巡检
保定公民2 小时前
Oracle 层次查询(CONNECT BY)完全指南:从入门到精通
数据库·sql·oracle·达梦数据库·层次查询
闪电悠米2 小时前
黑马点评-优惠券秒杀-03_basic_seckill_and_oversell
java·数据库·spring boot·spring·缓存·oracle·面试
rising start2 小时前
Python 实战:Redis 的基础操作与连接池(Pool)深度解析
redis·python·bootstrap
逍遥德2 小时前
PostgreSQL --- 数组函数详解
数据库·sql·postgresql
.Cnn2 小时前
MySQL事务和Spring事务
数据库·后端·mysql·spring
福大大架构师每日一题2 小时前
redis 8.8.0 发布:新数据结构、字段级通知、INCREX、XNACK 全面升级,8.6 到 8.8 变化一文看懂
数据结构·数据库·redis