异步执行任务(线程池)以及 Redis 锁
说明:异步执行任务(线程池的使用)以及 Redis 任务状态锁的实现
1. 异步配置类 AsyncConfig:(创建线程池)
1.作用:
①notificationExecutor 作为一个自定义线程池,主要用于处理异步任务,例如发送通知、处理后台作业等。②通过将耗时操作放入线程池中执行,可以避免阻塞主线程,提升系统响应速度和并发能力。
2. 常见配置参数及其意义:
corePoolSize:核心线程数,表示线程池中始终保持的最小线程数量。
maxPoolSize:最大线程数,表示线程池中允许创建的最大线程数量。
queueCapacity:任务队列容量,当核心线程都在忙碌时,新任务会进入队列等待。
keepAliveSeconds:非核心线程的空闲存活时间,超过该时间后会被回收。
threadNamePrefix:线程名称前缀,便于调试和监控。
3. 典型使用场景:
①异步通知:如发送邮件、短信或站内信等。②批量处理:如定时任务、数据同步等。③解耦主流程:将非关键路径的操作异步化,减少主流程耗。
4. 数值设置的意义:
①合理设置线程数:根据 CPU 核心数和任务类型(CPU 密集型或 IO 密集型)调整核心线程数和最大线程数。②队列容量控制:避免任务堆积过多导致内存溢出。③超时时间优化:防止线程长时间占用资源,提高资源利用率。
java
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(5);
// 最大线程数
executor.setMaxPoolSize(20);
// 队列容量
executor.setQueueCapacity(200);
// 线程名前缀
executor.setThreadNamePrefix("notification-");
// 拒绝策略:由调用线程处理
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务完成后再关闭
executor.setWaitForTasksToCompleteOnShutdown(true);
// 等待时间
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}
2. 异步执行排版任务以及 Redis 任务状态锁的实现类
@Async("notificationExecutor"): 该注解表明这是一个异步方法,使用名为 "notificationExecutor" 的线程池来执行。Spring 的 @Async 注解依赖于 AOP 代理机制,只有通过 Spring 容器获取的代理对象调用该方法时才会真正异步执行。
1. 异步执行排版任务:
①方法定义 :doFormatAsync 方法通过 @Async("notificationExecutor") 注解标记为异步执行。②调用方式 :在 startFormat 方法中,通过 ApplicationContext 获取代理对象调用 doFormatAsync,确保 @Async 生效。③核心逻辑 :从数据库查询原文件信息并保存到临时目录。调用外部排版服务(如公文排版)生成排版后的文件。上传排版结果文件到文件服务器,并保存结果项到 Redis。清理临时文件和任务状态。
2. Redis 任务状态锁:
①锁机制 :在 startFormat 方法中,使用 Redis 的 stringRedisTemplate.opsForValue().set 方法设置任务状态为 PROCESSING,防止重复提交。锁的 Key 格式为:format:task:{userId}:{styleId}:{nodeId},确保唯一性。
锁的过期时间为 24 小时,避免长时间占用。②锁检查 :在任务启动前,通过 stringRedisTemplate.opsForValue().get(taskKey) 检查任务是否已在处理中。如果任务状态为 PROCESSING,则抛出异常提示用户"文件正在排版处理中,请勿重复提交"。
3. 状态管理:
①排版中状态 :在 saveFormattingResultItem 方法中,保存一个临时结果项(状态为 FORMATTING),供前端轮询查询。②排版完成/失败 :排版成功后,删除临时结果项并保存最终结果。排版失败时,保存失败状态的结果项。③任务清理 :排版完成后,通过 stringRedisTemplate.delete(taskKey) 删除任务状态锁。
4. 异常处理:
① 使用 try-catch-finally 捕获所有异常,确保任务失败时也能清理临时文件和状态锁。② 日志记录详细的任务执行过程和异常信息,便于排查问题。
这种设计既保证了任务的幂等性和并发安全性,又通过异步执行提升了系统性能。
1. FormatService中:startFormat()
java
public void startFormat(StartFormatRequest request) {
String nodeId = request.getNodeId();
Integer styleId = request.getStyleId();
Long userId = SecurityUtils.getCurrentUserId();
// 1. 参数校验与初始化: 校验排版样式(fromCode会在styleId无效时抛出异常)
LayoutStyle.fromCode(styleId);
// 2.防重复提交控制:检查是否正在处理中.
// 通过 Redis 检查当前用户是否正在处理相同文件的排版任务,避免重复提交.
String taskKey = FORMAT_TASK_KEY_PREFIX + userId + ":" + styleId + ":" + nodeId;
String currentStatus = stringRedisTemplate.opsForValue().get(taskKey);//检查是否正在处理中
if (TASK_PROCESSING.equals(currentStatus)) {
log.info("FormatService##startFormat-1,文件正在排版处理中,请勿重复提交,nodeId={},styleId={}", nodeId, styleId);
throw new BizException(ErrorCode.BUSINESS_ERROR, "文件正在排版处理中,请勿重复提交");
}
// ....
// 3. 清理该文件的旧排版结果(同一userId + sourceNodeId 只保留最新一次)
// 调用 cleanupOldFormatResults 方法删除用户历史排版结果,确保只保留最新一次排版数据。
cleanupOldFormatResults(userId, nodeId);
// 4.设置任务状态为处理中(24小时过期)
// 在 Redis 中设置任务状态为"处理中"(PROCESSING),并设置 24 小时过期时间
stringRedisTemplate.opsForValue().set(taskKey, TASK_PROCESSING, 24, TimeUnit.HOURS);
// 5. 保存初始状态:立即保存"排版中"状态的结果项,让前端可以立即查询到
// 调用 saveFormattingResultItem 方法立即保存"排版中"状态的结果项,供前端实时查询
saveFormattingResultItem(nodeId, fileName, userId);
// 6. 异步执行排版任务:通过ApplicationContext获取代理对象,调用doFormatAsync方法异步执行排版逻辑
// 通过 ApplicationContext 获取代理对象调用,触发 @Async 异步执行
// 直接调用 doFormatAsync() 不会走 AOP 代理,@Async 不生效
// 注意:Spring 使用 JDK 动态代理,需要通过接口类型获取 Bean
FormatService proxy = applicationContext.getBean(FormatService.class);
proxy.doFormatAsync(styleId, null, nodeId, fileId, fileName, userId, taskKey);
// 7.日志记录与返回:方法立即返回,前端通过轮询 query 接口查询排版状态
// 记录关键操作日志,方法执行后立即返回,前端通过轮询接口查询排版状态
log.info("FormatService##startFormat-6,排版任务已提交异步执行,nodeId={}", nodeId);
}
2. FormatService中:doFormatAsync()
java
@Async("notificationExecutor")
public void doFormatAsync(Integer styleId, Integer formId, String sourceNodeId, String fileId, String fileName, Long userId, String taskKey) {
// 临时文件路径,用于最后清理
String tempFilePath = null;
try {
// 1. 初始化与前置校验: 校验原文件是否存在且未被删除。若校验失败,则保存失败状态并清理任务状态。
KbNode sourceNode = kbNodeMapper.selectById(sourceNodeId);
if (sourceNode == null || sourceNode.getDeleted() == 1) {
log.error("FormatService##doFormatAsync-0,排版任务失败,原文件节点不存在,sourceNodeId={}", sourceNodeId);
saveFailedResultItem(sourceNodeId, fileName, userId);
stringRedisTemplate.delete(taskKey);
return;
}
// 如果原文件有父节点则使用父节点,否则说明在根目录,使用原文件所属的根节点(通过查询获取)
String folderNodeId = StringUtils.hasText(sourceNode.getParentId()) ? sourceNode.getParentId(): sourceNodeId; // 根目录下的文件,暂时使用原nodeId,实际部署时可能需要调整
FileBean fileBean = fileBeanService.getById(fileId);// 从file表(db2)查询文件信息
if (fileBean == null || !StringUtils.hasText(fileBean.getFileUrl())) {
log.info("FormatService##doFormatAsync-1,排版任务失败,文件不存在,fileId={}", fileId);
// 保存失败状态的结果项
saveFailedResultItem(sourceNodeId, fileName, userId);
stringRedisTemplate.delete(taskKey);
return;
}
// 2. 文件下载与临时存储:将原始文件从存储系统下载到本地临时目录。
// 使用 saveFileToTemp 方法构建临时路径并复制文件。若下载失败,则记录失败状态并退出.
tempFilePath = saveFileToTemp(fileBean.getFileUrl(), fileName);
if (tempFilePath == null) {
log.error("FormatService##doFormatAsync-2,排版任务失败,文件保存到临时目录失败,fileUrl={}", fileBean.getFileUrl());
saveFailedResultItem(sourceNodeId, fileName, userId);
stringRedisTemplate.delete(taskKey);
return;
}
// 3. 调用外部排版服务:调用公文排版服务生成排版后的文件(PDF 和 Word)。
// callExternalFormatService中:读取临时文件内容。根据styleId选择模板。调用 govdocFormatterService.format() 执行排版。将排版结果保存到临时目录。
FormatCallbackResult callbackResult = callExternalFormatService(styleId, tempFilePath);
if (callbackResult == null || !callbackResult.isSuccess()) {
log.error("FormatService##doFormatAsync-4,排版任务失败,外部排版接口调用失败,sourceNodeId={}", sourceNodeId);
saveFailedResultItem(sourceNodeId, fileName, userId);
stringRedisTemplate.delete(taskKey);
return;
}
// 4. 删除"排版中"状态的临时结果项:清除任务启动时插入的"排版中"状态项,排版完成或失败时调用。
removeFormattingResultItem(sourceNodeId, userId);
// 5. 上传排版结果文件:将排版后的文件上传至文件服务器,并生成新的 nodeId,并保存结果。
// 分别处理PDF和Word文件、调用uploadSingleFile方法上传文件、FormatResultItem对象保存结果信息
List<FormatResultItem> resultItems = uploadFormattedFiles(callbackResult, sourceNodeId, fileName, userId, folderNodeId);
// 6. 保存每个结果项到 Redis,将排版结果持久化到 Redis 中,供前端查询。
// 使用 FORMAT_ITEM_KEY_PREFIX 保存结果项详情。建立用户、原文件与结果文件之间的映射关系.
for (FormatResultItem item : resultItems) {
saveResultItem(item, userId, sourceNodeId);
}
// 7. 删除任务状态标识并清理临时文件目录(表示任务完成)
stringRedisTemplate.delete(taskKey);
// 捕获所有异常(包括Error和Exception),确保任务失败时能正确记录状态并释放资源。
} catch (Throwable e) {
log.error("FormatService##doFormatAsync-e,排版任务异常,userId={}, sourceNodeId={}", userId, sourceNodeId, e);
// 删除"排版中"的临时结果项
removeFormattingResultItem(sourceNodeId, userId);
saveFailedResultItem(sourceNodeId, fileName, userId);
stringRedisTemplate.delete(taskKey);
} finally {
// 7. 清理临时文件目录(无论成功或失败都要清理)
cleanupTempDirectory(tempFilePath);
}
}
3. FormatService中:saveFormattingResultItem()
java
private void saveFormattingResultItem(String sourceNodeId, String fileName, Long userId) {
try {
// 排版中状态,结果nodeId使用原文件nodeId加后缀
String resultNodeId = sourceNodeId + "_formatting";
FormatResultItem formattingItem = FormatResultItem.builder()
.status(FormatStatus.FORMATTING.getCode())
.type("pending")
.nodeId(resultNodeId)
.sourceNodeId(sourceNodeId)
.fileName(getFileBaseName(fileName))
.build();
saveResultItem(formattingItem, userId, sourceNodeId);
log.debug("FormatService##saveFormattingResultItem-1,保存排版中状态成功,userId={}, sourceNodeId={}", userId, sourceNodeId);
} catch (Exception e) {
log.error("FormatService##saveFormattingResultItem-e,保存排版中状态结果项异常,userId={}, sourceNodeId={}", userId, sourceNodeId, e);
}
}
3. FormatService中:saveFormattingResultItem()
java
private void saveResultItem(FormatResultItem item, Long userId, String sourceNodeId) {
try {
String resultNodeId = item.getNodeId();
String itemKey = FORMAT_ITEM_KEY_PREFIX + resultNodeId;
String itemJson = objectMapper.writeValueAsString(item);
// 1. 保存结果项详情(24小时过期)
stringRedisTemplate.opsForValue().set(itemKey, itemJson, 24, TimeUnit.HOURS);
// 2. 添加原文件nodeId到用户列表(userId -> sourceNodeId)
String userKey = FORMAT_USER_KEY_PREFIX + userId;
stringRedisTemplate.opsForSet().add(userKey, sourceNodeId);
stringRedisTemplate.expire(userKey, 24, TimeUnit.HOURS);
// 3. 添加结果nodeId到原文件映射(userId:sourceNodeId -> resultNodeId)
String sourceKey = FORMAT_SOURCE_KEY_PREFIX + userId + ":" + sourceNodeId;
stringRedisTemplate.opsForSet().add(sourceKey, resultNodeId);
stringRedisTemplate.expire(sourceKey, 24, TimeUnit.HOURS);
} catch (JsonProcessingException e) {
log.error("FormatService##saveResultItem-e,保存排版结果项失败,item={}", item, e);
}
}
3. FormatService中:cleanupOldFormatResults()
java
private void cleanupOldFormatResults(Long userId, String sourceNodeId) {
try {
// 1. 获取该原文件的所有旧排版结果 nodeId
String sourceKey = FORMAT_SOURCE_KEY_PREFIX + userId + ":" + sourceNodeId;
Set<String> oldResultNodeIds = stringRedisTemplate.opsForSet().members(sourceKey);
if (!CollectionUtils.isEmpty(oldResultNodeIds)) {
// 2. 删除每个旧结果项的详情数据
for (String resultNodeId : oldResultNodeIds) {
String itemKey = FORMAT_ITEM_KEY_PREFIX + resultNodeId;
stringRedisTemplate.delete(itemKey);
log.debug("FormatService##cleanupOldFormatResults-1,删除旧排版结果项,resultNodeId={}", resultNodeId);
}
// 3. 删除原文件到结果的映射
stringRedisTemplate.delete(sourceKey);
log.info("FormatService##cleanupOldFormatResults-2,清理旧排版结果完成,userId={}, sourceNodeId={}, 清理数量={}",
userId, sourceNodeId, oldResultNodeIds.size());
}
// 4. 从用户列表中移除该原文件(后续保存新结果时会重新添加)
String userKey = FORMAT_USER_KEY_PREFIX + userId;
stringRedisTemplate.opsForSet().remove(userKey, sourceNodeId);
} catch (Exception e) {
log.warn("FormatService##cleanupOldFormatResults-e,清理旧排版结果异常,userId={}, sourceNodeId={}", userId, sourceNodeId, e);
// 清理失败不影响新任务启动
}
}
3. FormatService中:callExternalFormatService()
java
private FormatCallbackResult callExternalFormatService(Integer styleId, String tempFilePath) {
FormatCallbackResult result = new FormatCallbackResult();
try {
// 1. 读取原始文件(使用 File 类处理中文路径)
File sourceFile = new File(tempFilePath);
byte[] sourceDocx = Files.readAllBytes(sourceFile.toPath());
// 2. 根据 styleId 选择模板
String templateId = selectTemplate(styleId);
// 3. 调用公文排版服务
GovdocFormatterService.FormatOptions options = new GovdocFormatterService.FormatOptions(false, true);
GovdocFormatterService.FormatResult formatResult = govdocFormatterService.format(sourceDocx, templateId, options);
// 4. 保存排版后的文件到临时目录
String baseName = getFileBaseName(tempFilePath);
File tempDir = sourceFile.getParentFile();
// 保存 Word 文件
if (formatResult.formattedDocx() != null && formatResult.formattedDocx().length > 0) {
File wordFile = new File(tempDir, baseName + "_formatted.docx");
Files.write(wordFile.toPath(), formatResult.formattedDocx());
result.setWordFilePath(wordFile.getAbsolutePath());
}
// 保存 PDF 文件
if (formatResult.formattedPdf() != null && formatResult.formattedPdf().length > 0) {
File pdfFile = new File(tempDir, baseName + "_formatted.pdf");
Files.write(pdfFile.toPath(), formatResult.formattedPdf());
result.setPdfFilePath(pdfFile.getAbsolutePath());
}
result.setSuccess(true);
} catch (Exception e) {
result.setSuccess(false);
result.setErrorMessage(e.getMessage());
}
return result;
}
3. FormatService中:uploadFormattedFiles()
java
private List<FormatResultItem> uploadFormattedFiles(FormatCallbackResult callbackResult,
String originalNodeId,
String originalFileName,
Long userId,
String folderNodeId) {
List<FormatResultItem> resultItems = new ArrayList<>();
String baseName = getFileBaseName(originalFileName);
// PDF 结果
if (StringUtils.hasText(callbackResult.getPdfFilePath())) {
String pdfFileName = baseName + "(已排版).pdf";
String pdfNodeId = uploadSingleFile(callbackResult.getPdfFilePath(),pdfFileName,"application/pdf",userId,folderNodeId);
if (StringUtils.hasText(pdfNodeId)) {
FormatResultItem pdfItem = FormatResultItem.builder().status(FormatStatus.COMPLETED.getCode()).nodeId(pdfNodeId).sourceNodeId(originalNodeId).fileName(pdfFileName).build();
resultItems.add(pdfItem);
} else {
// 上传失败,记录失败状态
FormatResultItem pdfItem = FormatResultItem.builder().status(FormatStatus.FAILED.getCode()).type("pdf").nodeId(originalNodeId + "_pdf_failed").sourceNodeId(originalNodeId).fileName(pdfFileName).build();
resultItems.add(pdfItem);
}
}
// Word 结果
if (StringUtils.hasText(callbackResult.getWordFilePath())) {
log.info("FormatService##uploadFormattedFiles-2,开始上传Word文件,wordPath={}", callbackResult.getWordFilePath());
String wordFileName = baseName + "(已排版).docx";
String wordNodeId = uploadSingleFile(callbackResult.getWordFilePath(),wordFileName,"application/vnd.openxmlformats-officedocument.wordprocessingml.document",userId,folderNodeId
);
if (StringUtils.hasText(wordNodeId)) {
FormatResultItem wordItem = FormatResultItem.builder().status(FormatStatus.COMPLETED.getCode()).type("word").nodeId(wordNodeId).sourceNodeId(originalNodeId).fileName(wordFileName).build();
resultItems.add(wordItem);
} else {
// 上传失败,记录失败状态
FormatResultItem wordItem = FormatResultItem.builder().status(FormatStatus.FAILED.getCode()).type("word").nodeId(originalNodeId + "_word_failed").sourceNodeId(originalNodeId).fileName(wordFileName).build();
resultItems.add(wordItem);
}
} else {
log.warn("FormatService##uploadFormattedFiles-5,Word文件路径为空,跳过上传");
}
return resultItems;
}
3. FormatService中:uploadSingleFile()
java
private String uploadSingleFile(String filePath, String fileName, String contentType, Long userId, String folderNodeId) {
try {
// 1. 读取文件内容(使用 File 类处理中文路径)
File file = new File(filePath);
byte[] fileBytes = Files.readAllBytes(file.toPath());
// 2. 生成上传Token
GeneratorFileTokenRequest tokenRequest = new GeneratorFileTokenRequest();
tokenRequest.setAction(FileActionEnum.UPLOAD_FILE.getCode());
tokenRequest.setFileSize((long) fileBytes.length);
tokenRequest.set...
Map<String, String> tokenMap = kbFileService.generateFileToken(tokenRequest);
String capToken = tokenMap.get("capToken");
// 3. 构建上传参数
UploadFileDTO uploadFileDto = new UploadFileDTO();
uploadFileDto.setFolderNodeId(folderNodeId);
uploadFileDto.setTotalSize(fileBytes.length);
uploadFileDto.set...
// 4. 创建MultipartFile
MultipartFile multipartFile = new ByteArrayMultipartFile("file",fileName,contentType,fileBytes);
// 5. 调用上传接口
String result = fileUploadFeignClient.uploadFile(capToken, multipartFile, uploadFileDto);
JSONObject jsonObject = JSONUtil.parseObj(result);
// 6. 获取返回的nodeId
JSONObject data = jsonObject.getJSONObject("data");
String nodeId = data.getStr("nodeId");
return nodeId;
} catch (Exception e) {
log.error("FormatService##uploadSingleFile-e,上传文件异常,filePath={}, fileName={}", filePath, fileName, e);
return null;
}
}
3. FileUploadFeignClient 中:uploadFile()
java
/**
* @Author: luoJunChong
* @Date: 2026/1/26 20:03
* Feign客户端:调用文件上传接口
* name:自定义客户端名称
* url:服务端地址(若用注册中心可替换为服务名)
* configuration:绑定文件上传编码器配置
*/
@FeignClient(
name = "ecomind-file-service",
url = "${service.file.base-url}", // 替换为实际服务端地址
configuration = FeignMultipartConfig.class)
public interface FileUploadFeignClient {
/**
* 调用/uploadfile接口
* 注意:
* 1. consumes必须指定multipart/form-data
* 2. @RequestPart用于form-data参数(文件+普通参数)
* 3. 参数名需和服务端一致:capToken、file(对应UploadFileDTO的file字段)、其他DTO字段
*/
@PostMapping(
value = "/filetransfer/uploadfileV2",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE // 必须指定文件上传格式
)
String uploadFile(
@RequestParam("capToken") String capToken, // 对应服务端@RequestParam的capToken
@RequestPart("file") MultipartFile file, // 对应服务端request里的文件(参数名"file"要一致)
@RequestPart(value = "uploadFileDto", required = false) UploadFileDTO uploadFileDto // 其他业务参数
);
}
3. FileUploadFeignClient 中:uploadFile()