【无标题】

一、背景:一个"完美"的异步导入接口

某天,代码review时看到同事实现的一个数据导入接口,逻辑清晰且考虑周全:

  1. 接收Excel文件并保存到临时目录

  2. 使用Redisson分布式锁防止重复导入

  3. 异步执行导入任务,避免阻塞主线程

  4. 任务完成后在回调中释放锁

    @PostMapping("/importData")
    public Result<Void> importData(@RequestPart("file") MultipartFile file,
    @RequestParam("taskId") Long taskId) throws IOException {

    复制代码
     // 身份认证与临时文件存储
     User user = SecurityUtils.getUser();
     String tempFile = saveToTemp(file);
     
     // 获取分布式锁
     RLock lock = redissonClient.getLock(getLockKey(taskId));
     boolean locked = lock.tryLock();
     if (!locked) {
         throw new BusinessException("任务" + taskId + "正在导入中,请勿重复操作!");
     }
     
     // 异步执行导入任务
     CompletableFuture.runAsync(() -> dataImportService.process(tempFile, taskId, user), executor)
         .whenComplete((res, ex) -> {
             try {
                 if (ex != null) {
                     log.error("导入异常", ex);
                 }
             } finally {
                 lock.unlock(); // 确保释放锁
             }
         });
     
     return Result.success();

    }

然而,这段代码在上线第三天就引发了生产事故:用户反馈导入功能"彻底卡住",即使重启服务也无法恢复......


二、致命缺陷深度剖析

❌ 缺陷1:跨线程释放锁------Redisson的线程绑定机制

问题根源RLock线程绑定的(Thread-Local),只有获取锁的线程才能释放它。

复制代码
// 主线程(Tomcat线程)获取锁
boolean locked = lock.tryLock(); 

// CompletableFuture的回调在异步线程执行
CompletableFuture.runAsync(() -> {
    // ... 业务逻辑 ...
}, executor).whenComplete((res, ex) -> {
    // ❌ 错误:异步线程尝试释放主线程的锁
    lock.unlock(); // 抛出IllegalMonitorStateException
});

异常堆栈

复制代码
java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread
    at org.redisson.RedissonLock.unlock(RedissonLock.java:321)

严重后果 :当unlock()抛出异常时,锁将永远无法释放,导致后续所有请求被阻塞。


❌ 缺陷2:无超时时间------死锁的温床

复制代码
// 无参调用:不等待,且锁永不过期
lock.tryLock(); 

Redisson看门狗机制

  • 默认情况下,Redisson会启动一个Watch Dog线程,每10秒检查一次

  • 如果业务线程未释放锁,看门狗会自动续期(默认30秒)

  • 但是 ,如果进程被kill -9或OOM killed,看门狗线程消失,锁成为永久孤儿锁

Redis中的僵尸锁

复制代码
127.0.0.1:6379> GET data:import:task:123
"e64f9c12-7a6e-4acb-95ed-7a5f8b4e3c2a:52"
127.0.0.1:6379> TTL data:import:task:123
(integer) -1  # 永久有效,无过期时间

❌ 缺陷3:锁范围不合理

复制代码
// 文件已上传后才加锁
FileUtil.write(file, tempPath); // 不在锁保护内

Boolean locked = lock.tryLock(); // 此时才加锁

问题

  • 文件上传耗时较长,但锁只保护了"启动异步任务"这一瞬间

  • 主线程已返回success,但异步任务还在执行,锁的状态无法被前端感知

  • 如果获取锁失败后没有清理临时文件,导致临时目录堆积


❌ 缺陷4:finally块的虚假安全感

whenCompletefinally并不保证一定执行

场景 finally是否执行 原因
异步任务正常完成 ✅ 执行 任务线程正常释放
异步任务抛出异常 ✅ 执行 异常触发回调
线程池饱和 ❌ 不执行 回调任务无法获取线程
进程被kill -9 ❌ 不执行 JVM直接终止
Redisson客户端提前关闭 ❌ 不执行 shutdown()后无法通信

三、实战Debug:四个真实生产场景

🔍 场景1:异步任务死循环导致锁永驻

