Redis实例-2

异步执行任务(线程池)以及 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()

相关推荐
日月云棠21 小时前
各版本JDK对比:JDK 25 特性详解
java
用户8307196840821 天前
Spring Boot 项目中日期处理的最佳实践
java·spring boot
JavaGuide1 天前
Claude Opus 4.6 真的用不起了!我换成了国产 M2.5,实测真香!!
java·spring·ai·claude code
IT探险家1 天前
Java 基本数据类型:8 种原始类型 + 数组 + 6 个新手必踩的坑
java
花花无缺1 天前
搞懂new 关键字(构造函数)和 .builder() 模式(建造者模式)创建对象
java
用户908324602731 天前
Spring Boot + MyBatis-Plus 多租户实战:从数据隔离到权限控制的完整方案
java·后端
桦说编程1 天前
实战分析 ConcurrentHashMap.computeIfAbsent 的锁冲突问题
java·后端·性能优化
程序员清风1 天前
用了三年AI,我总结出高效使用AI的3个习惯!
java·后端·面试
beata1 天前
Java基础-13: Java反射机制详解:原理、使用与实战示例
java·后端
用户0332126663671 天前
Java 使用 Spire.Presentation 在 PowerPoint 中添加或删除表格行与列
java