异步任务提交 + Redis 状态轮询模式实战指南

异步任务提交 + Redis 状态轮询模式实战指南

一、概述

当一个业务操作耗时较长(如拆单发货、大文件处理、复杂计算),如果同步执行会导致接口超时或用户长时间等待。解决方案是:接口只负责提交任务,立即返回一个任务ID,实际处理交给 MQ 消费者异步执行,处理结果写入 Redis,前端通过任务ID轮询查询结果。

本文以一个"订单拆分处理"场景为例,系统介绍这种模式的完整实现。


二、核心设计

2.1 两个接口的职责

接口 职责 执行方式 返回
提交接口 参数校验 → 初始化状态 → 发MQ 同步(毫秒级) taskId
状态查询接口 根据 taskId 读取 Redis 同步(毫秒级) 处理状态+结果

2.2 状态流转

提交接口写入 Redis: status=1(处理中) ↓ MQ 消费者处理成功: status=2(成功)+ result ↓ 或 MQ 消费者处理失败: status=0(失败)+ errorMsg + errorCode

2.3 数据流向

提交接口: 前端 → Controller → 写Redis(status=1) → 发MQ → 返回taskId

异步处理: MQ Consumer → 业务处理 → 更新Redis(status=2或0)

状态查询: 前端 → Controller → 读Redis → 返回结果


三、Redis 数据结构设计

java 复制代码
@Data
public class TaskStatusDto implements Serializable {
    /** 状态:1-处理中 2-成功 0-失败 */
    private Integer status;
    /** 错误码(失败时返回,用于前端特殊处理) */
    private Integer errorCode;
    /** 错误信息(失败时返回) */
    private String errorMsg;
    /** 处理结果(成功时返回) */
    private TaskResultDto result;
}

Redis Key 设计

复制代码
task:{memberId}_{orderCode}_{uuid}
TTL: 1天

包含 memberId 和 orderCode 便于排查问题,UUID 保证唯一性。


注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

四、完整示例

4.1 提交接口

java 复制代码
@RestController
@RequestMapping("/api/page/order")
public class OrderSplitController {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private OrderSplitMqSender orderSplitMqSender;

    private static final String REDIS_KEY_FORMAT = "order:split:%s";

    /**
     * 提交拆单请求.
     */
    @PostMapping("/submit-order-split")
    public RestControllerResult<String> submitOrderSplit(
            @RequestBody OrderSplitParamDto paramDto) {
        // 1. 参数校验
        if (CheckEmptyUtil.isEmpty(paramDto.getOrderCode())) {
            throw new BusinessException("订单编号不能为空");
        }

        // 2. 生成任务ID(Redis Key)
        Integer memberId = UserContext.getMemberId();
        String uuid = memberId + "_" + paramDto.getOrderCode()
                + "_" + UUID.randomUUID().toString().replaceAll("-", "");
        String taskId = String.format(REDIS_KEY_FORMAT, uuid);

        // 3. 初始化Redis状态(处理中)
        TaskStatusDto statusDto = new TaskStatusDto();
        statusDto.setStatus(1); // 处理中
        stringRedisTemplate.opsForValue().set(
                taskId, JSON.toJSONString(statusDto), 1, TimeUnit.DAYS);

        // 4. 补充参数
        paramDto.setTaskId(taskId);
        paramDto.setOperatorId(UserContext.getUserId());
        paramDto.setMemberId(memberId);

        // 5. 发送MQ
        orderSplitMqSender.send(paramDto);

        // 6. 返回taskId
        return RestControllerResult.success(taskId);
    }

    /**
     * 查询拆单状态.
     */
    @GetMapping("/order-split-status")
    public RestControllerResult<TaskStatusDto> getOrderSplitStatus(
            @RequestParam("taskId") String taskId) {
        if (CheckEmptyUtil.isEmpty(taskId)) {
            throw new BusinessException("taskId不能为空");
        }
        String json = stringRedisTemplate.opsForValue().get(taskId);
        TaskStatusDto statusDto;
        if (CheckEmptyUtil.isEmpty(json)) {
            // Redis中无数据(可能已过期)
            statusDto = new TaskStatusDto();
            statusDto.setStatus(0);
            statusDto.setErrorCode(-1);
            statusDto.setErrorMsg("任务不存在或已过期");
        } else {
            statusDto = JSON.parseObject(json, TaskStatusDto.class);
        }
        return RestControllerResult.success(statusDto);
    }
}

4.2 MQ 生产者

java 复制代码
@Slf4j
@Component
public class OrderSplitMqSender {

    @Resource
    private RabbitTemplate rabbitTemplate;

    private static final String EXCHANGE = "order.split.exchange";
    private static final String ROUTING_KEY = "order.split.routing";

