实战:基于 XXL-JOB + 策略模式 + Nacos 配置化的 CSV 导出任务系统设计
本文分享一个生产级"异步任务结果导出 CSV"系统的完整设计与实现,涵盖定时任务调度、策略模式路由、配置化零代码扩展、游标分页、乐观锁并发控制、流式 CSV 生成等核心技术点,适合作为类似批量导出/报表生成场景的设计参考。
一、需求背景
系统中存在一类异步计算任务:上游计算引擎(如 Spark)执行完成后,将结果写入结果表,并把任务记录状态置为"计算完成"。下游需要一个定时任务:
- 每分钟扫描任务记录表中"计算完成"状态的任务;
- 根据任务类型(
program_enum)路由到对应结果表查询数据; - 生成 CSV 文件 并上传到对象存储,获取下载 URL;
- 更新任务状态为"完成"或"失败",并记录失败原因。
看似简单的需求,实际落地时要解决一堆经典问题:多节点并发抢占、深分页性能、CSV 中文乱码、磁盘临时文件泄漏、新表扩展成本、SQL 注入防护。下面逐一展开。
二、核心设计原则
| 原则 | 说明 |
|---|---|
| SQL 决定 CSV 列名 | Mapper XML 中 SELECT 什么字段,CSV 就导出什么字段,杜绝列名硬编码 |
| 单表查询配置化 | 单表场景通过 Nacos 配置(表名 + 字段列表 + CSV 列名)实现零代码新增 |
| 多表联查策略模式 | 复杂场景(JOIN)保留策略模式,新增策略类即可扩展 |
| 游标分页 | 统一按物理字段 row_num 排序分页,避免深分页性能问题 |
| 乐观锁并发控制 | execution_status + updated_at 组合条件,防止多节点并发处理同一任务 |
三、整体流程
XXL-JOB 定时任务(每分钟,Cron: 0 * * * * ?)
│
▼
┌──────────────────────────┐
│ 1. 扫描待处理任务 │ SELECT * FROM task_record
│ status = 2(计算完成) │ WHERE execution_status = '2'
│ LIMIT batchLimit(默认10)│ ORDER BY execution_start_time ASC
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ 2. 乐观锁抢占 状态 2 → 3 │ UPDATE ... WHERE id = ?
│ │ AND execution_status = '2'
│ │ AND updated_at = ?
└──────────────────────────┘
│ 抢占失败 → 跳过(已被其他节点处理)
▼ 抢占成功
┌──────────────────────────┐
│ 3. 策略路由 │ program_enum → 策略实现
│ (配置优先,找不到配置 │ → Mapper
│ 再走策略类) │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ 4. 游标分页查询 + 流式写CSV │ WHERE batch_no = ? AND row_num > ?
│ │ ORDER BY row_num LIMIT 500
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ 5. 检查结果 │ 空数据 → 状态5,"数据表中无数据"
│ │ 生成失败 → 状态5
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ 6. 上传对象存储,获取 URL │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ 7. 更新完成 状态 3 → 4 │ 写入 csv_export_url
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ 8. finally 删除临时文件 │ 先 close 流,再删文件
└──────────────────────────┘
状态流转表
| 场景 | 状态变化 | fail_cause |
|---|---|---|
| 正常流程 | 2 → 3 → 4 | - |
| 数据表为空 | 2 → 3 → 5 | 数据表中无数据 |
| CSV 生成异常 | 2 → 3 → 5 | CSV文件生成失败 |
| 文件上传失败 | 2 → 3 → 5 | 异常信息 |
| 无对应策略 | 2 → 3 抛异常 | 未找到对应的数据查询策略: xxx |
| 乐观锁抢占失败 | 状态不变,跳过 | - |
四、关键模块实现
4.1 XXL-JOB 定时任务入口
java
@Component
@RequiredArgsConstructor
public class CsvGenerateJobHandler {
private final TaskRecordService taskRecordService;
private final CsvGenerateService csvGenerateService;
private final CsvConfigProperties configProperties;
@XxlJob("csvGenerateJob")
public ReturnT<String> execute(String param) {
// 1. 查询 execution_status = '2' 的任务,LIMIT batchLimit(默认 10,可配置)
// 2. 遍历处理每个任务
// 3. 统计成功/失败/跳过数,记录日志
// 4. 返回 ReturnT<String>
}
}
4.2 策略模式 ------ 数据查询路由
策略接口统一使用 row_num 游标,接口层面不出现任何列名:
java
public interface DataQueryStrategy {
/** 策略唯一标识(对应 program_enum,如 PRG-001) */
String getProgramEnum();
/**
* 游标分页查询结果表数据
* Mapper XML 必须使用 resultType="java.util.LinkedHashMap"
* 以保持 SELECT 字段顺序
*/
List<Map<String, Object>> queryData(Long batchNo, Long lastRowNum, int pageSize);
}
多表联查场景的策略实现示例:
java
@Slf4j
@Component
@RequiredArgsConstructor
public class DemoTableQueryStrategy implements DataQueryStrategy {
private final DemoResultMapper demoMapper;
@Override
public String getProgramEnum() {
return "PRG-001";
}
@Override
public List<Map<String, Object>> queryData(Long batchNo, Long lastRowNum, int pageSize) {
return demoMapper.selectByBatchNoCursor(batchNo, lastRowNum, pageSize);
}
}
4.3 策略工厂 ------ 混合模式(配置优先)
这是本设计的亮点:单表走配置,多表走策略类,配置优先级更高。
java
@Slf4j
@Component
@RefreshScope
public class DataQueryStrategyFactory {
private final Map<String, DataQueryStrategy> strategyMap;
public DataQueryStrategyFactory(List<DataQueryStrategy> strategies,
CsvConfigProperties configProps,
DynamicSingleTableMapper dynamicMapper) {
this.strategyMap = new HashMap<>();
// 1. 先加载 Nacos 配置化的单表查询策略
if (configProps.getTableConfigs() != null) {
for (Map.Entry<String, TableConfig> entry : configProps.getTableConfigs().entrySet()) {
strategyMap.put(entry.getKey(),
new ConfigurableQueryStrategy(entry.getKey(), entry.getValue(), dynamicMapper));
}
}
// 2. 再加载 Spring Bean 策略类(多表联查 / 复杂逻辑)
// 配置优先:若 program_enum 已存在,Bean 策略不覆盖
for (DataQueryStrategy strategy : strategies) {
strategyMap.putIfAbsent(strategy.getProgramEnum(), strategy);
}
}
public DataQueryStrategy getStrategy(String programEnum) {
DataQueryStrategy strategy = strategyMap.get(programEnum);
if (strategy == null) {
throw ErrorCodes.STRATEGY_NOT_FOUND.createError(programEnum);
}
return strategy;
}
}
4.4 CSV 流式生成
java
@Service
public class CsvGenerateService {
/**
* @param outputFile 输出文件
* @param strategy 数据查询策略
* @param batchNo 批次号
* @param csvHeaders CSV 展示表头(为 null 时从查询结果动态提取)
* @param sqlColumns SQL 列名(用于从 Map 中取值,为 null 时回退到 csvHeaders)
* @param pageSize 分页大小
* @return 文件大小(字节),0 表示无数据,-1 表示生成异常
*/
public long generateCsv(File outputFile, DataQueryStrategy strategy,
Long batchNo, List<String> csvHeaders,
List<String> sqlColumns, int pageSize) {
// 1. 写入 UTF-8 BOM 头(使 Excel 正确识别中文编码)
// 2. 游标分页查询
// 3. 第一页数据不为空时:
// - csvHeaders 不为空 → 使用配置的表头
// - csvHeaders 为空 → 从 LinkedHashMap.keySet() 动态提取列名
// 4. 写入表头,继续分页查询并写入数据行
// 5. 通过 dataKeys(sqlColumns 或回退到 displayHeaders)从 Map 中取值
// 6. 第一页为空则返回 0,异常返回 -1
}
}
几个容易踩的坑:
- UTF-8 with BOM:不写 BOM 头,Excel 打开中文 CSV 会乱码;
LinkedHashMap保序 :MyBatisresultType用HashMap会导致列顺序随机,必须用LinkedHashMap;- 流式写入:每页 500 条边查边写,数据不堆积在内存;
- 临时文件删除 :
finally中删除,且必须先 close 文件流再删文件,否则 Windows/句柄未释放场景下删不掉。
4.5 文件上传(对象存储)
java
@Service
public class FileUploadService {
public String uploadCsv(File csvFile, String downloadName) {
String objectKey = NameUtils.generateUniqueFileName(csvFile.getName());
ObjectStorageClient client = ObjectStoragePortal.getClient("fileStorage");
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentDisposition("attachment; filename=" + downloadName);
// putObject 上传,返回 accessUrl
}
}
4.6 主流程服务
java
@Service
@RequiredArgsConstructor
public class CsvGenerateMainService {
public boolean processTask(TaskRecord task) {
// 1. 乐观锁抢占:状态 2 → 3
// 2. 重新查询获取最新 updated_at(用于后续乐观锁)
// 3. 获取策略
// 4. 获取 CSV 表头和 SQL 列名(配置化场景;
// 多表联查场景为 null,由 CsvGenerateService 动态提取)
// 5. 生成临时 CSV 文件
// 6. 校验生成结果(空数据 / 生成失败 / 正常)
// 7. 上传对象存储 → 状态 3 → 4
// 8. 异常处理 → 状态 3 → 5(重新查询任务获取最新 updated_at 后再更新)
// 9. finally 删除临时文件
// 返回:true=处理成功,false=跳过或失败
}
}
五、深分页方案:游标分页
传统 LIMIT offset, size 在 offset 很大时需要扫描并丢弃前面所有行,性能急剧下降。本方案统一使用物理游标字段 row_num(结果表建表时预置的自增序号):
xml
<!-- 多表联查策略 -->
<select id="selectByBatchNoCursor" resultType="java.util.LinkedHashMap">
SELECT
sample_id, batch_no, biz_key_md5,
score_a, score_b, ...
FROM result_table_demo
WHERE batch_no = #{batchNo}
<if test="lastRowNum != null">
AND row_num > #{lastRowNum}
</if>
ORDER BY row_num ASC
LIMIT #{pageSize}
</select>
<!-- 单表配置化查询(动态表名 + 动态字段) -->
<select id="selectByBatchNoCursorDynamic" resultType="java.util.LinkedHashMap">
SELECT ${columns}
FROM ${tableName}
WHERE batch_no = #{batchNo}
<if test="lastRowNum != null">
AND row_num > #{lastRowNum}
</if>
ORDER BY row_num ASC
LIMIT #{pageSize}
</select>
关键点:
resultType="java.util.LinkedHashMap":保持 SELECT 字段顺序,确保 CSV 表头顺序与 SQL 一致;- 每页 500 条,兼顾内存与查询效率;
LIMIT语法在 MySQL / Doris 等引擎均兼容;row_num仅用于分页,不导出到 CSV (不放入sql-columns)。
六、并发控制:乐观锁
多节点部署时,同一任务可能被多个节点同时扫到。不引入分布式锁(Redis/ZK),而是用 execution_status + updated_at 组合乐观锁:
xml
<update id="updateStatusWithOptimisticLock">
UPDATE task_record
SET
execution_status = #{newStatus},
updated_at = NOW(),
<if test="failCause != null">
fail_cause = #{failCause},
</if>
<if test="csvExportUrl != null">
csv_export_url = #{csvExportUrl},
</if>
id = id
WHERE id = #{id}
AND execution_status = #{oldStatus}
AND updated_at = #{oldUpdatedAt}
</update>
使用场景:
- 2 → 3(抢占) :传入状态 2 时的
updatedAt,UPDATE 影响行数为 0 说明被其他节点抢走,直接跳过; - 3 → 4(完成)/ 3 → 5(失败) :传入抢占成功后重新查询到的
generatingUpdatedAt。
"扫描 → 抢占"整个动作由一条 UPDATE 保证原子性,简单可靠。
七、配置化零代码扩展(重点)
7.1 痛点
纯策略模式下,每新增一个结果表需要 3 个文件:Mapper 接口 + Mapper XML + 策略类。对单表查询场景来说太重了。
7.2 方案:Nacos 配置驱动
单表场景只需在 Nacos 加一段配置,零 Java 代码:
yaml
backtest:
csv:
page-size: 500 # 游标分页每页大小
batch-limit: 10 # 定时任务每次扫描的任务数上限
table-configs:
PRG-003:
table-name: result_table_c
order-by: row_num
sql-columns: [sample_id, batch_no, biz_key_md5, credit_score, ...]
csv-headers: [样本ID, 批次号, 业务主键MD5, 信用分, ...]
设计决议:
- 双数组映射 :
sql-columns[i]与csv-headers[i]一一对应,决定 SELECT 字段顺序和 CSV 列名; - 热更新 :配置类标注
@RefreshScope,Nacos 变更后策略工厂自动重建,实时生效; - 双重校验 :
@PostConstruct启动时校验两个数组长度一致性(不通过阻止启动),ConfigurableQueryStrategy运行时再次校验(不通过抛异常)。
通用单表 Mapper:
java
@Mapper
public interface DynamicSingleTableMapper {
List<Map<String, Object>> selectByBatchNoCursor(
@Param("tableName") String tableName,
@Param("columns") String columns,
@Param("batchNo") Long batchNo,
@Param("lastRowNum") Long lastRowNum,
@Param("pageSize") int pageSize);
}
7.3 SQL 注入防护
动态表名/字段通过 ${} 拼接,存在注入风险。方案是正则单词边界匹配拦截危险关键词:
\b(delete|drop|truncate|insert|update|union|select|exec|alter|or)\b
| 输入 | 是否拦截 | 说明 |
|---|---|---|
delete / drop / union 等 |
✅ 拦截 | 独立关键词 |
undeleted / backdrop / order |
❌ 不拦截 | 非独立单词,不误伤正常字段名 |
; / -- |
✅ 拦截 | 语句分隔符 / SQL 注释 |
7.4 新旧方案对比
| 维度 | 纯策略模式 | 混合模式(配置化 + 策略) |
|---|---|---|
| 新增单表 | Mapper + XML + 策略类(3 文件) | Nacos 加几行配置(0 文件) |
| 新增多表联查 | 3 文件 | 3 文件(不变) |
| 灵活性 | 高 | 高(复杂逻辑仍走策略模式) |
| 维护成本 | 中(Java 类膨胀) | 低 |
八、新增结果表的标准扩展步骤
单表(推荐,零代码) :Nacos 中 table-configs 下新增一个 program_enum 节点,配置表名、字段、CSV 列名即可。
多表联查(需写代码):
- 新增 Mapper 接口 + XML,编写
selectByBatchNoCursor,resultType="java.util.LinkedHashMap"; - 新增策略类,实现
getProgramEnum()和queryData(batchNo, lastRowNum, pageSize); - 无需硬编码任何列名,无需修改已有代码(符合开闭原则)。
九、风险点与注意事项总结
- 并发处理:乐观锁防多节点抢占,无需分布式锁;
- 内存控制:游标分页 + 流式写入,数据不堆积;
- 磁盘清理 :
finally强制删除临时文件,先关流再删除; - 状态原子性:"扫描 → 置为处理中"由一条带条件的 UPDATE 保证;
- 空数据处理:主动标记失败并写明原因,避免生成无意义的空文件;
- SQL 注入 :
${}拼接必须经过关键词校验; - 配置一致性 :
sql-columns与csv-headers长度不一致时快速失败; - 编码问题:CSV 写 UTF-8 BOM 头,Excel 中文不乱码。
十、写在最后
这套方案的核心思想可以概括为一句话:"简单的交给配置,复杂的交给代码,并发的交给乐观锁,性能的交给游标分页。"
它并不是某个单一技术的炫技,而是把 XXL-JOB、策略模式、Nacos 配置中心、MyBatis 动态 SQL、乐观锁这些常见组件按场景合理组合的结果。如果你也在做批量导出、报表生成、异步结果落地类的系统,希望这篇设计复盘能给你一些参考。
欢迎评论区交流你的方案~