在分布式系统中,Redisson作为Redis的Java客户端,提供了强大的分布式锁功能。然而,许多开发者在异步编程场景中会遇到一个令人困惑的问题:明明成功获取了锁,却在释放时抛出 IllegalMonitorStateException。本文将深入分析这一问题的根源,并提供完整的解决方案。

引言:一个典型的异步导入场景

最近在优化一个Excel批量导入系统时,我遇到了一个棘手的分布式锁问题。系统需要处理大量用户同时上传Excel文件到数据库的场景,为了防止对同一张表的并发导入操作,我们使用了Redisson分布式锁。然而,在将导入任务异步化后,系统开始频繁报错。

以下是我最初实现的代码(关键信息已模糊处理):

java

@PostMapping("/uploadExcelToDb")
public Result<Void> uploadExcelToDb(@RequestPart("file") MultipartFile file, 
                                    @RequestParam("tableId") Long tableId,
                                    @RequestParam(value = "applicationName", required = false) String applicationName) 
    throws IOException {
    
    User user = SecurityUtils.getUser();
    
    // 生成分布式锁的key
    RLock lock = redissonClient.getLock(RedisKeyUtils.getImportTableKey(tableId.toString()));
    
    // 保存上传文件到临时目录
    String tempPath = FileUtils.TEMP_PATH;
    String originalFileName = FileUtils.generateUniqueFileName("import_excel");
    File tempFile = new File(tempPath, originalFileName);
    file.transferTo(tempFile);
    
    log.info("用户 {} 保存文件到临时目录:{}", user.getUserId(), tempFile.getAbsolutePath());
    
    // 尝试获取分布式锁
    boolean tryLock = lock.tryLock();
    
    // 获取锁失败,抛出业务异常
    if (!tryLock) {
        log.error("用户 {} 获取锁失败!", user.getUserId());
        throw new BusinessException(String.format("表 %s 正在导入中,请勿重复导入!", tableId));
    }
    
    // 异步执行导入任务
    CompletableFuture.runAsync(() -> {
        importService.uploadExcelToDb(tempFile, tableId, applicationName, user);
    }, threadPoolExecutor)
    .whenComplete((res, ex) -> {
        try {
            if (ObjectUtil.isNull(ex)) {
                log.info("用户 {} 执行导入完成", user.getUserId());
            } else {
                log.error("用户 {} 执行导入异常", user.getUserId(), ex);
            }
        } finally {
            lock.unlock(); // ❌ 问题所在:在异步回调中释放锁
        }
    });
    
    log.info("用户 {} 执行导入中", user.getUserId());
    return Result.success();
}

这段代码看似合理:主线程获取锁,然后提交异步任务,在任务完成后释放锁。但在高并发测试中,系统频繁抛出以下异常:

text

java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread
    at org.redisson.RedissonBaseLock.unlock(RedissonBaseLock.java:286)
    at com.example.service.ImportController.lambda$uploadExcelToDb$0(ImportController.java:78)

问题分析:Redisson的线程绑定原则

Redisson锁的内部机制

要理解这个异常,我们需要深入了解Redisson分布式锁的内部实现。Redisson锁遵循一个核心原则:锁的获取和释放必须在同一线程中执行。

这一设计并非Redisson的缺陷,而是故意为之的安全保障。让我们看看Redisson是如何实现这一机制的:

java

// RedissonLock 的简化版实现逻辑
public class RedissonLock {
    
    // 存储线程与锁的关联关系
    private final ConcurrentMap<Thread, LockEntry> entries = new ConcurrentHashMap<>();
    
    public void lock() {
        Thread current = Thread.currentThread();
        LockEntry entry = entries.computeIfAbsent(current, 
            t -> new LockEntry(current));
        entry.incrementLockCount(); // 支持可重入
        
        // 在Redis中存储锁信息,包含线程标识
        String lockValue = getLockValue(current);
        redisCommands.set(lockKey, lockValue, "NX", "PX", expireTime);
    }
    
    public void unlock() {
        Thread current = Thread.currentThread();
        LockEntry entry = entries.get(current);
        
        if (entry == null) {
            // 关键:如果不是当前线程持有的锁,抛出异常
            throw new IllegalMonitorStateException(
                "attempt to unlock lock, not locked by current thread");
        }
        
        // 减少重入计数
        if (entry.decrementLockCount() == 0) {
            entries.remove(current);
            // 从Redis中删除锁
            redisCommands.del(lockKey);
        }
    }
    
