十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot异步导入导出实战:从原理到生产级实现

SpringBoot异步导入导出实战:从原理到生产级实现 1. 项目概述为什么异步导入导出是后台系统的“刚需”做后台系统开发尤其是涉及到数据管理的导入导出功能几乎是标配。但很多新手甚至一些有经验的开发者在处理大批量数据时依然会采用同步阻塞的方式。用户点一下“导出”页面就卡住转圈圈直到几万条数据生成Excel文件下载或者上传一个几十兆的Excel文件页面就“假死”等待所有数据入库。这种体验用户骂娘服务器压力也大一个请求长时间占用连接和线程搞不好就直接超时或内存溢出了。异步任务的核心价值就是把这种“耗时且不确定”的操作从用户发起请求的HTTP线程中剥离出去。用户点了按钮系统立刻返回一个“任务已提交正在处理”的提示并给一个任务ID。真正的数据处理在后台由专门的线程池慢慢消化。用户可以去干别的或者过一会儿再来查询任务进度和结果。这不仅仅是用户体验的提升更是系统稳定性的保障。SpringBoot作为Java领域最流行的微服务框架其生态对异步处理的支持非常完善结合消息队列、线程池、数据库状态记录可以构建出非常健壮的异步任务处理模块。今天我就结合自己踩过的坑和最佳实践拆解一下在SpringBoot中实现一个生产可用的异步导入导出任务的完整思路和超详细流程。2. 整体架构设计与核心组件选型在动手写代码之前得先把蓝图画清楚。一个完整的异步任务系统不仅仅是开个线程跑那么简单它需要考虑任务的生命周期管理、状态持久化、结果反馈、异常处理和可观测性。2.1 核心流程与状态流转一个典型的异步导入导出任务其生命周期可以抽象为以下几个状态待处理PENDING - 处理中PROCESSING - 成功SUCCESS/ 失败FAILED。有些场景可能还需要**取消CANCELLED**状态。整个系统的参与角色和流程如下前端/客户端发起导入/导出请求。Controller层接收请求进行基础参数校验如文件非空、格式正确然后立即创建一个任务记录存入数据库状态为PENDING并提交一个异步任务到执行器。最后将任务ID返回给前端。异步任务执行器通常是一个由Async注解标记的方法或是一个被消息队列监听器调用的方法。它从数据库或消息中获取任务详情开始执行核心业务逻辑。任务服务层包含具体的导入导出逻辑如使用EasyExcel解析文件、进行数据校验和转换、分批写入数据库或从数据库查询大量数据、生成Excel/PDF文件并上传到对象存储如OSS、MinIO。状态更新与回调任务执行过程中需要定期更新任务进度如已处理/总条数执行结束后更新最终状态SUCCESS/FAILED并存储结果信息如成功条数、失败原因、结果文件URL。进度查询接口前端根据任务ID轮询或通过WebSocket查询任务实时进度和最终结果。2.2 技术栈选型与理由SpringBoot Spring Framework基础框架提供Async异步支持、事务管理。数据库MySQL/PostgreSQL用于持久化任务元数据任务ID、类型、状态、进度、创建时间、结果信息等。一张async_task表是核心。线程池ThreadPoolTaskExecutor执行异步任务的核心。绝对不要使用Spring默认的简单异步执行器必须自定义配置控制核心线程数、最大线程数、队列容量和拒绝策略防止资源耗尽。EasyExcel处理Excel导入导出的首选。阿里开源的这款工具内存占用低基于SAX模型解析API友好特别适合处理大数据量。对于复杂表头、数据校验、自定义转换器支持得很好。对象存储OSS/S3/MinIO强烈建议将生成的导出文件存放在对象存储而不是服务器本地磁盘或通过HTTP响应流直接返回。原因有三1) 文件可长期保存支持多次下载2) 避免大文件传输占用应用服务器带宽和内存3) 前端通过预签名URL下载安全又高效。消息队列RabbitMQ/RocketMQ/Kafka可选但推荐用于高可靠场景。如果系统并发量高或者要求任务绝对不能丢失即使应用重启可以将任务信息发送到消息队列。执行器作为消费者从队列拉取任务。这解耦了请求接收和任务执行提供了更好的削峰填谷能力和可靠性保障。对于大多数中小型项目使用Async配合数据库状态轮询或事件驱动也能满足需求。缓存Redis用于存储临时任务进度支持实时查询减轻数据库压力。也可以用于实现分布式锁防止同一个任务被重复执行。注意技术选型不是堆砌要根据实际业务规模和团队技术栈来。如果就是个小管理后台Async 数据库 EasyExcel 本地磁盘定期清理是完全可行的简化方案。3. 数据库设计与任务模型定义这是整个系统的基石设计得好后续扩展和排查问题会轻松很多。CREATE TABLE async_task ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, task_id varchar(64) NOT NULL COMMENT 任务唯一标识可UUID生成, task_type varchar(50) NOT NULL COMMENT 任务类型如USER_EXPORT, ORDER_IMPORT, task_name varchar(255) DEFAULT NULL COMMENT 任务名称便于识别, status varchar(20) NOT NULL DEFAULT PENDING COMMENT 任务状态PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED, progress int(11) DEFAULT 0 COMMENT 进度百分比0-100, progress_text varchar(500) DEFAULT NULL COMMENT 进度描述如“已处理200/1000条”, params_json text COMMENT 任务参数JSON如导出筛选条件、导入文件OSS路径, result_json text COMMENT 任务结果JSON如成功条数、失败详情、结果文件URL, error_message text COMMENT 失败时的错误信息, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, create_by varchar(64) DEFAULT NULL COMMENT 创建人, start_time datetime DEFAULT NULL COMMENT 开始处理时间, end_time datetime DEFAULT NULL COMMENT 结束时间, PRIMARY KEY (id), UNIQUE KEY uk_task_id (task_id), KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT异步任务记录表;关键字段解析task_id业务上使用的唯一ID前端凭此查询进度。通常用UUID比自增ID更安全避免被遍历。task_type用于区分不同的业务任务。这样同一个任务表可以支撑全站的异步操作。params_json这是一个灵活的设计。将请求参数如导出的时间范围、筛选条件序列化成JSON存入。异步执行器在执行时反序列化获取参数。避免了为每个任务类型单独建参数字段扩展性极强。result_json同理用于存储结构化的结果。对于导出可能是{fileUrl: https://oss.xxx.com/export/xxx.xlsx, count: 1000}对于导入可能是{successCount: 950, failCount: 50, failSample: [...]}。progress和progress_text用于前端展示进度条和文字说明提升用户体验。start_time和end_time用于监控任务执行时长分析性能瓶颈。对应的Java实体类AsyncTask就是这张表的映射这里不再赘述。我们会有一个AsyncTaskService来负责任务的创建、更新和查询。4. 核心实现异步执行器与任务派发这是异步功能的核心我们分两种模式来讲解基于SpringAsync的轻量级模式和基于消息队列的可靠模式。4.1 模式一基于Async与自定义线程池首先必须配置一个自定义线程池取代Spring默认的。Configuration EnableAsync // 启用异步支持 public class AsyncConfig { Bean(taskExecutor) public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数服务器CPU核心数 * 2 executor.setCorePoolSize(Runtime.getRuntime().availableProcessors() * 2); // 最大线程数根据业务量调整防止瞬间高峰击垮数据库 executor.setMaxPoolSize(20); // 队列容量用于缓冲不宜过大否则任务堆积内存压力大 executor.setQueueCapacity(200); // 线程名前缀便于日志追踪 executor.setThreadNamePrefix(async-task-); // 拒绝策略CallerRunsPolicy - 由调用者线程如HTTP线程直接执行保证任务不丢失但会拖慢请求 // 生产环境需根据业务权衡也可选择丢弃或记录日志报警 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }关键参数心得CorePoolSize不是越大越好。线程切换有开销I/O密集型任务可以设大点但我们的导入导出通常是CPU和I/O混合型。从CPU核心数的1-2倍开始调整。QueueCapacity这是缓冲池。如果任务产生速度持续远大于消费速度队列会满然后触发拒绝策略。设置一个合理的值配合监控能及时发现系统瓶颈。RejectedExecutionHandlerCallerRunsPolicy是一种保守但安全的策略能保证任务一定被执行但会拖慢提交任务的请求响应。对于非核心任务可以考虑DiscardPolicy或自定义策略如记录到数据库后续补偿。然后我们创建任务执行服务。这里以导出任务为例。Service Slf4j public class ExportService { Autowired private AsyncTaskService asyncTaskService; Autowired private UserService userService; // 假设是业务服务 Autowired private OssService ossService; // 对象存储服务 /** * 提交导出任务Controller调用此方法 */ public String submitExportTask(ExportRequest request, String operator) { // 1. 创建任务记录状态为PENDING AsyncTask task new AsyncTask(); task.setTaskId(UUID.randomUUID().toString()); task.setTaskType(USER_EXPORT); task.setTaskName(用户数据导出); task.setStatus(TaskStatus.PENDING); task.setCreateBy(operator); task.setParamsJson(JSON.toJSONString(request)); // 使用Fastjson或Jackson asyncTaskService.save(task); // 2. 提交异步任务指定使用我们配置的taskExecutor exportAsync(task.getTaskId()); // 3. 立即返回任务ID return task.getTaskId(); } /** * 异步导出方法 */ Async(taskExecutor) // 指定线程池 Transactional(propagation Propagation.REQUIRES_NEW) // 开启新事务避免污染主事务 public void exportAsync(String taskId) { AsyncTask task asyncTaskService.getByTaskId(taskId); if (task null || !TaskStatus.PENDING.equals(task.getStatus())) { log.warn(任务不存在或状态非待处理 taskId: {}, taskId); return; } try { // 更新状态为处理中 asyncTaskService.updateStatus(taskId, TaskStatus.PROCESSING, null, 0, 开始查询数据...); task.setStartTime(new Date()); // 3. 核心导出逻辑 ExportRequest request JSON.parseObject(task.getParamsJson(), ExportRequest.class); ListUser userList userService.getUsersByCondition(request); // 查询数据 // 更新进度 asyncTaskService.updateProgress(taskId, 30, 数据查询完毕共 userList.size() 条开始生成文件...); // 使用EasyExcel生成文件到本地临时目录 String fileName user_export_ System.currentTimeMillis() .xlsx; String tempFilePath /tmp/export/ fileName; EasyExcel.write(tempFilePath, UserExportVO.class) // UserExportVO是导出视图对象 .sheet(用户数据) .doWrite(userList); asyncTaskService.updateProgress(taskId, 70, 文件生成成功开始上传到OSS...); // 上传到OSS String ossUrl ossService.uploadFile(tempFilePath, export/ fileName); // 清理临时文件 Files.deleteIfExists(Paths.get(tempFilePath)); // 4. 更新任务为成功并存储结果 String resultJson JSON.toJSONString(new ExportResult(ossUrl, userList.size())); asyncTaskService.updateSuccess(taskId, resultJson, 100, 导出成功); } catch (Exception e) { log.error(导出任务执行失败 taskId: {}, taskId, e); // 5. 更新任务为失败 asyncTaskService.updateFailed(taskId, e.getMessage()); } } }这里有几个至关重要的坑点事务传播Async方法默认不在事务上下文中。即使你在调用方法上加了Transactional异步方法内部对数据库的更新也可能不会回滚。因此必须在异步方法上使用Transactional(propagation Propagation.REQUIRES_NEW)为其开启一个独立的新事务。这样任务状态更新和业务操作才能在一个事务单元里。临时文件处理生成在服务器本地的文件必须在使用后删除否则会逐渐撑满磁盘。使用try-with-resources或finally块确保删除。更优的方案是使用EasyExcel的WriteWorkbook直接写到OutputStream然后上传到OSS的流中避免落盘。状态更新时机在关键节点开始、查询完毕、上传前、完成更新进度和状态让前端有反馈。更新状态时最好带上时间戳方便排查卡住的任务。异常捕获异步方法内部的异常不会传播到调用方。必须用try-catch全面捕获并将失败状态和原因更新到数据库否则任务会“静默失败”用户永远等不到结果。4.2 模式二基于消息队列的可靠解耦当系统规模变大或者对任务可靠性要求极高时引入消息队列是更好的选择。这里以Spring Boot整合RabbitMQ为例。首先Controller提交任务后不是直接调用Async方法而是向一个特定的Exchange发送一条消息。Service public class TaskDispatcherService { Autowired private RabbitTemplate rabbitTemplate; public void dispatchExportTask(String taskId) { // 将任务ID发送到消息队列 rabbitTemplate.convertAndSend(task.exchange, task.export.routing.key, taskId); log.info(导出任务已发送到消息队列 taskId: {}, taskId); } }然后有一个独立的消费者服务可以是同一个应用也可以是另一个微服务来监听队列并执行任务。Component Slf4j public class ExportTaskConsumer { Autowired private ExportService exportService; // 这个Service包含了核心业务逻辑 RabbitListener(queues task.export.queue) public void handleExportTask(String taskId) { log.info(收到导出任务消息 taskId: {}, taskId); // 注意这里直接调用业务方法但该方法本身不能再是Async因为已经在消费者线程中了。 // 我们需要重构ExportService将真正的执行逻辑抽成一个doExport方法。 try { exportService.doExport(taskId); // 这是一个同步方法执行核心逻辑 } catch (Exception e) { log.error(消费导出任务失败 taskId: {}, taskId, e); // 消息队列通常会有重试机制如死信队列这里可以根据业务决定是否抛出异常触发重试 // 对于确定性失败如参数错误应记录日志并确认消息避免无限重试 } } }消息队列模式的优势解耦任务生产者和消费者完全独立可以独立部署、伸缩。削峰请求洪峰时任务积压在队列中消费者按能力处理保护后台系统。可靠RabbitMQ等消息队列提供持久化、ACK确认、重试等机制确保任务不丢失。易于监控队列长度、消费速率等是重要的系统健康指标。需要注意的点消息幂等性网络问题可能导致消息被重复投递。消费者逻辑必须保证幂等即同一任务ID执行多次的结果和只执行一次相同。我们的doExport方法在开始时应检查数据库任务状态如果已是PROCESSING或SUCCESS则直接跳过。失败处理要做好死信队列DLQ配置将多次重试仍失败的消息转移到DLQ并设置报警人工处理。5. 导入任务的特殊处理与细节导入任务比导出更复杂因为它涉及到外部不可控数据源的解析、校验和入库出错概率更高。5.1 使用EasyExcel进行流式解析与分批入库绝对不能一次性将整个Excel文件读入内存的ListObject。对于几十上百兆的文件这是内存杀手。public void doImport(String taskId, String ossFileUrl) { AsyncTask task asyncTaskService.getByTaskId(taskId); // ... 状态更新 // 1. 从OSS下载文件流或直接获取InputStream InputStream inputStream ossService.downloadFileStream(ossFileUrl); // 2. 定义监听器用于逐行处理 EasyExcel.read(inputStream, UserImportDTO.class, new UserImportListener(taskId, asyncTaskService, userService)) .sheet() .headRowNumber(1) // 表头行数 .doRead(); // 监听器处理完成后更新最终状态 }UserImportListener需要继承AnalysisEventListener这是EasyExcel的核心。Slf4j public class UserImportListener extends AnalysisEventListenerUserImportDTO { private String taskId; private AsyncTaskService asyncTaskService; private UserService userService; /** * 每隔1000条处理一次然后清理list方便内存回收。 * 这个参数需要根据数据大小和内存情况权衡。 */ private static final int BATCH_COUNT 1000; private ListUserImportDTO cachedList new ArrayList(BATCH_COUNT); private AtomicInteger totalProcessed new AtomicInteger(0); private AtomicInteger successCount new AtomicInteger(0); private ListImportError errorList new ArrayList(); Override public void invoke(UserImportDTO data, AnalysisContext context) { // 1. 数据校验 String error validateData(data); if (StringUtils.isNotBlank(error)) { errorList.add(new ImportError(context.readRowHolder().getRowIndex(), error, data)); return; // 校验失败跳过入库 } cachedList.add(data); // 2. 达到BATCH_COUNT了存储一次数据库 if (cachedList.size() BATCH_COUNT) { saveBatch(); cachedList.clear(); // 3. 更新进度 updateProgress(); } } Override public void doAfterAllAnalysed(AysisContext context) { // 最后一批数据可能不足BATCH_COUNT if (!cachedList.isEmpty()) { saveBatch(); } // 最终完成汇总结果 finishImport(); } private void saveBatch() { if (cachedList.isEmpty()) { return; } try { // 这里建议使用MyBatis的批量插入或者JPA的saveAll ListUser usersToSave cachedList.stream().map(this::convertToEntity).collect(Collectors.toList()); userService.batchSave(usersToSave); successCount.addAndGet(usersToSave.size()); } catch (Exception e) { log.error(批量保存数据失败, e); // 记录这批数据的错误 for (UserImportDTO dto : cachedList) { errorList.add(new ImportError(/*行号需额外记录*/, 批量入库失败: e.getMessage(), dto)); } } totalProcessed.addAndGet(cachedList.size()); } private void updateProgress() { // 这里可以读取总行数吗EasyExcel的AnalysisContext在读取过程中无法获取总行数。 // 一种方案是先快速读取一次文件获取总行数只读表头另一种是只展示已处理条数。 asyncTaskService.updateProgress(taskId, (int) ((totalProcessed.get() / (double) estimatedTotalRows) * 100), // estimatedTotalRows需提前获取 String.format(已处理 %d 条数据成功 %d 条, totalProcessed.get(), successCount.get())); } private void finishImport() { ImportResult result new ImportResult(); result.setTotal(totalProcessed.get()); result.setSuccessCount(successCount.get()); result.setFailCount(errorList.size()); result.setErrorSamples(errorList.subList(0, Math.min(10, errorList.size()))); // 只提供部分错误样本 // 可以将完整错误列表生成一个CSV文件上传到OSS将URL放在result中 String resultJson JSON.toJSONString(result); if (errorList.isEmpty()) { asyncTaskService.updateSuccess(taskId, resultJson, 100, 导入完成); } else { asyncTaskService.updateFailed(taskId, resultJson, 导入完成但有部分失败); } } }5.2 数据校验的实战技巧数据校验是导入的重中之重必须在入库前完成。基础格式校验在UserImportDTO字段上使用JSR-303注解如NotBlank、Email、Pattern。EasyExcel的ReadListener在invoke之前会进行转换和基础校验失败会进入onException方法。业务逻辑校验在validateData方法中进行。例如唯一性校验检查手机号、邮箱是否在数据库中已存在。注意不要每行都去查一次数据库可以先将本批次的待查字段收集起来一次性查询数据库进行比对性能提升巨大。关联性校验如导入订单需要校验商品ID是否存在、用户ID是否存在。同样采用批量查询优化。逻辑校验如结束日期不能早于开始日期金额不能为负等。校验结果反馈错误信息需要精确到行号和列并给出明确原因。ImportError对象应包含行索引、列名、错误信息、错误数据快照。最终结果中可以提供错误文件下载。6. 前端交互与进度查询实现后端异步了前端交互也得跟上。核心是轮询或WebSocket。6.1 轮询方案简单通用前端在提交任务拿到taskId后启动一个定时器每隔几秒如2-3秒调用一次任务状态查询接口。// 伪代码 function startPollingTaskStatus(taskId) { const timer setInterval(async () { const resp await fetch(/api/task/${taskId}/status); const data await resp.json(); updateProgressUI(data); // 更新进度条和文字 if (data.status SUCCESS || data.status FAILED) { clearInterval(timer); handleTaskFinish(data); // 处理完成如显示下载链接或错误信息 } }, 2000); // 2秒轮询一次 }后端查询接口非常简单就是根据taskId从数据库查询AsyncTask记录并返回。轮询的优缺点优点实现简单兼容性好无需额外组件。缺点有延迟不实时无效请求多增加服务器压力。6.2 WebSocket方案实时高效对于追求实时体验的应用WebSocket是更优选择。Spring Boot通过spring-boot-starter-websocket可以轻松集成。建立连接前端页面加载后建立WebSocket连接到后端如ws://your-domain.com/task-ws/{userId}。订阅任务提交任务后前端通过WebSocket发送一条消息告知服务器“我关心这个taskId的进度”。后端推送在AsyncTaskService的updateProgress和updateStatus方法中不仅更新数据库还通过WebSocket向订阅了该任务ID的客户端推送最新的进度消息。前端接收前端WebSocket监听消息实时更新UI。WebSocket的优缺点优点实时体验好服务器压力小只在状态变化时推送。缺点实现稍复杂需要处理连接保持、重连等问题在代理和负载均衡环境下可能需要额外配置如Sticky Session。个人建议初期或内部系统用轮询完全足够。等到业务量上来体验要求高时再升级为WebSocket。也可以采用混合策略短任务用轮询长任务用WebSocket。7. 生产环境进阶考量与运维一个能上生产环境的异步任务系统还需要考虑以下问题7.1 任务超时与中断处理有些任务可能因为数据量巨大或外部依赖挂掉而长时间运行甚至卡死。我们需要有超时中断机制。数据库超时标记在任务执行前记录start_time。可以有一个定时任务扫描状态为PROCESSING且start_time超过某个阈值如30分钟的任务将其标记为FAILED并记录错误信息“任务执行超时”。线程中断更主动的方式是在提交异步任务时返回一个Future对象。在管理后台或另一个监控线程中可以调用future.cancel(true)来尝试中断线程。但这需要任务代码正确响应中断信号检查Thread.currentThread().isInterrupted()并在合适的位置处理InterruptedException进行资源清理和状态回滚实现起来较复杂。进程级隔离对于特别重、特别容易出问题的任务可以考虑将其封装成一个独立的、可命令行执行的Jar包或脚本。主系统通过ProcessBuilder启动子进程执行并监控其输出和退出码。超时时可以直接杀死进程。这种方案隔离性好但复杂度最高。7.2 任务结果清理与归档任务记录和生成的文件不能无限期保存。文件清理对象存储通常可以配置生命周期规则自动删除超过一定时间如30天的文件。记录归档定时任务如每天凌晨将状态为SUCCESS或FAILED且完成时间超过N天的async_task记录转移到历史表或直接删除。务必注意在删除前要确保对应的结果文件链接已失效或文件已被清理。7.3 监控与报警没有监控的系统就是在裸奔。关键指标监控任务队列积压监控数据库中PENDING状态的任务数量或消息队列的长度。持续增长意味着消费者处理不过来。任务失败率监控FAILED状态任务的比例。突然升高意味着业务逻辑或外部依赖可能出了问题。任务平均耗时统计从start_time到end_time的差值。耗时变长可能是数据库慢查询或外部接口性能下降的信号。日志追踪为每个任务分配一个唯一的traceId可以和taskId一致并在该任务执行的所有日志中都打印这个traceId。这样在ELK或Graylog里可以轻松串联起一个任务的所有日志便于排查问题。报警当队列积压超过阈值、失败率超过阈值、或有超时任务出现时及时通过钉钉、企业微信或邮件报警。7.4 管理后台与任务重试一个简单的管理后台非常有用可以查看所有任务列表、状态、参数、结果和错误信息。更重要的是它应该提供手动重试功能。对于因临时网络抖动或依赖服务短暂不可用导致的失败任务管理员可以手动点击重试而不是让用户重新提交。重试的本质就是根据原任务的params_json和task_type重新创建一条PENDING状态的新任务记录。8. 常见问题排查与实战避坑指南问题Async方法不生效任务没有异步执行。排查首先检查启动类或配置类上是否有EnableAsync注解。其次检查调用Async方法的位置。Async必须通过代理对象调用才生效。如果在同一个类中方法A调用方法BB有Async由于是this.B()调用不走代理异步会失效。必须将Async方法放到另一个Bean中通过依赖注入调用。问题异步任务执行中报错但数据库事务没有回滚。解决确保异步方法上使用了Transactional(propagation Propagation.REQUIRES_NEW)。同时检查异常是否被捕获吞掉了。只有RuntimeException和Error才会触发回滚检查是否抛出了正确的异常类型。问题导入大量数据时虽然分批了但速度还是很慢数据库CPU很高。优化调整批处理大小BATCH_COUNT不是固定的。对于MySQL建议每批在500-2000条之间根据单条数据大小调整。可以写个测试找出最优值。使用rewriteBatchedStatementstrue在JDBC连接串加上这个参数能让MyBatis/JPA的批量插入真正使用MySQL的批量协议性能提升数倍。关闭自动提交在批量插入前手动控制事务。可以在saveBatch方法开始前设置connection.setAutoCommit(false)全部插入后再commit但要注意和Spring事务管理的协调。考虑使用LOAD DATA INFILE对于极大量数据百万级以上最快的办法是让EasyExcel生成CSV临时文件然后使用MySQL的LOAD DATA LOCAL INFILE命令导入。这需要文件在数据库服务器可访问的位置并处理好权限和安全问题。问题导出文件在OSS上前端下载时出现跨域或权限问题。解决不要直接返回OSS文件的永久URL。应该通过后端生成一个预签名URLPresigned URL。OSS服务商都提供此功能可以生成一个带有时效性如10分钟的临时下载链接返回给前端。这样既安全链接过期失效又避免了前端直接访问OSS可能遇到的跨域问题后端代理了下载请求的签发。问题任务状态长时间卡在PROCESSING但日志显示早已执行完。排查这是典型的状态更新丢失问题。可能发生在最后一步更新数据库状态时网络超时或数据库连接断开。解决方法是在任务执行逻辑的最后加入强状态同步。例如在finishImport或exportAsync的finally块中无论前面成功与否都根据最终结果如本地是否生成了文件、OSS是否返回了URL去强制更新一次数据库状态。甚至可以设计一个补偿定时任务定期扫描那些PROCESSING状态但start_time很久远且找不到对应活跃线程或进程的任务将其标记为FAILED并记录“状态同步超时”。问题使用消息队列时同一个任务被重复消费了多次。解决这就是幂等性问题。在消费者逻辑的入口处必须进行幂等检查。标准做法是根据消息中的taskId查询数据库。如果任务状态已经是SUCCESS直接返回成功ACK如果是PROCESSING需要谨慎判断可能是另一个消费者正在处理也可能是上次处理崩溃了。一种方案是引入分布式锁在开始处理前用taskId作为key尝试获取锁获取成功才能继续确保同一任务只有一个消费者在处理。处理完成后更新状态并释放锁。
返回列表