持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第23天,点击查看活动详情
前言
之前我们在 一篇文章帮你搞清并发与并行的区别 | Rust 异步编程和并发编程模型 中介绍了并发编程,而在实际生产中我们经常遇到处理高并发任务的问题。如果采用 Nodejs 单线程串行处理这些任务,会耗费大量的时间。结合我们之前所学习的内容,并使用 Redis 创建任务队列,我们来实战解决高并发任务的问题。
并行处理任务队列
假设此时我们需要处理一万条数据,我们可以把这一万条数据塞到一个队列中(此时这个队列不是进程中的线程,Redis 队列为我们解决了进程间上下文不共享的问题。因此可以将多个线程抽象成一个队列),让CPU处理器自发地从队列里面获取任务并执行它们。
一般CPU处理器可以有多个,它们同时从队列里面把任务取走并处理。取走任务时该任务出列,所以当任务队列为空,表示所有任务已经被认领完;当所有任务处理器完成任务,则表示所有任务已经被处理完。
首先是任务队列的问题。我们假设任务队列里面的每一个任务的数据都是字符串 string 的形式。为了方便起见,我们可以使用 Redis 的 List 数据结构来存放这些任务。另外由于项目是基于 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 队列的知识结合在了一起,成功的解决了高并发下任务处理的性能问题。当然,目前我们的程序还存在很多问题,比如当某个任务的处理出错时,线程要怎么结束等。