    private String getLockValue(Thread thread) {
        // 格式:客户端UUID:线程ID
        return clientId + ":" + thread.getId();
    }
}

Redis中的锁数据结构

当Redisson在Redis中创建锁时,存储的数据结构包含线程标识信息:

redis

# 查看锁的详细信息
127.0.0.1:6379> HGETALL "redisson_lock:import:table:1001"

1) "mode"             # 锁模式
2) "write"            # 写锁
3) "UUID-01:thread-1" # 关键:客户端UUID + 线程ID
4) "count"            # 重入次数
5) "1"

这个数据结构解释了为什么锁的获取和释放必须在同一线程:Redisson在释放锁时,会验证当前线程的标识是否与Redis中存储的线程标识匹配。

为什么需要线程绑定原则?

1. 防止锁的误释放

假设没有线程绑定机制,考虑以下危险场景:

java

// 线程A获取锁
new Thread(() -> {
    lock.lock();
    // 执行关键业务...
}).start();

// 线程B(可能是恶意代码)释放锁
new Thread(() -> {
    lock.unlock(); // 如果没有线程检查,这将错误地释放锁
}).start();

线程绑定机制确保了只有锁的持有者才能释放它,这是分布式锁安全性的基础保障。

2. 支持可重入锁

Redisson锁默认支持可重入,这意味着同一线程可以多次获取同一个锁:

java

public void reentrantMethod() {
    lock.lock();    // 第一次获取,计数为1
    try {
        lock.lock();    // 第二次获取(重入),计数为2
        // 执行业务逻辑...
    } finally {
        lock.unlock();  // 释放一次,计数为1
        lock.unlock();  // 再次释放,计数为0,完全释放锁
    }
}

如果没有线程绑定,多个线程的锁操作会干扰重入计数,导致锁状态混乱。

3. 锁续约机制(看门狗)

Redisson有一个著名的"看门狗"机制,可以自动为持有锁的线程续约,防止业务执行时间超过锁过期时间。这个机制也依赖于线程绑定:

java

// 看门狗只续约当前线程持有的锁
if (lock.isHeldByCurrentThread()) {
    scheduleRenewal(); // 安排续约任务
}

解决方案:正确的异步锁使用模式

理解了问题根源后,我们重构代码。以下是几种正确的实现方式:

方案一:在异步任务内部管理锁(推荐)

java

@PostMapping("/uploadExcelToDb")
public Result<Void> uploadExcelToDb(@RequestPart("file") MultipartFile file,
                                    @RequestParam("tableId") Long tableId,
                                    @RequestParam(value = "applicationName", required = false) String applicationName)
    throws IOException {
    
    User user = SecurityUtils.getUser();
    
    // 保存上传文件到临时目录
    String tempPath = FileUtils.TEMP_PATH;
    String fileName = FileUtils.generateUniqueFileName("import_excel");
    File tempFile = new File(tempPath, fileName);
    file.transferTo(tempFile);
    
    log.info("用户 {} 提交导入任务,文件已保存到:{}", 
             user.getUserId(), tempFile.getAbsolutePath());
    
    // 提交异步任务
    CompletableFuture.runAsync(() -> {
        // 在异步任务内部获取和释放锁
        String lockKey = RedisKeyUtils.getImportTableKey(tableId.toString());
        RLock lock = redissonClient.getLock(lockKey);
        
        boolean lockAcquired = false;
        try {
            // 尝试获取锁,设置等待时间和超时时间
            lockAcquired = lock.tryLock(5, 60, TimeUnit.SECONDS);
            
            if (!lockAcquired) {
                log.warn("用户 {} 导入表 {} 失败:表正在被其他用户导入", 
                         user.getUserId(), tableId);
                return;
            }
            
            log.info("用户 {} 开始导入表 {}", user.getUserId(), tableId);
            
            // 执行导入逻辑
            importService.processImport(tempFile, tableId, applicationName, user);
            
            log.info("用户 {} 导入表 {} 完成", user.getUserId(), tableId);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("用户 {} 导入任务被中断", user.getUserId(), e);
        } catch (Exception e) {
            log.error("用户 {} 导入表 {} 发生异常", 
                     user.getUserId(), tableId, e);
        } finally {
            // 确保释放锁
            if (lockAcquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
                log.debug("用户 {} 已释放表 {} 的导入锁", 
                         user.getUserId(), tableId);
            }
            
            // 清理临时文件
            cleanupTempFile(tempFile);
        }
    }, threadPoolExecutor);
    
    // 立即返回,不等待异步任务完成
    return Result.success("导入任务已提交,正在后台处理");
}