    public void send(OrderSplitParamDto paramDto) {
        String message = JSON.toJSONString(paramDto);
        rabbitTemplate.convertAndSend(EXCHANGE, ROUTING_KEY, message);
        log.info("拆单任务MQ发送成功, orderCode={}, taskId={}",
                paramDto.getOrderCode(), paramDto.getTaskId());
    }
}

4.3 MQ 消费者

java 复制代码
@Slf4j
@Component
public class OrderSplitMqConsumer {

    @Resource
    private OrderSplitService orderSplitService;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @RabbitListener(queues = "${mq.order.split.queue}")
    public void consume(String message) {
        OrderSplitParamDto paramDto = JSON.parseObject(message, OrderSplitParamDto.class);
        String taskId = paramDto.getTaskId();
        log.info("拆单任务消费开始, orderCode={}", paramDto.getOrderCode());

        try {
            // 执行拆单业务逻辑
            TaskResultDto result = orderSplitService.doSplit(paramDto);

            // 处理成功,更新Redis
            TaskStatusDto statusDto = new TaskStatusDto();
            statusDto.setStatus(2);
            statusDto.setResult(result);
            stringRedisTemplate.opsForValue().set(
                    taskId, JSON.toJSONString(statusDto));

            log.info("拆单任务处理成功, orderCode={}", paramDto.getOrderCode());

        } catch (Exception e) {
            log.warn("拆单任务处理失败, orderCode={}", paramDto.getOrderCode(), e);

            // 处理失败,更新Redis(区分错误码)
            TaskStatusDto statusDto = new TaskStatusDto();
            statusDto.setStatus(0);
            if (e instanceof BusinessException) {
                statusDto.setErrorCode(((BusinessException) e).getErrorCode());
                statusDto.setErrorMsg(e.getMessage());
            } else {
                statusDto.setErrorCode(-1);
                statusDto.setErrorMsg("处理异常,请稍后重试");
            }
            stringRedisTemplate.opsForValue().set(
                    taskId, JSON.toJSONString(statusDto));
        }
    }
}

4.4 业务处理Service(逐笔catch模式)

当任务内部包含多笔处理,且希望单笔失败不影响其他笔时:

java 复制代码
@Slf4j
@Service
public class OrderSplitServiceImpl implements OrderSplitService {

    @Override
    @Transactional(rollbackFor = Exception.class)
    public TaskResultDto doSplit(OrderSplitParamDto paramDto) {
        List<SplitItemDto> items = paramDto.getItems();
        int successNum = 0;
        int errorNum = 0;
        int errorCode = 0;
        List<FailItemDto> failList = new ArrayList<>();

        for (SplitItemDto item : items) {
            try {
                // 单笔处理逻辑(含各种校验)
                processSingleItem(paramDto, item);
                successNum++;
            } catch (Exception e) {
                log.warn("单笔拆单失败, itemCode={}", item.getItemCode(), e);
                errorNum++;
                // 记录失败信息
                FailItemDto failDto = new FailItemDto();
                failDto.setItemCode(item.getItemCode());
                failDto.setErrorMsg(e.getMessage());
                failList.add(failDto);
                // 捕获自定义错误码
                if (e instanceof BusinessException) {
                    errorCode = ((BusinessException) e).getErrorCode();
                }
            }
        }

        // 组装结果
        TaskResultDto result = new TaskResultDto();
        result.setTotalNum(items.size());
        result.setSuccessNum(successNum);
        result.setErrorNum(errorNum);
        result.setFailList(failList);
        // 如果有自定义错误码,设置到result中
        if (errorCode != 0) {
            result.setErrorCode(errorCode);
        }
        return result;
    }
}

五、错误码传递机制

5.1 问题

异步场景下,业务异常在 MQ 消费者中抛出,前端无法通过 HTTP 响应码直接感知。需要一种机制将错误码传递到前端。

5.2 两种传递路径

路径A:外层异常(整个任务失败)

复制代码
异常抛出 → Consumer catch → 写入 TaskStatusDto.errorCode → Redis → 前端轮询获取

路径B:内层异常(单笔失败,任务整体成功)

复制代码
异常抛出 → 内层 catch → 写入 TaskResultDto.errorCode → Redis → 前端从 result.errorCode 获取

5.3 前端判断逻辑

javascript 复制代码
const pollResult = async (taskId) => {
    const res = await api.getStatus(taskId);

    if (res.status === 1) {
        // 处理中,继续轮询
        return;
    }

    if (res.status === 0) {
        // 整体失败
        if (res.errorCode === 10001) {
            showBillUnpaidDialog(); // 跳转结算中心
        } else {
            showError(res.errorMsg);
        }
        return;
    }

    if (res.status === 2) {
        // 整体成功,但可能部分失败
        if (res.result.errorCode === 10001) {
            showBillUnpaidDialog(); // 部分失败因为账单未支付
        }
        showResult(res.result);
    }
};

