SheepDog1998

博客园 首页 新随笔 联系 订阅 管理
在实际开发中,经常会遇到 “需要异步轮询等待文件生成,生成后立即发送” 的场景(比如报表图片生成、文件导出后推送)。本文基于 Spring 的 TaskScheduler 实现了一套生产级的异步轮询方案,包含重试机制、异常防护、资源管控等核心设计,分享给大家。
 

一、场景背景

 
需求:
 
  1. 异步等待指定的 ReportRecord 记录生成(关联图片文件);
  2. 轮询检查文件是否生成,最多重试 20 次,每次间隔 30 秒;
  3. 图片生成后拼接 URL 并安全发送,防止发送过程中 Native 崩溃导致线程异常;
  4. 所有轮询任务使用独立线程池管控,保证应用优雅关闭。
 

二、核心实现代码

 
1. 第一步:配置专用的 TaskScheduler 线程池
 
首先创建独立的线程池配置类,避免轮询任务与其他任务抢占资源:
package com.push.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

/**
 * 异步轮询任务调度器配置
 * 为轮询任务提供独立线程池,避免资源竞争
 */
@Configuration
public class AsyncConfig {

    /**
     * 轮询任务专用调度器
     * @return 定制化的 ThreadPoolTaskScheduler
     */
    @Bean("pollingTaskScheduler")
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        // 核心线程数:根据并发轮询任务数调整,本例设置5
        scheduler.setPoolSize(5);
        // 线程名称前缀:便于日志排查问题
        scheduler.setThreadNamePrefix("polling-task-");
        // 应用关闭时等待任务执行完成
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        // 等待任务完成的超时时间:防止无限阻塞,最多等60秒
        scheduler.setAwaitTerminationSeconds(60);
        return scheduler;
    }

}
2. 第二步:核心轮询与发送逻辑
 
封装轮询、URL 构建、安全发送等核心方法,重点处理异常和重试:
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Component;

import java.io.File;
import java.time.Instant;

/**
 * 图片异步获取与发送核心逻辑
 */
@Slf4j
@Component
public class ImageSendService {

    // 注入自定义的轮询任务调度器
    @Autowired
    @Qualifier("pollingTaskScheduler")
    private TaskScheduler taskScheduler;

    // 依赖的Mapper和工具类(根据实际业务替换)
    @Autowired
    private ReportRecordMapper reportRecordMapper;
    @Autowired
    private GetJREPathHelper getJREPathHelper;

    // 业务配置项(可抽离到配置文件)
    private static final int MAX_RETRIES = 20; // 最大重试次数
    private static final long RETRY_DELAY_MS = 30000; // 重试间隔(30秒)
    private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 图片最大限制20MB
    private boolean testSwitch = false; // 测试环境开关
    private int sendTimes = 1; // 发送次数(根据业务调整)

    /**
     * 异步触发图片获取与发送(入口方法)
     * @param eqDto 业务DTO
     * @param thematicKey 主题标识
     */
    private void getImgAsync(RealAnalogEQDto eqDto, String thematicKey) {
        // 提交第一个轮询任务,立即执行
        taskScheduler.schedule(() -> pollForFile(eqDto, thematicKey, 1), Instant.now());
    }

