从需求到上线:异步任务框架实战指南
引言
前三篇文章我们深入探讨了异步任务框架的架构设计、租约机制和 Worker 实现。本文将通过一个真实的医疗系统需求,完整演示如何接入异步任务框架,从需求分析、设计方案、代码实现到上线部署的全流程。
业务需求
场景描述
某三甲医院需要从卫健委平台批量导入患者的医保信息。系统要求:
- 数据量:单次导入 5,000 ~ 50,000 条患者记录
- 耗时:每条记录需要调用医保接口验证,平均耗时 200ms
- 体验要求:用户提交后立即返回,后台异步处理,支持查看进度
- 容错要求:部分记录失败不影响整体导入,生成失败报告
- 审计要求:记录操作人、操作时间、导入结果
- 重试机制:网络异常时自动重试,人工失败支持手动重试
需求分析
为什么需要异步任务?
| 同步处理 | 异步处理 |
|---|---|
| 50,000 条 × 200ms = 2.7 小时 | 立即返回,后台处理 |
| HTTP 请求超时 | 无超时问题 |
| 服务器宕机任务丢失 | 持久化到数据库 |
| 无法查看进度 | 实时进度反馈 |
| 无法重试 | 支持自动/手动重试 |
异步任务框架如何解决?
用户操作 框架处理 医保接口
│ │ │
│──提交导入──→ │ │
│ │──持久化任务──→ │
│←─返回任务ID─ │ (数据库) │
│ │ │
│ │──Worker领取──→ │
│ │ │
│ │──────调用验证──────→ │
│──查询进度──→ │ │
│←─50%完成─── │ │
│ │←─────返回结果───── │
│ │ │
│ │──上报进度──→ │
│──查询进度──→ │ │
│←─100%完成── │ │
│──下载报告──→ │ │
│←─返回文件── │ │设计方案
1. 任务类型定义
/**
* 医保信息导入任务
*
* 任务类型:MEDICAL_INSURANCE.IMPORT
* 所属模块:患者管理
* 执行时长:预计 10-60 分钟
* 并发限制:单类型最多 2 个并发(避免打满医保接口)
*/
public static final String TASK_TYPE = "MEDICAL_INSURANCE.IMPORT";2. 数据模型
Payload(任务参数)
/**
* 医保导入任务参数
*/
@Data
public class MedicalInsuranceImportPayload {
/**
* Payload 版本(支持升级兼容)
*/
private String schemaVersion = "1.0";
/**
* 导入文件 ID(OSS 文件引用)
*/
private String fileId;
/**
* 导入文件名称
*/
private String fileName;
/**
* 导入批次号(业务唯一键)
*/
private String batchNo;
/**
* 导入配置
*/
private ImportConfig config;
@Data
public static class ImportConfig {
/**
* 是否跳过已存在的患者
*/
private boolean skipExisting = true;
/**
* 是否更新已存在的患者信息
*/
private boolean updateExisting = false;
/**
* 每批次处理记录数
*/
private int batchSize = 100;
}
}导入结果
/**
* 医保导入结果
*/
@Data
public class MedicalInsuranceImportResult {
/**
* 总记录数
*/
private long totalCount;
/**
* 成功导入数
*/
private long successCount;
/**
* 失败记录数
*/
private long failedCount;
/**
* 跳过记录数
*/
private long skippedCount;
/**
* 失败报告文件 ID
*/
private String errorReportFileId;
}3. 数据库设计
导入记录表
CREATE TABLE medical_insurance_import_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
batch_no VARCHAR(64) NOT NULL COMMENT '批次号',
task_id VARCHAR(64) NOT NULL COMMENT '异步任务ID',
file_id VARCHAR(128) NOT NULL COMMENT '源文件ID',
file_name VARCHAR(255) NOT NULL COMMENT '源文件名',
total_count BIGINT NOT NULL DEFAULT 0 COMMENT '总记录数',
success_count BIGINT NOT NULL DEFAULT 0 COMMENT '成功数',
failed_count BIGINT NOT NULL DEFAULT 0 COMMENT '失败数',
skipped_count BIGINT NOT NULL DEFAULT 0 COMMENT '跳过数',
error_report_file_id VARCHAR(128) COMMENT '失败报告文件ID',
status VARCHAR(20) NOT NULL COMMENT '状态',
create_by VARCHAR(64) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_batch_no (batch_no),
KEY idx_task_id (task_id),
KEY idx_create_by_time (create_by, create_time)
) COMMENT='医保信息导入记录';导入明细表(失败记录)
CREATE TABLE medical_insurance_import_detail (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
batch_no VARCHAR(64) NOT NULL COMMENT '批次号',
row_number INT NOT NULL COMMENT '行号',
patient_name VARCHAR(128) COMMENT '患者姓名',
id_card VARCHAR(32) COMMENT '身份证号',
insurance_no VARCHAR(64) COMMENT '医保卡号',
error_code VARCHAR(32) COMMENT '错误码',
error_message TEXT COMMENT '错误信息',
create_time DATETIME NOT NULL,
KEY idx_batch_no (batch_no),
KEY idx_row_number (batch_no, row_number)
) COMMENT='医保信息导入明细';代码实现
1. Handler 实现
package com.tudicloud.medical.patient.handler;
import com.tudicloud.framework.asynctask.core.model.AsyncTaskProgress;
import com.tudicloud.framework.asynctask.core.model.TaskExecutionResult;
import com.tudicloud.framework.asynctask.core.spi.AsyncTaskHandler;
import com.tudicloud.framework.asynctask.core.spi.TaskExecutionContext;
import com.tudicloud.medical.patient.model.MedicalInsuranceImportPayload;
import com.tudicloud.medical.patient.service.MedicalInsuranceImportService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 医保信息导入任务处理器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MedicalInsuranceImportHandler
implements AsyncTaskHandler<MedicalInsuranceImportPayload> {
private final MedicalInsuranceImportService importService;
private final FileStorageService fileStorageService;
private final MedicalInsuranceApiClient insuranceApiClient;
@Override
public String taskType() {
return "MEDICAL_INSURANCE.IMPORT";
}
@Override
public Class<MedicalInsuranceImportPayload> payloadType() {
return MedicalInsuranceImportPayload.class;
}
@Override
public void validate(MedicalInsuranceImportPayload payload) {
// 快速校验(无副作用)
if (payload.getFileId() == null) {
throw new IllegalArgumentException("文件ID不能为空");
}
if (payload.getBatchNo() == null) {
throw new IllegalArgumentException("批次号不能为空");
}
}
@Override
public TaskExecutionResult execute(
TaskExecutionContext context,
MedicalInsuranceImportPayload payload
) throws Exception {
String taskId = context.taskId();
String batchNo = payload.getBatchNo();
log.info("开始执行医保导入任务, taskId={}, batchNo={}, fileId={}",
taskId, batchNo, payload.getFileId());
// 1. 初始化导入记录
importService.initImportRecord(batchNo, taskId, payload);
// 2. 下载并解析文件
List<PatientRow> rows = parseFile(payload.getFileId());
long totalCount = rows.size();
log.info("文件解析完成, taskId={}, totalCount={}", taskId, totalCount);
// 3. 分批处理
int batchSize = payload.getConfig().getBatchSize();
long processedCount = 0;
long successCount = 0;
long failedCount = 0;
long skippedCount = 0;
List<ImportDetail> failedDetails = new ArrayList<>();
for (int i = 0; i < rows.size(); i += batchSize) {
// 检查取消标志
context.checkCancellation();
// 获取当前批次
int endIndex = Math.min(i + batchSize, rows.size());
List<PatientRow> batch = rows.subList(i, endIndex);
// 处理批次(幂等)
BatchResult result = processBatchIdempotently(
taskId, batchNo, batch, payload.getConfig()
);
successCount += result.getSuccessCount();
failedCount += result.getFailedCount();
skippedCount += result.getSkippedCount();
failedDetails.addAll(result.getFailedDetails());
processedCount = successCount + failedCount + skippedCount;
// 上报进度
context.reportProgress(new AsyncTaskProgress(
totalCount,
processedCount,
successCount,
failedCount,
skippedCount
));
log.info("批次处理完成, taskId={}, batch={}/{}, progress={}/{}",
taskId, (i / batchSize + 1),
(rows.size() + batchSize - 1) / batchSize,
processedCount, totalCount);
}
// 4. 生成失败报告
String errorReportFileId = null;
if (!failedDetails.isEmpty()) {
errorReportFileId = generateErrorReport(batchNo, failedDetails);
log.info("失败报告已生成, taskId={}, fileId={}, failedCount={}",
taskId, errorReportFileId, failedCount);
}
// 5. 更新导入记录
importService.completeImportRecord(
batchNo,
totalCount,
successCount,
failedCount,
skippedCount,
errorReportFileId
);
log.info("医保导入任务完成, taskId={}, success={}, failed={}, skipped={}",
taskId, successCount, failedCount, skippedCount);
// 6. 返回结果
if (failedCount == 0) {
return TaskExecutionResult.success(errorReportFileId);
} else {
return TaskExecutionResult.partialSuccess(errorReportFileId);
}
}
@Override
public boolean supportsManualRetry() {
// 支持手动重新排队
return true;
}
/**
* 幂等处理批次
*/
private BatchResult processBatchIdempotently(
String taskId,
String batchNo,
List<PatientRow> batch,
ImportConfig config
) {
// 使用任务ID+批次起始行号作为幂等键
int startRowNumber = batch.get(0).getRowNumber();
String idempotencyKey = taskId + "_" + startRowNumber;
// 检查是否已处理
if (importService.isBatchProcessed(idempotencyKey)) {
log.info("批次已处理,跳过, idempotencyKey={}", idempotencyKey);
return importService.getBatchResult(idempotencyKey);
}
// 处理批次
BatchResult result = new BatchResult();
for (PatientRow row : batch) {
try {
// 检查是否跳过
if (config.isSkipExisting() &&
importService.patientExists(row.getIdCard())) {
result.addSkipped(row);
continue;
}
// 调用医保接口验证
InsuranceInfo info = insuranceApiClient.queryPatientInfo(
row.getIdCard(),
row.getInsuranceNo()
);
// 保存到数据库
importService.savePatientInsurance(row, info);
result.addSuccess(row);
} catch (InsuranceApiException ex) {
// 医保接口异常,记录失败明细
log.warn("医保接口调用失败, row={}, error={}",
row.getRowNumber(), ex.getErrorCode());
result.addFailed(row, ex.getErrorCode(), ex.getMessage());
} catch (Exception ex) {
// 其他异常
log.error("处理患者记录失败, row={}", row.getRowNumber(), ex);
result.addFailed(row, "SYSTEM_ERROR", ex.getMessage());
}
}
// 记录批次处理结果(幂等标记)
importService.saveBatchResult(idempotencyKey, result);
return result;
}
/**
* 解析导入文件
*/
private List<PatientRow> parseFile(String fileId) throws IOException {
InputStream inputStream = fileStorageService.download(fileId);
try (ExcelReader reader = ExcelUtil.getReader(inputStream)) {
List<Map<String, Object>> rows = reader.readAll();
List<PatientRow> result = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
result.add(PatientRow.fromMap(i + 2, row)); // +2 因为表头占1行
}
return result;
}
}
/**
* 生成失败报告
*/
private String generateErrorReport(
String batchNo,
List<ImportDetail> failedDetails
) throws IOException {
// 生成 Excel 文件
ExcelWriter writer = ExcelUtil.getWriter();
writer.addHeaderAlias("rowNumber", "行号");
writer.addHeaderAlias("patientName", "患者姓名");
writer.addHeaderAlias("idCard", "身份证号");
writer.addHeaderAlias("insuranceNo", "医保卡号");
writer.addHeaderAlias("errorCode", "错误码");
writer.addHeaderAlias("errorMessage", "错误信息");
writer.write(failedDetails, true);
byte[] content = writer.flush().toByteArray();
writer.close();
// 上传到 OSS
String fileName = String.format(
"medical_insurance_import_error_%s_%s.xlsx",
batchNo,
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
);
return fileStorageService.upload(fileName, content);
}
}2. Controller 实现
package com.tudicloud.medical.patient.controller;
import com.tudicloud.framework.asynctask.core.model.*;
import com.tudicloud.framework.asynctask.core.service.AsyncTaskQueryService;
import com.tudicloud.framework.asynctask.core.service.AsyncTaskService;
import com.tudicloud.medical.common.model.BaseResult;
import com.tudicloud.medical.patient.model.MedicalInsuranceImportPayload;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.UUID;
/**
* 医保信息导入 Controller
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/medical-insurance/import")
@RequiredArgsConstructor
@Tag(name = "医保信息导入")
public class MedicalInsuranceImportController {
private final AsyncTaskService asyncTaskService;
private final AsyncTaskQueryService asyncTaskQueryService;
private final FileStorageService fileStorageService;
private final SecurityHelper securityHelper;
/**
* 提交导入任务
*/
@PostMapping("/submit")
@PreAuthorize("hasAuthority('patient:insurance:import')")
@Operation(summary = "提交医保信息导入任务")
public BaseResult<ImportTaskView> submitImportTask(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "skipExisting", defaultValue = "true") boolean skipExisting,
@RequestParam(value = "updateExisting", defaultValue = "false") boolean updateExisting
) {
// 1. 权限校验(已通过 @PreAuthorize)
// 2. 文件校验
validateFile(file);
// 3. 上传文件到 OSS
String fileId = fileStorageService.upload(
file.getOriginalFilename(),
file.getInputStream()
);
// 4. 生成批次号
String batchNo = generateBatchNo();
// 5. 构造任务参数
MedicalInsuranceImportPayload payload = new MedicalInsuranceImportPayload();
payload.setFileId(fileId);
payload.setFileName(file.getOriginalFilename());
payload.setBatchNo(batchNo);
MedicalInsuranceImportPayload.ImportConfig config =
new MedicalInsuranceImportPayload.ImportConfig();
config.setSkipExisting(skipExisting);
config.setUpdateExisting(updateExisting);
payload.setConfig(config);
// 6. 提交异步任务
AsyncTaskSubmission submission = new AsyncTaskSubmission(
"MEDICAL_INSURANCE.IMPORT",
payload,
securityHelper.getCurrentUserId(), // ownerId
batchNo, // idempotencyKey(批次号唯一)
3 // maxAttempts
);
AsyncTask task = asyncTaskService.submit(submission);
log.info("医保导入任务已提交, taskId={}, batchNo={}, userId={}, fileName={}",
task.id(), batchNo, securityHelper.getCurrentUserId(),
file.getOriginalFilename());
// 7. 返回任务信息
return BaseResult.success(toImportTaskView(task));
}
/**
* 查询任务状态
*/
@GetMapping("/{taskId}")
@PreAuthorize("hasAuthority('patient:insurance:import')")
@Operation(summary = "查询导入任务状态")
public BaseResult<ImportTaskView> getTask(@PathVariable String taskId) {
// 查询任务(带 owner 数据范围)
AsyncTask task = asyncTaskQueryService.getTask(
taskId,
AsyncTaskQueryScope.owner(securityHelper.getCurrentUserId())
);
if (task == null) {
return BaseResult.error("任务不存在或无权访问");
}
return BaseResult.success(toImportTaskView(task));
}
/**
* 查询任务列表
*/
@GetMapping("/list")
@PreAuthorize("hasAuthority('patient:insurance:import')")
@Operation(summary = "查询导入任务列表")
public BaseResult<AsyncTaskPage<ImportTaskView>> listTasks(
@RequestParam(defaultValue = "1") int pageNumber,
@RequestParam(defaultValue = "20") int pageSize
) {
// 构造查询条件
AsyncTaskPageQuery query = AsyncTaskPageQuery.builder()
.taskType("MEDICAL_INSURANCE.IMPORT")
.pageNumber(pageNumber)
.pageSize(pageSize)
.build();
// 查询任务列表(带 owner 数据范围)
AsyncTaskPage<AsyncTask> page = asyncTaskQueryService.pageTasks(
query,
AsyncTaskQueryScope.owner(securityHelper.getCurrentUserId())
);
// 转换为视图对象
AsyncTaskPage<ImportTaskView> viewPage = page.map(this::toImportTaskView);
return BaseResult.success(viewPage);
}
/**
* 取消任务
*/
@PostMapping("/{taskId}/cancel")
@PreAuthorize("hasAuthority('patient:insurance:import')")
@Operation(summary = "取消导入任务")
public BaseResult<Void> cancelTask(@PathVariable String taskId) {
asyncTaskService.requestCancellation(
taskId,
AsyncTaskQueryScope.owner(securityHelper.getCurrentUserId())
);
log.info("医保导入任务已取消, taskId={}, userId={}",
taskId, securityHelper.getCurrentUserId());
return BaseResult.success();
}
/**
* 手动重试
*/
@PostMapping("/{taskId}/retry")
@PreAuthorize("hasAuthority('patient:insurance:import')")
@Operation(summary = "手动重试导入任务")
public BaseResult<ImportTaskView> retryTask(@PathVariable String taskId) {
// 生成新的幂等键
String newIdempotencyKey = UUID.randomUUID().toString();
AsyncTask newTask = asyncTaskService.requeue(
taskId,
AsyncTaskQueryScope.owner(securityHelper.getCurrentUserId()),
newIdempotencyKey
);
log.info("医保导入任务已重新排队, oldTaskId={}, newTaskId={}, userId={}",
taskId, newTask.id(), securityHelper.getCurrentUserId());
return BaseResult.success(toImportTaskView(newTask));
}
/**
* 下载失败报告
*/
@GetMapping("/{taskId}/error-report")
@PreAuthorize("hasAuthority('patient:insurance:import')")
@Operation(summary = "下载失败报告")
public void downloadErrorReport(
@PathVariable String taskId,
HttpServletResponse response
) throws IOException {
// 查询任务(重新鉴权)
AsyncTask task = asyncTaskQueryService.getTask(
taskId,
AsyncTaskQueryScope.owner(securityHelper.getCurrentUserId())
);
if (task == null) {
throw new BusinessException("任务不存在或无权访问");
}
String artifactId = task.artifactId();
if (artifactId == null) {
throw new BusinessException("任务暂无失败报告");
}
// 下载文件
FileMetadata metadata = fileStorageService.getMetadata(artifactId);
InputStream inputStream = fileStorageService.download(artifactId);
// 设置响应头
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(metadata.getFileName(), "UTF-8"));
// 写入响应
IOUtils.copy(inputStream, response.getOutputStream());
response.flushBuffer();
}
/**
* 文件校验
*/
private void validateFile(MultipartFile file) {
if (file.isEmpty()) {
throw new IllegalArgumentException("上传文件不能为空");
}
String fileName = file.getOriginalFilename();
if (fileName == null || !fileName.endsWith(".xlsx")) {
throw new IllegalArgumentException("只支持 .xlsx 格式文件");
}
// 限制文件大小:10MB
if (file.getSize() > 10 * 1024 * 1024) {
throw new IllegalArgumentException("文件大小不能超过 10MB");
}
}
/**
* 生成批次号
*/
private String generateBatchNo() {
return "INSURANCE_" +
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) +
"_" + UUID.randomUUID().toString().substring(0, 8);
}
/**
* 转换为视图对象
*/
private ImportTaskView toImportTaskView(AsyncTask task) {
ImportTaskView view = new ImportTaskView();
view.setTaskId(task.id());
view.setStatus(task.status().name());
view.setBatchNo(task.idempotencyKey());
// 进度信息
if (task.progress() != null) {
view.setTotalCount(task.progress().totalCount());
view.setProcessedCount(task.progress().processedCount());
view.setSuccessCount(task.progress().successCount());
view.setFailedCount(task.progress().failedCount());
view.setSkippedCount(task.progress().skippedCount());
// 计算百分比
if (task.progress().totalCount() > 0) {
view.setProgressPercent(
(int) (task.progress().processedCount() * 100 / task.progress().totalCount())
);
}
}
// 时间信息
view.setCreatedAt(task.createdAt());
view.setFinishedAt(task.finishedAt());
// 结果文件
view.setErrorReportFileId(task.artifactId());
return view;
}
}3. 前端页面(Vue 3)
<template>
<div class="import-page">
<!-- 上传区域 -->
<el-card class="upload-card">
<template #header>
<span>医保信息导入</span>
</template>
<el-upload
ref="uploadRef"
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".xlsx"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传 .xlsx 文件,且不超过 10MB
</div>
</template>
</el-upload>
<el-form :model="form" label-width="120px" style="margin-top: 20px;">
<el-form-item label="导入策略">
<el-checkbox v-model="form.skipExisting">
跳过已存在的患者
</el-checkbox>
<el-checkbox v-model="form.updateExisting">
更新已存在的患者信息
</el-checkbox>
</el-form-item>
</el-form>
<el-button
type="primary"
:loading="uploading"
@click="submitImport"
>
开始导入
</el-button>
</el-card>
<!-- 任务列表 -->
<el-card class="task-list-card">
<template #header>
<span>导入任务列表</span>
<el-button
type="text"
@click="refreshTaskList"
style="float: right;"
>
刷新
</el-button>
</template>
<el-table :data="taskList" style="width: 100%">
<el-table-column prop="batchNo" label="批次号" width="200" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="300">
<template #default="{ row }">
<div v-if="row.status === 'RUNNING' || row.status === 'QUEUED'">
<el-progress
:percentage="row.progressPercent"
:status="getProgressStatus(row.status)"
/>
<div class="progress-text">
{{ row.processedCount }} / {{ row.totalCount }}
(成功: {{ row.successCount }},
失败: {{ row.failedCount }},
跳过: {{ row.skippedCount }})
</div>
</div>
<div v-else>
成功: {{ row.successCount }},
失败: {{ row.failedCount }},
跳过: {{ row.skippedCount }}
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250">
<template #default="{ row }">
<el-button
v-if="row.status === 'RUNNING' || row.status === 'QUEUED'"
type="text"
size="small"
@click="cancelTask(row.taskId)"
>
取消
</el-button>
<el-button
v-if="row.status === 'FAILED'"
type="text"
size="small"
@click="retryTask(row.taskId)"
>
重试
</el-button>
<el-button
v-if="row.errorReportFileId"
type="text"
size="small"
@click="downloadErrorReport(row.taskId)"
>
下载失败报告
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.pageNumber"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import * as api from '@/api/medical-insurance'
const uploadRef = ref()
const uploading = ref(false)
const form = ref({
skipExisting: true,
updateExisting: false
})
const taskList = ref([])
const pagination = ref({
pageNumber: 1,
pageSize: 20,
total: 0
})
let pollingTimer = null
// 提交导入
const submitImport = async () => {
const files = uploadRef.value.uploadFiles
if (files.length === 0) {
ElMessage.warning('请先选择文件')
return
}
uploading.value = true
try {
const formData = new FormData()
formData.append('file', files[0].raw)
formData.append('skipExisting', form.value.skipExisting)
formData.append('updateExisting', form.value.updateExisting)
await api.submitImportTask(formData)
ElMessage.success('导入任务已提交')
uploadRef.value.clearFiles()
refreshTaskList()
} catch (error) {
ElMessage.error('提交失败: ' + error.message)
} finally {
uploading.value = false
}
}
// 刷新任务列表
const refreshTaskList = async () => {
try {
const res = await api.listTasks({
pageNumber: pagination.value.pageNumber,
pageSize: pagination.value.pageSize
})
taskList.value = res.data.items
pagination.value.total = res.data.total
} catch (error) {
console.error('刷新任务列表失败', error)
}
}
// 取消任务
const cancelTask = async (taskId) => {
try {
await api.cancelTask(taskId)
ElMessage.success('任务已取消')
refreshTaskList()
} catch (error) {
ElMessage.error('取消失败: ' + error.message)
}
}
// 重试任务
const retryTask = async (taskId) => {
try {
await api.retryTask(taskId)
ElMessage.success('任务已重新提交')
refreshTaskList()
} catch (error) {
ElMessage.error('重试失败: ' + error.message)
}
}
// 下载失败报告
const downloadErrorReport = (taskId) => {
window.open(`/api/v1/medical-insurance/import/${taskId}/error-report`)
}
// 状态映射
const getStatusType = (status) => {
const map = {
QUEUED: 'info',
RUNNING: 'primary',
SUCCESS: 'success',
PARTIAL_SUCCESS: 'warning',
FAILED: 'danger',
CANCELLED: 'info'
}
return map[status] || 'info'
}
const getStatusText = (status) => {
const map = {
QUEUED: '排队中',
RUNNING: '执行中',
SUCCESS: '成功',
PARTIAL_SUCCESS: '部分成功',
FAILED: '失败',
CANCELLED: '已取消'
}
return map[status] || status
}
// 轮询刷新(有运行中的任务时)
const startPolling = () => {
pollingTimer = setInterval(() => {
const hasRunning = taskList.value.some(
task => task.status === 'RUNNING' || task.status === 'QUEUED'
)
if (hasRunning) {
refreshTaskList()
}
}, 3000) // 每 3 秒刷新一次
}
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
onMounted(() => {
refreshTaskList()
startPolling()
})
onUnmounted(() => {
stopPolling()
})
</script>配置调优
1. 应用配置
tudicloud:
async-task:
enabled: true
worker:
enabled: true
local-max-concurrency: 4
poll-interval: 1s
heartbeat-interval: 10s
lease-timeout: 60s
recovery-interval: 60s
max-execution-time: 1h # 单任务最长 1 小时
slow-task-threshold: 10m # 10 分钟以上算慢任务
limits:
default-global-concurrency: 10
default-type-concurrency: 2 # 医保导入限制 2 并发
default-max-attempts: 3
max-payload-bytes: 655362. 动态策略配置
// 初始化医保导入任务的并发策略
@Bean
public CommandLineRunner initMedicalInsurancePolicy(
AsyncTaskPolicyService policyService
) {
return args -> {
policyService.save(new AsyncTaskPolicy(
AsyncTaskPolicy.typePolicyKey("MEDICAL_INSURANCE.IMPORT"),
"MEDICAL_INSURANCE.IMPORT",
2, // 最多 2 个并发
3, // 最多重试 3 次
Duration.ofMinutes(5), // 重试延迟 5 分钟
true
));
};
}3. 数据库索引优化
-- 医保导入相关索引
CREATE INDEX idx_insurance_import_status
ON system_async_task (status, task_type, create_time)
WHERE task_type = 'MEDICAL_INSURANCE.IMPORT';
CREATE INDEX idx_insurance_import_owner
ON system_async_task (owner_id, create_time DESC)
WHERE task_type = 'MEDICAL_INSURANCE.IMPORT';上线检查清单
1. 功能验证
- [ ] 提交导入任务成功
- [ ] 任务持久化到数据库
- [ ] Worker 成功领取任务
- [ ] 进度实时更新
- [ ] 失败报告正确生成
- [ ] 取消功能正常
- [ ] 手动重试功能正常
- [ ] 幂等性验证(重复执行不产生副作用)
2. 性能验证
- [ ] 5,000 条记录导入时长 < 30 分钟
- [ ] 50,000 条记录导入时长 < 3 小时
- [ ] 并发 2 个导入任务互不影响
- [ ] 进度刷新频率合理(不过于频繁)
3. 容错验证
- [ ] Worker 宕机后任务自动恢复
- [ ] 数据库连接中断后自动重连
- [ ] 医保接口超时自动重试
- [ ] 租约续期失败时任务被其他 Worker 接管
4. 权限验证
- [ ] 普通用户只能查看自己的任务
- [ ] 管理员可以查看所有任务
- [ ] 失败报告下载需要重新鉴权
- [ ] 跨用户访问被正确拦截
监控与告警
1. 业务监控
// 自定义监控指标
@Component
public class MedicalInsuranceImportMetrics {
private final MeterRegistry meterRegistry;
// 导入成功率
public void recordImportSuccess(long successCount, long totalCount) {
meterRegistry.counter(
"medical.insurance.import.success_rate",
"result", "success"
).increment(successCount);
meterRegistry.counter(
"medical.insurance.import.success_rate",
"result", "total"
).increment(totalCount);
}
// 医保接口调用延迟
public void recordApiLatency(Duration latency) {
meterRegistry.timer(
"medical.insurance.api.latency"
).record(latency);
}
}2. Grafana 看板
# 导入任务成功率
sum(rate(medical_insurance_import_success_rate{result="success"}[5m])) /
sum(rate(medical_insurance_import_success_rate{result="total"}[5m]))
# 正在执行的导入任务数
async_task_active_count{task_type="MEDICAL_INSURANCE.IMPORT"}
# 导入任务平均执行时长
histogram_quantile(0.5,
rate(async_task_execution_seconds_bucket{task_type="MEDICAL_INSURANCE.IMPORT"}[5m])
)3. 告警规则
# 导入成功率过低
- alert: MedicalInsuranceImportLowSuccessRate
expr: |
sum(rate(medical_insurance_import_success_rate{result="success"}[10m])) /
sum(rate(medical_insurance_import_success_rate{result="total"}[10m])) < 0.8
for: 5m
annotations:
summary: "医保导入成功率低于 80%"
# 导入任务堆积
- alert: MedicalInsuranceImportTaskBacklog
expr: |
count(async_task_status{task_type="MEDICAL_INSURANCE.IMPORT", status="QUEUED"}) > 10
for: 10m
annotations:
summary: "医保导入任务排队数超过 10 个"总结
本文通过一个完整的医疗系统需求,展示了异步任务框架的接入全流程:
- ✅ 需求分析:明确业务场景和技术要求
- ✅ 方案设计:定义任务类型、数据模型、接口设计
- ✅ 代码实现:Handler、Controller、前端页面
- ✅ 配置调优:并发控制、动态策略、索引优化
- ✅ 上线验证:功能、性能、容错、权限
- ✅ 监控告警:业务指标、系统指标、告警规则
关键要点:
- Handler 必须实现幂等性
- 分批处理 + 定期上报进度
- 敏感操作需要重新鉴权
- 配置合理的并发和超时参数
- 完善的监控和告警体系
在下一篇文章中,我们将探讨异步任务框架的监控、运维与故障排查,敬请期待!
作者: 无声源语架构团队
发布日期: 2026-08-28
相关文章: