在分布式系统开发中,任务调度是一个常见且关键的需求场景。无论是机器学习模型训练、数据处理流水线,还是日常的业务后台任务,都需要一个可靠的任务分发和执行机制。RabbitMQ作为一款成熟的消息队列中间件,凭借其稳定的消息传递机制和灵活的路由策略,成为实现任务调度系统的理想选择。
本文将详细介绍如何使用RabbitMQ构建一个完整的任务调度系统,包含任务生产者(Producer)、任务消费者(Worker)和集群管理工具(Launcher)三个核心组件。这个系统具有以下特点:
整个任务调度系统由三个主要部分组成:
code复制[Producer] --> [RabbitMQ] --> [Worker集群]
↑
[Launcher管理]
在RabbitMQ中,我们设计了两种类型的队列来满足不同任务需求:
GPU任务队列:处理计算密集型任务,如模型训练、图像渲染等
CPU任务队列:处理I/O密集型或轻量级计算任务
这种区分使得我们可以针对不同类型的任务优化资源分配,避免GPU资源被大量小任务阻塞。
Producer的核心职责是将任务可靠地发送到RabbitMQ队列。以下是关键实现点:
javascript复制const amqp = require('amqplib');
// 使用命令行参数指定队列和任务数量
const [,, queue = 'task_task', count = '1'] = process.argv;
const total = parseInt(count, 10);
(async () => {
// 1. 建立连接和通道(使用Confirm模式)
const conn = await amqp.connect('amqp://localhost');
const ch = await conn.createConfirmChannel();
// 2. 声明持久化队列
await ch.assertQueue(queue, { durable: true });
// 3. 发送任务消息
for (let i = 1; i <= total; i++) {
const task = `task-${i}`;
ch.sendToQueue(queue, Buffer.from(task), { persistent: true }, (err) => {
if (err) console.error(`发送失败:${task}`, err);
});
}
// 4. 等待所有确认后关闭连接
await ch.waitForConfirms();
await ch.close();
await conn.close();
})();
Confirm模式:通过createConfirmChannel()创建的通道会提供消息确认机制,确保消息已经到达RabbitMQ服务器。相比普通通道,这提供了更强的可靠性保证。
持久化队列:durable: true参数确保队列定义会持久化到磁盘,即使RabbitMQ服务重启也不会丢失。
消息持久化:persistent: true选项将消息标记为持久化,防止RabbitMQ服务异常导致消息丢失。
命令行参数:支持通过命令行指定目标队列和任务数量,便于测试和脚本化操作。
提示:在生产环境中,建议添加重试逻辑和更完善的错误处理,特别是网络不稳定的场景。
Worker通过环境变量WORKER_CFG获取配置,支持以下参数:
javascript复制{
"name": "GPU-1", // Worker名称(用于日志标识)
"queue": "gpu_task", // 监听的队列名称
"prefetch": 1, // 预取消息数量
"heartbeat": 5000, // 心跳间隔(ms)
"concurrency": 2, // 并发任务数
"logFile": "logs/GPU-1.log", // 日志文件路径
"beatFile": "beats/GPU-1.beat", // 心跳文件路径
"taskTimeout": 60000 // 任务超时时间(ms)
}
Worker的主要工作流程分为以下几个步骤:
prefetch控制同时处理的消息数量javascript复制// 建立连接和通道
async function connect() {
conn = await amqp.connect('amqp://localhost');
ch = await conn.createChannel();
await ch.assertQueue(queue, { durable: true });
await ch.prefetch(prefetch);
}
// 消费消息
async function consume() {
await ch.consume(queue, async (msg) => {
const taskId = msg.content.toString();
try {
// 使用tiny-async-pool控制并发
await pool(concurrency, [taskId], (id) =>
Promise.race([
fakeConvert(id), // 实际任务
timeout(taskTimeout) // 超时控制
])
);
ch.ack(msg); // 确认消息处理成功
} catch (e) {
ch.nack(msg, false, true); // 处理失败,重新入队
}
}, { noAck: false });
}
并发控制:使用tiny-async-pool库实现可控的并发执行,避免资源过载。
任务超时:通过Promise.race实现任务执行超时控制,防止长时间卡住。
心跳检测:定期写入心跳文件,外部监控系统可以通过检查文件更新时间判断Worker是否存活。
优雅退出:捕获SIGINT信号,确保连接和通道正确关闭后再退出进程。
日志记录:同时输出到控制台和文件,便于问题排查。
Launcher是一个Worker进程管理工具,提供以下功能:
Launcher从JSON配置文件读取Worker配置,格式如下:
json复制[
{
"name": "GPU-Worker-1",
"queue": "gpu_task",
"prefetch": 1,
"concurrency": 2,
"logFile": "logs/GPU-1.log",
"beatFile": "beats/GPU-1.beat"
},
{
"name": "CPU-Worker-1",
"queue": "cpu_task",
"prefetch": 3,
"concurrency": 4,
"logFile": "logs/CPU-1.log",
"beatFile": "beats/CPU-1.beat"
}
]
Launcher的核心是使用Node.js的child_process.spawn来管理Worker进程:
javascript复制function start() {
const cfg = readCfg();
const children = [];
cfg.forEach(c => {
// 确保日志目录存在
fs.mkdirSync(path.dirname(c.logFile), { recursive: true });
// 设置环境变量并启动进程
const env = { ...process.env, WORKER_CFG: JSON.stringify(c) };
const child = spawn('node', ['worker.js'], { env, stdio: 'inherit' });
children.push(child);
});
// 保存PID用于后续管理
fs.writeFileSync(PID_FILE, JSON.stringify(children.map(c => c.pid)));
}
基础版本缺少任务状态跟踪,可以考虑以下增强:
Redis集成:使用Redis存储任务状态和进度
状态API:提供REST接口查询任务状态
javascript复制app.get('/task/:id', async (req, res) => {
const status = await redis.get(`task:${req.params.id}`);
res.json({ status });
});
当单机资源不足时,可以扩展为多服务器部署:
症状:任务偶尔会消失,没有被任何Worker处理
可能原因:
解决方案:
症状:Worker进程存在但不处理新任务
可能原因:
解决方案:
症状:任务处理速度跟不上产生速度
可能原因:
解决方案:
在多个生产环境部署此类系统后,我总结了以下几点经验:
预取(prefetch)设置:不是越大越好,应该根据任务平均处理时间调整。对于长时间任务,prefetch=1通常是最佳选择。
心跳间隔:心跳间隔应该小于任务超时时间,这样当Worker卡住时能更快被发现。
日志格式:采用结构化日志(如JSON格式)会大大简化后续日志分析工作。
环境隔离:不同环境(开发、测试、生产)应该使用不同的RabbitMQ virtual host,避免相互干扰。
连接管理:确保正确关闭连接,否则会导致RabbitMQ文件描述符泄漏。可以在Worker中添加定时ping检测连接状态。
这个任务调度系统虽然代码量不大,但包含了分布式系统设计的许多核心概念。通过合理配置和扩展,它能够满足从小型项目到中型企业级的各种任务调度需求。