【Nodejs】配合Redis解决高并发下任务处理的性能问题

1,558 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第23天,点击查看活动详情

前言

之前我们在 一篇文章帮你搞清并发与并行的区别 | Rust 异步编程和并发编程模型 中介绍了并发编程,而在实际生产中我们经常遇到处理高并发任务的问题。如果采用 Nodejs 单线程串行处理这些任务,会耗费大量的时间。结合我们之前所学习的内容,并使用 Redis 创建任务队列,我们来实战解决高并发任务的问题。

并行处理任务队列

假设此时我们需要处理一万条数据,我们可以把这一万条数据塞到一个队列中(此时这个队列不是进程中的线程,Redis 队列为我们解决了进程间上下文不共享的问题。因此可以将多个线程抽象成一个队列),让CPU处理器自发地从队列里面获取任务并执行它们。

一般CPU处理器可以有多个,它们同时从队列里面把任务取走并处理。取走任务时该任务出列,所以当任务队列为空,表示所有任务已经被认领完;当所有任务处理器完成任务,则表示所有任务已经被处理完。

首先是任务队列的问题。我们假设任务队列里面的每一个任务的数据都是字符串 string 的形式。为了方便起见,我们可以使用 RedisList 数据结构来存放这些任务。另外由于项目是基于 Nodejs 的,我们可以利用 Cluster 集群 来启动多个任务处理器,并行地处理任务。

接下来,我们来实现上述内容。

Nodejs 操作 Redis

昨天我们详细介绍了在 CentOS 中安装 Redis 的详细流程。其实如果你并不常用 Redis,并且安装过 Docker 的话,你可以直接安装一个 Redis 的Docker镜像,并在后台启动它:

docker pull redis:latest
docker run -itd --name redis-local -p 6379:6379 redis
# 启动了 Redis 服务,并将工作端口映射到本地6379端口

可视化工具

你只可以下载一个 Redis 可视化软件来实时查看、修改 Redis 的内容,这里推荐 Another Redis Desktop Manager

💡 在连接远程 Redis 客户端的时候,如果你遇到了Redis Client On Error: Error: connect ECONNREFUSED [IP]:6379 Config right? 这个问题,你可以编辑 redis.conf 文件,注释掉第87行 bind 127.0.0.1 -::1

然后重启 Redis 服务:

systemctl stop redis
systemctl start redis

当然,也有可能是你的防火墙设置问题,请确保你的服务器开放了 6379 端口!

封装 Nodejs 代码

回想一下昨天我们使用 Nodejs 操作 Redis 的案例:

const redis = require("redis");
const config = {
  host: 'YOUR_ADRESS',
  port: 6379,
  password: 'YOUR_PASSWORD'
}
// 连接 Redis
const client = redis.createClient(config.port, config.host);

client.auth(config.password);

// 错误监听器
client.on("error", function (error) {
    console.error(error);
});

client.on('connect', () => {
  console.log('redis connect success')
})

// 存储一个 key value
/* 
  redis.print 是一个函数,用来打印出,API执行完之后的结果
  该处可以换成一个自定义的回调函数
*/
client.set("name", "sarainoq", redis.print);
// 读取 name 这个 key 的值
client.get("name", redis.print);
// 退出 Redis
client.quit();

Redis 本质上仍是一个数据库,我们的业务中对数据库的操作绝大部分事件就是增删改查(CRUD)。虽然这段代码非常简陋,但他完成了服务端与数据库的连接、增改查这些基本的功能。同时我们也会发现一个问题,那就是 Redis API 都是异步的,所有的操作都需要在回调函数中操作。

我们可以对 Redis API 进行一次封装,首先将创建 Redis Client 的过程拆分到 utils/client.js 文件中:

const redis = require("redis");
const config = {
  host: 'YOUR_ADRESS',
  port: 6379,
  password: 'YOUR_PASSWORD'
}
// 连接 Redis
const client = redis.createClient(config.port, config.host);

client.auth(config.password);

// 错误监听器
client.on("error", function (error) {
    console.error(error);
});

client.on('connect', () => {
  console.log('redis connect success')
})

