锁、分布式锁、Redis分布式锁、定时任务


锁的介绍

在有限的资源情况下或某个场景下,需要控制同一时间(段)只有某些线程(用户/服务器)能访问到资源。

Java中实现锁:synchronized 关键字、并发包的类

这种锁的方式是在代码中的某个方法或者某个类中限定了,这导致这样的锁只对当前单个 JVM 有效。A服务器上的锁和B服务器上的锁是独立的,互不影响的。


锁的获取可以粗暴的理解为,在要执行某段代码前定义: 必须获取到某个信物或者武林盟主的令牌,获取到了这个信物的才能干某件事,没有获取到的就不能执行。

并同时可以定义在获取到信物后的一些使用规则: 可以指定这个信物它的 自开始使用的有效时间、过期了自动续期、信物到期了是否要回收/销毁 等操作。


分布式锁

为啥需要分布式锁?

  1. 在有限的资源情况下或某个场景下,需要控制同一时间(段)只有某些线程(用户/服务器)能访问到资源。

    比如说,代码中的定时任务,由于代码部署了多台服务器,但设定的定时任务只需要在多台服务器上有一台服务器执行了就行。

  2. 单机锁只对单个JVM有效。

实现:需要设置一个抢锁机制。

分布式锁实现的关键

抢锁机制

怎么保证同一时间只有 1 个服务器能抢到锁?

核心思想:先来操作的人先把数据改成自己的标识(比如服务器的ip),后来的人发现标识已存在,表明抢锁失败,继续等待。

等先来的人执行方法结束,把标识清空,其他的人(线程)继续抢锁。

实现方式:
  1. 通过数据库(如MySQL)中实现设置抢锁机制:

    • 行级锁(最简单),select for update,保证同一时间段只有一个线程去先查数据然后进行更新数据,然后在这期间该数据是不能被其他的线程给更改的。相当于是MySQL数据库在本地给加了一把锁。
    • 乐观锁
  2. Redis 实现:内存数据库,读写速度快。支持 setnx、lua脚本,比较方便实现分布式锁。比较主流的实现方式。

    setnx脚本命令语法:set if not exists 如果不存在,则进行设置;只有设置成功才会返回true,否则返回 false。

    set name ming nx
    

    image-20240819131445574


    通过Redis实现分布式锁的注意事项

    1. 抢到锁,执行完相关代码后,要记得释放锁。(有的时候执行方法的时间会比设置的锁的过期时间短,这时候可以提前释放锁,腾地方,及时给别的人用)

    2. Redis在使用锁时,一定要设置锁的过期时间,到期直接释放。即使抢到锁的服务器挂掉了,也能正常过期释放。

    3. 如果获得锁的方法执行的时间过长,但是锁设置的过期时间到了 / 提前过期了?

      会产生一系列的连锁反应的问题:释放掉别人的锁,这样还是会存在多个方法同时进行的情况

      • A方法抢到了锁,打上了A抢到锁的标识,但是A方法执行时间太长,要用40秒,而锁的到期时间是30秒,锁过期了; 这时候,A方法还在执行中,B方法又抢到锁开始执行,并打上标识,标识B抢到了锁。
      • 基于上面一种情况,锁被打上了B的标识,B正在执行,但A这时候执行完了,然后把锁的标识给清空,释放了。B打上的锁的标识被清空,然后这时候别的方法又可以来抢锁了。更猛烈了。
      抢到的锁到期了,怎么解决:
      • 续期。思路:代码里加判断标识——> 只要当前线程的方法还没有结束,就执行续期。
    4. 在方法A中释放锁的时候,有可能在判断过程中出现问题;比如上一步刚执行判断出是自己的锁,但这时候锁设置的过期时间刚好到期了,这时候别的B方法又获取到锁,执行B的方法,这时候A再去释放锁这时候还是释放了别人的锁。

      解决方案: 利用 Redis 的原子性操作。通过 Redis + Lua 脚本实现,类似Java里面的事务处理机制。

    5. Redis 如果是集群(而不是只有一个 Redis),如果分布式锁的数据不同步怎么办?

      解决方案:https://blog.csdn.net/feiying0canglang/article/details/113258494 拒绝自己实现!!!直接用现成的。


  3. Zookeeper 实现(不推荐)