    /**
     * 核心轮询逻辑:检查文件是否生成,失败则重试
     * @param eqDto 业务DTO
     * @param thematicKey 主题标识
     * @param attempt 当前重试次数
     */
    private void pollForFile(RealAnalogEQDto eqDto, String thematicKey, int attempt) {
        try {
            // 查询图片关联的记录
            ReportRecord record = reportRecordMapper.getThematic(eqDto.getId(), thematicKey);

            if (record != null) {
                // 1. 记录存在:拼接URL并发送
                log.info("第 {} 次尝试:成功获取到 ReportRecord", attempt);
                String imgUrl = buildImgUrl(record);
                log.info("准备发送图片: {}", imgUrl);
                safeSendPicture(imgUrl);
            } else if (attempt < MAX_RETRIES) {
                // 2. 记录不存在且未达最大重试次数:延迟重试
                log.warn("第 {} 次尝试:文件未生成,{}秒后重试", attempt, RETRY_DELAY_MS / 1000);
                taskScheduler.schedule(
                        () -> pollForFile(eqDto, thematicKey, attempt + 1),
                        Instant.now().plusMillis(RETRY_DELAY_MS)
                );
            } else {
                // 3. 达到最大重试次数:终止任务
                log.error("经过 {} 次重试,文件仍未生成,任务终止。", MAX_RETRIES);
            }

        } catch (Exception e) {
            // 4. 执行异常:记录并重试(防止单次异常终止任务)
            log.error("轮询过程发生异常", e);
            if (attempt < MAX_RETRIES) {
                taskScheduler.schedule(
                        () -> pollForFile(eqDto, thematicKey, attempt + 1),
                        Instant.now().plusMillis(RETRY_DELAY_MS)
                );
            }
        }
    }

    /**
     * 构建图片完整URL(抽离路径拼接逻辑,符合单一职责)
     * @param record 图片关联记录
     * @return 完整图片URL
     */
    private String buildImgUrl(ReportRecord record) {
        // 获取基础路径并格式化(统一分隔符为/)
        String jrePath = getJREPathHelper.getJREPath()
                .replace("/target", "")
                .replaceAll("\\\\", "/");
        // 拼接相对路径
        String relativePath = "/" + record.getFilePath();
        return jrePath + relativePath;
    }

    /**
     * 安全发送图片:防OOM、防Native崩溃、全量异常捕获
     * @param imgUrl 图片完整URL
     */
    private void safeSendPicture(String imgUrl) {
        try {
            // 前置检查:防止超大文件导致OOM
            File file = new File(imgUrl);
            if (file.length() > MAX_FILE_SIZE) {
                log.warn("图片过大,跳过发送: {} 大小: {}MB", imgUrl, file.length() / (1024 * 1024));
                return;
            }

            // 正式发送图片
            sendPicture(imgUrl, sendTimes);
            log.info("【发送成功】文件大小: {} KB", file.length() / 1024);
            
            // 测试环境额外处理
            if (testSwitch) {
                sendPictureTest(imgUrl, sendTimes);
            }
        } catch (Throwable t) { // 捕获Throwable(包含Error),防止Native崩溃
            log.error("【严重】发送图片时发生致命错误(可能是Native内存问题): {}", imgUrl, t);
            // 可选:添加告警、资源重启等兜底逻辑
        }
    }

    // 以下为业务方法(示例,根据实际场景实现)
    private void sendPicture(String imgUrl, int sendTimes) {
        // 实际发送逻辑:如调用第三方推送接口、本地文件推送等
    }

    private void sendPictureTest(String imgUrl, int sendTimes) {
        // 测试环境发送逻辑
    }
}

三、关键设计思路与优化点

 
1. 线程池管控(生产级必备)
 
  • 使用 ThreadPoolTaskScheduler 而非默认调度器,独立线程池避免资源竞争;
  • 配置 waitForTasksToCompleteOnShutdownawaitTerminationSeconds,保证应用关闭时任务优雅退出;
  • 线程名称前缀便于日志定位问题。
 
2. 重试机制设计
 
  • 固定重试间隔(30 秒)+ 最大重试次数(20 次),总超时 10 分钟,避免无限轮询;
  • 异常捕获后仍重试,防止数据库查询失败、网络抖动等临时问题导致任务终止;
  • 轮询逻辑与业务逻辑解耦,重试仅针对 “文件未生成” 场景。
 
3. 安全防护措施
 
  • 防 OOM:发送前检查文件大小,限制 20MB 以内;
  • 防线程崩溃safeSendPicture 捕获 Throwable(包含 Error),防止 JNI/Native 层崩溃导致整个调度线程死亡;
  • 路径格式化:统一文件路径分隔符为 /,兼容 Windows/Linux 系统。
 
4. 代码可读性优化
 
  • 抽离 buildImgUrl 方法,将路径拼接逻辑与轮询 / 发送逻辑解耦,符合单一职责原则;
  • 常量抽离(如 MAX_RETRIES),便于配置管理;
  • 日志分级(info/warn/error),便于问题排查。
 