/**
 * 清理临时文件
 */
private void cleanupTempFile(File tempFile) {
    if (tempFile != null && tempFile.exists()) {
        try {
            boolean deleted = tempFile.delete();
            if (deleted) {
                log.debug("临时文件已清理:{}", tempFile.getAbsolutePath());
            }
        } catch (SecurityException e) {
            log.warn("清理临时文件失败:{}", tempFile.getAbsolutePath(), e);
        }
    }
}

方案二:使用Redisson的异步API

java

@PostMapping("/uploadExcelToDbAsync")
public Result<Void> uploadExcelToDbAsync(@RequestPart("file") MultipartFile file,
                                         @RequestParam("tableId") Long tableId,
                                         @RequestParam(value = "applicationName", required = false) String applicationName)
    throws IOException {
    
    User user = SecurityUtils.getUser();
    
    // 保存文件
    File tempFile = saveToTemp(file);
    
    // 获取锁
    String lockKey = RedisKeyUtils.getImportTableKey(tableId.toString());
    RLock lock = redissonClient.getLock(lockKey);
    
    // 使用异步方式获取锁
    RFuture<Boolean> lockFuture = lock.tryLockAsync(5, 60, TimeUnit.SECONDS);
    
    lockFuture.onComplete((acquired, throwable) -> {
        if (throwable != null) {
            log.error("用户 {} 获取锁异常", user.getUserId(), throwable);
            cleanupTempFile(tempFile);
            return;
        }
        
        if (!acquired) {
            log.warn("用户 {} 无法获取锁,表 {} 可能正在被导入", 
                     user.getUserId(), tableId);
            cleanupTempFile(tempFile);
            return;
        }
        
        try {
            // 执行导入逻辑
            importService.processImport(tempFile, tableId, applicationName, user);
            log.info("用户 {} 异步导入完成", user.getUserId());
        } catch (Exception e) {
            log.error("用户 {} 导入异常", user.getUserId(), e);
        } finally {
            // 异步释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlockAsync();
            }
            cleanupTempFile(tempFile);
        }
    });
    
    return Result.success("异步导入任务已提交");
}

方案三:封装锁工具类

对于频繁使用分布式锁的场景,可以封装一个工具类来简化使用:

java

/**
 * 分布式锁工具类
 */
@Component
public class DistributedLockHelper {
    
    @Autowired
    private RedissonClient redissonClient;
    
    /**
     * 尝试获取锁并执行任务
     */
    public <T> Optional<T> tryLockAndExecute(String lockKey, 
                                             long waitTime, long leaseTime,
                                             TimeUnit timeUnit,
                                             Supplier<T> task) {
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            boolean acquired = lock.tryLock(waitTime, leaseTime, timeUnit);
            if (!acquired) {
                return Optional.empty();
            }
            
            try {
                T result = task.get();
                return Optional.ofNullable(result);
            } finally {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取锁被中断", e);
        }
    }
    
    /**
     * 异步执行锁保护的任务
     */
    public CompletableFuture<Void> tryLockAndExecuteAsync(String lockKey,
                                                         long waitTime, long leaseTime,
                                                         TimeUnit timeUnit,
                                                         Runnable task,
                                                         Executor executor) {
        return CompletableFuture.runAsync(() -> {
            tryLockAndExecute(lockKey, waitTime, leaseTime, timeUnit, () -> {
                task.run();
                return null;
            });
        }, executor);
    }
}

