真 · serverless的SQL数据库

数据库服务天生就是带有非常强的server意味,和serverless服务站在两端,格格不入。市面上大多数所谓的serverless数据库也几乎都只是一个数据库server按照serverless的方式计费而已。那有没有可能实现真正的无服务器数据库,既提供标准的SQL服务,又实际上不启动任何服务器,也不带来附加的服务器成本呢?答案是有!

之前在云函数里一直调用云开发数据库,虽然延迟有点不稳定也忍了。最近有一个需求连续对数据库进行一系列的操作,云开发数据库的性能抖动一下就被放大了,函数经常性的运行超时,这就不能忍了,因为数据量本来也不算大,动起了用nodejs的嵌入式数据库的歪心思。

看了一下nedb,性能好像是不错,但是太久没维护了,npm提示很多安全风险……过。

测试了一下sql.js,还是很容易上手的,不过做完内存中的写操作以后,要手工export到文件而不是自动维护的。如果担心丢数据就要不停的export,感觉有点……过。

测试sqlite3,它通过node-gyp本地构建依赖了一些基于本地运行环境的c的模块,并且它还需要通过v3或者v6的node-API来访问它们,而腾讯云的云函数运行环境只能支持v3的node-api接口,5.0.3以上的版本需要用node11或者node8的环境来构建层才能让层使用v3的版本,不过就算这样也没用,5.0.3和更高的版本上需要的_libm.so.6 和_ libstdc++.so.6版本都超过了云函数运行环境的版本(除非用一些特殊的方法)。所以最后能用的最高版本的sqlite3是5.0.2。本机mac上做出来的layer放到腾讯云上是用不了的,所以要在docker里面做一下:

1
2
3
4
5
echo "cd /usr/src;npm install sqlite3@5.0.2 --save">tmp.sh
chmod +x tmp.sh
docker run --rm -v "$PWD":/usr/src node:16 /usr/src/tmp.sh
zip -q -r sqlite_502_node16.zip node_modules
rm -rf node_modules package-lock.json package.json

这样就能得到一个5.0.2版本的sqlite3的层:

#sqlite_502_node16.zip#

测试一下:

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
43
44
45
46
'use strict';
exports.main_handler = async (event, context) => {
const sqlite3 = require('sqlite3').verbose();
// const db = new sqlite3.Database(':memory:');
const db = new sqlite3.Database('/tmp/sqlite.db');
let d=Date.now();
let statement;
await new Promise(res=>{
db.get("select * from users",(err,row)=>{
if(err){
db.exec(
"CREATE TABLE users (ID INTEGER PRIMARY KEY AUTOINCREMENT, \
name TEXT NOT NULL, phone, address);\
CREATE UNIQUE INDEX id on users (id);\
CREATE INDEX name on users (name);",
(err)=>{
if(err) console.log("创建表失败");
else res()
}
)
}else{
console.log("表已存在")
res()
}
});
})
d=Date.now();
let n=100;
statement = db.prepare(`INSERT INTO users ( name, phone, address) VALUES (?, ?, ?)`);
let promises = [];
for(let i=0;i<n;i++){
promises.push(new Promise(res=>{
statement.run( Math.random().toString(36),Math.random().toString(36),Math.random().toString(36),res)
}))
}
await Promise.all(promises).then(()=>{statement.finalize()})
console.log("插入"+n+"条数据耗时"+(Date.now()-d)+"ms");
d=Date.now();
let result = await new Promise(res=>{
db.all("select * from users",(err,rows)=>{
res(rows)
})})
console.log("查询全部记录耗时"+(Date.now()-d)+"ms,查询到"+result.length+"条结果")
db.close();
return "ok"
};

性能表现一般般。能不能更快一点呢?又找到了一个更快的 bettersqlite,同时还发现有人为aws lambda制作了一个基于node12的layer生成工具,读了一下代码,其实基本上就是做了这么一件事:

1
2
3
docker run --rm -v "$PWD":/var/task lambci/lambda:build-nodejs12.x npm install better-sqlite3@6.0.1 --save
zip -r sqlite.zip node_modules
rm -rf node_modules package-lock.json

