NodeJS进阶之一:Redis and Bull

概述 

作为一个开发人员,学习是贯穿始终的。我从事前端开发,但是对后端开发也很感兴趣。最近正在学习 Redis,我们公司的项目使用了 Redis 作为服务器缓存,同时结合 Bull 实现了队列功能。

实现队列的方案

Implementing queues

我们公司的项目前端使用 Angular 框架,后端语言是 Nodejs,使用 Loopback 快速搭建后端服务器。Redis 作为服务器缓存方案,配合 Bull 实现队列。

在 package.json 中一共有4个和 Bull 相关的插件。

  1. bull
  2. @types/bull
  3. @bull-board/api
  4. @bull-board/express

第一个是 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

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 脚本,其中多处用到了下面的命令

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));
  };
}

它实现了:

  1. 格式化传入参数
  2. 对特殊数据的处理,比如需要压缩的参数
  3. 创建日志
  4. 一些 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 的组合提供了一个比较完美的实现队列的方式。我们不再需要自己造轮子了,这大大增加了开发效率。虽然实践的过程会遇到一些问题,但它依然是值得的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值