NoSQL学习——文档数据库MongoDB(二)数据建模与读写
# 数据建模
在了解MongoDB的读写操作之前,应当首先建立对MongoDB数据建模,即如何组织数据的认识。
MongoDB采用类似SQL的方式进行组织。最顶层的是database,与SQL类数据库类似,通常可用于区分不同应用的数据。向下一层是collection,与SQL不同的是,collection是文档的集合,而SQL中到了database内部之后就是不同的表格。对于MongoDB而言,最基本的数据组织单位是文档,不同的数据存储在一个文档中时就归属于一个标识符管辖的对象。而这一责任在SQL中是通过表格中一行的主键来进行管理的。与SQL的主键不同的是,MongoDB的主键默认采用的是名为_id
的全局摘要字段,其中包含了时间、机器信息等因素,因此不具备SQL中自增主键的连续性。下表是对MongoDB和SQL数据模型的总结。
SQL | MongoDB | 备注 |
---|---|---|
Database | Database | |
Table | Collection | |
Row | Document | SQL中每行的元数据是预定义的,MongoDB则用文档描述 |
可以看到,MongoDB相较于SQL类数据库存在着更灵活的存储方式,但通常一个collection中只会存放一类schema极度相似的文档,例如一组用户信息。这是由于MongoDB的模型是由调用的系统进行管理,为了便于进行数据的组织和读写,仍然需要开发者在应用中定义数据的存放。
由于MongoDB将各类实体抽象为了文档,实体之间的关系就需要存放在文档中。MongoDB的关系通常使用两种方式进行组织。其一是Referencing,可以理解为存储关系另一方的标识符,如在一篇文章中存储user_id,然后通过user_id索引到用户文档。其二是Embedding,通过将对象存储在文档的字段内保存关系。一下是两种方法的简单例子。
Referencing | Embedding |
---|---|
Document1: {title: "How to code", author_id: xxxxxx} | Document2: {title: "How to code", url_list: [https://..., https://..., ...]} |
第一种方法适合关联的对象具有有实际意义的标识符的方式,例如前文提到的用户-文章之间的关系;第二种方法更适合存储少量的由数据对应的对象的标识符决定的值对象,例如某连锁店的地址集合等。通过以上的实现方式的讨论,我们可以发现,MongoDB更倾向于存储单向的关系,因此在进行数据建模时,要考虑关系的方向设计,避免关系的循环和重复等。
第三种方法则类似SQL中3NF中的关系组织方法,即两个对象之间的关系及其属性存放于一个文档中。
# MongoDB的查询语句
# 读操作(Read)
MongoDB的读可通过查询语句实现,其读操作只针对一个collection进行,通过明确查询的criteria(类似SQL中where后的条件)过滤结果,并通过projection(类似select...as)过滤文档内的属性。另一方面,MongoDB也提供了modifier用于控制查询到的文档集合返回的数量和顺序等。
MongoDB采用了JavaScript作为默认的查询语言,通常使用的命令行工具是mongosh。通过db对象表示一个database,其属性表示collection,最后用这个属性的方法findOne
、findMany
进行查询操作。在查询操作中,分别使用json表示criteria和projection。例如,以下是一个典型的读操作:
db.users.find(
{name: "Ted Nelson"}, // criteria
{_id: false} // projection
).limit(5) // modifier
读操作会返回一个cursor对象,通过该迭代器的hasNext()
和next()
进行操作。下面对criteria、projection和modifier分别进行更加细致的讨论。
# Criteria
在一个criteria里,每个键是一个需要筛选的文档所包含的键。文档筛选采用的是由MongoDB提供的操作符,通常以$开头,作为筛选键的值object里的键。criteria包含的操作符主要分为比较,逻辑运算,eval运算,元素存在等。
- 比较运算:包含
$gt
,$gte
,$eq
等。例子:db.users.find({age: {$lt:30, $gte:20}})
表示查找age为[20, 30)的用户。 - 逻辑运算:包含
$and
,$or
,nor
等,用一个array作为条件的值。例子:db.users.find({$and: [{ age: {$gt: 20} }, { gender: "m" }]})
。可用于查找20岁以上的男性用户。 - Eval运算:可处理字符串、文档内字段对比等情况。由于包含的方法较为复杂,在下面分点说明:
$expr
:用于生成表达式,可做字段之间的对比。例子:db.budget.find({$expr: { $gt: {"$spent", "$budget"} } })
。需要注意的是在这里,文档内的字段需要使用$表示,否则只是一个值。$regex
:用于查找字符串匹配。例如db.tweets.find({topic: {$regex: '(?i)nosql'}})
表示查询正则表达式为不区分大小写的NoSQL是否存在于topic字段。
- 元组存在:
$exists
,通常用于查找存在某些字段的文档。例子:db.users.find({name: {$exists: true}})
。 - Array查询:列表的操作较多,主要分为查找包含、按索引查找及查找Array的聚合结果。
- 包含:直接通过
field: {filter}
进行查找。 - 按索引:通过
field.{index}: {filter}
进行查找,{index}
为数字,从0开始。如果查询的索引超过Array的长度,则该文档不返回。 - 查询聚合:通过各类聚合函数如
$size
,$
等进行匹配,。
- 包含:直接通过
- 嵌入文档查询:MongoDB同样提供基于嵌入文档的查询。查询方式主要有两种。其一是通过文档嵌套的方式
{field: {sub_field_1: val1, sub_field_2: val2, ...}}
的方式,其二是通过点分隔键{field.sub_field: value}
的方式。
# Projection
使用1/0或true/false表示字段是否在结果中返回,例如{_id:0, id:true}
表示不返回MongoDB生成的id,而返回通过应用定义的id。
# Modifier
Modifier可以修改返回的cursor给出的结果,其返回的也是一个cursor。
limit:用于限制返回数量,使用一个整型数字明确。
skip:通常用于分页结果,同样使用整型数字。
sort:用于排序返回的文档,使用js的object明确,键表示字段,值(1或-1)表示排序的方向,靠前的键优先判断。如
{age:-1, register_time:1}
表示按年龄降序、注册时间升序排序。sort支持使用索引,对于单一索引sort始终支持;对于复合索引,一种情况是sort的顺序需要与索引完全一致或者完全反向才能使用该索引,另一种情况是当高级别(创建索引时靠前,如createIndex({a:1,b:1})
)的键在find中通过相等关系查找时,如find({a:5}).sort({b:1})
。关于索引请参考下一章。foreach:传入一个带有一个参数的函数,参数表示文档,用于执行与每个文档相关的指令。
# 写操作
与SQL类似,MongoDB的写操作主要分为三类,插入、修改与删除。另一方面,由于MongoDB的写操作是基于文档而非整个表的,其写操作都被分为了单个文档与多个文档的操作。因此,对于单文档的写操作是原子的。
# 插入
插入文档与find
类似,采用的是collection上的insert
类方法。事实上,自MongoDB的较新版本(4.*)开始,官方推荐使用insertOne
或insertMany
。插入操作使用object表示插入的文档。在文档中,如果没有包含_id
字段,则MongoDB会自动为其添加该字段,使用的类型为ObjectID
,及前文中提到的与机器信息和时间有关的字段。一个插入的例子如下:
db.users.insertOne({
_id: 54321, // 此处指明了_id,MongoDB不新生成ObjectID。
name: “Mary Sharp”,
emails: ["mary@gmail.com", "mary_sharp@foxmail.com"],
age: 27,
address: {
number: 1,
name: “cleveland street”,
suburb: “chippendale”,
zip: 2008
}
})
# 修改
修改操作一般包含两步,查找与修改。其中查找采用的是与读操作相同的criteria object。修改部分同样使用object,Mongo为此提供了另一套操作符,包括修改单一域的$set
,$unset
和修改Array的$push
, $pull
和$pullAll
。以下是部分使用的例子:
db.users.updateMany(
{age: {$gt:60}}, // 查找年龄大于60的用户
{
$set: {subsidy: 200},
$unset: {company: 1}
} // 将subsidy设置为200,移除company
)
db.users.updateOne(
{name: "Nick"}, // 查找用户Nick
{
$push: {emails: {$each: ["nick@foxmail.com", "nick@gmail.com"]}, zip_code: 2002}, // 使用$each添加多个值
$pull: {activities: "t1-56"}, // 移除一个值
$pullAll: {pending: ["t1-57", "t1-58"]}, // 移除多个值,注意移除与查询中值的顺序有关,若pullAll值为空则不产生任何改变
}
)
# 删除
删除使用的是delete类指令,包括deleteMany()
和deleteOne()
。参数是用于筛选的criteria object。因此若指明criteria object,则会按照查询结果删除;若不使用参数,则分别会删除全部文档和第一个文档。以下是使用的例子:
db.users.deleteMany() // 删除collection中的所有文档
db.users.deleteMany({age:{$lte:18}}) // 删除筛选到的大于18岁的用户的文档
db.users.deleteOne({name: "Nick"}) // 删除名字为Nick的用户文档
# 值得注意的Null类型
MongoDB提供对null类型的支持,对于Null的解释取决于Null出现的地方,可能表示的类型有域存在但没有值、域不存在或两者均有。以下是关于Null的一些值得注意的地方:
- Null与空字符串不同,空字符串仍然表示一个值;
- 使用
$exists
与null,可以检查域是否存在; - 使用null可以同时查找不存在域或域值为null的文档;
# 事务
MongoDB虽然支持以事务的方式读写数据,但通常其建议的做法是在一个文档内处理数据,因为一个文档的读写始终具有原子性,且相互之间独立。另外,由于MongoDB可以分布式运行,分布式事务如何成功就成为了一点重要的考量。因此,在类似MongoDB这样的分布式文档型数据库中,相较于追求强一致性的ACID事务,一种更为流行的一致性方案是BASE(基本可用最终一致性)。这种方案不保证在其执行期间数据是中一致,但强调在结果上能够保证一致性。这种方案更加适合一些对即时的数据一致性相对不敏感的应用。
# 总结
在这一章中,我们了解了文档型数据库的数据组织方式及关系的建模方法并了解了读写的查询编写。下一章中,我们将探索MongoDB的第二种查询方法,聚合(Aggregation)。