一、背景:一个"完美"的异步导入接口
某天,代码review时看到同事实现的一个数据导入接口,逻辑清晰且考虑周全:
-
接收Excel文件并保存到临时目录
-
使用Redisson分布式锁防止重复导入
-
异步执行导入任务,避免阻塞主线程
-
任务完成后在回调中释放锁
@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块的虚假安全感
whenComplete的finally块并不保证一定执行:
| 场景 | 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; // 只能跳出内层循环
}
}
// 外层循环永不退出
}
}
现象:
-
日志卡在"执行导入中",不再输出"执行导入完成"
-
查看Redis:
TTL data:import:task:123持续刷新(看门狗续期) -
第二次请求永远返回"任务正在导入中"
线程堆栈分析:
"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块也不是万能的。必须深入理解锁的实现机制,才能在异步场景下正确使用。