异步任务提交 + 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秒 |
九、最佳实践清单
- 提交接口只做参数校验和发MQ,不执行业务逻辑
- Redis Key 包含业务标识(memberId、orderCode),便于排查
- 设置合理的 Redis TTL,避免数据堆积
- 分布式锁防重复消费
- 内层 catch 记录 errorCode,不仅记录 errorMsg
- 前端设置轮询上限,避免无限轮询
- Redis 数据不存在时返回明确状态(任务不存在或已过期)
- 区分整体失败和部分失败,给前端不同的处理依据
- 事务提交后再写 Redis,保证数据一致性
- 日志记录完整链路:提交时记录 taskId,消费时记录开始/成功/失败