实际中,怎么实现 Redis 分布式锁呢?拒绝自己写上面的实现逻辑

Redisson 实现分布式锁

Redisson: Java 客户端、数据网格、实现了很多 Java 里支持的接口和数据结构。

Redisson 是一个 Java 操作 Redis 的客户端,提供了大量的分布式数据集来简化对 Redis 的操作和使用可以让开发者像使用Java代码里的集合一样使用 Redis,完全感知不到 Redis 的存在(或者说使用过程中感知不到是在操作一个额外的东西)


Redisson 2 种引入方式

  1. Spring boot start 引入(引入很方便,但是不推荐,和Springboot之间有版本对应关系,redisson版本迭代太快,容易冲突): https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter

  2. 直接引入: https://github.com/redisson/redisson#quick-start 官方使用教程,Quick start

    引入依赖

    <dependency>
       <groupId>org.redisson</groupId>
       <artifactId>redisson</artifactId>
       <version>3.34.1</version>
    </dependency>  
    

    Java 新建 Redisson配置类

    @Configuration
    @ConfigurationProperties(prefix = "spring.redis")
    @Data
    public class RedissonConfig {
        /**
         * 定义redis连接地址
         */
        private String host;
        /**
         *定义redis连接端口号
         */
        private String port;
    
        @Bean
        public RedissonClient redissonClient() {
            // 1. 创建配置
            Config config = new Config();
            /**
             * String redissonAddress = "redis:127.0.0.1:6379";
             * 这里可以不写死,用在yml配置文件的配置,动态读取redis配置
             * @ConfigurationProperties(prefix = "spring.redis") prefix 添加读取的前缀
             */
            String redissonAddress = String.format("redis://%s:%s", host, port);
            config.useSingleServer()
                    .setAddress(redissonAddress)
                    // 分布式锁的存储可以和其他的缓存数据隔开,选择其他的库
                    .setDatabase(1);
            // 2. 创建实例
            RedissonClient redisson = Redisson.create(config);
            return redisson;
        }
    }
    

    注意: redissonClient 操作 redis 的语法中,设置redis的key是通过 “getXxx()”来实现的

    比如,redisson在redis中创建一个list:RList<String> rList = redissonClient.getList("test-list");

    这个“test-list”就是对应redis中设置的集合的key,rList.add("mmm");值就是通过add方法。

    测试类,通过 Redisson 实现操作 redis存取数据:

    @SpringBootTest
    public class RedissonTest {
        @Resource
        private RedissonClient redissonClient;
    
        @Test
        void test() {
            // list jvm
            List<String> list = new ArrayList<>();
            list.add("ming");
            System.out.println(list.get(0));
            list.remove(0);
    
            // redis
            RList<String> rList = redissonClient.getList("test-list");
            rList.add("mmm");
            System.out.println(rList.get(0));
    //        rList.remove(0);
    
            // map
            Map<String, Integer> map = new HashMap<>();
            map.put("ming", 12);
            map.get("ming");
    
            RMap<Object, Object> rMap = redissonClient.getMap("test-map");
            rMap.put("ming", 12);
    
            // set
        }
    }
    

代码中怎么实现Redisson分布式锁的使用?

比如定时任务,给定时任务加锁:

定时任务中只有获取到了某个锁,才能继续正式执行定时任务中的代码,进行定时任务的业务逻辑。

@Component
@Slf4j
public class PreCacheTask {
    @Resource
    private RedisTemplate redisTemplate;

    @Resource
    private RedissonClient redissonClient;

