线上问题-redis分布式锁

一、问题描述:在xxl-job中,因为项目使用了mysql的悲观锁,导致加锁时间非常慢,因此采用了redis的分布式锁,发现5个进程同时运行时,有的任务同一秒钟会触发两次,导致撮合系统压力巨大。
二、问题定位
2.1、工作原理:
现货任务是通过定时任务执行的,执行周期可配置,然后定时任务会定时执行该任务。
通过xxl-job的执行日志的调度时间有时同一秒调用两次同一个任务,可分析处应该是调度系统出现问题,当任务执行周期设置为5秒时,应该是每5秒执行一次。
三、机理分析
正常情况下,xxljob获取数据的条数是根据(快的线程池中最大线程数+慢的线程池最大线程数)*20=本次最多可以执行多少个定时任务。每次执行任务是查询数据库的条件是本次最多可执行的任务数和当前时间+5秒的数据。有6个进程在同时执行这块的逻辑,只有抢到锁的进程才能真正的查询数据库做后面的操作。
而当前的处理的逻辑恰好是分布式的逻辑出现问题导致同一秒任务会重复执行。下面是发生问题的代码。hasKey和stringRedisTemplate.opsForValue().set方法并不是原子性操作,在多线程的情况下存在竞态场景,A线程发现key不存在,于是加锁,当A正在加锁但是没有加锁成功时,此时B也判断是否有key,于是B也去加锁,而stringRedisTemplate.opsForValue().set方法会直接覆盖之前的锁,这样就会导致多个线程会覆盖之前的锁

public void lock() {
        if(!stringRedisTemplate.hasKey(lockKey)){
            Long redisLockTimeout = XxlJobAdminConfig.getAdminConfig().getRedisLockTimeout();
            stringRedisTemplate.opsForValue().set(lockKey,"1",redisLockTimeout, TimeUnit.SECONDS);
            isLock = true;
        }

    }

更重要的下面是这段代码,加了锁之后直接就开始查询数据库了,并没有判断锁是否加成功。这就导致了任务会重复执行的另一个原因,就等于没有加锁,任何进程都可以随时调用这块的逻辑。

 long addlockStartTime = System.currentTimeMillis();
                    distributedLock.lock();
                    long addlockEndTime = System.currentTimeMillis();
                    logger.info(">>>>>>>>> addLock cost:{}ms",addlockEndTime-addlockStartTime);
                    // 1、pre read
                    long nowTime = System.currentTimeMillis();
                    List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);

四、问题复现
编写相关的测试代码对bug进行测试

    @GetMapping("/test")
    public void test() throws InterruptedException {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(3, 5, 20, TimeUnit.SECONDS, new ArrayBlockingQueue<>(5), new ThreadPoolExecutor.DiscardOldestPolicy());
        while (true) {
            for (int i = 0; i < 10; i++) {
                threadPoolExecutor.execute(() -> {
                    String threadName = Thread.currentThread().getName();
                    String startTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                    System.out.println(startTime+"---"+threadName+"开始获取锁");
                    redisLock.lock();
                    if(redisLock.isLock()){
                        String format = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                        System.out.println(format+"---"+threadName+"获取锁成功");
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        redisLock.unLock();
                        String releaseTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS"));
                        System.out.println(releaseTime + " --- " + threadName + " 释放锁");
                    }else {
                        String failTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS"));
                        System.out.println(failTime + " --- " + threadName + " 获取锁失败");
                    }
                });
            }
            Thread.sleep(10000);
            String format1 = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
            System.out.println(format1+"|||休眠好了,开始新一轮的获取锁");
        }
    }
}

通过下面的日志可看到同一时间确实有多个线程同时获取锁成功

2025-12-20 11:49:14---pool-3-thread-5开始获取锁
2025-12-20 11:49:14---pool-3-thread-2开始获取锁
2025-12-20 11:49:14---pool-3-thread-4获取锁成功
2025-12-20 11:49:14---pool-3-thread-3获取锁成功
2025-12-20 11:49:14---pool-3-thread-1获取锁成功
2025-12-20 11:49:14---pool-3-thread-2获取锁成功
2025-12-20 11:49:14---pool-3-thread-5获取锁成功
11:49:14.544 --- pool-3-thread-2 释放锁
11:49:14.544 --- pool-3-thread-4 释放锁
11:49:14.544 --- pool-3-thread-3 释放锁
11:49:14.544 --- pool-3-thread-5 释放锁
11:49:14.544 --- pool-3-thread-1 释放锁
2025-12-20 11:49:19|||休眠好了,开始新一轮的获取锁
2025-12-20 11:49:19---pool-3-thread-2开始获取锁
2025-12-20 11:49:19---pool-3-thread-4开始获取锁
2025-12-20 11:49:19---pool-3-thread-3开始获取锁
2025-12-20 11:49:19---pool-3-thread-5开始获取锁
2025-12-20 11:49:19---pool-3-thread-1开始获取锁
2025-12-20 11:49:19---pool-3-thread-4获取锁成功
2025-12-20 11:49:19---pool-3-thread-1获取锁成功
2025-12-20 11:49:19---pool-3-thread-2获取锁成功
2025-12-20 11:49:19---pool-3-thread-3获取锁成功
2025-12-20 11:49:19---pool-3-thread-5获取锁成功
11:49:19.725 --- pool-3-thread-1 释放锁
11:49:19.725 --- pool-3-thread-5 释放锁
11:49:19.726 --- pool-3-thread-3 释放锁
11:49:19.729 --- pool-3-thread-2 释放锁
11:49:19.730 --- pool-3-thread-4 释放锁