复现条件

复制代码
public void process(File tempFile, Long taskId, User user) {
    List<Data> list = parseExcel(tempFile);
    
    // Bug:嵌套循环逻辑错误,导致死循环
    for (Data d1 : list) {
        for (Data d2 : list) {
            if (d1.getId().equals(d2.getId())) { // 条件永远成立
                break; // 只能跳出内层循环
            }
        }
        // 外层循环永不退出
    }
}

现象

  1. 日志卡在"执行导入中",不再输出"执行导入完成"

  2. 查看Redis:TTL data:import:task:123持续刷新(看门狗续期)

  3. 第二次请求永远返回"任务正在导入中"

线程堆栈分析

复制代码
"pool-2-thread-1" #50 RUNNABLE
  at com.service.DataImportService.process(DataImportService.java:42)
  at com.controller.DataController.lambda$importData$0(DataController.java:85)
  at java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1640)
  at java.util.concurrent.ThreadPoolExecutor.runWorker(...)

🔍 场景2:线程池饱和,回调无法执行

线程池配置

复制代码
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    1,  // coreSize
    1,  // maxSize
    0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<>(1)  // 队列容量为1
);

执行流程

请求1: 启动任务占用唯一线程请求1:whenComplete回调排队等待线程请求2: 启动任务队列已满,拒绝执行请求2:whenComplete回调无法进入队列任务完成尝试执行回调,但无线程可用

结果 :请求1的锁无法释放,请求2的任务和回调都无法执行,形成死锁


🔍 场景3:OOM Killer导致的僵尸锁

复现步骤

复制代码
# 1. 限制JVM内存(模拟资源紧张)
java -jar -Xmx256m app.jar

# 2. 上传500MB大文件(触发OOM)
curl -X POST -F "file=@huge_file.xlsx" http://localhost:8080/importData?taskId=123

# 3. 在导入过程中kill进程
kill -9 <PID>

Redis残留数据

复制代码
127.0.0.1:6379> KEYS *import*
1) "data:import:task:123"

127.0.0.1:6379> TTL data:import:task:123
(integer) -1  # 永久有效,成为僵尸锁

后续影响:即使重启服务,锁依然存在,所有请求被永久阻塞。


🔍 场景4:应用重启引发的孤儿锁

复制代码
@PreDestroy
public void shutdown() {
    redissonClient.shutdown(); // 应用关闭时关闭客户端
}

// 此时如果有异步任务在执行...
CompletableFuture.runAsync(() -> {
    Thread.sleep(5000); // 模拟耗时操作
}).whenComplete((res, ex) -> {
    lock.unlock(); // ❌ Redisson已关闭,unlock失败
});

四、正确实现方案

✅ 方案1:锁的获取与释放同线程(推荐)

