实战:基于 XXL-JOB + 策略模式 + Nacos 配置化的 CSV 导出任务系统设计

实战:基于 XXL-JOB + 策略模式 + Nacos 配置化的 CSV 导出任务系统设计

本文分享一个生产级"异步任务结果导出 CSV"系统的完整设计与实现,涵盖定时任务调度、策略模式路由、配置化零代码扩展、游标分页、乐观锁并发控制、流式 CSV 生成等核心技术点,适合作为类似批量导出/报表生成场景的设计参考。

一、需求背景

系统中存在一类异步计算任务:上游计算引擎(如 Spark)执行完成后,将结果写入结果表,并把任务记录状态置为"计算完成"。下游需要一个定时任务:

  1. 每分钟扫描任务记录表中"计算完成"状态的任务;
  2. 根据任务类型(program_enum路由到对应结果表查询数据;
  3. 生成 CSV 文件 并上传到对象存储,获取下载 URL;
  4. 更新任务状态为"完成"或"失败",并记录失败原因。

看似简单的需求,实际落地时要解决一堆经典问题:多节点并发抢占、深分页性能、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 保序 :MyBatis resultTypeHashMap 会导致列顺序随机,必须用 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 &gt; #{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 &gt; #{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 列名即可。

多表联查(需写代码)

  1. 新增 Mapper 接口 + XML,编写 selectByBatchNoCursorresultType="java.util.LinkedHashMap"
  2. 新增策略类,实现 getProgramEnum()queryData(batchNo, lastRowNum, pageSize)
  3. 无需硬编码任何列名,无需修改已有代码(符合开闭原则)。

九、风险点与注意事项总结

  1. 并发处理:乐观锁防多节点抢占,无需分布式锁;
  2. 内存控制:游标分页 + 流式写入,数据不堆积;
  3. 磁盘清理finally 强制删除临时文件,先关流再删除;
  4. 状态原子性:"扫描 → 置为处理中"由一条带条件的 UPDATE 保证;
  5. 空数据处理:主动标记失败并写明原因,避免生成无意义的空文件;
  6. SQL 注入${} 拼接必须经过关键词校验;
  7. 配置一致性sql-columnscsv-headers 长度不一致时快速失败;
  8. 编码问题:CSV 写 UTF-8 BOM 头,Excel 中文不乱码。

十、写在最后

这套方案的核心思想可以概括为一句话:"简单的交给配置,复杂的交给代码,并发的交给乐观锁,性能的交给游标分页。"

它并不是某个单一技术的炫技,而是把 XXL-JOB、策略模式、Nacos 配置中心、MyBatis 动态 SQL、乐观锁这些常见组件按场景合理组合的结果。如果你也在做批量导出、报表生成、异步结果落地类的系统,希望这篇设计复盘能给你一些参考。

欢迎评论区交流你的方案~

相关推荐
邪修king1 小时前
Re:Linux 系统篇(二十九):动静态库Chapter2:动态库深度辨析 —— 核心本质、制作流程、双阶段查找模型与排错指南
android·java·linux·开发语言
泡海椒2 小时前
告别 iText 繁杂配置:jquick-pdf 极简 PDF 生成实战(零基础上手)
java·开发语言·pdf
Bs_MoneyMagnet5 小时前
基于springboot+vue的滑雪场票务与装备租赁系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
野生技术架构师10 小时前
2026 Java 面试全套总结,八股 + 场景 + AI 相关面试考点
java·人工智能·面试
香吧香11 小时前
java服务异常日志只打印异常类型,没有堆栈定位分析
java·jvm·异常
李昊哲小课11 小时前
Spring Boot SSE 实时推送教程
spring boot·websocket·flux·sseemitter
qq_25183645711 小时前
AI 疾病自查功能SpringbootAI项目实战 · 技术实现文档
java·ai·ai编程
逆境不可逃12 小时前
Pi Agent 学习笔记:对话太长以后,如何压缩上下文
java
MetaLite12 小时前
JDK 8~26 核心特性一览:从 Stream 到 Scoped Values
java