六、关键设计点

6.1 Redis TTL 设置

java 复制代码
// 提交时设置 TTL = 1天
stringRedisTemplate.opsForValue().set(taskId, json, 1, TimeUnit.DAYS);

// 消费者更新时不重设 TTL(保持原有过期时间)
stringRedisTemplate.opsForValue().set(taskId, json);

建议:

  • 提交时设 1 天 TTL
  • 消费者更新时不改 TTL
  • 超过 1 天未处理完的任务自动失效

6.2 分布式锁防重复处理

java 复制代码
try (DistributedLock lock = lockProvider.getLock(lockKey, TimeUnit.MINUTES, 20)) {
    if (lock.tryLock(TimeUnit.MINUTES, 10)) {
        // 执行业务
    }
}

防止同一任务被多个消费者实例重复处理。

6.3 事务与 Redis 写入的关系

java 复制代码
@Transactional(rollbackFor = Exception.class)
public TaskResultDto doSplit(OrderSplitParamDto paramDto) {
    // 业务处理...
}

注意:如果方法标注了 @Transactional,在方法内部写入 Redis 后如果事务回滚,Redis 中的数据不会回滚。建议:

  • 成功时:在事务提交后通过回调写入 Redis
  • 失败时:在 catch 中写入 Redis(此时事务已回滚)

6.4 前端轮询策略

javascript 复制代码
let retryCount = 0;
const maxRetry = 60; // 最多轮询60次(2分钟)
const interval = 2000; // 2秒一次

const timer = setInterval(async () => {
    retryCount++;
    const res = await api.getStatus(taskId);

    if (res.status !== 1 || retryCount >= maxRetry) {
        clearInterval(timer);
        if (retryCount >= maxRetry && res.status === 1) {
            showError("处理超时,请稍后在历史记录中查看");
        } else {
            handleResult(res);
        }
    }
}, interval);

七、异常处理分层

层级 异常类型 处理方式 对前端的影响
提交接口 参数校验失败 直接抛异常,接口返回错误 接口报错,不进入轮询
MQ消费者外层 获取锁失败、未知异常 写入 status=0 + errorMsg 轮询拿到失败结果
MQ消费者内层 单笔业务异常 记录到 failList + errorCode 轮询拿到部分成功结果

八、与纯同步接口的对比

维度 同步接口 异步提交+轮询
接口响应时间 与处理时间成正比 固定毫秒级
超时风险
错误码传递 HTTP 响应中直接返回 写入 Redis,轮询获取
用户体验 页面卡住等待 显示进度/加载状态
实现复杂度
适用场景 处理时间 < 3秒 处理时间 > 3秒

九、最佳实践清单

  1. 提交接口只做参数校验和发MQ,不执行业务逻辑
  2. Redis Key 包含业务标识(memberId、orderCode),便于排查
  3. 设置合理的 Redis TTL,避免数据堆积
  4. 分布式锁防重复消费
  5. 内层 catch 记录 errorCode,不仅记录 errorMsg
  6. 前端设置轮询上限,避免无限轮询
  7. Redis 数据不存在时返回明确状态(任务不存在或已过期)
  8. 区分整体失败和部分失败,给前端不同的处理依据
  9. 事务提交后再写 Redis,保证数据一致性
  10. 日志记录完整链路:提交时记录 taskId,消费时记录开始/成功/失败
相关推荐
GEO_youxuan3 小时前
2026年自定义报表工具推荐:五家优选品牌专业深度评测
数据库
mN9B2uk173 小时前
数据库性能优化三:程序操作优化
数据库
霸道流氓气质3 小时前
Spring Boot + Jasypt 实战指南:配置文件敏感信息加密完全手册
数据库·spring boot·oracle
AI人工智能+电脑小能手3 小时前
【大白话说Java面试题 第94题】【Mysql篇】第24题:什么是单路排序?什么是双路排序??
java·开发语言·数据库·mysql·面试·排序算法
我是一颗柠檬3 小时前
【Java项目技术亮点】多级缓存一致性方案:Canal+MQ实现数据库与缓存的最终一致
java·数据库·spring·缓存·kafka·rocketmq
WarPigs3 小时前
C# EntityFramework笔记
数据库·c#
csdn_aspnet3 小时前
mysql 查询树形,id与pid关联
数据库·mysql·tree·树形
Solis程序员3 小时前
拿捏登录安全:RS256 + 双令牌,把非法请求拦在 Redis 白名单门外
java·安全·缓存·面试·bootstrap·html
郝学胜-神的一滴3 小时前
系统设计 014:缓存深度实战:如何用 Cache 优雅优化数据库读写?
java·数据库·python·缓存·oracle·php·软件构建