一个NodeJS多进程共享内存,高性能,文件存储,轻量KV数据库

需求

最近在做一个网页静态化的工具, 后台用的 node+ express, 但是没有用 DB, 主要是考虑到业务的数据量实在是小的可怜, 就是几个配置和日志文件 , 所以我把数据用 fs.writeFile 直接写到 json 文件中, 然后每次读都直接 fs.readFile 读出来

优化一

但是这样子貌似是有些简陋, 其中完全可以把 文件读取相关的封装起来, 然后提供对象读写接口 类似于 get() put() remove() 等接口,

既然是读多写少 , 那么上面的方案肯定是有优化点的, 比如写操作, 可以直接调用 fs readFileSync 同步的写, 反正量少 而读是大头, 经常一个 cgi 请求在返回之前可能会有好几个读的操作 , 那么我这样子直接调用readFileSync 是不是有些简单粗暴

说干就干, 搞一个 file-storage 的内部组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 简化版本的伪代码
function Storage(filename) {
if (!filename) {
throw new Error('Storage requires path to a storage file');
}
var self = this;
try {
this..store = JSON.parse(fs.readFileSync(this.filename));
} catch(e) {
if (e.code !== 'ENOENT') {
throw e;
}
this..store = {};
}
}

Storage.prototype.put = function (key, value) {
this._setDeep(key, value, false);
this.queue.push();
};

Storage.prototype.get = function (key) {
return this._getDeep(key);
};

Storage.prototype.remove = function (key) {
this._setDeep(key, undefined, true);
this.queue.push();
};
module.exports = Storage;

这里我的方案是直接把数据存到内存中, 读直接读内存, 写的时候同时更新内存和文件的数据. 这样子貌似读性能很棒哦, 和当前的业务完美契合

惨痛的现实

生活哪有那么简单,要是有这么简单就不叫生活了

我在本地调试的时候, 只有一个node 进程, 所以相安无事,但是部署到服务器的时候, 问题暴露了, 服务器中的是以多进程的模式在运行的, 也就说前后两个请求,并不一定是同一个进程在处理的, 那么, 各个进程中的 数据缓存就不一样了

举例:

在进程1 中, 我写了一条数据, store.put(‘hello’, ‘world’)
这里时候文件中内容已经更新了, 这个进程中的内存缓存数据也更新了, 但是, 其他进程的内存缓存数据并没有更新, 也就出现多个进程中数据不同步的现象

方案调研

遇到这种情况, 解决的办法通常有两种

  1. 用 memcache 或者 redis 等内存数据库进行多进程数据同步
  2. ipc 进程通信同步各个进程的数据

想想我们的应用穷的连数据库都上不起, 还想上内存数据库,运营成本有点大, 所以这里我选择 ipc 通信来同步各个 worker 进程中的数据

进程间通信(IPC,InterProcess Communication)是指在不同进程之间传播或交换信息。

常用的 IPC通信模式很多

  • 管道(Pipe)及有名管道(named pipe,FIFO)
  • unix 域socket
  • 信号(Signal)
  • 消息队列(Posix消息队列system V消息队列)
  • 信号量(semaphore)
  • 共享内存( POSIX共享内存对象POSIX内存映射文件SYSTEM V共享内存)

通过 分析各种 ipc 通信的原来和使用场景
我们选择使用 POSIX共享内存对象 的模式进行 IPC

1
fd = shm_open(*name, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR)

shm_open创建一个名称为name,大小为O_RDWR的共享内存区对象后,
在/dev/shm/下可以看到对应的文件,cat可以看到内容.

访问速度:非常快, 因为 /dev/shm 是tmpfs的文件系统,tmpfs是linux/Unix系统上的一种基于内存的文件系统。tmpfs可以使用您的内存或swap分区来存储文件。可以看成是直接对内存操作的,速度是非常快的。

所以, 我可以在我的组件中加上内存共享的功能了, 这样子多进程之间的及不会有数据不同步的烦恼了,

救星来了

有现成的内存共享模块干嘛要自己写, 再说一个前端开发工程师去写 node 的 c++模块不专业, 这里我使用了 https://github.com/kyriosli/node-shared-cache