当然也不是一定要用他那个node12镜像,这样也可以:

1
2
3
4
5
echo "cd /usr/src;npm install better-sqlite3@6.0.1 --save">tmp.sh
chmod +x tmp.sh
docker run --rm -v "$PWD":/usr/src node:12 /usr/src/tmp.sh
zip -r sqlite.zip node_modules
rm -rf node_modules tmp.sh package-lock.json

然后就生成了一个node12的better-sqlite3@6.0.1的layer(6.0.1后面的下一个版本就是7.0.0,开始要求_libstdc++.so.6支持CXXABI_1.3.9,而腾讯云scf的运行环境下最高只有CXXABI_1.3.8,做出来layer也运行不了_):

#betterSqliteLambdaLayer.zip#

直接开一个node12的云函数调用一下:

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
43
44
45
46
'use strict';
exports.main_handler = async (event, context) => {
const Database = require('better-sqlite3');
const db = new Database('/mnt/sqlite.db', {});
let d=Date.now();
try{
db.prepare("select * from users").get()
console.log("查询一条记录耗时"+(Date.now()-d)+"ms")
}catch(e){
if(/no such table/.test(e.message)){
d=Date.now();
db.prepare("CREATE TABLE users (ID INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone, address);").run();
console.log("创建表耗时"+(Date.now()-d)+"ms");
d=Date.now()
db.prepare("CREATE UNIQUE INDEX id on users (id);").run();
db.prepare("CREATE INDEX name on users (name);").run();
console.log("创建索引耗时"+(Date.now()-d)+"ms");
}else{
throw e;
}
}
db.exec("PRAGMA journal_mode=WAL;");//提升事务性能,多实例的时候可能影响一致性,尽量用单实例多并发
db.exec("PRAGMA synchronous=OFF;");//写盘交给操作系统,尽量用web云函数或者node12的事件函数的延迟退出能力
db.exec("PRAGMA default_cache_size = 8000;");
db.exec("PRAGMA page_size = 16384;");
db.exec("PRAGMA mmap_size = 4194304;");
console.log("优化参数已设置");
d=Date.now();
let n=100000;
const insert = db.prepare(`INSERT INTO users ( name, phone, address) VALUES (?, ?, ?)`);
const insertMany = db.transaction(() => {
for(let i=0;i<n;i++){
insert.run('John Doe', '+234-907788', '12 Igodan Street, Okitipupa')
}
});
insertMany();
console.log("插入"+n+"条数据耗时"+(Date.now()-d)+"ms");
d=Date.now();
let result = db.prepare("select * from users").all();
console.log("查询全部记录耗时"+(Date.now()-d)+"ms,查询到"+result.length+"条结果")
d=Date.now();
db.exec("DROP TABLE users;")
console.log("删除表耗时"+(Date.now()-d)+" ms");
db.close();
return "ok"
};

密集读写的时候,同步操作果然比异步操作快得多。因为要测试实际工作性能,这次数据库文件没有挂到/tmp下而是挂在/mnt/目录下,因此需要挂载一个CFS来做文件系统。受限于cfs的延迟,单次简单查询操作毫秒级,单次写操作十多毫秒,都比云开发数据库快了一个数量级。做小数据量小型应用够用了。

注意这是单个进程的读写。sqlite在多进程并发写的时候是有可能出现死锁的,尤其是bettersqlite这种同步式的操作。而我们做serverless最喜欢的就是处理瞬间的访问量剧增,那怎么办呢?一个解决方案是读写分离到不同的scf中,限制写的scf上限只能一个,这也容易出现写瓶颈。另一个更好的办法是利用云函数的单实例多并发特性,用单一个云函数来处理大量的并发(因为实例分配到的cpu资源与内存相关联,所以遇到更高并发量的时候单个实例的处理能力还可以通过调整实例内存来提升),这样就可以尽可能的避免写竞争的情况了。当然也可以两个办法一起上,读写分离并且把写请求都交给同一个单实例多并发的云函数。

根据这篇文章里的测试,sqlite对很大的数据量似乎也能有不错的性能。看来如果恰当优化一下的话数据量大一点也不是问题。