for循环查询数据表中deleted=1的已删除文件名字,每次分页查询查1000条,1000条任务交给线程池去完成对文件的并行物理删除,要求1000个任务全部执行完主线程再去查询下一批次的1000条数据。
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.*;
@Configuration(proxyBeanMethods = false)
public class AppManageThreadPoolConfig {
// destroyMethod = "shutdown"其实可以去除,spring容器关闭时会对有shutdown()的资源自动调用shutdowen
@Bean(name = "appManageThreadPool", destroyMethod = "shutdown")
public ExecutorService getThreadPool() {
int cpuCores = Runtime.getRuntime().availableProcessors();
int corePoolSize = cpuCores * 2; // 删除文件是IO密集型任务
int maxPoolSize = corePoolSize * 2; // 最大线程数 = corePoolSize * 2 / corePoolSize
int queueCapacity = 800; // 有界队列容量
ThreadFactory threadFactory = Executors.defaultThreadFactory(); // 默认线程工厂,线程名为固定的 "pool-[poolNumber]-thread-[threadNumber]"格式,如pool-1-thread-1
return new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
60L, TimeUnit.SECONDS, // 线程空闲存活时间
new ArrayBlockingQueue<>(queueCapacity), // 有界队列(默认非公平队列,可通过添加第二个参数true设置为公平队列,公平队列会牺牲性能)
threadFactory,
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时的拒绝策略:当线程数达到maxPoolSize并且队列达到queueCapacity上限时,新任务由提交它的线程(主线程)执行
// CallerRunsPolicy的设计精髓在于其负反馈调节机制。当它开始将任务回退给主线程执行时,会产生两个关键效果:
// 1.降低提交速度:由于主线程需要亲自执行被拒绝的任务(调用任务的 run方法,是同步操作),它提交新任务到线程池的循环自然会变慢甚至被暂时阻塞。这相当于从源头降低了新任务涌入线程池的速度,为线程池中的工作线程争取了时间来处理队列中积压的任务
// 2.避免资源耗尽:这是一种比无限制地堆积任务更优的策略。它通过让调用者付出"代价"(亲自运行任务),来保护整个系统不会因为任务无限增长而导致内存溢出(如果使用无界队列)或彻底崩溃
// 尽管有上述机制,如果任务处理时间非常长,或者持续有超大量的任务涌入,主线程长时间忙于执行任务确实可能影响其原有的职责(比如接收用户请求或事件),适当增大队列容量可以在一定程度上延缓拒绝策略的触发,但需权衡内存占用和任务延迟
);
}
}
java
@Resource
CompRevisionService revisionService;
@Resource
ComponentUploadService uploadService;
/**
* 清理无效应用
*/
@GetMapping("/cleanInvalidApp")
@ResponseBody
@InterfaceLog
public Result cleanInvalidApp() {
final int BATCH_SIZE = 1000;
try {
for (int page = 0; ; page++) {
int offset = page * BATCH_SIZE;
// LIMIT指定返回行数,OFFSET指定跳过的行数,Mysql中LIMIT 20, 10 等同于 LIMIT 10 OFFSET 20
List<String> batchFileNameList = revisionService.getInvalidAppFileNameBatch(BATCH_SIZE, offset);
// 如果当前批次没有数据,跳出循环
if (CollectionUtils.isEmpty(batchFileNameList)) {
break;
}
int batchNum = page + 1;
log.info("第 {} 批次, 需处理 {} 个文件", batchNum, batchFileNameList.size());
// 多线程处理该批次文件,该批次全部处理完再处理下一批
boolean batchCompleted = uploadService.parallelDeleteFiles(batchFileNameList, batchNum, batchFileNameList.size());
if (!batchCompleted) {
String eMsg = "第 " + batchNum + " 批次处理出现异常,停止后续批次的处理";
log.warn(eMsg);
throw new ApiException(ErrorCode.B0.code(), eMsg);
}
// 如果当前批次数据量小于分页大小,说明已是最后一页,跳出循环
if (batchFileNameList.size() < BATCH_SIZE) {
break;
}
}
return Result.newSuccess("清理完成!", null);
} catch (Exception e) {
String eMsg = "清理出错!";
if (e instanceof AssertUtil.BusinessException) {
eMsg = e.getMessage();
}
log.error(LogUtil.formatErrorMessage(ErrorCode.B0, eMsg), e);
throw new ApiException(ErrorCode.B0.code(), eMsg, e);
}
}
java
//注入线程池
@Resource(name = "appManageThreadPool")
private ExecutorService executorService;
@Override
public boolean parallelDeleteFiles(List<String> fileNameList, int batchNum, int fileNum) {
Path basePath = Paths.get(applicationCompressedPath);
if (!Files.exists(basePath)) {
throw new AssertUtil.BusinessException("错误:基础目录不存在 - " + applicationCompressedPath);
}
log.info("开始处理第 {} 批文件,该批次总计需删除 {} 个文件", batchNum, fileNum);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger notExistCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
AtomicInteger totalProcessed = new AtomicInteger(0);
// 使用CountDownLatch等待所有任务完成,确保每个批次完全处理完成后再继续下一批次
CountDownLatch latch = new CountDownLatch(fileNameList.size());
boolean batchSuccess = true;
try {
for (String fileName : fileNameList) {
executorService.submit(() -> {
try {
// 安全地构建文件路径,防止路径遍历攻击
Path filePath = basePath.resolve(fileName).normalize();
// 验证路径安全性,确保文件在基础目录内
if (!filePath.startsWith(basePath)) {
log.error("路径遍历攻击尝试: {}", fileName);
failCount.incrementAndGet();
return;
}
if (!Files.exists(filePath)) {
log.debug("文件不存在,跳过: {}", fileName);
notExistCount.incrementAndGet();
return;
}
// 执行删除
Files.delete(filePath);
successCount.incrementAndGet();
log.debug("成功删除: {}", fileName);
} catch (IOException e) {
log.error("删除失败: {} - {}", fileName, e.getMessage());
failCount.incrementAndGet();
} catch (Exception e) {
log.error("处理文件时发生意外错误: {} - {}", fileName, e.getMessage());
failCount.incrementAndGet();
} finally {
totalProcessed.incrementAndGet();
latch.countDown(); // 标记任务完成 此时工作线程不会被阻塞,可以继续执行其他任务或进入空闲状态。而主线程则在await()处等待,直到所有工作线程都完成任务、计数器归零后才被唤醒继续执行
}
});
}
// 主线程在此等待1000个子任务全部完再继续执行,设置超时时间避免无限等待
// 如果在这9分钟内,计数器成功递减到零(所有要删除的文件都处理完毕了),latch.await方法返回 true,表示等待成功
// 如果等待了9分钟后,计数器仍然大于零(还有文件没删完),latch.await方法将停止等待并返回 false,表示超时
if (!latch.await(9, TimeUnit.MINUTES)) { // nginx超时时间为10分钟,为了让前端拿到响应,这里设置为9分钟
log.error("第 {} 批次文件删除操作超时", batchNum);
batchSuccess = false;
// 在超时发生时,并未尝试中断那些尚未完成的任务,一种改进思路是,在latch.await超时后,中断仍在运行的任务
}
} catch (InterruptedException e) { // 若阻塞的主线程(latch.await会让主线程阻塞)被外部设置了中断标记,await()会抛出该异常并让主线程退出阻塞
log.error("主线程在等待该批次任务全部完成时被中断(即本批次的处理流程因被中断而未完整地同步等待至结束)!" +
"设置该批次处理失败标记(其实该批次未必失败,因为工作线程组仍在继续运行),主线程在获取这个失败标记后会抛出ApiException异常,放弃后续批次的处理"
, e);
Thread.currentThread().interrupt();
// 设置该批次处理失败标记(其实该批次未必失败,因为工作线程组仍在继续运行),主线程在获取这个失败标记后会抛出ApiException异常,放弃后续批次的处理
batchSuccess = false;
}
// 输出最终统计结果
int total = totalProcessed.get(); // 实际已处理的数量
int pendingOnTimeout = fileNameList.size() - total; // 计算因超时尚未得到处理结果的数量
log.info("第 {} 批次删除操作完成!总计需处理: {} 个文件;实际已处理: {} 个文件,其中成功: {} 个,失败: {} 个,不存在: {} 个。{}",
batchNum,
fileNameList.size(),
total,
successCount.get(),
failCount.get(),
notExistCount.get(),
pendingOnTimeout > 0 ? "(超时时刻,尚有 " + pendingOnTimeout + " 个文件正在处理!)" : "");
return batchSuccess && (failCount.get() == 0);
}