批量异步处理 + MQ + Redis 进度追踪实战指南

批量异步处理 + MQ + Redis 进度追踪实战指南

一、概述

在业务系统中,批量操作(如批量发货、批量审批、批量导入)如果同步执行,会导致接口超时、数据库长事务、用户等待时间过长等问题。常见的解决方案是:接口同步接收请求 → 拆分为多条消息发送到 MQ → 消费者逐条异步处理 → Redis 记录进度 → 前端轮询结果。

本文以一个"批量订单审批"场景为例,系统介绍这种架构模式的设计思路和实现细节。


二、架构设计

整体流程如下:

第一阶段:同步提交

  1. 前端提交批量请求(如50个订单)
  2. Controller 补充参数(用户信息、权限等)
  3. Service 执行同步前置校验(整批校验,如预算总额)
  4. 初始化 Redis 进度数据(总数=50,成功=0,失败=0)
  5. 逐条发送 MQ 消息(50条消息)
  6. 返回批次ID给前端(接口响应结束,耗时毫秒级)

第二阶段:异步处理

  1. MQ Consumer 逐条消费消息
  2. 每条消息在独立事务中执行业务逻辑
  3. 处理成功 → Redis 中 successNum + 1
  4. 处理失败 → Redis 中 errorNum + 1,记录失败原因

第三阶段:进度轮询

  1. 前端每2秒调用进度查询接口
  2. 读取 Redis 中的进度数据返回
  3. 当 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 笔

九、最佳实践清单

  1. 整批校验放同步:能快速判断整批不可行的校验在提交时执行
  2. 单笔独立事务 :使用 REQUIRES_NEW,单笔失败不影响其他
  3. 防重复消费:Redis SETNX 或数据库唯一键保证幂等
  4. Redis 进度设 TTL:避免数据堆积,建议 1 小时
  5. MQ 消息持久化:确保消费者宕机后消息不丢失
  6. 失败详情记录:记录每笔失败的单据号和原因,方便用户排查
  7. 前端轮询间隔:2~3 秒,处理完毕后停止轮询
  8. 批次 ID 包含 memberId:方便按用户隔离和排查
  9. 消费者日志完善:每笔处理记录开始/成功/失败日志
  10. 降级方案:MQ 或 Redis 不可用时有兜底处理
相关推荐
smart19981 小时前
数据备份解决方案,适合金融等关键业务需求
数据库·科技·存储
拾起零碎1 小时前
U8/固定资产反结账报错
数据库·oracle
念恒123061 小时前
MySQL connect 访问
数据库·mysql
六月雨滴1 小时前
Oracle 归档日志性能优化
数据库·oracle·性能优化
码不停蹄的玄黓1 小时前
MySQL 死锁:已产生死锁的解决方法 + 永久避免方案
数据库·mysql
Leon-Ning Liu1 小时前
【真实经验分享】ORA-600 [4187]发生在回滚段(undo segment)的 wrap# 接近最大值时
数据库·oracle
Java 码思客1 小时前
【Redis分布式缓存实战】第3章 Redis核心机制深度解析
redis·分布式·缓存
Leon-Ning Liu1 小时前
【真实经验分享】MySQL两个线程同时对表新增字段,被异常取消,导致表结构崩溃
数据库·经验分享·mysql
小饼干在学嘎瓦1 小时前
秒杀场景Redis做预扣减,问题在哪里?
数据库·redis·mybatis