读 《node.js实战2.0》,进行学习记录总结。
存储数据
本章内容:
- 关系型数据库:PostgreSQL
- 非关系型数据库:MongoDB
- ACID 类
- 云数据库和存储服务
关系型数据库
关系型数据库以关系代数和集合论为理论基础。用模式指定各种数据类型的格式和这些数据类型之间的关系。例如,做社交网络时会有 User 和 Post 类型,它们之间还会有一对多的关系,然后用结构化查询语言(SQL)发起数据查询,比如“给我ID 为 123 的用户名下的所有帖子”,用 SQL 表示就是:
1 | SELECT * FROM post WHERE user_id = 123 |
PostgreSQL
在 Node 程序中,MySQL 和 PostgreSQL 都是常用的关系型数据库。选择看个人喜好。
安装和配置
Postgres 的安装(win),从 https://postgresql.org 下载安装器。安装和配置教程
win 启动:psql -h localhost -U postgres
从 Node 中连接 Postgres
在 Node 中与 Postgres 交互最受欢迎的包就是 pgm,也可以用 npm包装:
1 | npm install pg --save |
连接数据库
1 | const pg = require('pg'); |
期间报的一些错误例如:
1 | role "Administrator" does not exist |
可以通过 pgAdmin4 进行可视化的配置,创建新的角色并且赋予一些功能,例如可以登入或者是设定为超级用户。
定义表
首先像下面这样定义表,确定表中存储的数据形式。
1 | const pg = require('pg'); |
可以shell 里面看到该table 表
或者是通过 pgAdmin 查看
插入数据
表定义好了后,可以像下面这样使用 INSERT 查询插入数据。如果不指定 id,PostgreSQL 会自动生成一个。要想知道生成ID 是什么,需要在查询语句里加上 RETURNING id,然后可以在回调函数的结果集参数中得到id的值。
1 | db.connect((err, client) => { |
更新数据
插入数据后,可以像下面的 UPDATE 查询更新数据。受影响的记录数放在查询结果中的 rowCount 属性上。
1 | db.connect((err, client) => { |
查询数据
关系型数据库的能量主要体现在复杂的数据查询上,查询语句使用 SELECT ,例如
1 | db.query(` |
Knex
很多开发人员不喜欢把 SQL 直接放在代码里,希望有一个抽象层隔离一下。因为用字符串拼接 SQL 语句太繁琐了,而且查询可能会变得越来越难以理解和维护。对于 JavaScript 来说更是如此,因为在 ES6 模板常量出来之前,它连表示多行字符串的语法都没有。
Knex是一个轻便的 SQL 抽象包,它被称为查询构建器。我们可以通过查询构建器的声明式 API 构造出 SQL 字符串,Knex 的 API 简单直白:
1 | knex({ client:'mysql'}) |
这段代码会生成一个 MySQL 的参数化 SQL 查询:
1 | select * from `users` where `id` = ? |
查询构建器
尽管业界在 20世纪 80年代中期就确立了 ANSI 和 ISO SQL 标准。不过直到现在,大多数数据库用的仍然是自己的 SQL 方言。但 PostgreSQL 是个例外,它遵循了 SQL:2008 标准。查询构建器能在支持多种数据库的同时消除各种 SQL 方言的差异,提供一个统一的 SQL 生成接口。对于经常要在不同的数据库技术之间进行切换的团队来说,查询构建器提供的好处不言而喻。
Knex.js 支持的数据库有:
- PostgreSQL
- MSSQL
- MYSQL
- MariaDB
- SQLite3
- Oracle
Knex 支持 promise 和 Node 风格的回调
用 Knex 实现连接和查询
Knex 不像其他查询构建器,它还可以根据选定的数据库驱动连接数据库并执行查询语句:
1 | db('articles') |
Knex 查询默认返回 promise,但也提供了 .asCallback 方法,可以按照惯例支持回调函数:
1 | db('articles') |
下面代码用 sqlit3s实现了简单的 Article 模型,将它保存为 db.js 。安装依赖
1 | npm install knex@~0.12.0 sqlite3@~3.1.0 --save |
1 | const knex = require('knex'); |
现在可以用 db.Article 添加 Article 记录了。
1 | const db = require('./db'); |
SQLite 几乎不需要配置,不用启动服务器守护进程,也不用在程序外面创建数据库。SQLite 把所有东西都写到一个文件里。运行前面的代码后,当前目录下会有一个 .sqlite 文件。删掉这个文件就能把数据库抹掉:
1 | rm articles.sqlite |
SQLite 还有内存模式,完全不用往硬盘里写东西。在进行自动化测试时,一般会用这种模式降低运行时间,带有:memory: 的特殊文件名会启用内容模式。在启用内存模式后,如果有多个连接,那么每个连接都会有自己的私有数据库:
1 | const db = knex({ |
切换数据库
因为用了 Knex,所以把之前代码换成 PostgreSQL 很容易,跟 PostgreSQL 服务器交互需要用到 pg 包,要将其安装好并跑起来。把 pg 包安装,并用 PostgreSQL 的 createdb 命令创建对应的数据库:
1 | npm install pg --save |
只要修改 Knex 的配置就能换成这个新数据库。对外的 API 和使用还是一样的:
1 | const db = knex({ |
注意抽象漏洞
查询构建器能够对 SQL 语法做标准化处理,但改变不了数据库的行为。有些特性只有特定数据库提供支持,而且对于同样的查询,不同的数据库可能会有不同的行为,比如下面这两个特定主键的方法:
- table.increments(‘id’).primary();
- table.integer(‘id’).primary()
在 SQLite3 上都没有问题,但在 PostgreSQL 上插入记录时,第二个会出错。
1 | "null value in column "id" violates not-null constraint" |
在 SQLite 上,如果插入的记录主键为 Null,不管是否配置自增长主键,都会自动赋给它一个自增长的 ID 。而 PostgreSQL 只有主键显式定义为自增长主键时才会如此处理。这样的行为差异很多,并且有些差异导致的错误可能无法轻易发现。如果切换数据库,一定要进行充分的测试。
MySQL 和 PostgreSQL
MySQL 和 PostgreSQL 都是成熟高效的数据库系统,并且对于很多项目来说,它们几乎没有什么差别。直到项目需要扩张时,开发人员才会感觉到它们在接口边缘或接口之下的差异。
得注意的差别:
- PostgreSQL 支持一些表达能力更强的数据类型,比如数组、JSON 和用户定义的类型;
- PostgreSQL 自带全文搜索功能;
- PostgreSQL 全面支持 ANSI SQL:2008 标准;
- PostgreSQL 的复制功能不如 MySQL 强大,或者说没有经受过那么严苛的考验;
- MySQL 资历更老、社区更大,有更多的工具和资源;
- MySQL 有很多有微妙差别的分支(比如 MariaDB 和 WebScaleSQL 这些受到 Facebook、Google、Twitter 等公司支持的版本)
- MySQL 的可插拔存储引擎不太好理解,管理和调优也有一定的难度。不过换个角度来看,这也意味着可以对它的性能做更精细的控制。
选哪个数据库并不会影响项目成功与否,所以不要在这个问题上纠结。如果有必要,以后也可以做数据库迁移,但 PostgreSQL 应该足以满足你对功能特性和扩展能力的需求。但如果你恰好要对数据库的评估选型负责,则有必要熟悉一下 ACID 保证
ACID 保证
ACID(原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和耐用性(Durability)) 是对数据库事务的一组要求:原子性、一致性、隔离线和耐用性。这些术语的确切定义可能会变。但一般来说,系统对 ACID 保证越严格,在性能上作出的让步就越大。开发人员用ACID 分类来交流不同方案所做的妥协,比如在聊 NoSQL 系统时。
原子性:无论成败,事务必须整体执行
原子性事务不能被部分执行:或者整个操作都完成了,或者数据库保持原样。比如说要删除某个用户的所有理论,如果作为一个事务的话,或者全部删除了,或者一条也没有删。最终不能是有些删除,有些还保持原来的状态,甚至在系统出错或断电后,仍然保持原子性。原子性:不可再分。
一致性:始终确保约束条件
成功完成的事务必须符合系统中定义的所有数据完整性约束。比如主键必须唯一、数据要符合某种特定的模式,或者外键指向存在的实体。产生不一致状态的事务一般也会失败,然后小问题是可以自动解决的,比如将数据转换成正确的形态。
隔离性:并发事务不会互相干扰
不管是并发还是线性执行,隔离性事务的执行结果应该都是一样的。系统的隔离水平会直接影响它执行并发操作的能力。全局锁是一种比较低幼的隔离方式,由于在事务期间会把整个数据库锁住,所以只能串行处理事务。这是很强的隔离性保证,但效率也极低;那些跟事务完全没有关联的数据库集根本不应该被锁住(比如说,一个用户添加评论时不应该导致另一个用户无法更行自己的个人资料)。在现实情况中,数据库系统会提供精细的和有选择性的锁模式(比如锁自己的个人资料)。在现实情况中,数据库系统会提供更加精细的和有选择性的锁模式(比如锁表、锁记录和锁数据域),以实现各种程序的隔离水平。更复杂的系统甚至有可能会采用隔离水平最低的锁模式,乐观地执行所有事务,直到检测到冲突时才会逐步细化锁模式。
耐用性:事务是永久性的
事务的耐用性是对持久性生效的保证,在重启,断电、系统错误甚至是硬件失效的情况下,持续化的效果依然不受影响。比如 SQLite 内存模式下的事务就很没有耐用性,进程退出后所有数据都没有了。而在 SQLite 把数据写到硬盘中时,事务的耐用性就很好了,因为机器重启后数据还在。
这看起来好像很简单:只要把数据写到硬盘里就好了,事务就有耐用性了。但硬盘 I/O 是比较慢的操作,即便程序规模增长比较温和时,I/O 操作也会迅速变成性能瓶颈。为了保证系统性能,有些数据库会提供不同的耐用性折中方案。
NoSQL
非关系模型的数据存储统称为 NoSQL 。因为现在有些 NoSQL 数据库确实支持 SQL ,所以 NoSQL 的含义更接近非关系型,或者被当做不仅是 SQL 的缩写。
下面举一些 NoSQL 的范式以及相应的数据库的例子:
- 键-值/元组存储——DynamoDB、LevelDB、Redis、etcd、Aerospike、Berkeley DB
- 图存储——Neo4J、OrientDB
- 文档存储——CouthDB、MongoDB、RRDtool
- 列存储——Cassandra、HBase
- 时间序列存储——Graphite、InfluxDB、RRDtool
- 多范式——Couchbase(文档数据库、键/值存储、分布式缓存)
NoSQL 官网上有更完整的 NoSQL 数据库列表。
如果你只用过关系型数据库,可能不太容易接受 NoSQL 的概念,因为 NoSQL 的用法经常会违反你已经习惯了的最佳实践:没有模式定义、重复的数据、松散的强制性约束。NoSQL 系统经常会将赋予数据库的责任放到应用程序上。这看起来可能会很乱。
般情况下,只要一小部分访问模式就会创建大量的数据库工作负载,比如生成程序登录画面的查询,需要获取很多域对象。在关系型数据库中,要提高读取性能时一般会做非规范化,给客户端的域查询要经过预处理并形成可以降低查询次数的形态
一般来说,NoSQL 数据默认就是非规范化的,甚至会跳过域建模。这样就不会在数据模型上做过多工作,修改起来会更迅速,并形成更简单、更好执行的设计。
分布式数据库
程序可以在垂直和水平两个方向上扩展,垂直扩展是指增加机器的能力,水平扩展是增加机器的数量。垂直扩展一般会更简单,但受限于一台机器所能达到的硬件水平,而且成本上升很快。相对来说,水平扩展时,系统的能力是随着处理器和机器的增加而增长的。因为要协调更多动态组件,所以会增加复杂性。所有增加的系统最终都会达到一个点,之后只能做水平扩展。
分布式数据库从一开始就是按照水平扩展设计的。把数据存储在多台机器上解决了单点故障问题,可以提升耐用性。很多关系型系统都可以用分片、主/从、主/主复制等形态进行一定的水平扩展,但不会超过几百个节点。比如 MySQL 集群的上限是 255节点。而分布式数据库可以有几千个节点。
MongoDB
MongoDB 是面向对象的分布式数据库,使用它的 Node 开发人员特别多。时髦的 MEAN 栈中的 M 就是 MongoDB(另外三个是 Express、Angular 和 Node),一般我们刚开始接触 Node 时遇到的第一个数据库就是它。
MongoDB 受到的批评和争议非常多。尽管如此,它仍然是很多开发人员的主存储。很多著名公司都部署了 MongoDB,包括 Adobe、LinkedIn、eBay,甚至欧洲粒子物理研究所(CERN)的大型强子对撞机组件上都在用它。
MongoDB 数据库把文档存储在无模式的数据集中。不需要预先为文档定义模式,同一个数据集中的文档也不用遵循相同的模式。这给了 MongoDB 很大的灵活性,但程序要因此承担起保证数据一致性的责任,确保文档的结构是可预测的。
安装和配置
不同系统上的 MongoDB 安装是不一样的。MacOS 上就是简单的:
1 | brew install mongodb |
MongoDB 服务器是用可执行文件 mongod 启动的:
1 | mongod --config /usr/local/etc/mongod.conf |
Christian Amor Kvalheim 的官方 mongodb 包是最受欢迎的 MongoDB 驱动:
1 | npm install mongodb@^2.1.0 --save |
Windows 用户注意一下,这个驱动的安装需要 Microsoft Visual Studio 的 msbuild.exe
1 | const { MongoClient } = require('mongodb'); |
连接成功的处理器会得到一个数据库客户端实例,所有数据库命令都是交给它执行的。
大部分数据库交互都是通过 collection API 完成的:
API | 说明 |
---|---|
collection.insert(doc) | 插入一个或多个文档 |
collection.find(query) | 找出跟查询匹配的文档 |
collection.remove(query) | 移除跟查询匹配的文档 |
collection.drop() | 移除整个数据集 |
collection.update(query) | 更新跟查询匹配的文档 |
collection.count(query) | 对跟查询匹配的文档计数 |
collection.insertOne(query) | 插入单个文档 |
collection.insertMany([doc1,doc2]) | 插入多个文档 |
collection.findOne(query) | 找出一个跟查询匹配的文档 |
collection.updateMany(query) | 更新所有跟查询匹配的文档 |
插入文档
collection.insertOne 将单个对象作为文档存到数据集里,成功处理器会得到一个包含操作元信息的对象。
1 | const { MongoClient } = require('mongodb'); |
insertMany 的用法也差不多,只是参数是包含多个对象的数组。insertMany 的结果不再是一个 insertedId,而是包含多个 ID 的 insertedIds 数组,ID 的顺序跟作为参数的数组中的文档顺序一样
查询
从数据集中读取文档的方法(比如 find、update 和 remove )都会有一个查询参数,用来匹配文档。最简单的查询就是一个对象,MongoDB 会匹配结构和值相同的文档。比如下面这个查询会匹配所有标题为 ‘I like cake’ 的文档。
1 | db.collection('articles') |
或者基于查询操作符匹配:
1 | db.collection('articles') |
MongoDB 查询语言中的查询操作符很多,比如:
$eq
——等于某个值;$neq
——不等于某个值;$in
——在数组中;$nin
——不在数组中;$lt
、$lte
、$gt
、$gte
——大于/小于或等于比较值;$near
——地理位置值在某个区域附近;$not
、$and
、$or
、$nor
——逻辑操作符。
这些操作符几乎可以组合出所有查询条件,创造出可读性强、精巧的、富有表达力的查询语句。更多与查询和查询操作符有关的内容请浏览 Query and Projection Operators 网站。
用 MongoDB 实现 Article API
1 | const { MongoClient, ObjectID } = require('mongodb'); |
用法示范:
连接数据库返回的 promise,然后用 Article 的 create 方法创建了一篇文章。再加载所有文章,输出。
1 | const db = require('./db'); |
使用 MongoDB 标识
MongoDB 的标识是二进制 JSON (BSON)格式的。文档上的 _id 是一个 JavaScript 对象,其内部封装了 BSON 格式的 objectID。BSON 格式是文档在 MongoDB 内部的表示和传输格式,它比 JSON 的空间利用率高,解析速度快,也就是说可以用更低的带宽达成更快的数据库交互。
BSON 格式的 ObjectID 并不是随机的字节序列,它编码了ID 何时在何处生成的元数据。比如 ObjectID 的前四个字节,它们是时间戳。因此文档中没有必要再单独保存一个 createdAt 时间戳:
1 | const id = new ObjectID(61bd7f57bf1532835dd6174b); |
在终端输出是,ObjectID 表面上看起来可能像字符串一样,但实际上是对象。所以在进行比较,解释器会报告说两个看起来完全一样的值是不同的,因为它们是指向不同对象的引用值。这就是典型的对象比较陷阱。
下面的代码两次提取相同的对象。我们试图用 Node 自带的 assert 模板断言这两个 ID 或者说对象是相对的,结果却失败了:
1 | const { MongoClient } = require('mongodb'); |
ObjectID 有个 equal 方法,所有的 _id 都可以用这个方法判断它们是否相等。另外,你也可以将标识强制转换为字符串进行比较,或者用 assert 模块的 deepEquals 方法
1 | article1._id.equals(article2._id); |
传给 mongodb 驱动的标识必须是 BSON 格式的 ObjectID。ObjectID 构造器可以将字符串转换成 ObjectID:
1 | const { ObjectID } = require('mongodb'); |
要尽可能保持 BSON 格式。在 BSON 和字符串之间的相互转换会以牺牲性能为代价,这违背了 MongoDB 把 BSON 格式的标识交给客户端的初衷。请参阅 BSON 官网了解 BSON 格式的详细信息。
使用复制集
MongoDB 的分布式功能,多个 mongod 进程可以作为复制集的节点/成员运行。复制集是由一个主节点和无数从节点组成的。复制集中的每个成员都会分到唯一的端口和目录存储自己的数据。各个实例不能共享端口和目录,并且在启动之前这些目录必须是已经存在了。
下面代码为每个成员创建唯一的目录,并从端口 27017 开始按顺序启动它们。如果不想让 mongod 在后台运行(命令中不带 &),可以为每个 mongod 命令开一个新的终端标签。
启动一个复制集
1 | mkdir -p ./mongodata/db0 ./mongodata/db1 ./mongodata/db2 |
复制集跑起来之后,MongoDB 需要执行一些初始化操作。你需要连接到希望让它做主节点的那个实例(默认是 27017),并像下面这样调用 rs.initiate()。然后把这些实例作为成员添加到复制集中。注意要提供所连机器的主机名。
1 | mongo --eval "rs.initiate()" |
在建立连接时,MongoDB 客户端需要知道所有的复制集成员,但并不要求所有成员都在线。连上之后就可以照常使用了。
创建复制集
1 | const os = require('os'); |
即便有节点崩溃,但只要仍在运行的 mongod 节点不少于两个,系统就能继续工作。如果主节点崩溃了,系统会自动推举一个从节点升为主节点。
了解写关注
在使用 MongoDB 时,开发人员能够对性能和安全上的折中选项做精细的控制,以满足程序不同区域的需要。要想不出意外,必须掌握 MongoDB 的写关注和读关注这两个概念,特别是在复制集中的节点不断增多时。
写关注本质上是个数量值,表明 MongoDB 在返回操作整体成功的响应之前,需要把数据成功写入多少个 mongod 实例。如果不特别指明,写关注的默认值是 1,即确保数据成功写入至少一个节点。对于重要数据而言,这样的保证水平是不够的。如果在数据复制到其他节点之前,这个节点下线了,那数据可能就丢了。
从程序角度来说,实际上是经常希望把写关注设为0,即程序根本不想为 MongoDB 的响应而等待:
1 | db.collection('data').insertOne(data,{w:0}); |
写关注为0时性能水平达到最高,但同时耐用性保证降到最低,一般只在临时或不重要的数据上使用(比如写日志或缓存)
在连到复制集上时,写关注可以大于1,把数据复制到更多节点上可以降低其丢失的风险,但代价是操作延时会更长
1 | db.collection('data').insertOne(data,{w:2}); |
写关注也可以随着集群中节点数量的变化而变化,当写关注被设为 majority 时,MongoDB 能自行动态调整它的值。此时数据一定会写入至少 50% 的可用节点:
1 | db.collection('data').insertOne(data,{w:'majority'}); |
默认值的写关注1可能无法保证重要数据的安全。如果在数据复制到其他节点之前,这个节点下线了,那数据可能就丢了。
写关注大于1时,可以确保在继续操作之前数据会写入多个 mongodb 实例上。在同一台机器上运行多个实例确实可以提高数据的安全性,但出现系统性故障时,比如硬盘空间或RAM 耗光了,这样的配置是无济于事的。如果把节点分布在多台机器上,并确保写入操作会传播到这些节点上时,可以保证数据不受机器故障的影响,但同样,整个数据中心都出现问题就不行了,并且写操作会更慢。把节点部分到多个数据中心可以保证数据不受数据中心级故障的影响,但将数据复制到多个数据中心性能影响非常大。
保障越多,系统越慢,也越复杂。不仅 MongoDB 如此,所有数据存储都这样。没有完美的解决方案,你需要决定将程序各部分的风险水平控制在什么范围内
键/值存储
键/值存储中的所有记录都是由一个键值对构成的。大多数键/值 系统都不对值的数据类型、长度和结构做限制。在键/值数据库看来,值是不透明的原子:数据库不知道,或者说不关心值的数据类型,并且值作为一个整体,不会切分或访问其中的部分数据。在关系型数据库中,数据一行行地存在表中,每一行都被分成了预先定义好的列。但键/值存储跟它相反。其把管理数据格式的任务交给了应用程序。
键/值存储经常出现在程序性能的关键路径上。理想情况下,值应该是按照用最少的读取次数完成任务的标准来摆放的。相较于其他数据库来说,键/值存储的查询功能比较简单。复杂查询最好是预先计算好的。否则应该放在程序里,而不是交给数据库执行。有了这样的限制,数据库的性能特征就更容易理解和预测了。
像Redis 和 Memcached 这些最火的键/值存储经常用来做易失性存储(进程退出后数据就没了)。避免写盘操作是提升性能的最佳方式。如果数据可以重新生成,或者丢了也没有太多关系,这种折中是可以接受的,比如作为缓存和存储用户会话数据。
Redis
Redis 是热门的结构化内存数据库,尽管很多人认为 Redis 是键/值 存储,但实际上键和值都是 Redis 所支持的众多数据结构中的一种,它还支持很多实用的基础结构。
Redis 原生支持的数据结构包括:
- 字符串
- 散列表
- 列表
- 集合
- 有序表
Redis 还有很多实用的功能:
- 位图数据——直接在值上进行位操作
- 地理位置索引——存储带半径查询的地理位置数据
- 频道——一种发布/订阅数据传递机制
- TTL——数据可以有过期时间,过期之后自动清除
- LRU逐出——有选择地移除最近不用的数据,以便维持内存的利用率
- HyperLogLog——用很低的内存占用求集合基数的高性能算法(不需要存储所有成员)
- 复制、集群和分区——水平扩展和数据耐用性
- Lua脚本——可以用 Redis 添加自定义的命令
Redis 真的是一个超强的多面手,http://redis.io/commands 上有更详细的介绍。
安装和配置
可以用系统上的包管理工具安装 Redis。
1 | npm i redis |
用可执行文件 redis-server 启动服务器:
1 | redis-server /usr/local/etc/redis.conf |
服务器默认的监听端口是 6397。
初始化
Redis 客户端实例使用 redis npm 包的 createClient 函数创建的:
1 | const redis = require('redis'); |
这个函数以端口和服务器的主机地址为参数。如果 Redis 运行在本机的默认端口上,则无须提供参数:
1 | const db = redis.createClient(); |
因为 Redis 客户端实例是一个 EventEmitter,所以我们可以通过它监听各种 Redis 状态事件。不用等着连接准备好再向客户端发送命令,这些命令会缓存到连接就绪。
连接到 Redis 监听状态事件
1 | const redis = require('redis'); |
出现连接或客户端方面的问题时会触发错误处理器。如果发生了 error 事件,但没有监听该事件的错误处理器,程序会抛出错误然后退出。Node 中的所有 EventEmitter 都是这样的。如果连接失败后有错误处理器,Redis 客户端会尝试重新连接。
处理键/值对
Redis 可以当做普通的键/值存储用,支持字符串和任何二进制数据。分别用 get 和 set 方法读写键/值对:
1 | db.set('color','red',err=>{ |
如果写入的键已经存在,那么原来的值会被覆盖掉。如果读取的键不存在,则会得到值 null,而不会被当做错误。
下面这些命令是用来获取和处理值的:
- append
- decr
- decrby
- get
- getrange
- getset
- incr
- incrby
- incrbyfloat
- mget
- mset
- msetnx
- psetex
- set
- setex
- setnx
- setrange
- strlen
处理键
exists 可以检查某个键是否存在,它能接受任何数据类型:
1 | db.exists('users'.(err,doseExist) =>{ |
除了 exists,下面这些命令都可以用在键上,任何类型的值都可以(这些命令可以接受字符串、集合、列表等类型)
- del
- exists
- rename
- renamex
- sort
- scan
- type
编码与数据类型
在 Redis 服务器里面。键和值是二进制对象,跟传给客户端时所用的编码没关系。所有有效的 JavaScript 字符串(UCS2/UTF16)都是有效的键或值:
1 | db.set('greeting','你好',redis.print); |
默认情况下,在写入时会将键和值强制转换成 字符串。比如说,如果设定某个键的值是数字,那么读取这条记录时,得到的键将会是个字符串:
1 | db.set('colors',1,(err) =>{ |
Redis 客户端会默默将数字、布尔值和日期转换成字符串,它也乐意接受缓冲区对象。除此之外,设定其他任何 JavaScript 类型(比如对象、数字、正则表达式)的值时,客户端都会发出一个不应被忽略的警告:
1 | db.set('users',{},redis.print); |
将来这会变成错误,所以一定要让程序确保传给 Redis 客户端的数据类型是正确的
1.陷阱:单值和多值数组
如果值是包含多个值的数组,那么客户端会报一个很神秘的错误,即“ReplyError:ERR syntax error”:
1 | db.set('users', ['Alice', 'Bob'], redis.print); |
但如果数组中只有一个值,则不会报错
1 | db.set('user', ['Alice'], redis.print); |
2. 带缓冲区的二进制数据
Redis 可以存储任何二进制数据,也就是说它可以存储任何类型的数据。Node 客户端对这一功能的支持是用 Node 的 Buffer 类型实现的。当 Redis 客户端收到缓冲区类型的键或值时,会原封不动地将这些字节发给 Redis 服务器。为了避免可能会出现的数据破坏或性能损失,客户端不会进行缓冲区和字符串之间的类型转换比如说,如果要把硬盘或网络上的数据直接写到 Redis中,那么直接写缓冲区里的数据明显会比先把数据转成字符串再写更高效。
缓冲区:缓冲区是 Node 核心文件和网络 API 默认提供的结果。它们是二进制数据连续块的容器,在 JavaScript 还没有自己的原生二进制数据类型(Unit8Array,Float32Array 等)时就已经在 Node 中了。现在它是 Unit8Array 的特殊子类。Buffer API 在 Node 中是可以全局访问的,用它不需要 require 任何东西。
Redis 最近添加了一些操作字符串上单个位的命令,在处理缓冲区时也可以用:
- bitcount
- bitfield
- bitop
- setbit
- bitpos
使用散列表
散列表是键/值对的数据集。hmset 命令的参数是一个键和一个表示散列键/值对的对象。hmget 可以读出这个包含键/值对的对象。代码如下
1 | // 设定散列表键/值对 |
Redis 散列表中不能存储带嵌入结构的对象,只能有一层。
下面这些是操作散列表的命令:
- hdel
- hexists
- hget
- hset
- hgetall
- hincrby
- hincrbyfloat
- hkeys
- hmget
- hmset
- hset
- hsetnx
- hstrlen
- hvals
- hscan
使用列表
列表是包含字符串值的有序数据集,可以存在同一值的多个副本。列表在概念上跟数组类似。最好当做是栈(LIFO:后进先出)或队列(FIFO:先进先出)来用。
下面的代码演示了如何将值存到列表中然后读取出来。 lpush 命令向列表中添加一个值。lrange 命令按范围读取,有起始和结束索引。因为-1表示列表中的最后一个元素,所以下例中的 lrange 会取出列表中的所有元素。
1 | client.lpush('tasks','Paint the bikeshed red',redis.print); |
列表既没有提供确定某个值是否存在其中的方法,也没有提供确定某个值的索引的方法。我们只能通过手动遍历获取这些消息,但做这件事效率很低,应该尽量避免,如果你确实需要这样的功能,应该考虑使用其他数据结构,比如集合,甚至可以跟列表配合使用。为了充分利用各种功能特性,把数据复制到多个数据结构中是正常的。
操作列表的命令:
- blpop
- brpop
- lindex
- llen
- lpop
- lpush
- lpushx
- lrange
- lrem
- lset
- ltrim
- rpop
- rpush
- rpushx
使用集合
集合时无序数据集,其中不允许有重复值,集合是一种高性能的数据结构,检查成员、添加和移除记录都可以在 O(1)时间内完成,所以其非常适合对性能要求比较高的任务:
1 | db.sadd('admins','Alice',redis,print); |
下面这些是操作集合的命令:
- sadd
- scard
- sdiff
- sdiffstore
- sinter
- sinterstore
- sismember
- smembers
- spop
- srandmember
- sunion
- sunionstore
- sscan
用频道实现发布/订阅功能
Redis 不仅仅是传统意义上的数据存储系统,它还提供了频道,频道是可以实现发布/订阅功能的数据传输机制。聊天和博彩等实时程序都需要这样的功能。
Redis 客户端既可以订阅频道上的消息,也可以向频道发布消息。发给频道的消息会传递给所有订阅频道的客户端。发布者不需要知道谁是订阅者,订阅者也不知道发布者是谁。将发布者和订阅者解耦是种强大清晰的模式。
下面的例子用 Redis 的发布/订阅功能实现的聊天服务器
1 | const net = require('net'); |
提高性能
npm 包 hiredis 是从 JavaScript 到官方 Hiredis 的 C 语言库的本地绑定。Hiredis 能显著提 Node Redis 程序的性能,特别是在大型数据库上使用 sunion、sinter、lrange 和 zrange 这些操作时.
只要装好 hiredis,redis 包下次启动时就会自动检测到 hiredis,然后自动使用:
1 | npm install hiredis --save |
hiredis 几乎没什么缺点,但因为它是从 C 代码编译来的,所以在某些平台上构建 hiredis 可能会受到一些限制,或者比较复杂。跟所有本地添加包一样,升级 Node 后可能需要用 npm rebuild 重新构建 hiredis
嵌入式数据库
使用嵌入式数据库时不需要安装或管理一个外部服务器,它是嵌入在程序进程里运行的。程序一般通过直接的过程调用跟嵌入式数据库通信,不需要通过进程间通信(IPC)通道或网络。
因为很多时候程序要做成自包含的,所以只能选嵌入式数据库(比如移动端或桌面程序),嵌入式数据库也可以用在 Web 服务器上,经常用来实现高吞吐性的功能,比如用户会话或缓存,有时甚至会作为主存储。
Node 和 Electron 程序中常用的嵌入式数据库有:
- SQLite
- LevelDB
- Aerospike
- EJDB
- NeDB
- LokiJS
- Lowdb
NeDB、LokiJS 和 Lowdb 都是用纯 JavaScript 写的,天生就适合嵌入到 Node 和 Electron 程序中。尽管有 SQLite 这样著名的可嵌入式关系型数据库,但大多数嵌入式数据库都是简单的键/值 或文档存储。
LevelDB
LevelDB 是 Google 在 2011 年初开发的嵌入式持久化键/值存储,最开始是要给 Chrome 里实现的 IndexedDB 做后台存储的。LevelDB 的设计理念源于 Google 的 Bigtable 数据库。它的竞争对手是 Berkley DB、Tokyo/Kyoto Cabinet 和 Aerospike 这些数据库,可以把它当作最小功能集的嵌入式 Redis。跟大多数嵌入式数据库一样,LevelDB 也不是多线程的,不支持使用同一个底层文件存储的多实例,所以无法脱离程序的封装分布式使用。
LevelDB 中的键是按字典顺序排好序的,值是用 Google 的 Snappy 压缩算法压缩过的。跟Redis 之类的内存数据库不同,LevelDB 总是把数据写到硬盘上,所以总的数据容量不受机器内存的限制。
LevelDB 只提供了几个一看就明白的操作命令:Get、Put、Del 和 Batch。LevelDB 还能用快照捕获当前的数据库状态,创建能在数据集上前后移动的双向循环器。创建循环器也会隐含着创建快照,后续写操作无法改变循环器见到的数据。
LevelDB 还形成了一些支脉,演化出了其他一些数据库。由于有数量众多的支脉,LevelDB自身反而可以变得越来越简单:
- Facebook 的 RocksDB
- Hyperdex 的 HyperLevelDB
- Basho 的 Riak
- Mojang(Minecraft 的创作者)的 leveldb-mcpe
- 用于比特币项目的 bitcoin/leveldb
LevelUP 与 LevelDOWN
Node 中对 LevelDB 提供支持的是 LevelUP 和 LevelDOWN 包。LevelDOWN 用 C++简单直白地将 LevelDB 绑定到 Node上,我们不太可能直接跟它交互。LevelUP 对 LevelDOWN 的 API 做了封装,为我们提供了更方便、也更习惯的 Node 接口。LevelUP 还增加了一些功能,包括键/值编码、JSON、等待数据库打开的写缓存,以及将 LevelDB 循环器接口封装在了 Node 流中。
安装
在 Node 程序中使用 LevelDB 最方便的地方就是它是嵌入式的:所有需要的东西都可以用 npm 安装。不需要安装任何额外的软件。
1 | npm install level -S |
level 包里封装了 LevelUP 和 LevelDOWN,提供了预先配置好用 LevelDOWN 做后台的LevelUP API。level 提供的 LevelUP API 在 LevelUP 的介绍文件里
API 概览
LevelDB 客户端存储和获取数据的主要方式如下:
- db.put(key,value,callback)——存储键值对
- db.get(key,callback)——获取指定键的值
- db.del(key,callback)——移除指定键的值
- db.batch().write()——执行指定键的值
- db.batch().write()——执行批处理
- db.createKeyStream(options)——创建数据库中键的流
- db.createValueStream(options)——创建数据库中值的流
初始化
初始化 level 需要提供一个存储数据的路径,如果指定的目录不存在,则会自动创建,人们一般会用 .db 做这个目录的后缀。
初始化 level 数据库
1 | const level = require('level') |
调用过 level() 后,返回的 LevelUP实例可以马上接受命令,以同步方式执行。在 LevelDB 存储打开之前发出的命令会缓存起来,一直等到存储打开。
键/值编码
因为 LevelDB 中的键和值可以是任何类型的数据,所以程序要负责处理数据的序列化和反序列化。可以将 LevelUP 配置为直接支持下面这些数据类型:
- utf8
- json
- binary
- id
- hex
- ascii
- base64
- ucs2
- utf16le
键/值默认都是 UTF-8 的字符串。上面代码中,键仍然是 UTF-8 字符串,但值是用JSON 编码/解码的。经过 JSON 编码后,在某种程序上来讲,对象或数组这样的结构化数据的存储和获取都可以用 MongoDB 那样的文档存储一样了。但并不像真正的文档存储,LevelDB 没有办法读取值里面的键,值是不透明的。用户也可以用自己定制的编码,比如说想 MessagePack 这样的结构化数据形态。
键/值对的读写
核心 API 很简单:用 put(key,value)写,用 get(key) 读,用 del(key)删除。
1 | const level = require('level') |
如果把值放到已经存在的键上,旧值会被覆盖。当视图读取的键不存在时会发生错误。错误对象的类型是 NotFoundError,还有个特殊的属性 err.notFound,可以把它跟其他错误区分开。大部分数据库一般不会将其作为错误,但因为 LevelDB 没有提供检查某个键是否存在的地方,所以 LevelUP 需要区分不存在的值和未定义的值。与 get 不同,del 不存在的键不会报错。
1 | db.get('this-key-does-not-exist',(err,value)=>{ |
所有的数据读写操作都可以通过一个可选的参数改变当前操作的编码,
1 | db.put(new Uint8Array([1, 2, 3]), '0xFF0099', options, (err) => { |
可插拔的后台
把 LevelUP/LevelDOWN 分开还有个好处,LevelUP 可以用其他数据库做存储后台。所有能用 MemoDown API 封装的东西都可以变成 LevelUP 的存储后台,从而允许你用完全相同的 API 跟这些数据存储交互。
下面这些数据库都可以做 LevelUP 的存储后台:
- MySQL
- Redis
- MongoDB
- JSON 文件
- Google 电子表格
- AWS DynamoDB
- Windows Azure 表存储
- 浏览器 Web 存储(IndexedDB/localStorage)
拥有了这种可以轻松切换存储介质,甚至可以编写自己的存储后台的功能,我们就可以用一套数据库 API 应对各种情况和环境,用一套数据库 API 掌握一切。
memdown 是比较常用的后台,它把值都存在内存里,就像使用内存模式的 SQLite 一样,非常适合放在测试环境里降低测试配置和重置的成本。
安装依赖
1 | npm i -S levelup memdown |
通过 LevelUP 使用 memdown
1 | const level = require('levelup') |
上面的例子用了之前的 level 包,因为它只是 LevelUP 的封装,但如果你不想用 level 中的 LevelDOWN ,可以直接用 LevelUP,以免因为 LevelDOWN 形成了对 LevelDB的依赖。
模块化数据库
下面是一些可以通过 npm 包实现的 LevelDB 模块化功能:
- 原子更新
- 自增长的键
- 地址位置查询
- 实时更新流
- LRU 逐出
- Map/reduce 任务
- 主/主复制
- 主/从复制
- SQL 查询
- 二级索引
- 触发器
- 版本化数据
昂贵的序列化和反序列化
JSON 操作是昂贵的阻塞式操作。在进程将数据装进 JSON ,或从 JSON 中取出数据时,根本做不了别的事情。大多数序列化格式都是如此。所以序列化操作一般都是 Web 服务器上的瓶颈。要想降低影响,最好的方法就是减少这种操作的频率和要处理的数据量。
改变序列化格式(比如 MessagePack 或 Protocol Buffer)可能会加快处理速度,但在考虑改变序列化之前,要尽可能先降低负载和优化序列化/反序列化步骤来改善性能。
JSON.stringify 和 JSON.parse 是原生函数。已经充分优化过了,但是在需要处理以兆字节为单位的数据时,还是很容易垮掉。下面例子是序列化和反序列化 10MB 数据的性能表现。
1 | const bytes = require('pretty-bytes'); |
在一台装了 Node 6.2.2 的 2015 3.1GHZ Intel Core i7MacBook Pro 上,对这将近 10MB 的数据,序列化几乎用了 140 毫秒,反序列化用了 335 毫秒。这样的负载放到 Web 服务器上就是场灾难,因为这些步骤是阻塞式的,只能串行处理。在序列化时,这样的服务器每秒大概只能处理 7 个请求,反序列化时每秒只处理 3 个
浏览器内存储
Node 采用的异步编程模型可以适用于很多场景,因为对大多数 Web 程序来说,最大的瓶颈就是 I/O 。所以利用客户端数据既可以降低服务器负载,还可以提升用户体验,这是效果最显著的做法,不用等着程序在网上跑来跑去取数据的用户会很开心。客户端存储还可以提高程序的可用性,因为即便用户或者服务掉线了,程序里有些功能还是可以用的。
Web 存储:localStorage 和 sessionStorage
Web 存储定义了简单的键/值存储,其在客户端和移动端浏览器上都有很好的支持。域可以用 Web 存储在浏览器里保存一定量的数据,即便是经过网站刷新、标签页关闭,甚至浏览器关闭后,这些数据仍然存储。Web 存储是客户端持久化的首选,简单朴素是它的优势。
有两种 Web 存储 API ,localStorage 和 sessionStorage。sessionStorage 的 API 跟 localStorage 一样,只是持久化行为不同。虽然它们存储的数据在页面重新加载之后都会得到保留,但 sessionStorage 数据只会保留到页面会话结束(标签或浏览器关闭时),并且不能在不同的浏览器窗口之间共享。
开发 Web 存储API 是为了克服浏览器 cookie 的限制。确切地说,cookie 不适合在多个活动标签间共享同一域的数据。如果用户要跨域多个标签完成一项任务,可以用 sessionStorage 保存这些标签共享的状态数据,从而省掉因网络传输带来的开销。
要保留跨越多个会话、标签和窗口的长期数据,cookie 也不好用。设计 localStorage 就是为了解决这些问题。不同的浏览器有不同的数据存储空间上限。移动端浏览器只有 5MB 的存储空间。
API 概览
localStorage API 提供的方法包括:
- localStorage.setItem(key,value)——存储键值对
- localStorage.getItem(key)——获取指定键对应的值
- localStorage.removeItem(key)——移除指定键对应的值
- localStorage.clear()——移除所有键值对
- localStorage.key(index)——获取指定索引处的值
- localStorage.length——localStorage 中的键总数
值的读写
键和值只能是字符串,如果提供的值不是字符串,会被强制转换成字符串。这种转换用的 toString
。不会产生JSON 字符串。所以对象的序列化结果就是 [object object]。因为要想在 Web 存储中存放比较复杂的数据类型,只能让应用程序做转换处理。下面是在 localStorage 中存放 JSON 的例子。
1 | const examplePreferences = { |
访问 Web 存储中的数据是同步操作,也就是说执行读写操作时,Web 存储会阻塞 UI 线程,但因为 速度相当快,在工作负载比较小时,用户察觉不出来这种变化。但是,还是应该尽量避免过度读写,尤其要避免出现大量数据的读写操作。可惜 Web worker 无法访问 Web 存储,所以所有读写只能在主 UI 线程中进行。
Web 存储 API 没有查询功能,不能按范围选择键,或者搜查特定的值,只能通过键来访问数据项。如果想要实现搜索功能,只能自己维护一套索引,或者数据集非常小的话,可以进行循环遍历,下面就是对 localStorage 中的所有键进行循环遍历
1 | function getAllKeys() { |
跟大多数键/值存储一样,Web 存储中的键也只有一个命名空间。比如说,我们不能分别为 posts 和 comments 创建各自的存储。不过可以通过给键增前缀的方式创建‘命名空间’,比如下面的这样:
1 | localStorage.setItem(`/posts/${posts.id}`, post); |
获取某个命名空间中中的所有数据:
1 | function getNamespaceItems(namespace) { |
这样会循环遍历 localStorage 中所有的键,所以如果数据项比较多,要考虑一下对性能的影响。因为 localStorage API 是永不的,所以用起来限制还是比较多。比如说,对于那些以 JSON 序列化数据为参数,并返回结果也是这样的数据的函数,你可能会用 localStorage 缓存记忆(memoize)它的结果。
用 localStorage 持久化记忆
1 | // 以后调用时如果参数相同,可以直接返回之前记住的结果 |
不过只有操作特别慢的时候,记住结果的收益才会大于序列化/反序列化处理的开销(比如加密算法)。因此最好使用 localStorage 节省因为要在网络上传输数据而开销的时间。
Web 存储确实会受到限制,但只要使用它得当。依然是简单而且强大的工具。
localForage
Web 存储的主要缺点主是它的阻塞式同步 API 和某些浏览器中有限的存储空间。除了 Web 存储,大多数浏览器都支持 WebSQL 或者是 IndexedDB,或者同时支持两种存储。它们都是非阻塞,并且存储空间比 Web 的大。
但是不建议用 Web 存储直接用。WebSQL 已经被废弃了,它的继任者是 IndexedDB,提供的 API 即不友好,也不简洁。要想在浏览器中用非阻塞的方式存储数据,可以用一种标准化的非标配工具,其中来自 Mozilla 的 localForage 库。
API 概览:
localForage 的接口基本上与 Web 存储是一样的,只不过是异步非阻塞方式的:
- localforage.setItem(key,value,callback)——存储键值对
- localforage.getItem(key,callback)——获取指定的键的值
- localforage.removeItem(key,callback)——移除指定键的值
- localforage.clear(callback)——移除所有的键值对
- localforage.key(index,callback)——获取指定索引的值
- localforage.keys(callback)——获取所有的键
- localforage.iterate(iterate,callback)——循环遍历键值对
读和写
localForage API 中有 promise 和回调两种方式。
localStorage 和 localForage 的数据读取
1 |
|
localForage 会在底层使用当前浏览器环境中最好的存储机制。如果有 IndexedDB,就使用它。否则就尝试 WebSQL,接着就是 Web 存储。这些存储的优先级是可以配置的,设置是可以禁止使用某种存储:
1 | // 不用 localStorage |
localForage 可以存储字符串之外的其他类型的数据。它支持大多数的 JS 原始类型,比如数组或者是对象,以及二进制数据类型:TypeArray、ArrayBuffer和 Blob 。IndexedDB是唯一支持二进制数据存储的后台,也就是说后台用的是 WebSQL 和 localStorage ,会有编组开销:
1 | Promise.all([ |
将 API 做成跟 Web 存储一样让 localForage 用起来也简单,也解决了很多缺点和兼容性问题。
存储托管
使用存储托管不需要管理自己的服务器端存储,可以迅速尝试所有工具,甚至无需自己搭建自己的数据库主机就能部署对外开放的生成程序。但部署自己的数据存储越来越简单,很多云服务提供商都有预先配置好的服务器映像,安装了运行所选数据库所需的全部软件,并且全都配置好了。
简单存储服务
Amazon 的简单存储服务(S3)是一种远程文件托管服务,包含在大受欢迎的 ASW 包中,用 S3存储和托管开放的文件有成本上的优势。它是云端的文件系统。可以用 RESTful HTTP 调用将文件和不超过 2KB的元数据上传到桶中。然后通过 HTTP GET 或 BitTorrent 协议访问这些内容。
我们可以对桶中及其中的内容进行各种访问许可配置,包括基于时间的访问。还可以给桶里的内容指定一个生存期(TTL),生存期过后会从桶中删除,再也访问不到。将 S3 数据提升到内容交付网络(CDN)中也很容易。AWS 提供了 CloudFront CDN,可以轻松链接到你的文件,然后用很低的延时提供给全世界。
S3 可以存储任何文件,只要不超过 5TB,任何格式都可以。在处理要作为一个整体来访问的、不怎么变化的大块数据时,S3 的表现最好。
总结
- Node 既能用关系型数据库,也能用 NoSQL 数据库
- 简单的 pg 模块很擅长处理 SQL 语言
- Knex 模块可以使用几个数据库
- ACID 是一组数据库事务属性,也可以保证安全性
- MongoDB 是使用 JavaScript 的 NoSQL 数据库
- Redis 是可以当做数据库和缓存用的数据结构化存储
- LevelDB 是源自 Google 高速键/值对存储,可以将字符串映射到值
- LevelDB 是模块化数据库
- 基于 Web 的存储,包括 LocalForage 和 localStorge ,可以将数据保存在浏览器中,
- 可以用 Amazon S3 这样的存储服务把数据保存到云提供商那里