四、使用注意事项

 
  1. 线程池大小调整:根据实际并发轮询任务数调整 setPoolSize,如果同时有 10 个轮询任务,建议设置核心线程数为 10;
  2. 重试策略优化:可将固定间隔改为 “指数退避”(如 30s→60s→120s),减少服务器压力;
  3. 配置抽离:将 MAX_RETRIESRETRY_DELAY_MS 等常量抽离到 application.yml,便于动态调整;
  4. 监控告警:添加轮询失败、发送失败的告警(如钉钉 / 邮件通知),及时发现问题。
 

五、线程池(ThreadPoolTaskScheduler)是并发编程的核心载体

  配置的ThreadPoolTaskScheduler本质是 Spring 封装的线程池(底层基于 JDK 的ThreadPoolExecutor),这是并发编程中管理多线程的核心组件:
  • setPoolSize(5):设置 5 个核心线程,意味着可以同时并发执行 5 个轮询任务(比如 5 个不同的eqDto对应的图片轮询);
  • 线程池会自动管理线程的创建、复用、销毁,避免手动创建线程的开销和风险(如线程泄露)。 

六、异步任务调度实现 “并发执行”

  • getImgAsync方法通过taskScheduler.schedule()提交任务,当前线程不会阻塞,而是由线程池中的独立线程执行轮询逻辑;
  • 多个getImgAsync调用会生成多个独立的轮询任务,这些任务会在线程池的不同线程中并发执行(比如同时轮询 3 个不同的图片文件)。

七、 轮询重试的 “异步并发” 特性

 
  每次重试都是通过taskScheduler.schedule()提交新的任务,而非单线程sleep等待:
 
  • 即使某个轮询任务在重试等待(30 秒),线程池中的其他线程仍可处理其他轮询任务;
  • 单个轮询任务的多次重试,也会被调度到线程池的不同线程(或复用线程)执行,避免单线程阻塞。
 

八、与 “传统底层并发编程” 的区别

 
虽然属于并发编程,但和直接使用 JDK 底层 API(如ThreadRunnableLockCountDownLatch)的 “底层并发编程” 有明显区别:
 
维度 我的代码(Spring 封装) 传统底层并发编程
核心 API Spring TaskScheduler/ 线程池 JDK Thread/Lock/Callable
关注点 任务调度、业务逻辑 线程创建、锁机制、资源竞争
复杂度 低(Spring 封装了并发细节) 高(需手动处理线程安全、死锁)
适用场景 业务层异步任务、定时 / 轮询任务 框架层、底层组件开发
 
简单来说:
 
  • 该代码是业务层的并发编程实践(Spring 屏蔽了底层并发的复杂细节);
  • 无需手动处理synchronizedLock等线程安全问题(因为每个轮询任务处理的是独立的业务数据,无共享资源竞争);
  • 核心目标是 “提升任务执行的并发性和响应性”,而非 “解决底层线程安全问题”。

 

总结

 
这套方案基于 Spring TaskScheduler 实现了生产级的异步轮询逻辑,核心亮点是:
 
  1. 独立线程池管控,保证任务隔离和优雅退出;
  2. 完善的重试与异常防护机制,适配各种异常场景;
  3. 代码分层清晰,符合面向对象设计原则,便于维护和扩展。适用于文件生成、报表推送、异步回调等需要 “轮询等待 + 重试” 的业务场景,可直接复用或根据实际需求调整。
  4. 这套代码属于并发编程,核心特征是使用线程池管理多任务的异步调度与并发执行;
  5. 它是 Spring 生态下高易用性的并发编程实践,无需关注底层线程安全细节,适合业务层异步任务场景;
  6. 区别于底层并发编程(如手动创建线程、处理锁),更聚焦于 “任务调度” 和 “业务逻辑”,是生产环境中最常用的并发编程方式之一;
posted on 2026-01-20 09:48  SheepDog1998  阅读(42)  评论(0)    收藏  举报