这个模块就是基于上面提到的 “共享内存” 的方法实现的,具体的细节不过多讨论, 反正就是很 niubility

提供一些简单的对象的读写功能, 但是有这两个功能对于我来说就够了

1
2
3
4
5
6
7
8
9
10
11
12
13
// create cache instance
var cache = require('node-shared-cache');
var obj = new cache.Cache("test", 557056);
// setting property
obj.foo = "bar";

// getting property
console.log(obj.foo);

// 有个一个需要注意的地方是 和 原生的 js 对象之间无法互相引用, 有一定局限
// 因为原生的 js 对象在进程的堆中, 而共享内存又是单独的一个内存区域
var test = obj.foo = {'foo': 'bar'};
test === obj.foo; // false

整合 node-shared-cache

所以先我们在 file-storage 组件中增加共享内存的模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 精简版的代码
var cache = require('node-shared-cache');
// 增加共享内存功能的的 file-storage
function Storage(filename) {
if (!filename) {
throw new Error('Storage requires path to a storage file');
}
var self = this;
// 初始化 obj 和 store
let shmName = filename.replace(/\//g, "-").slice(-5, filename.length);
this.obj = new cache.Cache(shmName, 1073741824);
this.obj.store = self._load();
this.store = this.obj.store;
}

从上面的代码中可以看出来我把原本存储数据的 store 对象替换成 node-shared-cache 的对象了, 实现多进程共享 store数据的功能

实战使用

我们可以在项目中安装使用

注明: 需要 node-gyp 编译 c++模块 , 需要 gcc4.4.8版本支持 c++11

我们一起来看看所以可以看到我修改后的 file-storage 组件在多进程中使用demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const cluster = require('cluster');
const http = require('http');
const path= require('path');
const numCPUs = require('os').cpus().length;
let Storage = require('file-storage');

if (cluster.isMaster) {
console.log(`主进程 ${process.pid} 正在运行`);
let store = new Storage(path.dirname(__filename) + '/test');
store.put('processid', []);

// 衍生工作进程。
for (let i = 0; i < numCPUs; i++) {
setTimeout(() => {
cluster.fork();
}, i * 1000);
}
} else {

let store = new Storage(path.dirname(__filename) + '/test');
let processid = store.get('processid');
if (!processid) {
processid = [process.pid]
} else {
processid.push(process.pid);
}
store.put('processid', processid);
console.log(`工作进程${process.pid}, 查询的 store 对象为: ${JSON.stringify(store.get('processid'))} `);

http.createServer((req, res) => {
let visitnum = store.get('visitnum');
if (visitnum) {
visitnum = visitnum + 1;
store.put('visitnum', visitnum)
} else {
store.put('visitnum', 1);
}
res.writeHead(200);
res.end(`工作进程${process.pid}, 查询的 store 对象为: ${JSON.stringify(store.get('processid'))} 访问次数: ${store.get('visitnum')} `)
}).listen(8000);

}
1
2
3
4
5
6
7
8
9
10
// 以下的数据可以看到不同进程是在共享同一个记数 visitnum

curl http://127.0.0.1:8000/
//工作进程24143, 查询的 store 对象为: [24135,24137,24138,24139,24141,24142,24143] 访问次数: 49

curl http://127.0.0.1:8000/
//工作进程24141, 查询的 store 对象为: [24135,24137,24138,24139,24141,24142,24143] 访问次数: 50

curl http://127.0.0.1:8000/
//工作进程24137, 查询的 store 对象为: [24135,24137,24138,24139,24141,24142,24143] 访问次数: 51

同时去直接查看 test 文件,也能看到我们写入的进程 id 和浏览量的数据

总结

这个组件使用场景还是有限的, 主要是在小数据量,单机多进程, 读多写少的情况比较适用, 读性能大概是原生 js对象的 1/4, 在研究 node-shared-cache 的时候, 也顺便迷上了 node 体系和 v8 引擎的一些原理, 以后研究的再深入一些也来写写自己的一些简单的见解