复制代码
@PostMapping("/importData")
public Result<Void> importData(@RequestPart("file") MultipartFile file, 
                               @RequestParam("taskId") Long taskId) throws IOException {
    
    User user = SecurityUtils.getUser();
    String tempFile = saveToTemp(file);
    
    // 1. 定义带超时的锁
    RLock lock = redissonClient.getLock(getLockKey(taskId));
    boolean isLocked = false;
    
    try {
        // 2. 尝试获取锁(等待5秒,租约60秒)
        isLocked = lock.tryLock(5, 60, TimeUnit.SECONDS);
        if (!isLocked) {
            FileUtil.del(tempFile); // 清理临时文件
            throw new BusinessException("任务" + taskId + "正在导入中,请勿重复操作!");
        }
        
        // 3. 启动异步任务
        CompletableFuture.runAsync(() -> {
            try {
                dataImportService.process(tempFile, taskId, user);
            } catch (Exception e) {
                log.error("导入失败", e);
            } finally {
                FileUtil.del(tempFile); // 在任务线程清理文件
            }
        }, executor);
        
        return Result.success();
        
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        FileUtil.del(tempFile);
        throw new BusinessException("获取锁被中断");
    } finally {
        // 4. 在主线程释放锁
        if (isLocked && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

关键点

  • tryLock(5, 60, TimeUnit.SECONDS):设置等待时间和自动续期时间

  • isHeldByCurrentThread():确保当前线程持有锁才释放

  • 锁只保护"启动异步任务"的瞬间,不保护任务执行过程


✅ 方案2:在异步任务内部管理锁(更严谨)

复制代码
public Result<Void> importData(@RequestPart("file") MultipartFile file, 
                               @RequestParam("taskId") Long taskId) {
    
    User user = SecurityUtils.getUser();
    String tempFile = saveToTemp(file);
    
    // 仅校验,不加锁
    if (isTaskRunning(taskId)) {
        FileUtil.del(tempFile);
        throw new BusinessException("任务正在执行中");
    }
    
    // 异步任务内部管理锁
    CompletableFuture.runAsync(() -> {
        RLock lock = redissonClient.getLock(getLockKey(taskId));
        try {
            // 在业务线程获取锁
            if (!lock.tryLock(0, 300, TimeUnit.SECONDS)) {
                log.warn("获取锁失败,taskId: {}", taskId);
                return;
            }
            
            dataImportService.process(tempFile, taskId, user);
            
        } catch (Exception e) {
            log.error("导入异常", e);
        } finally {
            // 在同一线程释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
            FileUtil.del(tempFile);
        }
    }, executor);
    
    return Result.success();
}

五、分布式锁最佳实践

1. 必须设置超时时间

复制代码
// 正确
lock.tryLock(5, 60, TimeUnit.SECONDS);

// 错误
lock.tryLock(); // 永不超时

2. 获取锁失败必须清理资源

复制代码
if (!locked) {
    FileUtil.del(tempFile); // 删除临时文件
    dbConnection.close();   // 关闭连接
    throw new BusinessException("获取锁失败");
}

3. 使用try-finally确保释放

复制代码
try {
    lock.tryLock();
    // 业务逻辑
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

4. 监控锁状态

复制代码
// 在管理后台提供解锁接口
@GetMapping("/unlock")
public Result<Void> forceUnlock(@RequestParam("taskId") Long taskId) {
    RLock lock = redissonClient.getLock(getLockKey(taskId));
    if (lock.isLocked()) {
        lock.forceUnlock(); // ⚠️ 谨慎使用
        log.warn("强制解锁,taskId:{}", taskId);
    }
    return Result.success();
}

5. 避免锁范围过大

  • 锁只保护临界区(如检查+启动任务)

  • 耗时操作(文件上传、数据处理)应在锁外执行


六、总结

反模式 正确模式
跨线程释放锁 同线程获取/释放
tryLock()无参 tryLock(waitTime, leaseTime, TimeUnit)
依赖whenComplete释放 使用try-finally确保释放
锁内执行耗时操作 锁只保护临界区
获取锁失败不清理资源 失败时清理所有临时资源

核心教训 :分布式锁不是魔法,finally块也不是万能的。必须深入理解锁的实现机制,才能在异步场景下正确使用。

相关推荐
特立独行的猫a2 小时前
QT开发鸿蒙PC应用:第一个Qt Widget应用入门
数据库·qt·harmonyos·鸿蒙pc·qtwidget
l1t2 小时前
sqlite递归查询指定搜索顺序的方法
数据库·sql·sqlite·dfs·递归·cte
摇滚侠2 小时前
Java 零基础全套视频教程,异常,处理异常,自定义异常,笔记 124-129
java·笔记
盛世宏博北京2 小时前
守护千年文脉:图书馆古籍库房自动化环境治理(温湿度 + 消毒)技术方案
服务器·数据库·自动化·图书馆温湿度监控
「光与松果」2 小时前
Mongodb 日常维护命令集
数据库·mongodb
哥哥还在IT中2 小时前
etcd内存占用高如何排查
数据库·etcd
Web极客码2 小时前
使用phpMyAdmin轻松操作WordPress数据库
数据库·oracle
伯明翰java2 小时前
【无标题】springboot项目yml中使用中文注释报错的解决方法
java·spring boot·后端
企微自动化2 小时前
企业微信二次开发:深度解析外部群主动推送的实现路径
java·开发语言·企业微信