五、措施及验证情况
修改分布式锁的加锁逻辑:

public void lock1() {
        String token = UUID.randomUUID().toString();
		/**
     * 安全的加锁方法 - 使用UUID作为锁值
     * 
     * 为什么使用UUID?
     * 1. UUID是全局唯一的,可以唯一标识每个获取锁的线程/进程
     * 2. 在释放锁时,可以通过比较UUID来判断锁是否还是自己的
     * 3. 避免误删别人的锁(见下面的场景说明)
     * 
     * 场景说明:为什么可能会删掉别人的锁?
     * 
     * 时间线:
     * T1: 线程A获取锁,锁值="uuid-A",过期时间=1秒
     * T2: 线程A开始执行业务逻辑(假设需要2秒)
     * T3: 1秒后,锁自动过期,Redis中锁被删除
     * T4: 线程B获取锁,锁值="uuid-B"
     * T5: 线程A执行完毕,调用unLock()
     * 
     * 如果使用unLock()方法(不检查UUID):
     *   - 线程A会直接删除锁
     *   - 但此时锁是线程B的!
     *   - 结果:线程B的锁被误删,其他线程可以获取锁,导致并发问题
     * 
     * 如果使用unLock1()方法(检查UUID):
     *   - 线程A尝试删除锁时,会先检查锁值是否等于"uuid-A"
     *   - 发现锁值是"uuid-B",不匹配
     *   - 不会删除锁,保护了线程B的锁
     */
        Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, token, 1L, TimeUnit.SECONDS);
        if(aBoolean){
            isLock = true;
            lockValue = token;
        } else {
            isLock = false;
        }
    }

释放锁的逻辑也需要修改,错误版本:

/**
     * 不安全的释放锁方法 - 存在严重问题!
     * 
     * 问题:只检查key是否存在,不检查锁值是否匹配
     * 这会导致误删别人的锁(见lock1()方法的注释说明)
     * 
     * 危险场景:
     * 1. 线程A获取锁,但执行时间超过锁的过期时间
     * 2. 锁过期后,线程B获取了锁
     * 3. 线程A执行完毕,调用此方法删除锁
     * 4. 结果:线程B的锁被误删!
     */
    public void unLock() {
        if(stringRedisTemplate.hasKey(lockKey)){
            stringRedisTemplate.delete(lockKey);
            isLock = false;
        }
    }

正确版本

/**
     * 安全的释放锁方法 - 使用Lua脚本保证原子性
     * 
     * 为什么使用Lua脚本?
     * 1. Lua脚本在Redis中原子执行,保证"检查+删除"操作的原子性
     * 2. 避免在高并发场景下的竞态条件
     * 
     * Lua脚本逻辑:
     * - 先获取锁的值(get)
     * - 比较锁值是否等于自己的token(ARGV[1])
     * - 如果匹配,才删除锁(del)
     * - 如果不匹配,返回0,不删除锁
     * 
     * 这样即使锁过期后被其他线程获取,也不会误删别人的锁
     * 
     * @param scheduleThreadToStop 参数(当前未使用,保留用于扩展)
     */
    public void unLock1(boolean scheduleThreadToStop) {
        if (!isLock || lockValue == null) return;

        // Lua脚本:原子性地检查锁值并删除
        // 伪代码:if redis.get(lockKey) == myToken then redis.del(lockKey) else return 0 end
        String lua =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "  return redis.call('del', KEYS[1]) " +  // 只有锁值匹配才删除
                        "else " +
                        "  return 0 " +  // 锁值不匹配,不删除(可能是别人的锁)
                        "end";
        org.springframework.data.redis.core.script.DefaultRedisScript<Long> script =
                new org.springframework.data.redis.core.script.DefaultRedisScript<>(lua, Long.class);

        try {
            // 执行Lua脚本,传入锁的key和自己的token
            stringRedisTemplate.execute(script, java.util.Collections.singletonList(lockKey), lockValue);
        } finally {
            isLock = false;
            lockValue = null;
        }
    }

redis的分布式锁注意事项:
1、多线程竞态场景加锁注意:
1.1、Redis的key是UUID而且要和线程绑定,存储到ThreadLocal中。
1.2、判断锁是否存在和加锁一定是原子性操作,使用:stringRedisTemplate.opsForValue().setIfAbsent(lockKey, token, 1L, TimeUnit.SECONDS);
1.3、删除锁要用加锁的线程的key(从ThreadLocal中取)删除,不能删除别人的锁。
2、多进程中只有一个线程获取redis的分布式锁则相对简单。只需要将UUID存到内存中即可。
六、分析结论
综上所述,在分布式加锁的过程中,有好几处bug导致问题的产生,问题定位准确,机理分析清楚,解决措施有效,问题归零。

posted @ 2025-12-20 00:14  Charlie-Pang  阅读(2)  评论(0)    收藏  举报