4. 导出请求链路(二):任务队列与调度——基于消息队列的任务分发、优先级队列、去重与幂等性设计

在上一节中,我们完成了导出请求的接收与校验。当请求通过网关进入核心服务后,真正的挑战才刚刚开始:如何将成千上万的导出任务高效、可靠地分发给后端工作节点?如何确保高优先级报表(如实时大屏导出)不被低优先级任务阻塞?如何防止网络抖动或重试导致的任务重复执行?

本节将深入剖析基于消息队列(RabbitMQ / Kafka)的任务分发架构,并重点讲解优先级队列、去重与幂等性设计这三个关键环节。

4.1 为什么需要消息队列?

在报表导出场景中,直接使用 HTTP 同步调用或数据库轮询会带来以下问题:

  • 削峰填谷:导出任务生成速度远高于处理速度,MQ 作为缓冲区防止后端被冲垮。
  • 解耦:API 层与 Worker 层不直接依赖,Worker 宕机不影响任务接收。
  • 异步化:用户提交后立即返回“处理中”,提升用户体验。
  • 可靠投递:通过 ACK 机制和持久化,确保任务不丢失。
选型建议:如果团队对消息顺序要求不高、需要灵活的路由和死信队列,RabbitMQ 是更轻量的选择;如果导出任务量级达到百万级/天、需要高吞吐和日志回溯,Kafka 更合适。

4.2 任务分发链路总览

下图展示了从请求接收、任务入队到 Worker 消费的完整链路:

导出任务分发链路 导出任务分发链路(RabbitMQ / Kafka) API Gateway 任务生成器 优先级队列 去重缓存 (Redis) Worker 集群 结果存储 (OSS/DB) 关键说明: 1. 任务生成器为每个导出请求生成唯一 taskId,并计算优先级。 2. 优先级队列根据优先级将任务路由到不同队列或分区。 3. Worker 消费前先查询去重缓存,确保同一 taskId 只执行一次。

4.3 优先级队列设计

报表导出任务天然具有优先级差异:

优先级等级 典型场景 预期延迟 队列策略
P0(紧急) 实时大屏导出、故障排查报表 < 30 秒 独立高优队列,独占 Worker 资源
P1(高) 管理层日报、周报 < 5 分钟 高优队列,权重优先消费
P2(普通) 常规业务报表 < 30 分钟 普通队列,公平调度
P3(低) 历史归档、批量导出 < 2 小时 低优队列,闲时处理

4.3.1 RabbitMQ 实现方案

使用 死信队列 + TTL 或 优先级队列插件:

// RabbitMQ 优先级队列声明(Java 示例)
Map<String, Object> args = new HashMap<>();
args.put("x-max-priority", 10); // 0-10 优先级
Channel channel = connection.createChannel();
channel.queueDeclare("export.queue", true, false, false, args);

// 发送消息时设置优先级
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
    .priority(8) // P0 对应 8-10
    .build();
channel.basicPublish("", "export.queue", props, messageBody);

4.3.2 Kafka 实现方案

Kafka 原生不支持优先级,但可以通过 多分区 + 消费者权重 模拟:

  • 创建 4 个 Topic(export-p0, export-p1, export-p2, export-p3)。
  • P0 分配 4 个消费者线程,P1 分配 2 个,P2 和 P3 各 1 个。
  • 消费者组内通过 partition.assignment.strategy 控制分配。
生产实践:在 Kafka 方案中,建议为 P0 任务单独设置一个 Topic 并配置更高的副本因子和更短的日志保留时间,确保紧急任务的高可用和快速清理。

4.4 去重设计

去重的核心目标是:同一导出请求(taskId)在消息队列中只被消费一次。去重分为两个层面:

  1. 生产者去重:API 层在生成任务前,先检查 Redis 中是否已存在该 taskId。
  2. 消费者去重:Worker 在开始处理前,再次检查 Redis 并设置原子锁。

4.4.1 基于 Redis 的幂等锁

// 消费者去重伪代码
public boolean tryAcquireTask(String taskId) {
    // SET NX EX 60:如果 key 不存在则设置,过期时间 60 秒
    String result = redis.call("SET", taskId, "processing", "NX", "EX", 60);
    if ("OK".equals(result)) {
        return true; // 首次获取,可以执行
    }
    // 检查是否已完成
    String status = redis.get("task:" + taskId + ":status");
    if ("completed".equals(status)) {
        return false; // 已完成,直接跳过
    }
    // 锁过期但任务未完成,等待重试
    return false;
}

4.5 幂等性设计

幂等性保证:即使同一个任务被多次消费,最终结果也一致。在报表导出中,幂等性主要通过以下方式实现:

幂等策略 实现方式 适用场景
数据库唯一约束 taskId 作为主键或唯一索引,重复插入报错 结果写入关系型数据库
文件覆盖写 同一 taskId 生成的文件名相同,后写入覆盖前写入 导出文件存储到 OSS
状态机校验 任务状态从 pending → processing → completed,不允许回退 任务生命周期管理
分布式事务表 使用独立的事务表记录已处理的 taskId 跨多个存储系统的复杂导出

4.5.1 状态机驱动的幂等处理

// 状态机校验示例
public void processTask(String taskId, TaskMessage message) {
    // 1. 检查当前状态
    String currentStatus = taskStatusService.getStatus(taskId);
    if ("completed".equals(currentStatus) || "processing".equals(currentStatus)) {
        log.warn("Task {} already processed, skip", taskId);
        return;
    }
    // 2. CAS 更新状态为 processing
    boolean success = taskStatusService.compareAndSet(taskId, "pending", "processing");
    if (!success) {
        return; // 并发竞争失败,等待重试
    }
    try {
        // 3. 执行导出逻辑
        exportService.export(message);
        // 4. 更新状态为 completed
        taskStatusService.updateStatus(taskId, "completed");
    } catch (Exception e) {
        taskStatusService.updateStatus(taskId, "failed");
        throw e;
    }
}

4.6 任务调度与重试机制

即使有了去重和幂等,任务仍可能因 Worker 宕机、网络超时等原因失败。我们需要一个健壮的重试调度策略:

  • 本地重试:Worker 内部重试 3 次,间隔指数退避(1s, 2s, 4s)。
  • 死信队列:超过重试次数后,消息进入死信队列,由专门的调度服务重新入队。
  • 定时扫描:调度服务每分钟扫描“处理中超过 10 分钟”的任务,重新投递。
注意:重试时必须保证幂等性。建议在消息体中携带 retryCount 字段,并在消费者端判断是否超过最大重试次数,避免无限循环。

4.7 本章小结

本节我们深入探讨了基于消息队列的导出任务分发架构:

  • 通过 优先级队列 确保紧急报表的及时处理。
  • 通过 Redis 去重缓存 和 数据库唯一约束 防止任务重复执行。
  • 通过 状态机 + 幂等锁 保证即使重复消费也不会产生数据不一致。
  • 通过 死信队列 + 定时扫描 构建可靠的重试机制。