// 使用工具类重构业务代码
@PostMapping("/uploadExcelToDbWithHelper")
public Result<Void> uploadExcelToDbWithHelper(@RequestPart("file") MultipartFile file,
                                              @RequestParam("tableId") Long tableId,
                                              @RequestParam(value = "applicationName", required = false) String applicationName)
    throws IOException {
    
    User user = SecurityUtils.getUser();
    File tempFile = saveToTemp(file);
    
    String lockKey = RedisKeyUtils.getImportTableKey(tableId.toString());
    
    // 使用工具类简化锁管理
    distributedLockHelper.tryLockAndExecuteAsync(
        lockKey,
        5, 60, TimeUnit.SECONDS,
        () -> {
            try {
                importService.processImport(tempFile, tableId, applicationName, user);
            } finally {
                cleanupTempFile(tempFile);
            }
        },
        threadPoolExecutor
    );
    
    return Result.success("导入任务已提交");
}

最佳实践和注意事项

1. 合理设置锁参数

java

// 不建议:没有超时时间,可能导致死锁
lock.tryLock();

// 建议:设置合理的等待时间和过期时间
lock.tryLock(5, 30, TimeUnit.SECONDS);
  • 等待时间:获取锁的最大等待时间,避免线程长时间阻塞

  • 过期时间:锁自动释放的时间,防止业务异常导致锁无法释放

2. 使用try-with-resources模式(Java 8+)

虽然Redisson的RLock没有实现AutoCloseable,但我们可以自己封装:

java

public class RedissonLockWrapper implements AutoCloseable {
    private final RLock lock;
    private final boolean locked;
    
    private RedissonLockWrapper(RLock lock, boolean locked) {
        this.lock = lock;
        this.locked = locked;
    }
    
    public static RedissonLockWrapper lock(RLock lock, long waitTime, long leaseTime, TimeUnit unit) 
        throws InterruptedException {
        boolean acquired = lock.tryLock(waitTime, leaseTime, unit);
        return new RedissonLockWrapper(lock, acquired);
    }
    
    public boolean isLocked() {
        return locked;
    }
    
    @Override
    public void close() {
        if (locked && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

// 使用示例
try (RedissonLockWrapper wrapper = RedissonLockWrapper.lock(lock, 5, 30, TimeUnit.SECONDS)) {
    if (wrapper.isLocked()) {
        // 执行业务逻辑
        importService.processImport(...);
    } else {
        log.warn("获取锁失败");
    }
} // 自动释放锁

3. 监控和日志

在生产环境中,完善的监控和日志至关重要:

java

@Slf4j
@Aspect
@Component
public class DistributedLockMonitor {
    
    @Around("@annotation(distributedLock)")
    public Object monitorLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) 
        throws Throwable {
        
        String lockKey = distributedLock.value();
        long startTime = System.currentTimeMillis();
        
        log.debug("尝试获取分布式锁: {}", lockKey);
        
        try {
            Object result = joinPoint.proceed();
            long cost = System.currentTimeMillis() - startTime;
            log.debug("分布式锁 {} 执行完成,耗时: {}ms", lockKey, cost);
            return result;
        } catch (Exception e) {
            log.error("分布式锁 {} 执行异常", lockKey, e);
            throw e;
        }
    }
}

// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String value(); // 锁的key
    long waitTime() default 5;
    long leaseTime() default 30;
}

4. 处理锁竞争和死锁

java

@Component
public class LockManager {
    
    @Autowired
    private RedissonClient redissonClient;
    
    /**
     * 获取锁,支持重试机制
     */
    public boolean acquireLockWithRetry(String lockKey, int maxRetries) {
        RLock lock = redissonClient.getLock(lockKey);
        
        for (int i = 0; i < maxRetries; i++) {
            try {
                if (lock.tryLock(1, 30, TimeUnit.SECONDS)) {
                    return true;
                }
                
                log.info("第 {} 次尝试获取锁 {} 失败,等待重试", i + 1, lockKey);
                Thread.sleep(1000); // 等待1秒后重试
                
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
        
        return false;
    }
}

总结

Redisson分布式锁的线程绑定原则是确保分布式锁安全性和正确性的重要机制。在异步编程场景中,我们必须特别注意:

  1. 遵循"谁获取,谁释放"原则:确保锁的获取和释放在同一线程中

  2. 避免主线程获取、异步线程释放的错误模式

  3. 合理设置锁参数,包括等待时间和过期时间

  4. 完善异常处理和资源清理,防止锁泄漏和资源泄漏

  5. 使用工具类封装,提高代码复用性和可维护性

通过正确理解和使用Redisson分布式锁,我们可以构建出既高效又安全的分布式系统。希望本文的分析和解决方案能够帮助你在实际开发中避免类似的陷阱。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