SpringBoot中异步导入处理-任务管理机制示例
一、为什么需要任务管理
异步处理的一个核心问题:前端发起请求后,如何知道后端处理到哪一步了?
同步接口很简单------请求返回就是结果。但异步导入导出涉及:
- 文件上传后,后端还在解析和校验
- 用户确认后,后端还在写数据库
- 导出请求后,后端还在生成文件
需要一个"任务记录"来追踪每次操作的状态。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、表结构
2.1 exchange_task(任务主表)
sql
CREATE TABLE exchange_task (
id INT AUTO_INCREMENT PRIMARY KEY,
biz_type VARCHAR(32), -- 业务类型(DEMO/...)
exchange_type VARCHAR(16), -- 操作类型(IMPORT/EXPORT)
file_name VARCHAR(256), -- 文件名称
file_path VARCHAR(512), -- 文件路径(导出完成后的OSS地址)
status INT, -- 任务状态
expires DATETIME, -- 过期时间(导出文件的有效期)
create_user_id INT, -- 创建人ID
create_user_name VARCHAR(64), -- 创建人姓名
update_user_id INT,
update_user_name VARCHAR(64),
create_time DATETIME,
update_time DATETIME,
version INT DEFAULT 0 -- 乐观锁版本号
);
2.2 exchange_task_detail(任务明细表)
sql
CREATE TABLE exchange_task_detail (
id INT AUTO_INCREMENT PRIMARY KEY,
task_id INT, -- 关联 exchange_task.id
data TEXT, -- 行数据(JSON字符串)
status INT -- 行状态
);
2.3 两张表的关系
exchange_task(一条记录 = 一次导入/导出操作)
│
│ 1 : N
▼
exchange_task_detail(每条记录 = Excel中的一行数据)
示例:用户上传了一个包含 8 万行的 Excel
exchange_task:
id=1, biz_type=CUSTOMER_STOCK, status=2(校验完成), file_name=xxx
exchange_task_detail:(8万条记录)
id=1, task_id=1, data='{"customerCode":"8700001","stockQty":"100"}', status=1(校验成功)
id=2, task_id=1, data='{"customerCode":"8700002","stockQty":"abc"}', status=-1(校验失败)
id=3, task_id=1, data='{"customerCode":"8700003","stockQty":"50"}', status=1(校验成功)
...
三、任务状态定义
3.1 任务主表状态(exchange_task.status)
java
public enum ExchangeTaskStatus {
// 导入相关
WAIT_CHECK(0, "待校验"), // 创建后等待MQ消费
CHECKING(1, "校验中"), // MQ消费端正在校验
CHECK_DONE(2, "校验完成"), // 校验完成,等待用户确认
IMPORTING(3, "导入中"), // 用户确认后,正在入库
SUCCESS(4, "完成"), // 导入/导出 成功完成
CHECK_FAIL(-1, "校验失败"), // 校验过程异常
IMPORT_FAIL(-3, "导入失败"), // 导入过程异常
// 明细行状态
CHECK_SUCCESS(1, "校验通过"),
CHECK_FAIL_ROW(-1, "校验不通过"),
IMPORT_SUCCESS(3, "导入成功"),
IMPORT_FAIL_ROW(-3, "导入失败"),
}
3.2 状态流转图
导入任务:
创建任务
│
▼
┌──── 待校验(0) ────┐
│ │
│ 发MQ │ 异常
▼ ▼
校验中(1) 校验失败(-1)
│
│ 校验完成
▼
校验完成(2) ←─── 前端轮询到此状态,展示结果
│
│ 用户点击确认
▼
导入中(3)
│
│ 入库完成
▼
完成(4)
│
│ 入库异常
▼
导入失败(-3)
导出任务:
创建任务 → 待处理(0) → 发MQ → 处理中(1) → 生成文件 → 完成(4)
│
│ 异常
▼
导出失败(-3)
四、ExchangeTaskService 核心方法
4.1 创建导入任务
java
public ExchangeTaskDto createImportTask(BizType importType, MultipartFile file) {
// 1. 根据 bizType 找到对应的 Importer
AbstractImportTemplate<?> importer = findImporter(importType);
// 2. 解析 Excel 文件
ExcelDetailDto<?> excelDetail = importer.read(file);
List<?> dataList = excelDetail.getData();
// 3. 创建 exchange_task 主记录
ExchangeTask task = new ExchangeTask();
task.setBizType(importType.name());
task.setExchangeType("IMPORT");
task.setFileName(excelDetail.getFileName());
task.setStatus(0); // 待校验
task.setCreateTime(new Date());
taskMapper.insert(task);
// 4. 每行数据序列化后存入 exchange_task_detail
for (Object rowData : dataList) {
ExchangeTaskDetail detail = new ExchangeTaskDetail();
detail.setTaskId(task.getId());
detail.setData(JsonUtil.serialize(rowData)); // Java对象 → JSON字符串
detail.setStatus(0); // 待校验
detailMapper.insert(detail);
}
// 5. 发送 RocketMQ 消息触发异步校验
importTaskMqSender.sendCheck(task);
// 6. 返回任务信息
return convertToDto(task);
}
4.2 MQ 消费------异步校验
java
public void checkForMq(ExchangeTask task) {
// 1. 更新任务状态为"校验中"
task.setStatus(1);
taskMapper.updateById(task);
// 2. 查出所有明细行
List<ExchangeTaskDetail> details = detailMapper.selectByTaskId(task.getId());
// 3. 找到对应的 Importer
AbstractImportTemplate importer = findImporter(BizType.valueOf(task.getBizType()));
// 4. JSON反序列化 + 包装为 ExchangeTaskDetailDto
List<ExchangeTaskDetailDto> dtoList = details.stream().map(d -> {
ExchangeTaskDetailDto dto = new ExchangeTaskDetailDto();
dto.setId(d.getId());
dto.setTaskId(d.getTaskId());
dto.setData(importer.convert(d.getData())); // JSON → 业务对象
dto.setStatus(d.getStatus());
return dto;
}).toList();
// 5. 调用业务校验逻辑
importer.checkData(dtoList);
// 6. 回写每行的校验结果到数据库
for (ExchangeTaskDetailDto dto : dtoList) {
ExchangeTaskDetail detail = new ExchangeTaskDetail();
detail.setId(dto.getId());
detail.setStatus(dto.getStatus());
detail.setData(JsonUtil.serialize(dto.getData())); // 含 checkResult
detailMapper.updateById(detail);
}
// 7. 更新任务状态为"校验完成"
task.setStatus(2);
taskMapper.updateById(task);
}
4.3 确认导入
java
public ExchangeTaskDto confirm(Integer taskId) {
ExchangeTask task = taskMapper.selectById(taskId);
// 只有校验完成的任务才能确认
if (task.getStatus() != 2) {
throw new RuntimeException("当前状态不允许确认导入");
}
// 更新状态为"导入中"
task.setStatus(3);
taskMapper.updateById(task);
// 发MQ触发异步入库
importTaskMqSender.sendConfirm(task);
return convertToDto(task);
}
4.4 MQ 消费------异步入库
java
public void importForMq(ExchangeTask task) {
// 1. 查出校验成功的行
List<ExchangeTaskDetail> successDetails = detailMapper.selectByTaskIdAndStatus(
task.getId(), ExchangeTaskStatus.CHECK_SUCCESS.getStatus());
// 2. 反序列化
AbstractImportTemplate importer = findImporter(BizType.valueOf(task.getBizType()));
List<ExchangeTaskDetailDto> dtoList = ... // 同校验流程
// 3. 调用业务入库逻辑
importer.importDate(dtoList);
// 4. 回写导入结果
for (ExchangeTaskDetailDto dto : dtoList) {
detailMapper.updateStatus(dto.getId(), dto.getStatus());
}
// 5. 更新任务状态为"完成"
task.setStatus(4);
taskMapper.updateById(task);
}
4.5 查询任务详情(前端轮询)
java
public ExchangeTaskDto queryTask(Integer taskId, Integer status, Integer pageNum, Integer pageSize) {
// 1. 查主表
ExchangeTask task = taskMapper.selectById(taskId);
ExchangeTaskDto dto = convertToDto(task);
// 2. 统计明细:成功数、失败数
int successCount = detailMapper.countByStatus(taskId, CHECK_SUCCESS);
int failCount = detailMapper.countByStatus(taskId, CHECK_FAIL);
dto.setSuccessCount(successCount);
dto.setFailCount(failCount);
// 3. 分页查明细(可按状态筛选)
Page<ExchangeTaskDetail> page = detailMapper.selectPage(taskId, status, pageNum, pageSize);
dto.setDetails(page.getRecords());
return dto;
}
4.6 创建导出任务
java
public ExchangeTaskDto createExportTask(BizType type, String queryParams) {
// 1. 创建任务记录
ExchangeTask task = new ExchangeTask();
task.setBizType(type.name());
task.setExchangeType("EXPORT");
task.setStatus(0);
taskMapper.insert(task);
// 2. 发MQ触发异步导出
exportTaskMqSender.send(task, queryParams);
return convertToDto(task);
}
4.7 MQ 消费------异步导出
java
public void exportForMq(ExchangeTask task) {
// 1. 找到对应的 Exporter
AbstractExportTemplate exporter = findExporter(BizType.valueOf(task.getBizType()));
// 2. 调用导出逻辑(分页查询 + 写Excel + 上传OSS)
String ossFilePath = exporter.export(task.getQueryParams(), exporter.excelName());
// 3. 将OSS路径存入任务记录
task.setFilePath(ossFilePath);
task.setStatus(4); // 完成
task.setExpires(DateUtils.addDays(new Date(), 1)); // 1天有效期
taskMapper.updateById(task);
}
五、前端轮询机制
5.1 为什么用轮询而非 WebSocket
| 方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 轮询 | 实现简单,兼容性好 | 有延迟(轮询间隔),浪费请求 | 大多数场景 |
| WebSocket | 实时推送 | 需要维护长连接,实现复杂 | 实时性要求极高 |
| SSE | 服务端推送,比WS简单 | 浏览器兼容性一般 | 中等实时性 |
导入导出场景通常几秒到几十秒完成,2秒一次轮询完全够用。
5.2 前端轮询示例
javascript
async function waitForTaskComplete(taskId) {
const MAX_RETRY = 60; // 最多轮询60次(2分钟)
let retry = 0;
while (retry < MAX_RETRY) {
const res = await fetch(`/api/page/import/task/query-task-detail?taskId=${taskId}`);
const { data } = await res.json();
switch (data.status) {
case 0: // 待处理
case 1: // 处理中
// 显示进度
showProgress('处理中...');
break;
case 2: // 校验完成
return { type: 'checkDone', data };
case 4: // 完成
return { type: 'success', data };
case -1: // 校验失败
case -3: // 导入/导出失败
return { type: 'failed', data };
}
await sleep(2000); // 等待2秒
retry++;
}
throw new Error('任务超时');
}
六、乐观锁(Version 字段)
java
@Version
private int version;
exchange_task 表有 version 字段,配合 MyBatis-Plus 的 @Version 注解实现乐观锁:
sql
-- MyBatis-Plus 自动生成的 UPDATE 语句
UPDATE exchange_task
SET status = 2, version = version + 1
WHERE id = 1 AND version = 0;
解决的问题:防止并发更新冲突。比如:
- MQ 消费端在更新状态为"校验完成"
- 同时用户可能在重复点击"确认"
有了乐观锁,只有一个操作能成功 UPDATE(version 匹配的那个),另一个因为 version 不匹配而更新 0 行。
七、完整示例
7.1 简化版任务管理------"文件转换服务"
场景:用户上传 Word 文件,后台异步转换为 PDF,转换完成后用户下载。
7.2 任务表
sql
CREATE TABLE convert_task (
id INT AUTO_INCREMENT PRIMARY KEY,
file_name VARCHAR(256) NOT NULL, -- 原始文件名
source_path VARCHAR(512), -- 原始文件路径
result_path VARCHAR(512), -- 转换结果路径
status INT NOT NULL DEFAULT 0, -- 0=待处理 1=处理中 2=完成 -1=失败
error_msg VARCHAR(512), -- 失败原因
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
7.3 Entity
java
package com.example.entity;
import com.baomidou.mybatisplus.annotation.*;
import java.util.Date;
import lombok.Data;
@Data
@TableName("convert_task")
public class ConvertTask {
@TableId(type = IdType.AUTO)
private Integer id;
private String fileName;
private String sourcePath;
private String resultPath;
private Integer status;
private String errorMsg;
private Date createTime;
private Date updateTime;
}
7.4 状态枚举
java
package com.example.enums;
import lombok.Getter;
@Getter
public enum TaskStatus {
PENDING(0, "待处理"),
PROCESSING(1, "处理中"),
COMPLETED(2, "完成"),
FAILED(-1, "失败");
private final int code;
private final String desc;
TaskStatus(int code, String desc) {
this.code = code;
this.desc = desc;
}
}
7.5 Service
java
package com.example.service;
import com.example.entity.ConvertTask;
import com.example.enums.TaskStatus;
import com.example.mapper.ConvertTaskMapper;
import jakarta.annotation.Resource;
import java.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class ConvertTaskService {
@Resource
private ConvertTaskMapper taskMapper;
@Resource
private ConvertMqProducer mqProducer;
/**
* 创建转换任务.
* 1. 保存原始文件
* 2. 创建任务记录(状态=待处理)
* 3. 发MQ消息
* 4. 返回taskId
*/
public ConvertTask createTask(String fileName, String sourcePath) {
ConvertTask task = new ConvertTask();
task.setFileName(fileName);
task.setSourcePath(sourcePath);
task.setStatus(TaskStatus.PENDING.getCode());
task.setCreateTime(new Date());
taskMapper.insert(task);
// 发送MQ,异步处理
mqProducer.send(task.getId());
log.info("转换任务已创建, taskId={}", task.getId());
return task;
}
/**
* 查询任务状态(前端轮询调用).
*/
public ConvertTask queryTask(Integer taskId) {
return taskMapper.selectById(taskId);
}
/**
* MQ消费端调用:执行转换.
*/
public void processTask(Integer taskId) {
ConvertTask task = taskMapper.selectById(taskId);
if (task == null || task.getStatus() != TaskStatus.PENDING.getCode()) {
return;
}
// 更新状态为"处理中"
task.setStatus(TaskStatus.PROCESSING.getCode());
taskMapper.updateById(task);
try {
// 执行实际的文件转换逻辑
String resultPath = doConvert(task.getSourcePath());
// 更新状态为"完成"
task.setResultPath(resultPath);
task.setStatus(TaskStatus.COMPLETED.getCode());
taskMapper.updateById(task);
log.info("转换完成, taskId={}", taskId);
} catch (Exception e) {
// 更新状态为"失败"
task.setStatus(TaskStatus.FAILED.getCode());
task.setErrorMsg(e.getMessage());
taskMapper.updateById(task);
log.error("转换失败, taskId={}", taskId, e);
}
}
private String doConvert(String sourcePath) {
// 实际转换逻辑...
return "oss://converted/result.pdf";
}
}
7.6 Controller
java
package com.example.controller;
import com.example.entity.ConvertTask;
import com.example.service.ConvertTaskService;
import jakarta.annotation.Resource;
import java.util.Map;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/convert")
public class ConvertController {
@Resource
private ConvertTaskService taskService;
/**
* 上传文件并创建转换任务.
*/
@PostMapping("/create")
public Map<String, Object> create(@RequestParam MultipartFile file) {
// 保存文件到 OSS/本地...
String sourcePath = saveFile(file);
// 创建任务
ConvertTask task = taskService.createTask(file.getOriginalFilename(), sourcePath);
return Map.of("success", true, "data", Map.of(
"taskId", task.getId(),
"status", task.getStatus(),
"statusDesc", "待处理"
));
}
/**
* 查询任务状态(前端轮询).
*/
@GetMapping("/status")
public Map<String, Object> status(@RequestParam Integer taskId) {
ConvertTask task = taskService.queryTask(taskId);
if (task == null) {
return Map.of("success", false, "errorMsg", "任务不存在");
}
return Map.of("success", true, "data", Map.of(
"taskId", task.getId(),
"status", task.getStatus(),
"resultPath", task.getResultPath() != null ? task.getResultPath() : "",
"errorMsg", task.getErrorMsg() != null ? task.getErrorMsg() : ""
));
}
}
7.7 前端交互
javascript
// 1. 上传文件
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const res = await fetch('/api/convert/create', { method: 'POST', body: formData });
const { taskId } = (await res.json()).data;
// 2. 轮询等待完成
const checkStatus = async () => {
const res = await fetch(`/api/convert/status?taskId=${taskId}`);
const { data } = await res.json();
if (data.status === 2) {
// 完成,下载结果
window.open(data.resultPath);
return;
}
if (data.status === -1) {
// 失败
alert('转换失败:' + data.errorMsg);
return;
}
// 继续轮询
setTimeout(checkStatus, 2000);
};
checkStatus();
八、任务管理的核心价值
| 没有任务管理 | 有任务管理 |
|---|---|
| 前端不知道后台处理到哪了 | 前端随时可查状态 |
| 处理失败用户不知道 | 失败原因清楚记录 |
| 无法追溯历史操作 | 所有操作都有记录 |
| 重复提交无法识别 | 通过 taskId 去重 |
| 无法断点续传/重试 | 失败的任务可以重新触发 |
| 并发操作混乱 | 乐观锁保证状态一致 |
九、总结
任务管理 = 数据库记录 + 状态机 + 前端轮询
核心思想:
用一张表记录异步操作的生命周期,
前端通过轮询获取最新状态,
后端通过状态流转控制执行顺序。
exchange_task 表的角色:
- 对前端:进度看板(当前第几步了)
- 对后端:状态机(只有特定状态才能执行下一步)
- 对运维:操作日志(谁在什么时候导入了什么)
- 对系统:去重凭证(同一个任务不重复处理)