module.exports = client;

然后将所有工具方法都放在 utils/api.js 文件中:

const client = require('./client');

// 获取 Redis 中某个 key 的内容
const getRedisValue = (key) => {
  return new Promise(resolve => {
    client.get(key, (err, reply) => {
      resolve(reply)
    })
  })
}

// 设置 Redis 中某个 key 的内容
const setRedisValue = (key, value) => {
  return new Promise(resolve => {
    client.set(key, value, resolve)
  })
}

// 删除 Redis 中某个 key-value
const delRedisItem = (key) => {
  return new Promise(resolve => {
    client.del(key, resolve)
  })
}

const lrange = (key, start, end) => {
  return new Promise((resolve, reject) => {
    client.lrange(key, start, end, (err, reply) => {
      if (err) {
        reject(err)
      }
      resolve(reply)
    });
  })
}

// 出队列
const popList = (key) => {
  return new Promise(async resolve => {
    client.rpop(key);
    resolve((await lrange(key, -1, -1))[0]);
  })
}

module.exports = {
  getRedisValue,
  setRedisValue,
  delRedisItem,
  lrange,
  popList,
}

然后再编写一个方法,实现在 Redis 中创建一个任务队列 utils/pushTasks.js

const {
  lrange
  delRedisItem,
} = require('./api');
const client = require('./client');
// 创建 1500 个任务,队列名为 task_list
const TASKS_AMOUNT = 1500;
const TASKS_NAME = 'task_list';

client.on('ready', async () => {
  // 若有则删除该 key
  await delRedisItem(TASKS_NAME)
  for (let i = TASKS_AMOUNT; i > 0; i--) {
    client.lpush(TASKS_NAME, `task-${i}`)
  }
  
  // 返回该key指定区域的元素
  const res = await lrange(TASKS_NAME, 0, TASKS_AMOUNT);
  console.log(res);
  client.quit();
})

运行该脚本,然后在 Redis 可视化工具中就可以查看到刚才添加的子任务:

处理任务队列

我们假设CPU处理每条任务需要1s的时间,这样可以使用一个异步方法来模拟该过程:

const handleTask = (task) => {
  return new Promise((resolve) => {
    setTimeout(async () => {
      console.log(`Handling task: ${task}...`)
      resolve()
    }, 1000)
  })
}

然后我们编写一段获取List队列中最后一个元素的代码 utils/handleTask.js,并将结果交由上面那段“CPU”处理:

const { popList } = require('./api');

const handleTask = (task) => {...}

const tasksHandler = async (taskName) => {
  // 从队列中取出一个任务
  const task = await popList(taskName)
  // 处理任务
  await handleTask(task)
  // 递归完成该事务
  await tasksHandler(taskName)
}

module.exports = {
  tasksHandler
}

至此,我们所有的功能代码就写完了。

完成剩余内容

现在让我们回到根目录,创建 index.js 文件。现在项目的目录结构如下:

src
|—— index.js
|—— utils
|—— |—— api.js
|—— |—— client.js
|—— |—— pushTasks.js
|—— |—— handleTask.js

该入口文件主要功能是在 client 准备好之后,调用任务处理方法:

const client = require('./utils/client');
const { tasksHandler } = require('./utils/handleTask');
const TASK_NAME = 'task_list';

client.on('ready', async () => {
  console.log('Redis is ready!');
  await tasksHandler(TASK_NAME);
})

最后运行 node index.js,任务开始运行。

Cluster 集群

前面章节我们介绍了使用 node:cluster 模块创建多线程。在这个情境中我们同样可以这样做。现在我们直接使用 PM2 来简化创建进程的工作:

pm2 start index.js -i 'max' && pm2 logs # 多进程集群启动,启动 n 个server后台进程
# max等于CPU的核心数

总结

通过本节学习,把我们之前学习的多线程(Cluster)集群、并发编程和 Redis 队列的知识结合在了一起,成功的解决了高并发下任务处理的性能问题。当然,目前我们的程序还存在很多问题,比如当某个任务的处理出错时,线程要怎么结束等。