概述
作为一个开发人员,学习是贯穿始终的。我从事前端开发,但是对后端开发也很感兴趣。最近正在学习 Redis,我们公司的项目使用了 Redis 作为服务器缓存,同时结合 Bull 实现了队列功能。
实现队列的方案
Implementing queues
我们公司的项目前端使用 Angular 框架,后端语言是 Nodejs,使用 Loopback 快速搭建后端服务器。Redis 作为服务器缓存方案,配合 Bull 实现队列。
在 package.json 中一共有4个和 Bull 相关的插件。
第一个是 Bull 的本体,第二个是支持 Typescript 的类型,第三个是基于 Bull 构建的一个用户界面,用于可视化队列及其作业,第四个是根据服务器框架(Express) 选择的适配器。
关于 Bull
Bull是一个Node库,它基于Redis实现了快速且强大的队列系统。
尽管可以使用Redis命令直接实现队列,但该库提供了一个API,可以照顾所有低级细节并丰富Redis基本功能,从而可以轻松处理更复杂的用例。
Bull 通过 lua 文件控制 Redis 命令,封装 Queue 和 Job 等类,通过将函数挂载在 prototype 上的方式提供控制队列的 API。
实践
Redis + Bull 的组合实现了队列功能,队列的实现有助于帮助我们解决很多问题。
结合我接触过的项目,以 CSV 的下载为例来介绍 Redis + Bull 如何配置以及实现功能。
类似下载的功能,有时会面对多次或多项目的下载。同时下载的过程不能阻碍其它的交互。这种情况下将任务推入队列中是一个很好的解决方案。
搭建 Redis 服务器
Redis 是一个开源的、高性能的键值存储数据库,支持多种数据结构(如字符串、哈希、列表、集合、有序集合等),并以内存存储和持久化机制著称,广泛应用于缓存、消息队列和高性能应用场景。
Redis 作为应用程序缓存层,意味着它需要传统服务器来配合运行。文章所涉及的项目使用传统服务器,采用 docker 部署 Redis。
Docker 中安装和运行 Redis 服务器方便项目的快速部署和扩展。它能提供一致的环境,减少因环境引起的兼容性问题。
Bull
- 延迟任务: 可以设置任务在特定的延迟时间之后执行。
- 任务优先级: 可以为任务设置不同的优先级,确保重要任务优先得到处理。
- 任务处理模式: 支持 FIFO(先进先出)和 LIFO(后进先出)等多种队列处理模式。
- 并发控制: 可以精确控制每个工作进程(worker)同时处理的任务数量
Redis + Bull 应用
Bull 有 3 个概念来管理队列,根据我们的例子,Dashboard 导出 CSV 的功能,我们来介绍它们
import Queue from "bull";
const bullProp = 'bull';
function setupQueue(app, startProcessingJobs) {
// getIORedisOptsForBull return { host port password }
const queue = new Queue('bull', {
redis: redisConfig.getIORedisOptsForBull()
});
queue.on('error', logger.error);
queue.registerJob = function(jobType, concurrency, jobHandler) {
return queueManager.registerJob(jobType, concurrency, jobHandler);
};
app[bullProp] = queue;
}
初始化队列,除了将队列实例化并添加参数外,我们还封装了 Job 的创建方法,然后将它赋值给了全局 app。
Events and listeners
每当一个Job被创建并推入队列后,Bull都会检测它的状态,并且在事件(启动、完成、失败)发生后触发监听器。监听器提供一个回调函数,帮助我们处理不同状态下的 Job。
queue.on('completed', (job, result) => {
sendUserSuccessMsg(job.id, result);
});
queue.on('failed', (job, result) => {
sendUserFailedMsg(job.id, result);
});
Bull 还提供了可以监听所有事件的API
queue.on('global:completed', jobId => {
console.log(`Job with id ${jobId} has been completed`);
})
Producers
生产者负责将Job添加到队列。创建一个 Job 需要一些必要的信息。
const data = await getDashboardData(dashboardId);
queue.add({
require: 'exportDashboardToCSV',
parameters: data
});
Consumers
负责处理被添加到队列中的Job。每当队列中存在 Job 都会被触发,我们需要依据传入的参数对不同的 Job 进行处理。比如这里生成的Blob。
queue.process(async (job, done) => {
const { require, parameters } = job.data;
try {
switch (require) {
case "exportDashboardToCSV":
const blob = new Blob([parameters], type: 'text/csv');
await saveAs(blob, 'Dashboard' + moment(new Date()).format('MM/DD/YYYY HH:mm:ss'));
break;
// ...
done(null, {
status: "successful",
resultMessage: "The csv has correctly be produced";
});
} catch(error) {
done(error);
}
})
consumers 也可以称为 worker。如果当前的 worker 不可用,我们依然可以创建 Job 到队列中。一旦 worker 出现,它便会处理队列中堆积的 Jobs。
这意味着当队列中的 Job 已经都被处理完了,我们可以停用 worker。当新的 Job 被创建并进入队列,我们唤醒 worker,并在它完成所有堆积的 Job 之后再次停用它,直到新的 Job 进入队列。
Bull 在创建客户端 (ioredis) 时加载 Redis Lua 脚本,其中多处用到了下面的命令
- LPUSH/RPUSH:用于将任务添加到队列头部/尾部
- BRPOP/BLPOP:阻塞式获取队列任务,实现高效的任务消费
- ZADD:实现延迟队列和优先级队列,通过分数排序控制执行顺序
- HSET/HGET:存储任务元数据和进度信息
- PUBLISH/SUBSCRIBE:用于事件通知和跨进程通信
- SCAN:遍历查找停滞任务
Progress
另外,当我们需要将进度值通知给用户时,我们可以使用 progress() 函数。
queue.process( async (job) => {
let progress = 0;
for(i = 0; i < 100; i++){
await exportDashboardToCSV(job.data);
progress += 10;
job.progress(progress);
}
});
限制
毫无疑问,Bull是一款出色的产品,但是它依然有一些不足之处。
TTL(Time To Live)是 Redis 提供的一种自动过期机制。应用于队列中它可以控制 Job 的存活时间。当 TTL 到期后,Redis 会自动删除该 Job,避免长期占用内存。但是 Bull 并没有实现这个功能。
为防止内存被大量占用,我们可以使用 Rate Limiter,在创建队列时限制它。
// Limit queue to max 1.000 jobs per 5 seconds.
const myRateLimitedQueue = new Queue('rateLimited', {
limiter: {
max: 1000,
duration: 5000
}
});
我们还可以为每个 Job 创建时间戳来实现 TTL。但是我们需要谨慎的去手动管理时间戳。
经验
多队列
项目中可以存在多个队列,队列的创建是很廉价的,而可以根据不同的需求创建不同的队列。
将队列相关的功能封装进 BullQueueManager 类中
class BullQueueManager {
constructor(isNativeWorker, settings) {
this.isNativeWorker = isNativeWorker;
this.isActingWorker = isNativeWorker;
this.settings = settings;
this.queues = {};
}
async registerJob(jobType, concurrency, handler) {
// Register a job type, opening a new queue
if (!this.queues[jobType]) {
const jobQueue = new Queue(jobType, this.settings);
jobQueue.process(this.settings.prefix + jobType, concurrency, handler);
}
}
async stopListening() {
this.isActingWorker = false;
await this.queues[jobType].pause(true /* isLocal */, true /* doNotWaitActive */);
}
async startListening() {
this.isActingWorker = true;
await this.queues[jobType].resume(true /* isLocal */);
}
}
按照不同的 JobType 创建队列,将不同的功能拆分开,比如:消息通知队列、文件下载队列等。
同时,这么做也是因为 Bull 的并发是在队列级别的,而不是由 Job 来控制。
async registerJob(jobType, concurrency, handler) {
// Register a job type, opening a new queue
if (!this.queues[jobType]) {
const jobQueue = new Queue(jobType, this.settings);
jobQueue.on('error', logger.error);
this.queues[jobType] = jobQueue;
if (!this.isActingWorker) {
await jobQueue.pause(true /* isLocal */, true /* doNotWaitActive */);
}
jobQueue.process(this.settings.prefix + jobType, concurrency, handler);
}
}
当设置concurrency > 1时,多个作业同时执行而非顺序处理。我们需要根据不同的Job类型来控制它是否需要并发,所以我们同样也封装了 process,根据不同的类型创建更加灵活的队列。
自定义API
对于创建一个 Job 我们也进行了个性化处理,首先我们先观察 bull/lib/queue.js 的一段代码。
Queue.prototype.disconnect = async function() {
await Promise.all(
this.clients.map(client =>
client.blocked ? client.disconnect() : redisClientDisconnect(client)
)
);
};
Queue.prototype.removeJobs = function(pattern) {
return Job.remove(this, pattern);
};
Queue.prototype.close = function() {
// do something
}
可以发现 bull 通过将通用的方法挂载到 prototype 的方式,使所有被实例化的队列都具备了这些方法。
我们借鉴了这种做法。鉴于我们已经将队列实例绑定在了全局 app 上,那么相比于在后端文件中再次引入队列,将函数绑定在 prototype 上更加简洁。同时也减少了后端引入文件的数量。
在所有自定义函数中,最重要的就是 createBasicJobPromise。
Queue.prototype.createBasicJobPromise = async function (jobType, jobData, options) {
const jobQueue = queueManager.queues[jobType];
const jobData = {};
jobData.metadata = {
...
};
let jobPriorityObj = { low: 10, normal: 20, medium: 30, high: 40, critical: 50 };
let jobPriority = _.get(options, 'priority', 'normal');
let attempts = _.get(options, 'attempts', 1);
let completedHandler = _.get(options, 'completedHandler');
let failedHandler = _.get(options, 'failedHandler');
let job;
try {
job = await jobQueue.add(destinationNamespace + jobType, jobData, {
priority: jobPriorityObj[jobPriority],
attempts,
removeOnComplete: _.get(options, 'removeOnComplete') || queueManager.defaultJobOptions.removeOnComplete || { age: 60 * 60 }, // 1 hour
removeOnFail: _.get(options, 'removeOnFail') || queueManager.defaultJobOptions.removeOnFail || { age: 2 * 24 * 60 * 60 }, // 2 days
});
job.compressParameters();
const result = await job.finished().catch(err => { /* ignore because we'll handle jobIsFailed */});
const [jobIsCompleted, jobIsFailed, finishedJob] = await Promise.all([
job.isCompleted(),
job.isFailed(),
jobQueue.getJob(job.id)
]);
if (jobIsCompleted) {
if (!jobData.metadata.heartbeat) {
logger[jobData.metadata.logLevel](job.data.metadata.type + ': Completed %s job (id=%d)', _.join(_.compact([jobType, options.jobLabel]), ' '), job.id);
}
if (completedHandler) {
await completedHandler(finishedJob);
}
const jobResults = job.uncompressResults(job, result);
if (_.get(job, ['data', 'metadata', 'logLongResults']) || (await environmentVariables.BACKGROUND_JOBS_LOG_LONG_RESULTS()) || _.size(JSON.stringify(jobResults)) <= 1000) {
job.log(`Results: ${JSON.stringify(jobResults, null, ' ')}`);
} else {
job.log(`Truncated Results: ${JSON.stringify(jobResults).substring(0, 1000)}...`);
}
return Promise.resolve(jobResults);
} else if (jobIsFailed) {
logger.error(job.data.metadata.type + ': Failed %s job (id=%d)', _.join(_.compact([jobType, options.jobLabel]), ' '), job.id);
const errMsg = _.join(_.concat([finishedJob.failedReason], finishedJob.stacktrace), '\n');
if (failedHandler) {
await failedHandler(finishedJob);
}
return Promise.reject(new Error(errMsg));
}
} catch (err) {
logger.error(err);
job.log(`Failure Message: ${err.message}`);
return Promise.reject(new Error(err.message));
};
}
- 格式化传入参数
- 对特殊数据的处理,比如需要压缩的参数
- 创建日志
- 一些 Job 需要被保存到存储器
高级参数设定
settings: {
lockDuration: 2 * 3600 * 1000, // 2 hours
lockRenewTime: 15 * 1000, // 15 seconds
}
在创建队列时我们可以选择传入一些高级参数,对队列的行为进行更加精细的控制。但是注意,请一定确保足够了解每一个参数的作用。
数据处理
当我们需要传递的参数过大,或者需要获取的数据过大时,就要考虑到压缩的问题。
参数的压缩需要我们在创建 Job 时进行,然后在 queue.process 时解压缩。
数据的压缩在 queue.process 时进行,在 Job Complete 时解压缩
谨慎的删除Job
defaultJobOptions: {
removeOnComplete: { age: 60 * 60 }, // 1 hour
removeOnFail: { age: 2 * 24 * 60 * 60 } // 2 days
}
Job 完成后,我们会在钩子函数中处理数据。我们不会立即删除工作,因为我们需要它至少待了足够长的时间,以便我们获得结果 /错误。同时,方便我们使用Bull Board UI对其进行审查。
必要的日志
job.log('Compressing parameters');
使用 job.log 创建日志,尤其在一些复杂的功能完成/失败后。
总结
队列可以以一种优雅的方式解决许多不同的问题,Redis and Bull 的组合提供了一个比较完美的实现队列的方式。我们不再需要自己造轮子了,这大大增加了开发效率。虽然实践的过程会遇到一些问题,但它依然是值得的。