4. 导出请求链路(二):任务队列与调度——基于消息队列的任务分发、优先级队列、去重与幂等性设计
在上一节中,我们完成了导出请求的接收与校验。当请求通过网关进入核心服务后,真正的挑战才刚刚开始:如何将成千上万的导出任务高效、可靠地分发给后端工作节点?如何确保高优先级报表(如实时大屏导出)不被低优先级任务阻塞?如何防止网络抖动或重试导致的任务重复执行?
本节将深入剖析基于消息队列(RabbitMQ / Kafka)的任务分发架构,并重点讲解优先级队列、去重与幂等性设计这三个关键环节。
4.1 为什么需要消息队列?
在报表导出场景中,直接使用 HTTP 同步调用或数据库轮询会带来以下问题:
- 削峰填谷:导出任务生成速度远高于处理速度,MQ 作为缓冲区防止后端被冲垮。
- 解耦:API 层与 Worker 层不直接依赖,Worker 宕机不影响任务接收。
- 异步化:用户提交后立即返回“处理中”,提升用户体验。
- 可靠投递:通过 ACK 机制和持久化,确保任务不丢失。
选型建议:如果团队对消息顺序要求不高、需要灵活的路由和死信队列,RabbitMQ 是更轻量的选择;如果导出任务量级达到百万级/天、需要高吞吐和日志回溯,Kafka 更合适。
4.2 任务分发链路总览
下图展示了从请求接收、任务入队到 Worker 消费的完整链路:
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)在消息队列中只被消费一次。去重分为两个层面:
- 生产者去重:API 层在生成任务前,先检查 Redis 中是否已存在该 taskId。
- 消费者去重: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 去重缓存 和 数据库唯一约束 防止任务重复执行。
- 通过 状态机 + 幂等锁 保证即使重复消费也不会产生数据不一致。
- 通过 死信队列 + 定时扫描 构建可靠的重试机制。