    /**
     * 每天执行一次  预热加载公共用户信息
     */
    @Scheduled(cron = "30 17 17 * * *")
    public void doCacheCommonUserData() {
        // 通过redissonClient 添加并设置锁的 key
        RLock lock = redissonClient.getLock("user-center:PreCacheTask:doCacheCommonUserData:lock");
        try {
            // 判断锁,只有当获取到了锁才执行这个定时任务 只有一个线程能获取到锁
            if (lock.tryLock(0, 30000L, TimeUnit.MILLISECONDS)) {
                oneScheduleTaskCode();
            }
        } catch (InterruptedException e) {
            log.error("doCacheCommonUserData error", e);
            throw new RuntimeException(e);
        } finally {
            // 加判断 lock.isHeldByCurrentThread() 实现只能释放自己的锁
            if (lock.isHeldByCurrentThread()) {
                // 释放锁  释放资源一类的操作一定要写在finall里
                lock.unlock();
            }
        }

    }

    /**
     * 某个定时任务要执行的业务代码逻辑
     */
    private void oneScheduleTaskCode() {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        Map<String, Object> mapKey = new HashMap<>();
        mapKey.put("mingString", "dong");
        mapKey.put("mingInt", 1);
        mapKey.put("mingDouble", "2.1");
        User user = new User();
        user.setId(1L);
        user.setUserName("mingdongdong");
        mapKey.put("mingUser", user);
        mapKey.forEach((key, value) -> {
            String redisKey = String.format("user-center:%s", key);
            // 将数据存到 redis 缓存,并设置过期时间
            valueOperations.set(redisKey, value, 10, TimeUnit.MINUTES);
        });

        // 查
        String mingString = (String) valueOperations.get("user-center:mingString");
        int mingInt = (Integer) valueOperations.get("user-center:mingInt");
        Object mingDouble = valueOperations.get("user-center:mingDouble");
        Object mingUser = valueOperations.get("user-center:mingUser");
        System.out.println(mingString);
        System.out.println(mingInt);
        System.out.println(mingDouble);
        System.out.println(mingUser);
        RedisOperations operations = valueOperations.getOperations();
        RedisSerializer keySerializer = operations.getKeySerializer();
        System.out.println(keySerializer);
    }
}

定时任务 + 锁

lock.tryLock(0, 30000L, TimeUnit.MILLISECONDS)

tryLock方法,这里的tryLock(waitTime, -1, unit)有三个参数:

  • waitTime:获取锁的最大等待时间(没有传默认为-1)指某个线程在获取锁时最多等待的时间,超过时间没获取到就不等了
  • leaseTime:锁自动释放的时间(没有传的话默认-1) 指某个线程获取到锁后,到释放锁的时间,也就是指定该线程可以持有该锁的时长。
  • unit:时间的单位(等待时间和锁自动释放的时间单位)
  1. 因为定时任务只执行一次,锁的等待时间waiTime参数设置为 0,只抢一次,抢不到就放弃。
  2. 注意锁的释放一定要写在 finally 中。
  3. leaseTime 设置为 -1 ,表示不给锁手动设置指定的过期时间,交给 redisson 自动管理锁到期和续期。默认的续期时间是 30s,并且方法没执行完会自动续期。[看门狗机制]

锁的理解:

锁的获取可以粗暴的理解为,在要执行某段代码前定义: 必须获取到某个信物或者武林盟主的令牌,获取到了这个信物的才能干某件事,没有获取到的就不能执行。

并同时可以定义在获取到信物后的一使用规则: 可以指定这个信物它的 自开始使用的有效时间、过期了自动续期、信物到期了是否要回收/销毁 等操作。


看门狗机制

redisson 中提供的续期机制,给redis自动续期

开启一个监听线程,如果加锁的方法还没执行完,就帮你重置 redis 锁的过期时间。

原理:

  1. 监听当前加锁执行方法的线程,每 10 秒执行续期一次。

    只要方法没有执行完,代码中没有执行释放锁操作,就会保持这个 10 秒自动续期。

    理论上 ,加锁的方法中没有释放锁的代码,Watch dog 会一直续期锁的过期时间。

    所以,使用锁,一定要在最后有释放锁的操作。

  2. 如果线程挂掉(注意debug模式也会被它当成服务器宕机),则不会自动续期了

面试题:看门狗机制下,能自动续期,为什么还要设置30s的自动续期?

防止宕机,redis设置过期时间是一个必备的保险操作。

https://blog.csdn.net/qq_26222859/article/details/79645203

SpringBoot
JAVA-技能点
知识点