批量异步处理 + MQ + Redis 进度追踪实战指南
一、概述
在业务系统中,批量操作(如批量发货、批量审批、批量导入)如果同步执行,会导致接口超时、数据库长事务、用户等待时间过长等问题。常见的解决方案是:接口同步接收请求 → 拆分为多条消息发送到 MQ → 消费者逐条异步处理 → Redis 记录进度 → 前端轮询结果。
本文以一个"批量订单审批"场景为例,系统介绍这种架构模式的设计思路和实现细节。
二、架构设计
整体流程如下:
第一阶段:同步提交
- 前端提交批量请求(如50个订单)
- Controller 补充参数(用户信息、权限等)
- Service 执行同步前置校验(整批校验,如预算总额)
- 初始化 Redis 进度数据(总数=50,成功=0,失败=0)
- 逐条发送 MQ 消息(50条消息)
- 返回批次ID给前端(接口响应结束,耗时毫秒级)
第二阶段:异步处理
- MQ Consumer 逐条消费消息
- 每条消息在独立事务中执行业务逻辑
- 处理成功 → Redis 中 successNum + 1
- 处理失败 → Redis 中 errorNum + 1,记录失败原因
第三阶段:进度轮询
- 前端每2秒调用进度查询接口
- 读取 Redis 中的进度数据返回
- 当 successNum + errorNum = totalNum 时,停止轮询,展示最终结果
流程图:
| 阶段 | 组件 | 动作 |
|---|---|---|
| 提交 | Controller | 接收请求,补充参数 |
| 提交 | Service | 同步校验 → 初始化Redis → 发MQ |
| 提交 | 前端 | 拿到批次ID,开始轮询 |
| 处理 | MQ Consumer | 逐条消费,独立事务执行 |
| 处理 | Redis | 记录每笔处理结果 |
| 轮询 | 前端 | 定时查询进度,展示实时结果 |
关键数据流:
- 请求方向:前端 → Controller → Service → MQ → Consumer → DB
- 进度方向:Consumer → Redis → 进度查询接口 → 前端
- 两条链路完全解耦,互不阻塞
三、示例场景
一个采购审批系统,支持批量审批多个采购单:
- 每个采购单审批逻辑独立(校验库存、校验预算、更新状态、发通知)
- 单笔审批耗时约 500ms
- 一次可能提交 50~100 个采购单
- 要求:单笔失败不影响其他单据,前端实时展示进度
四、核心组件
4.1 Redis 进度数据结构
java
@Data
public class BatchProgressDto {
private Integer totalNum; // 总数
private Integer successNum; // 成功数
private Integer errorNum; // 失败数
private List<ErrorDetail> errorList; // 失败详情
@Data
public static class ErrorDetail {
private String orderCode; // 单据编号
private String errorMsg; // 失败原因
}
}
4.2 Redis Key 设计
batch:approve:{memberId}_{uuid} → JSON(BatchProgressDto)
TTL: 1小时
五、完整实现
5.1 Controller 层
java
@RestController
@RequestMapping("/api/page/approve")
public class BatchApproveController {
@Resource
private BatchApproveService batchApproveService;
/**
* 批量审批提交.
*/
@PostMapping("/batch-approve")
public RestControllerResult<String> batchApprove(
@RequestBody List<ApproveParamsDto> paramsList) {
// 参数补充
Integer userId = JwtTokenUtil.getUserId();
String userName = JwtTokenUtil.getUserName();
for (ApproveParamsDto params : paramsList) {
params.setOperatorId(userId);
params.setOperatorName(userName);
}
// 返回批次ID,前端用此ID轮询进度
String batchId = batchApproveService.batchApprove(paramsList);
return new RestControllerResult<>(batchId);
}
/**
* 批量审批进度查询.
*/
@GetMapping("/batch-approve-progress")
public RestControllerResult<BatchProgressDto> getBatchProgress(
@RequestParam String batchId) {
return new RestControllerResult<>(batchApproveService.getProgress(batchId));
}
}
5.2 Service 层(生产者)
java
@Slf4j
@Service
public class BatchApproveServiceImpl implements BatchApproveService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private BatchApproveMqSender batchApproveMqSender;
@Resource
private BudgetService budgetService;
private static final String REDIS_KEY_PREFIX = "batch:approve:";
@Override
public String batchApprove(List<ApproveParamsDto> paramsList) {
// 1. 生成批次ID
String batchId = paramsList.get(0).getMemberId() + "_"
+ UUID.randomUUID().toString().replaceAll("-", "");
// 2. 同步前置校验(整批校验,不通过则整批失败)
BigDecimal totalAmount = paramsList.stream()
.map(ApproveParamsDto::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (!budgetService.checkBudgetSufficient(totalAmount)) {
throw new JshCheckException("预算不足,无法批量审批");
}
// 3. 初始化Redis进度
BatchProgressDto progress = new BatchProgressDto();
progress.setTotalNum(paramsList.size());
progress.setSuccessNum(0);
progress.setErrorNum(0);
progress.setErrorList(new ArrayList<>());
stringRedisTemplate.opsForValue().set(
REDIS_KEY_PREFIX + batchId,
JSON.toJSONString(progress),
1, TimeUnit.HOURS);
// 4. 逐条发送MQ消息
for (ApproveParamsDto params : paramsList) {
params.setBatchId(batchId);
batchApproveMqSender.send(params);
}
// 5. 返回批次ID
return batchId;
}
@Override
public BatchProgressDto getProgress(String batchId) {
String json = stringRedisTemplate.opsForValue().get(REDIS_KEY_PREFIX + batchId);
if (json == null) {
return null;
}
return JSON.parseObject(json, BatchProgressDto.class);
}
/**
* 更新Redis进度(成功).
*/
public void updateProgressSuccess(String batchId) {
String key = REDIS_KEY_PREFIX + batchId;
// 使用Redis锁保证并发安全
String json = stringRedisTemplate.opsForValue().get(key);
BatchProgressDto progress = JSON.parseObject(json, BatchProgressDto.class);
progress.setSuccessNum(progress.getSuccessNum() + 1);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(progress), 1, TimeUnit.HOURS);
}
/**
* 更新Redis进度(失败).
*/
public void updateProgressFail(String batchId, String orderCode, String errorMsg) {
String key = REDIS_KEY_PREFIX + batchId;
String json = stringRedisTemplate.opsForValue().get(key);
BatchProgressDto progress = JSON.parseObject(json, BatchProgressDto.class);
progress.setErrorNum(progress.getErrorNum() + 1);
BatchProgressDto.ErrorDetail detail = new BatchProgressDto.ErrorDetail();
detail.setOrderCode(orderCode);
detail.setErrorMsg(errorMsg);
progress.getErrorList().add(detail);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(progress), 1, TimeUnit.HOURS);
}
}
5.3 MQ 生产者
java
@Slf4j
@Component
public class BatchApproveMqSender {
@Resource
private RabbitTemplate rabbitTemplate;
private static final String EXCHANGE = "batch.approve.exchange";
private static final String ROUTING_KEY = "batch.approve.routing";
public void send(ApproveParamsDto params) {
try {
String message = JSON.toJSONString(params);
rabbitTemplate.convertAndSend(EXCHANGE, ROUTING_KEY, message);
log.info("批量审批MQ发送成功, orderCode={}", params.getOrderCode());
} catch (Exception e) {
log.error("批量审批MQ发送失败, orderCode={}", params.getOrderCode(), e);
}
}
}
5.4 MQ 消费者
java
@Slf4j
@Component
public class BatchApproveMqConsumer {
@Resource
private ApproveTransactionService approveTransactionService;
@Resource
private BatchApproveServiceImpl batchApproveService;
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String DEDUP_KEY_PREFIX = "batch:approve:dedup:";
@RabbitListener(queues = "${mq.batch.approve.queue}")
public void consume(String message) {
ApproveParamsDto params = JSON.parseObject(message, ApproveParamsDto.class);
String orderCode = params.getOrderCode();
String batchId = params.getBatchId();
// 1. 防重复消费
String dedupKey = DEDUP_KEY_PREFIX + orderCode;
Boolean setResult = stringRedisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 5, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(setResult)) {
log.warn("重复消费, orderCode={}", orderCode);
return;
}
// 2. 执行审批(独立事务)
try {
approveTransactionService.doApprove(params);
// 3. 更新进度(成功)
batchApproveService.updateProgressSuccess(batchId);
log.info("批量审批成功, orderCode={}", orderCode);
} catch (Exception e) {
// 4. 更新进度(失败)
batchApproveService.updateProgressFail(batchId, orderCode, e.getMessage());
log.warn("批量审批失败, orderCode={}, error={}", orderCode, e.getMessage());
}
}
}
5.5 事务处理服务(独立事务)
java
@Slf4j
@Service
public class ApproveTransactionService {
@Resource
private OrderRepository orderRepository;
@Resource
private ApproveRecordRepository approveRecordRepository;
/**
* 单笔审批处理(独立事务,单笔失败不影响其他).
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void doApprove(ApproveParamsDto params) {
// 1. 查询订单
Order order = orderRepository.findByOrderCode(params.getOrderCode());
if (order == null) {
throw new RuntimeException("订单不存在");
}
// 2. 状态校验
if (!"PENDING".equals(order.getStatus())) {
throw new RuntimeException("订单状态不允许审批");
}
// 3. 业务校验(库存、预算等)
// ...
// 4. 更新状态
order.setStatus("APPROVED");
order.setApproveTime(new Date());
order.setApproveUserId(params.getOperatorId());
orderRepository.save(order);
// 5. 记录审批流水
ApproveRecord record = new ApproveRecord();
record.setOrderCode(params.getOrderCode());
record.setOperatorId(params.getOperatorId());
record.setOperatorName(params.getOperatorName());
record.setApproveTime(new Date());
record.setResult("PASS");
approveRecordRepository.save(record);
}
}
六、关键设计点
6.1 同步校验 vs 异步校验
| 校验类型 | 执行时机 | 示例 |
|---|---|---|
| 整批校验(同步) | 提交时立即执行 | 预算总额校验、权限校验 |
| 单笔校验(异步) | MQ 消费时执行 | 库存校验、状态校验 |
原则:能快速判断整批不可行的校验放同步,单笔独立的校验放异步。
6.2 防重复消费
java
// 使用 Redis SETNX 实现幂等
Boolean result = redis.opsForValue().setIfAbsent(dedupKey, "1", 5, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(result)) {
return; // 已消费过,跳过
}
6.3 独立事务(REQUIRES_NEW)
java
// 每笔在独立事务中执行,单笔失败只回滚自己
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void doApprove(ApproveParamsDto params) { ... }
为什么用 REQUIRES_NEW:
- 消费者方法本身可能有事务
- 单笔失败需要回滚该笔数据,但不能影响消费者的外层逻辑(如更新 Redis 进度)
6.4 Redis 进度并发更新
多个消费者线程同时更新 Redis 进度时可能有并发问题。解决方案:
java
// 方案1:Redis INCR 原子操作(推荐)
redis.opsForHash().increment(key, "successNum", 1);
// 方案2:分布式锁
try (DistributedLock lock = lockProvider.getLock("progress:" + batchId)) {
// 读取 → 修改 → 写回
}
// 方案3:Lua 脚本(原子性最强)
6.5 前端轮询策略
javascript
// 前端轮询示例
const pollProgress = async (batchId) => {
const interval = setInterval(async () => {
const result = await api.getBatchProgress(batchId);
updateUI(result);
// 全部处理完毕,停止轮询
if (result.successNum + result.errorNum >= result.totalNum) {
clearInterval(interval);
showFinalResult(result);
}
}, 2000); // 每2秒轮询一次
};
七、异常处理策略
| 异常场景 | 处理方式 |
|---|---|
| MQ 发送失败 | 记录日志,该笔直接计入失败 |
| 消费者处理异常 | catch 后更新 Redis 失败进度,不重试 |
| Redis 不可用 | 降级为日志记录,前端提示"请稍后查看结果" |
| 消费者宕机 | MQ 消息未 ACK,自动重新投递 |
| 重复消费 | Redis SETNX 幂等控制 |
八、与同步批量的对比
| 维度 | 同步批量 | 异步MQ批量 |
|---|---|---|
| 接口响应时间 | 随数量线性增长 | 固定(毫秒级) |
| 单笔失败影响 | 可能整批失败 | 只影响该笔 |
| 超时风险 | 高(100笔 × 500ms = 50s) | 无 |
| 实现复杂度 | 低 | 中高 |
| 用户体验 | 等待时间长 | 实时进度反馈 |
| 事务控制 | 整批一个事务(或逐笔) | 逐笔独立事务 |
| 适用数量 | < 10 笔 | 10 ~ 1000 笔 |
九、最佳实践清单
- 整批校验放同步:能快速判断整批不可行的校验在提交时执行
- 单笔独立事务 :使用
REQUIRES_NEW,单笔失败不影响其他 - 防重复消费:Redis SETNX 或数据库唯一键保证幂等
- Redis 进度设 TTL:避免数据堆积,建议 1 小时
- MQ 消息持久化:确保消费者宕机后消息不丢失
- 失败详情记录:记录每笔失败的单据号和原因,方便用户排查
- 前端轮询间隔:2~3 秒,处理完毕后停止轮询
- 批次 ID 包含 memberId:方便按用户隔离和排查
- 消费者日志完善:每笔处理记录开始/成功/失败日志
- 降级方案:MQ 或 Redis 不可用时有兜底处理