MongoDB保姆级指南(上):七万字从零到进阶,助你掌握又一款强大的NoSQL!

引言

在早期的程序开发过程中,单体+MySQL足以应对业务需求,可随着业务不断发展,系统架构演变成分布式,而数据存储的需求亦是多样化,单纯依赖于MySQL或其他某一款关系型数据库,再也难以支撑系统的存储需求,于是,现如今一个复杂系统中,可能会牵扯到MySQL、Redis、ES、MongoDB、FastDFS、CDH......多种数据存储产品,根据不同的业务特性、响应要求,会将数据存储在不同组件中以满足系统需要。

在之前的文章中,关于数据存储这个方向,咱们用《全解MySQL专栏》深入剖析了MySQL这个关系型数据库,也单开过《Redis综述篇》来讲述Redis这个非关系型数据库,可是数据存储太过庞大,表现优异的产品还有很多,本文咱们聊聊MongoDB

MongoDB是数据库家族中的一员,是一款专为扩展性、高性能和高可用而设计的数据库,它可以从单节点部署扩展到大型、复杂的多数据中心架构,也能提供高性能的数据读写操作;而且提供了数据复制、无感知的故障自动选主等功能,从而实现数据节点高可用。

MongoDB并不是一款关系型数据库,而是一款基于"分布式存储 "的非关系型数据库(NoSQL),由C++编写而成。它与Redis、Memcached这类传统NoSQL不同,MongoDB具有半结构化特性,啥意思呢?下面仔细聊聊。

声明:相较于以往技术文,本篇内容不算特别有趣,如若各位小伙伴有足够的摸鱼时间,可以玩玩作为技术储备;反之,如果目前时间不算充裕,可以收藏等到往后有所需要时再回来阅读~

关于MongoDB相关的文章,总共会分为上中下三篇,同时为了屏蔽语言之间的差异,前面两篇都会使用MongoDB原生的语法进行阐述,为此,无论你是Java,还是PHP、Node、Go、Python、C#......任何语言的开发者,都可以畅通无阻般阅读!

PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职......,为大家打造了一套"从求职到跳槽"的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,感兴趣的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!

一、MongoDB快速入门

MongoDB的数据存储格式非常松散,采用"无模式、半结构化"的思想,通过BSON格式来存储数据。

BSON的全称为Binary JSON,翻译过来是指二进制的JSON格式,在存储和扫描效率高于原始版JSON,但是对空间的占用会更高。同时,BSONJSON基础之上,多加了一些类型及元数据描述,具体可参考《MongoDB官网-BSON类型》

之所以说MongoDB具有半结构化特性,这是由于BSON拥有着JSON的特性,JSON天生具备结构性,可以便捷的存储复杂的结构数据。但是Mongo的数据结构又特别灵活,没有关系型数据库那种"强结构化"的规范。

强结构化:所有数据入库前,库中必须先定义好结构,并且入库的数据,每个值要与定义好的字段一一对应,当结构发生变更时,再按原有结构插入数据则会报错,必须同步修改插入的数据,使用起来特别"繁琐"。

MongoDB中用BSON来存储数据,而BSON是一种文档,所以它也被称之为:文档型数据库 ,文档由一或多个K-V键值对组成,其中的Key只能由字符串表示,值则可以是任意类型,如数组、字符串、数值......。简单来说,你可以把MongoDB中的一个文档,看做成一个JSON对象。

上面提到"文档"这个概念,初次接触的伙伴或许会疑惑,这里我们与MySQL数据库对比一下,其实两者的结构很类似,如下:

MySQL MongoDB
数据库(DataBase 数据库(DataBase
数据表(Table 数据集合(Collection
数据行(Row 数据文档(Document
列/字段(Column 字段(Field
索引(Index 索引(Index

MongoDB中的一个集合,对应MySQL中的一张表;一个文档对应着MySQL一行数据......,层级结构的理念相同,只不过叫法和细节不同。当然,光看概念也很难帮大家认识Mongo,接下来快速搭建下环境,实操理解的效果更佳。

1.1、MongoDB环境搭建

MongoDB分为免费社区版、收费企业版,虽说前者功能有所阉割,但好在免费,并且可以满足大多数项目需求,这里咱们先去MongoDB官网-社区版选择对应的操作系统、版本下载安装包,同Redis一样,版本号的第二位数,为奇数代表开发版,为偶数代表是稳定版,我这里选择下载CentOS7-x64-6.0.8-tgz版的安装包。

❶创建MongoDB目录,并通过工具上传安装包至服务器(或虚拟机):

shell 复制代码
[root@~]# mkdir /soft && mkdir /soft/mongodb/
[root@~]# cd /soft/mongodb/

如果不想这么麻烦,也可以用wget命令在线拉取安装包:

shell 复制代码
[root@~]# wget https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel70-6.0.8.tgz

没有wget命令,可以通过yum安装一下:

shell 复制代码
[root@~]# yum -y install wget

❷解压相应目录下的安装包,并重命名解压后的目录:

shell 复制代码
[root@~]# tar -xvzf mongodb-linux-x86_64-rhel70-6.0.8.tgz
[root@~]# mv mongodb-linux-x86_64-rhel70-6.0.8 mongodb6.0.8

由于我们下载的是压缩包,属于开箱即用版,无需任何额外配置。但为了方便启动,咱们可以将bin目录拷贝出来:

shell 复制代码
[root@~]# cp -a /soft/mongodb/mongodb6.0.8/bin /soft/mongodb/bin

❸创建单机版MongoDB存储配置文件、数据、日志的目录:

shell 复制代码
[root@~]# mkdir /soft/mongodb/data && 
          mkdir /soft/mongodb/log && 
          mkdir /soft/mongodb/conf && 
          mkdir /soft/mongodb/data/standalone && 
          mkdir /soft/mongodb/log/standalone && 
          mkdir /soft/mongodb/conf/standalone

❹编写MongoDB核心配置文件(没有会自动创建):

shell 复制代码
[root@~]# vi /soft/mongodb/conf/standalone/mongodb.conf

这里大家可以直接复制下述yaml配置,配置的具体含义可参考注释(也支持传统.conf形式配置):

shell 复制代码
# MongoDB日志存储相关配置
systemLog:
    # 将所有日志写到指定文件中
    destination: file
    # 记录所有日志信息的文件路径
    path: "/soft/mongodb/log/standalone/mongodb.log"
    # 当服务重启时,将新日志以追加形式写到现有日志尾部
    logAppend: true
# 指定MongoDB存储数据的目录
storage:
    dbPath: "/soft/mongodb/data/standalone"
# 以后台进程方式运行MongoDB服务
processManagement:
    fork: true
# ------ MongoDB网络相关配置 ------
net:
    # 绑定服务实例的IP,默认是localhost,0.0.0.0表示任意IP(线上换成具体IP)
    bindIp: 0.0.0.0
    #绑定的端口,默认是27017
    port: 27017

复制时记住把数据、日志的目录,改成自己前面新建的,接着按下Esc,输入:wq保存退出。

❺启动Mongo服务,这里和Redis类似,都是以启动脚本+配置文件的方式:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -f /soft/mongodb/conf/standalone/mongodb.conf

看到successfully表示启动成功,大家也可以通过ps命令来查看后台进程,确保正常启动:

shell 复制代码
[root@~]# ps aux | grep mongod

❻连接MongoDB测试是否可以正常使用,不过下述命令只适用于6.x以下:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongo -port 27017

到了MongoDB6.x版本,官方不再默认内置Shell客户端工具,想要使用则需额外下载:官方Shell工具,这里咱们照样以wget方式下载:

shell 复制代码
[root@~]# wget https://downloads.mongodb.com/compass/mongosh-1.10.3-linux-x64.tgz

接着将其解压,并拷贝bin目录下的Shell工具,放到原本的bin目录中:

shell 复制代码
[root@~]# tar -zxvf mongosh-1.10.3-linux-x64.tgz
[root@~]# cp /soft/mongodb/mongosh-1.10.3-linux-x64/bin/mongosh /soft/mongodb/bin/

然后再通过mongosh来连接测试:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongosh -port 27017
test> show dbs;

如果上述操作执行完成后,能正常显示三个默认库,说明MongoDB安装成功。

❼关闭MongoDB服务,这里有三种方式:

shell 复制代码
# 1.通过启动项来关闭
[root@~]# /soft/mongodb/bin/mongod --shutdown -f /soft/mongodb/conf/standalone/mongodb.conf

# 2.先通过客户端连接MongoDB服务,再进入admin库关闭(执行完后客户端会报连接出错)
[root@~]# /soft/mongodb/bin/mongosh -port 27017
test> use admin;
test> db.shutdownServer();

# 3.通过kill命令+进程号强杀进程
[root@~]# kill -9 [pid]

一般推荐使用前面两种,因为kill强杀进程,可能会导致数据损坏,如果损坏则需手动修复:

shell 复制代码
# 先删除数据目录下的所有锁文件
[root@~]# rm -rf /soft/mongodb/data/standalone/*.lock
# 再通过启动项对数据目录进行修复
[root@~]# /soft/mongodb/bin/mongod  --repair --dbpath=/soft/mongodb/data/standalone

❽单纯的命令行不便于操作,所以通常会使用可视化工具来操作,而MongoDB官方提供了一个可视化工具:MongoDB Compass,可以先点击>>官网下载地址<<,直接下载解压即用的ZIP包。

当然,其实官方这款工具并不算好用,如果你有Navicat Premium(黄色图标那款),可以直接通过Navicat来连接MongoDB操作。

❾为了外部可以连接,需要开放MongoDB端口、更新防火墙(关闭防火墙也行):

shell 复制代码
[root@~]# firewall-cmd --zone=public --add-port=27017/tcp --permanent
[root@~]# firewall-cmd --reload
[root@~]# firewall-cmd --zone=public --list-ports

开放端口后,就可以通过可视化工具来连接操作了(如果你通过Navicat工具连接,是看不到三个默认库的)。

1.2、MongoDB增删改查

搭建好MongoDB的环境后,接着学习一下最基本的CRUD操作,首先声明:MongoDB中的多数操作都带有隐式创建特性,啥意思呢?好比你使用一个数据库,如果没有则会自动创建;使用一个集合时,没有也会自动创建......

这里咱们先创建一个数据库和集合,如下:

JavaScript 复制代码
// 切换到zhuzi库,没有会自动创建
use zhuzi;
// 向xiong_mao集合插入一条数据(没有集合会自动创建)
db.xiong_mao.insert({_id:1});

Python一样,最后的分号可写可不写,不过建议写上,以此增加代码的可读性。

现在继续往xiong_mao集合中插入两条数据:

JavaScript 复制代码
db.xiong_mao.insert({_id:2, name:"肥肥", age:3, hobby:"竹子"});
db.xiong_mao.insert({name:"花花", color:"黑白色"});

此时大家会发现,这两条数据同样可以插入成功,前面咱们并没有像使用MySQL一样,先定义好表结构!从这里就印证了一开始所说的"无模式"特点,接着学学查询:

JavaScript 复制代码
// 查询xiong_mao集合的所有数据
db.xiong_mao.find();

// ------- 返回结果 ----------
[
  { _id: 1 },
  { _id: 2, name: '肥肥', age: 3, hobby: '竹子' },
  {
    _id: ObjectId("64d0b843fa3b00006f0044d6"),
    name: '花花',
    color: '黑白色'
  }
]

注意观察第三条数据,在插入时我们并未指定_id值,可为啥还是有这个字段呢?因为_idmongoDB的默认字段,每个文档(每行数据)必须要有该字段,如果插入时未指定该字段,会自动生成一个类似于雪花IDObjectId值,mongoDB会使用_id来维护数据的存储结构(类似于InnoDB的隐藏列-row_id,后面再细聊)。

如何根据指定条件查询数据呢?如下:

JavaScript 复制代码
// 查询_id=2的数据
db.xiong_mao.find({_id:2});
// 查询name=花花的数据
db.xiong_mao.find({name:"花花"});

大家会发现,前面我们以Json形式将数据插入到集合后,在查询时,咱们可以直接通过Json中的字段名来操作,这也就印证了最开始所说的"半结构化"特征,因为像Redis、Memcached这类NoSQL,虽然也能把数据Json序列化后存进去,但想要操作时,只能先读出来再反序列化成对象,不能直接对序列化后的Json字符串进行操作!

最后再来学习一下修改、删除操作:

JavaScript 复制代码
// 将_id=2的数据,爱好修改为:吃竹子、睡觉
db.xiong_mao.update({_id:2},{hobby:"吃竹子、睡觉"});
// 上面那种方式,会导致其他字段变null,要记得加{$set:},表示局部修改
db.xiong_mao.update({_id:2},{$set:{hobby:"吃竹子、睡觉"}});

// 删除_id=1的数据
db.xiong_mao.remove({_id:1});

OK,我们过了一下最简单的CRUD操作,这里主要是让大家先熟悉下语法,诸位也可以自行多练习一下。

相较于SQL的语法来说,MongoDB的语法显得有点反人类;反观同为NoSQLRedis,它的命令则显得简洁清爽许多。当然,熟悉JS的小伙伴,接触这个语法不算太难,毕竟这就是JS的语法,如果只是纯后端的开发者,想适应过来需要一定的练习。

1.3、库与集合的命令

熟悉了基本的CRUD语法后,进阶一点的语法咱们放到后面慢慢学,这里先熟悉下常用的库、集合的命令:

  • 查询所有数据库:show dbs;show databases;
  • 切换/创建数据库:use 库名;
  • 查看目前所在的数据库:db;
  • 查看MongoDB目前的连接信息:db.currentOp();
  • 查看当前数据库的统计信息:db.stats();
  • 删除数据库:db.dropDatabase("库名");
  • 查看库中的所有集合:show collections;show tables;
  • 显式创建集合:db.createCollection("集合名");
  • 查看集合统计信息:db.集合名.stats();
  • 查看集合的数据大小:db.集合名.totalSize();
  • 删除指定集合:db.集合名.drop();

上述这些管理数据库、集合的命令只需简单了解,毕竟大多情况下,都可以通过可视化工具来完成这些操作,所以下面开始学习MongoDB中的文档操作命令。

二、MongoDB渐悉

下次再次从CRUD操作开始,对MongoDB的命令建立全面认知,先来看看新增/插入操作。

2.1、新增操作

MongoDB中提供了三个显式向集合插入数据的方法:

JavaScript 复制代码
// 向集合插入单条或多条数据(需要用[]来包裹多个文档)
db.xiong_mao.insert([
    {_id:4, name:"黑熊", age:3, food:{name:"黄金竹", grade:"S"}},
    {_id:5, name:"白熊", age:4, food:{name:"翠绿竹", grade:"B"}}
]);

// 向集合插入单条数据
db.xiong_mao.insertOne({_id:6, name:"棕熊"});

// 向集合批量插入多条数据
db.xiong_mao.insertMany([
    {_id:7, name:"红熊", age:2, food:{name:"白玉竹", grade:"S"}},
    {_id:8, name:"粉熊", age:6, food:{name:"翡翠竹", grade:"A"}}
]);

从案例中可发现,每个字段可以无限嵌套json。同时,这三个insert方法都有两个可选项,如下:

JavaScript 复制代码
db.collection.insertXXX(
   [ <document 1> , <document 2>, ... ],
   {
      writeConcern: <document>,
      ordered: <boolean>
   }
)

writeConcern表示嵌套文档,这个咱们后续再细说;ordered表示本次是否按顺序插入,2.6以上版本默认为true,表示按指定的顺序插入数据。

除开上面三个常用的插入方法外,MongoDB还可以通过其他方式实现数据新增,如:

JavaScript 复制代码
const bulkOps = [
  {insertOne: {document: {_id: 9, name: "黄熊"}}},
  {insertOne: {document: {_id: 10, name: "灰熊"}}}
];
db.xiong_mao.bulkWrite(bulkOps);

bulkWrite()方法可以传入一个操作数组,MongoDB会按照给定的操作顺序依次执行,其中还可以放修改、删除等MongoDB支持的操作。最后,还能通过修改命令,向集合中隐式插入数据,在使用修改命令时,将upsert设为true即可(后面会演示)。

2.2、删除操作

MongoDB中的删除命令,有remove、delete两类方法:

JavaScript 复制代码
// 根据指定条件删除集合中的数据
db.xiong_mao.remove({_id:1});

// 删除集合中的所有数据(不写条件即可)
db.xiong_mao.remove({});

// 根据条件删除单条数据(有多条数据满足条件时,只会删第一条)
db.xiong_mao.deleteOne({_id:6, name:"棕熊"});

// 根据条件删除多条数据(满足条件的全部删除)
db.xiong_mao.deleteMany({name:"红熊"});

// 根据条件删除满足条件的第一条数据,并返回删除后的数据
db.xiong_mao.findOneAndDelete({_id:2});

当然,这里只列出了根据等值条件删除的操作,类似于SQL中的<、>、in、or、and......怎么写呢?这些放到后续的查询操作中讲解,因为条件过滤器在MongoDB中是通用的。

2.3、修改操作

想要更新一个集合中的数据时,可以使用update、replace操作:

JavaScript 复制代码
// 根据条件修改集合中的单条数据(多条数据满足条件时,只修改第一条)
db.xiong_mao.updateOne({_id:7}, {$set:{name:"英熊"}});

// 根据条件修改集合中的多条数据(color字段不存在时,自动创建并赋值)
db.xiong_mao.updateMany({age:3}, {$set:{color:"黑白色"}});

// 根据条件替换掉单行数据(使用新数据替换老的数据)
db.xiong_mao.replaceOne({_id:6}, {name:"棕熊", age:3, color:"棕色"});

// 根据条件修改集合中的单条数据(可以通过.分割的形式,修改嵌套的json)
db.xiong_mao.update({_id:8}, {$set:{age:3, "food.grade":"C"}});

// 根据条件修改集合中的单/多条数据(如果想通过update更新多条,需要使用multi)
db.xiong_mao.update({age:3}, {$set:{hobby:"竹子"}},{multi:true});

修改操作默认是全量修改,即使用指定的值,覆盖原有的整条数据,如果有些字段值在修改时未给定,则会变为null(消失)。为此,如若只想修改某几个字段,记得在前面加上$set选项。

当然,这里也仅是用_id在做等值条件修改,后面讲完查询操作后,大家可以自行套入各种复杂条件进行修改。

还记得提过的"修改时隐式新增"嘛?演示一下:

JavaScript 复制代码
db.xiong_mao.update(
	{_id:11}, 
	{name:"紫熊", age:3, food:{name:"明月竹", grade:"A"}}, 
	{upsert:true}
);

在修改时,只需指定upsert:true即可,这样在未匹配到对应条件的数据时,会将本次修改的数据插入到集合中,而前面演示的所有修改方法,都支持指定upsert选项。

修改操作除开上述方法外,还可以通过findAndModify()、findOneAndUpdate()、findOneAndReplace()方法来修改,如:

JavaScript 复制代码
db.xiong_mao.findAndModify({
  query:{_id:8},
  update:{$set:{color: "紫色"}},
  new: true
});

这些findAnd开头的修改方法,会根据条件修改满足条件的第一条数据,修改完之后,如果new指定为true,则会返回修改后的数据,如果为false,则返回修改前的数据(其余两个方法类似,不再继续演示)。

2.4、查询操作

增删改查的前三项过完了,接着来说说查询操作,这应该属于最复杂的一项,咱们一点点开始接触。

2.4.1、基本查询语句

JavaScript 复制代码
// 查询集合所有数据
db.xiong_mao.find();

// 根据单个等值条件查询数据
db.xiong_mao.find({_id:2});

// 查询满足多个等值条件的数据(and查询)
db.xiong_mao.find({age:3, "food.grade":"S"});

// 查询满足任意一个条件的数据(or查询)
db.xiong_mao.find({$or: [{age:2}, {"food.grade":"S"}]});

// 查询单字段满足任意一个条件的数据(in查询)
db.xiong_mao.find({age:{$in: [2, 4]}});

// 查询颜色为黑白色,并且年龄小于5岁的数据
db.xiong_mao.find({color:"黑白色", age:{$lt: 5}});

// 查询爱好为竹子,或id大于9的数据
db.xiong_mao.find({$or: [{hobby:"竹子"}, {_id: {$gt:9}}]});

// 查询id小于等于5,并且(age大于等于2 或 name为肥肥)的数据
db.xiong_mao.find({
	_id:{$lte:5}, 
	$or: [{age: {$gte:2}}, {name:"肥肥"}]
});

// 查询name不为"白熊" 或 age 不大于 2 的数据
db.xiong_mao.find({$nor: [{name:"白熊"}, {age:{$gt:2}}]});

// 查询id在3~5之间的数据(between and范围查询)
db.xiong_mao.find({$and: [{_id:{$gte:3}}, {_id:{$lte:5}}]});

// 查询name以"粉"开头的数据(like右模糊查询)
db.xiong_mao.find({name:/^粉/});

上述一些查询语句,对应着SQL中的基本查询,如=、<、>、<=、>=、in、like、and、or,大家仔细观察会发现,其中有许多$开头的东东,这个是啥?在MongoDB中称之为操作符,操作符有许多,分别对应着SQL中的关键字与特殊字符,下面列写常用的:

SQL MongoDB
= :
=、<、>、<=、>=、!= $eq、$lt、$gt、$lte、$gte、$ne
in、not in $in、$nin
and、or、not、is null $and、$or、$not、$exists
+、-、*、/、% $add、$subtract、$multiply、$divide、$mod
group by、order by $group、$sort
...... ......

上表仅仅只列出了一些在SQL中比较常见的,实则MongoDB提供了几百个操作符,以此来满足各类场景下的需求,如果你想要详细了解,可以参考MongoDB官网-Operators,当然,如果你英语阅读能力欠佳,可以参考MongoDB中文网-运算符

接着来看看其他查询的语法:

JavaScript 复制代码
// 对指定字段去重,并返回去重后的字段值列表
db.xiong_mao.distinct("age");

// 根据条件统计集合内的数据行数
db.xiong_mao.count({age:3});

// 根据条件进行分页查询(limit写行数,skip写跳过前面多少条)
db.xiong_mao.find({_id:{$lt:6}}).skip(0).limit(2); // 第一页
db.xiong_mao.find({_id:{$lt:6}}).skip(2).limit(2); // 第二页

// 根据指定字段进行排序查询(order by查询)
// 根据年龄升序,年龄相同根据id降序(1:升序,-1:降序)
db.xiong_mao.find().sort({age:1, _id:-1});

// 投影查询:只返回指定的字段(0表示不返回,1代表返回)
db.xiong_mao.find({color:"黑白色"}, {_id:0, name:1, color:1});

这里又列出了一些SQL中经常执行的操作,如统计、去重、排序、分页、投影查询等,和SQL一样,不同类型的函数,执行的优先级不一样,例如:

JavaScript 复制代码
db.xiong_mao.find({_id:{$lt:6}}).limit(2).skip(2).sort({_id:-1}).count();

这条查询语句中,包含了limilt()、skip()、sort()、count()四个方法,可实际的执行顺序,跟书写的顺序无关,这些方法同时存在时,执行的优先级为:sort() > skip() > limilt() > count()

2.4.2、聚合管道查询

好了,如果你想要实现更复杂的查询操作,则可以通过MongoDB提供的聚合管道来完成,语法如下:

JavaScript 复制代码
db.collection.aggregate(
   pipeline: [ <stage>, <...> ],
   options: {
     explain: <boolean>,
     allowDiskUse: <boolean>,
     cursor: <document>,
     maxTimeMS: <int>,
     bypassDocumentValidation: <boolean>,
     readConcern: <document>,
     collation: <document>,
     hint: <string or document>,
     comment: <any>,
     writeConcern: <document>,
     let: <document> // Added in MongoDB 5.0
   }
);

聚合管道方法接收两个入参,第一个是数组型的聚合操作,第二个是可选项,先解释下常用的选项:

  • explain:传true表示返回聚合管道的详细执行计划;
  • allowDiskUse:是否允许使用硬盘进行聚合操作,内存不足时使用磁盘临时文件进行计算;
  • maxTimeMS:指定聚合操作的最大执行时间(单位ms),超时会被强制终止;
  • hint:显式指定使用那些索引进行聚合操作;

简单了解几个常用选项后,我们来重点关注一下pipeline参数,使用方式如下:

JavaScript 复制代码
db.collection.aggregate([
  // 阶段1
  {$stage1: { /* 阶段1的操作 */ }},
  // 阶段2
  {$stage2: { /* 阶段2的操作 */ }},
  // ...
]);

观察上述语法,首先咱们需要指定操作符,这是为了声明当前阶段的类型,例如$group,接着可以指定每个阶段具体要做的事情。同时,聚合管道中的每个阶段,都会将上一阶段输出的数据,视为当前阶段输入的数据,和Stream流类似,下面上些例子理解,如下:

JavaScript 复制代码
/* 按年龄进行分组,并统计各组的数量(没有age字段的数据统计到一组) */
db.xiong_mao.aggregate([
    // 1:通过$group基于age分组,通过$sum实现对各组+1的操作
    {$group: {_id:"$age", count: {$sum:1}}},
    // 2:基于前面的_id(原age字段)进行排序,1代表正序
    {$sort: {_id:1}}
]);

/* 按年龄进行分组,并得出每组最大的_id值 */
db.xiong_mao.aggregate([
    // 1:先基于age字段分组,并通过$max得到最大的id,存到max_id字段中
    {$group: {_id:"$age", max_id: {$max:"$_id"}}},
    // 2:按照前面的_id(原age字段)进行排序,-1代表倒序
    {$sort: {_id: -1}}
]);

/* 过滤掉食物为空的数据,并按食物等级分组,返回每组_id最大的熊猫姓名 */
db.xiong_mao.aggregate([
    // 1:通过$match操作符过滤food不存在的数据
    {$match: {food: {$exists:true}}},
    // 2:通过$sort操作符,基于_id字段进行倒排序
    {$sort: {_id: -1}},
    // 3:通过$group基于食物等级分组,并通过$max得到_id最大的数据,
    // 并通过$first拿到分组后第一条数据(_id最大)的name值
    {$group: {_id:"$food.grade", max_id: {$max:"$_id"}, name:{$first: "$name"}}},
    // 4:最后通过$project操作符,只显示_id(原food.grade)、name字段
    {$project: {_id:"$_id", name:1}}
]);

上面举了三个简单的聚合操作例子,但相信对于绝大多数刚接触的小伙伴来说,这些语句看起来十分头大,一眼望过去全都是符号+符号,这是因为MongoDB中,使用操作符代替了SQL中的函数与关键字,所以越是复杂的场景,使用到的操作符越多,对传统型的SQL Boy来说,可读性会越差~

这里为了便于大家理解,先列出MongoDB聚合管道操作符和SQL聚合关键字/函数的对比:

SQL关键字/函数 MongoDB聚合操作符
where、having $match
group by $group
order by $sort
select field1、field2... $project
limit $limit、$skip
sum()、count()、avg()、max()、min() $sum、$sum:1、$avg、$max、$min
join $lookup

当然,上面列出的仅是沧海一粟,MongoDB的聚合管道中可用的操作符有几百个,不管是SQL中有的,还是没有的功能/函数,都能找到对应的操作符代替,具体大家可以参考MongoDB官网-Aggregation Pipeline Operators,或MongoDB中文网-聚合管道操作符

话归正题,咱们来稍微解读一下前面写出的聚合管道查询语句,先拿最简单的第一条来说:

JavaScript 复制代码
/* 按年龄进行分组,并统计各组的数量 */
db.xiong_mao.aggregate([
    {$group: {_id:"$age", count: {$sum:1}}},
    {$sort: {_id:1}}
]);

在第一阶段中,使用$group操作符来完成分组动作,其中_id并不是原数据中的_id,而是代表想聚合的数据主键,上面的需求想基于年龄分组,所以_id设置为$age即可。

注意:在聚合查询中,也会使用$来引用原数据中的字段,案例中的$age并不是一个操作符,而是获取原数据的age字段,这种方式也可以用来拿其他字段,如$name、$color......

在第一阶段中,咱们还定义了一个count字段,这相当于SQL中的别名,该字段的值,则是由{$sum:1}统计得出的,这段逻辑相当于:

java 复制代码
Map<Integer, Integer> groupMap = new HashMap<>();
for (XiongMao xm : xiongMaoList) {
    Integer age = xm.getAge();
    if (groupMap.get(age) == null) {
        groupMap.put(age, 1);
    } else {
        Integer count = groupMap.get(age) + 1;
        groupMap.put(age, count);
    }
}

第一阶段的分组统计工作完成后,接着会来到第二阶段,其中使用了$sort操作符,对_id字段进行了升序排序,不过这里要注意,因为第二阶段输入的数据,是第一阶段输出的数据,所以第二阶段的_id,实际上是原本的age字段,这意味着在对年龄做升序排序!

OK,相信经过这番解释后,大家对MongoDB的聚合管道操作有了一定理解,那接着继续看些案例加深印象(也可以尝试着自己练习练习):

JavaScript 复制代码
/* 多字段分组:按食物等级、颜色字段分组,并求出每组的年龄总和 */
db.xiong_mao.aggregate([
    // 1:_id中写多个字段,代表按多字段分组,接着通过$sum求和age字段
    {$group: {_id: {grade:"$food.grade", color:"$color"}, total_age: {$sum:"$age"}}}
]);

/* 分组后过滤:根据年龄分组,然后过滤掉数量小于3的组 */
db.xiong_mao.aggregate([
    // 1:先按年龄进行分组,并通过$sum:1对每组数量进行统计
    {$group: {_id: "$age", count: {$sum:1}}},
    // 2:通过$match操作符,保留数量>3的分组(过滤掉<3的分组)
	{$match: {count: {$gt:3}}}
]);

/* 分组计算:根据颜色分组,求出每组的数量、最大/最小/平均年龄、所有姓名、首/尾的姓名 */
db.xiong_mao.aggregate([
    // 1:按颜色分组
    {$group: {_id: "$color", 
        // 计算每组数量
        count: {$sum:1}, 
        // 计算每组最大年龄
        max_age: {$max: "$age"},
        // 计算每组最小年龄
        min_age: {$min: "$age"},
        // 计算每组平均年龄
        avg_age: {$avg: "$age"},
        // 通过$push把每组的姓名放入到集合中
        names: {$push:"$name"},
        // 获取每组第一个熊猫的姓名
        first_name: {$first: "$name"},
        // 获取每组最后一个熊猫的姓名
        last_name: {$last: "$name"}}}
]);

/* 分组后保留原数据,并基于原_id排序,然后跳过前3条数据,截取5条数据 */
db.xiong_mao.aggregate([
    // 1:先基于color分组,并通过$$ROOT引用原数据,将其保存到数组中
    {$group: {_id: "$color", xiong_mao_list: {$push: "$$ROOT"}}},
    // 2:通过$unwind操作符,将xiong_mao_list数组分解成一条条数据
    {$unwind: {path:'$xiong_mao_list', 
        // 使用index字段记录数组下标,preserveNullAndEmptyArrays可以保证不丢失数据
        includeArrayIndex:"index", preserveNullAndEmptyArrays: true}},
    // 3:基于分解后的_id字段进行排序,1代表升序
    {$sort: {"xiong_mao_list._id": 1}},
    // 4:通过$skip跳过前3条数据
    {$skip: 3},
    // 5:通过$limit获取5条数据
    {$limit: 5}
]);

/* 根据年龄进行判断,大于3岁显示成年、否则显示未成年(输出姓名、结果) */
db.xiong_mao.aggregate([
    // 1:通过$project操作符来完成投影输出
    {$project: {
      // 不显示_id字段,将name字段重命名为:"姓名"
      _id:0, 姓名:"$name",
      // 通过$cond实现逻辑运算,如果年龄>=3,显示成年,否则显示未成年
      result: {
        $cond: {
          if: {$gte: ["$age", 3]},
          then: "成年",
          else: "未成年"
    }}}}
]);

好了,关于聚合管道的案例,暂时就写到这里,想要学习其他操作符的使用,可以参考之前给出的官网链接。这里主要说明几点要素。

首先聚合管道的每个阶段,内存限制一般为100MB,如果超出这个限制,执行时会报错,因此当需要处理大型数据集时,记得把allowDiskUse选项置为true,允许MongoDB使用磁盘临时文件来完成计算。

其次呢,使用聚合管道时也要牢记,筛选、过滤类型的阶段,最好放到前面完成,这样有两个好处:一是可以快速将不需要的文档过滤掉,减少管道后续阶段的工作量;二是如果在$project、$group这类操作前执行$match$match阶段可以使用索引,因为此时属于在操作原本的文档,索引自然不会失效,可如果放到$project、$group......后面再执行,原数据被改变后,前面的阶段会形成新的文档,并输出到$match阶段中,这时索引不一定能继续生效。

最后,按官网所说,除了$out、$merge$geoNear这些阶段外,其他类型的所有阶段,都可以在管道中出现多次,这意味着MongoDB会比传统型SQL更强大,比如可以多次分组、筛选......,结合管道里的几百个操作符,你可以实现各种复杂场景下的聚合操作。

2.4.3、嵌套文档的增删改查

在关系型数据库中,我们可以通过树表、主子表等思想,抽象出各种结构以满足业务需求,举个例子理解,比如下单业务中,用户一笔订单可能会包含多个商品,所以会用主子表的思想,对订单数据进行存储,订单数据存到主表中、订单详情数据放到子表,两表之间通过主外键关联,后续可以通过join来查询、使用。

但由于MongoDB是无模式、半结构化的存储方式,因此无法像传统型数据库那样,通过主外键来关联不同表(集合),毕竟一个集合没有明确的字段定义,可以随意插入不同字段。那MongoDB又该如何满足树表、主子表这种业务的存储需求呢?答案是通过文档嵌套来代替!

在前面学习insert操作时,大家不难得知:MongoDB集合中的每个字段,可以继续嵌套Json对象,因为MongoDB本身就是以Json格式存储数据,所以只要是能用Json格式表达出来的数据,都可以放到集合中存储,比如:

JavaScript 复制代码
db.xiong_mao.insert([
    {_id:12, name:"金熊", age:4, hobby:["吃饭","睡觉"], food:{name:"黄金竹", grade:"S"}}
]);

在插入数据时,字段的值可以放入普通类型,也可以放入数组、另一个Json对象,所以早期的MongoDB,推荐通过嵌入文档,来代替SQL中的join多表连接查询,前面举例提到的订单、订单详情这种一对多的主子关系,可以用如下方式代替:

JavaScript 复制代码
/* 以下数据仅为示例,不要纠结订单数据的不完整性 */
db.order.insert([{
    _id: 1, 
    order_number: "x-20230811185422", 
    order_status: "已付款",
    pay_fee: 888.88, 
    pay_type: "他人代付",
    order_user_id: "2222222",
    pay_user_id:"11111111",
    pay_time: new Date(),
    order_details: [
    	{order_detail_id:1, shop_id:6, shop_price:666.66, status:"待发货"},
    	{order_detail_id:2, shop_id:2, shop_price:222.22, status:"待发货"}
    ]
}]);

这样,当需要查询一笔订单的详情记录时,就直接查询oder集合即可,因为订单详情数据存储在order_details字段中,这个是一个数组,每个数组的元素则是一条条Json格式的订单详情。

下面来聊聊嵌套文档的增删改查,这里跟操作普通文档有些许出入,如下:

JavaScript 复制代码
/* 新增单条订单详情:通过order集合的update方法来实现 */
db.order.update(
    // 修改条件:修改_id=1的订单数据
    {_id: 1},
    // 通过$push操作符,往order_details中压入一条新记录
    {$push: {
      order_details: {
        order_detail_id: 3,
        shop_id: 4,
        shop_price: 444.44,
        status: "待发货"
        }
    }}
);

/* 新增多条订单详情记录 */
db.order.update(
  {_id: 1},
  {$push: {
      order_details: {
        // 这里使用$each操作符,声明本次要push多个元素
        $each: [
          {order_detail_id: 4, shop_id: 3, shop_price: 333.33, status: "待发货"},
          {order_detail_id: 5, shop_id: 1, shop_price: 111.11, status: "待发货"}
        ]
      }
	}}
);

/* 删除满足条件的订单详情数据 */
db.order.update(
  {_id: 1},
  // 通过$pull操作符,将order_details满足条件的数据弹出
  {$pull: {
      // 删除order_detail_id大于3的订单详情数据
      order_details: {order_detail_id: {$gt:3}}
    }
});

/* 修改单条满足条件的订单详情数据 */
db.order.update(
  // 这里给定了两个条件,订单ID=1,并且订单详情ID=1
  {_id: 1, "order_details.order_detail_id": 1},
  // 通过$set操作符,来对集合中的文档进行局部修改
  {$set: {
      // 将状态改为已发货,这里的$,表示数组中的当前元素,即条件匹配的数组元素
      "order_details.$.status": "已发货"
  }}
);

/* 修改多条满足条件的订单详情数据 */ 
db.order.update(
  // 先查询到订单ID=1的数据
  {_id: 1},
  {$set: {
      // 这里使用的是$[elem]占位符,代表着要更新的元素
      "order_details.$[elem].status": "已发货"
  }},
  // 这里定义了一个数组过滤器,会把满足条件的订单详情记录筛选出来
  {arrayFilters: [
        // 这里的elem代表数组中的元素,过滤条件为:订单详情ID=2、3的数据
    	{"elem.order_detail_id": {$in: [2,3]}}
    ]}
);

/* 修改满足多个条件的单条数据 */
db.order.update(
  {
    // 这里是基于order_details字段在做条件过滤,可以跨文档(在不同数据行中筛选)
    order_details: {
      // $elemMatch操作符代表匹配多个条件
      $elemMatch: {
        status: "已发货",
        shop_id: 2
    }}
  },
  // 对满足多个条件的单条数据进行修改(要修改多条,参考上个案例的用法即可)
  {$set: {
      "order_details.$.status": "已签收"
  }}
);

/* 查询符合条件的单条数据 */
db.order.find(
    // 查询order_detail_id==1的订单详情数据
    {"order_details.order_detail_id":1}, 
    // 限制返回的字段,不返回_id,order_details.$表示只返回满足条件的订单详情数据
    {"_id":0, "order_details.$":1}
);

/* 查询满足多个条件的多条数据 */
db.order.aggregate([
    // 使用$project操作符完成投影查询
    {$project: {
      // 不返回_id字段
      _id: 0,
      // 只返回order_details字段
      order_details: {
        // 通过$filter操作符,实现按条件过滤数据
        $filter: {
          // 这里相当于for循环,$order_details是要遍历的数组,$detail是别名
          input: "$order_details",
          as: "detail",
          // 这里是过滤的条件,$and表示两个条件都需要满足
          cond: {
            $and: [
              // 返回order_detail_id>=1 && status为"已签收"的数据
              {$gte: ["$$detail.order_detail_id", 1]},
              {$eq: ["$$detail.status", "已签收"]}
            ]
          }
        }
    }}}
]);

好了,上面把嵌套文档的增删改查操作简单过了一遍,其实嵌入式文档的CRUD,都依赖于外部集合的CRUD方法完成,唯一有区别的地方就在于:我们需要结合$push、$pull、$elemMatch、$[elem]等各种数组操作符,来实现嵌入式文档的增删改查。不过如若只嵌入了一个文档,而不是一个文档数组时,操作的方法有有些不同,如下:

JavaScript 复制代码
// 以之前xiong_mao集合中,id=1的这条数据为例
db.xiong_mao.insert({_id:1, name:"肥肥", age:3, hobby:"竹子"});

// 为其添加一个内嵌文档
db.xiong_mao.update(
	{_id:1},
	{$set: {food: {name:"帝王竹", grade:"A"}}}, 
	{upsert:true}
);

// 查询一次看看这条数据(此时可以看到内嵌文档已经被添加)
db.xiong_mao.find({_id:1});
{
    _id: 1,
    name: '肥肥',
    age: 3,
    hobby: '竹子',
    color: '黑白色',
    food: { name: '帝王竹', grade: 'A' }
}

// 修改内嵌文档中的某个字段值
db.xiong_mao.updateMany(
	{_id:1},
	// 修改单个字段,要用 . 的形式
	{$set: {"food.grade":"S"}}, 
	{upsert:true}
);

// 删除内嵌文档中的单个字段
db.xiong_mao.update(
    {_id:1},
    // 通过$unset操作符,将对应字段置为空即可
    {$unset:{"food.grade":""}},
    {upsert:true}
);

// 删除整个内嵌文档
db.xiong_mao.update(
    {_id:1},
    // 将整个内嵌文档字段置为空即可
    {$unset:{food:""}},
    {upsert:true}
);

ok,关于文档嵌入的内容咱们先就此打住,来思考一个问题:真的所有带有主外键关系的结构,都可以通过文档嵌入代替吗?

这个答案明显是NO,为什么?就拿咱们前面"订单-订单详情"例子中的商品来说,每一笔订单详情记录,都需要跟商品产生绑定关系吧?这时我们总不能把整个商品的数据,全部嵌入到订单详情的某个字段中吧?因为一个商品可以被多次购买!如果真按这样存,假设一个商品单月销量10W+,产生的10W+订单详情记录,难道都把这个商品的完整数据嵌入一次吗?显然不合理。

正因如此,有些场景下,咱们无法通过文档嵌入来代替原本的多表关联,所以MongoDB3.2版本以后,也支持了多个集合之间关联查询,就类似于SQL中的join一样!只不过功能没SQL强大,并且必须在聚合管道中使用,下面一起来看看。

2.4.4、多个集合关联查询

现在假设有商店shop_store、商品product两个集合,数据如下:

JavaScript 复制代码
db.shop_store.insertMany([
    {_id: 1, name: "熊猫高级会所", address:"地球市天上人间街道888号", grade:"五星"},
    {_id: 2, name: "竹子商店", address:"地球市绿竹林街道666号", grade:"五星"}
]);
db.product.insertMany([
    {_id:1, shop_store_id:1, name:"水", description:"能有效缓解口渴", price:2.00},
    {_id:2, shop_store_id:2, name:"米饭", description:"能有效缓解饥饿", price:1.00},
    {_id:3, shop_store_id:1, name:"香烟", description:"能有效缓解寂寞", price:88.88},
    {_id:4, shop_store_id:1, name:"酒", description:"能有效缓解忧愁", price:99.00},
    {_id:5, shop_store_id:1, name:"牛奶", description:"能帮助长身体", price:4.00},
    {_id:6, shop_store_id:2, name:"咖啡", description:"能有效缓解疲劳", price:9.99},
    {_id:7, shop_store_id:1, name:"棉衣", description:"能有效缓解寒冷", price:188.00},
    {_id:8, shop_store_id:2, name:"辣条", description:"能有效缓解嘴馋", price:5.00}
]);

在以外,为了实现数据的关联性,我们会直接将商店数据,嵌入到商品数据的某个字段中,而在目前的这个例子中,却是通过shop_store_id字段来关联商品所在的商店,查询时又该如何处理呢?如下:

JavaScript 复制代码
db.shop_store.aggregate([
  {
    $lookup: {
      from: "product",
      localField: "_id",
      foreignField: "shop_store_id",
      as: "products"
    }
  }
]);

在上述代码中,使用了聚合管道中的$lookup操作符,来实现多个集合之间的关联查询,其中主要有四个参数:

  • from:要关联的集合名称,这里是用商店关联商品,所以写product
  • localFieldshop_store集合中用于关联的字段,这里是_id
  • foreignFieldproduct集合中用于关联的字段,这里是shop_store_id
  • as:指定两个集合匹配的数据,要保存到的字段名称,这里写了products

由于关联查询,也依靠于聚合管道来完成,为此大家可以在关联查询的前后,插入各种业务所需的阶段,以满足各种特殊场景下的需求,不过这里注意:关联查询结束后,驱动集(调用aggregate方法的集合)的字段会完整输出,而被驱动集的字段,会以Json对象的形式,放入到指定的字段中,最终形成一个Json数组,即案例中的products

最后,如果你的需求中,需要关联并筛选数据,那么最好将筛选动作放到关联查询前面,尽可能的减小关联查询时的数据集,因为$lookup操作符,底层就是两个for循环,如果两个集合都有100W条数据,未做筛选直接关联查询,此时性能堪忧......

2.5、数据类型

关于CRUD操作相关的命令咱们就此打住,掌握上述那些基本够用了,下面来看看MongoDB中的数据类型。

看完前几个阶段的内容大家会发现,MongoDB的命令,用的其实就是JavaScript的语法,所以在数据类型这块,原生JS中支持的MongoDB几乎都支持,这里就简单列一下:

  • String:存储变长的字符串;
  • Number:存储整数或浮点型小数;
  • Boolean:存储true、flase两个布尔值;
  • Date:存储日期和时间;
  • Array:可以存储由任意类型组成的数组;
  • Embedded-Document:可以将其他结构的文档,嵌套到一个文档的字段中;
  • Binary:存储由0、1组成的二进制数据,如图像、音/视频等;
  • CodeMongoDB支持直接存储JavaScript代码;
  • Timestamp:存储格林威治时间(GMT)的时间戳;
  • GeoJSON:存储地理空间数据;
  • Text:存储大文本数据;

从上面列出的类型不难发现,其实这些数据,的的确确都是原生JS支持的类型~

2.6、事务与锁机制

MongoDB作为数据库家族的一员,自然也支持事务机制,只不过相较于InnoDB的事务机制而言,MongoDB事务方面并没有那么强大,这倒不是因为官方技术欠缺,而是由于MongoDB的定位是:大数据、高拓展、高可用、分布式 ,因此在实现事务时,不仅仅要考虑单机事务,而且需要考虑分布式事务,复杂度上来之后,自然无法做到MySQL-InnoDB那种单机事务的强大性。

这里也列出MongoDB事务方面的改进过程,如下:

  • 3.0版本中,引入WiredTiger存储引擎,开始支持单文档事务;
  • 4.0版本中,开始支持多文档事务,以及副本集(主从复制)架构下的事务;
  • 4.2版本中,开始支持分片集群、分片式多副本集架构下的事务。

再次类比MySQLMongoDB支持了分片集群中的事务,而MySQL只支持主从集群下的事务,并不支持分库环境下的事务,在这方面,MongoDB强于MySQL,下面来看看事务怎么使用:

JavaScript 复制代码
// 开启一个会话
var session = db.getMongo().startSession({readPreference:{mode: "primary"}});
// 开启事务:指定读模式为快照读,写模式为半同步,即写入半数以上节点后再返回成功
session.startTransaction({readConcern: {level:"snapshot"}, writeConcern:{w: "majority"}});
// 获取要操作的集合对象
var trx_coll = session.getDatabase("库名").getCollection("集合名");

// 要在事务里执行的CRUD操作
......

// 回滚事务命令
session.abortTransaction();
// 提交事务命令
session.commitTransaction();
// 关闭会话命令
session.endSession();

大家一眼看下来,会发现使用起来非常麻烦,首先需要先拿到一个会话,接着开启事务时,还要指定一堆参数,回滚/提交事务的命令也不一样......,为此,如若之前没接触过的小伙伴,看着或许会很别扭。

这里重点解释一下开启事务的startTransaction()方法的参数,以及可选项,如下:

  • readConcern:指定事务的读取模式
    • level:指定一致性级别
      • local:读取最近的数据,可能包含未提交的事务更改;
      • available:读取已提交的数据,可能包含尚未持久化的事务更改;
      • snapshot:读取事务开始时的一致快照,不包含未提交的事务更改;
  • writeConcern:指定事务的写入模式
    • w:指定写操作的确认级别(同步模式)
      • [number]:写操作在写入指定数量的节点后,返回写入成功;
      • majority:写操作在写入大多数节点(半数以上)后,返回写入成功;
      • tagSetName:写操作在写入指定标签的节点后,返回写入成功;
    • j:写入是否应被持久化到磁盘;
    • wtimeout:指定写入确认的超时时间;
  • readPreference:定义读操作的节点优先级和模式
    • mode:指定读取模式
      • primary:只从主节点读取数据;
      • secondary:只在从节点上读取数据;
      • primaryPreferred:优先从主节点读取,主节点不可用,转到从节点读取;
      • secondaryPreferred:优先在从节点读取,从节点不可用,转到主节点读取;
      • nearest:从可用节点中选择最近的节点进行读取;
    • tagSets:在带有指定的标签的节点上执行读取操作

OK,对于MongoDB的事务机制,了解上述命令即可,毕竟MongoDB本身就不适用于强事务的场景,原因如下:

  • MongoDB的事务必须在60s内完成,超时将自动取消(因为要考虑分布式环境);
  • ②涉及到事务的分片集群中,不能有仲裁节点(后面会解释仲裁节点);
  • ③事务会影响集群数据同步效率、节点数据迁移效率;
  • ④多文档事务的所有操作,必须在主节点上完成,包括读操作;

综上所述,就算MongoDB支持事务,可实际使用起来也会有诸多限制,因此在不必要的情况下,不建议使用其事务机制。

接着再来说说MongoDB的锁机制,由于其天生的分布式特性,所以内部的锁机制尤为复杂,MySQL中有的锁概念,MongoDB中几乎都有,为此,详细、深入的内容可参考:MongoDB锁机制,这里只简单列出手动操作锁的命令,如下:

JavaScript 复制代码
# 获取锁
db.collection.fsyncLock();
# 释放锁
db.collection.fsyncUnlock();

三、MongoDB索引机制

任何数据库都有索引这一核心功能,MongoDB自然不例外,而且MongoDB在索引方面特别完善,毕竟作为数据库领域的后起之秀,它集百家之长,将"借鉴"这一思想发挥到了极致,先来看看MongoDB索引的概念词:

单列索引、组合索引、唯一索引、全文索引、哈希索引、空间(地理位置)索引、稀疏索引、TTL索引......

怎么样?相信看过MySQL索引这篇文章的小伙伴一定眼熟,其中的索引名词和MySQL十分相似,接下来咱们挨个接触一下,当然,这里也先给出官方文档地址:MongoDB索引机制

PS:早版本的MongoDB中,索引底层默认使用B-Tree结构,而4.x版本后,MongoDB推出了V2版索引,默认使用变种B+Tree来作为索引的数据结构(和MySQL索引的数据结构相同)。

3.1、初识MongoDB索引

在之前提到过,MongoDB会为每个集合生成一个默认的_id字段,该字段在每个文档中必须存在,可以手动赋值,如果不赋值则会默认生成一个ObjectId,该字段则是集合的主键,MongoDB会基于该字段创建一个默认的主键索引,后续基于_id字段查询数据时,会走索引来提升查询效率。

但当咱们基于其他字段查询时,由于未使用_id作为条件,这会导致find语句走全表查询,即从第一条数据开始,遍历完整个集合,从而检索到目标数据。当集合中的数据量,达到百万、千万、甚至更高时,意味着效率会直线下滑,在这种情况下,必须得由我们手动为频繁作为查询条件的字段建立索引。

这里咱们先来说说索引的分类,同MySQL一样,站在不同维度上来说,可以衍生出不同的分类。

从字段数量的维度划分:

  • 单列索引:基于一个字段建立的索引;
  • 组合索引:也叫复合索引、多列索引、联合索引,是由多个字段组成的索引;
  • 多键索引:和上面的不同,如果索引字段为数组类型,MongoDB会专门为每个元素生成索引条目;
  • 部分索引:使用一个或多个字段的前N个字节创建出的索引;

从排序维度上划分:

  • 升序索引:一或多个字段组成的索引,排序方式完全为升序(从小到大);
  • 降序索引:一或多个字段组成的索引,排序方式完全为降序(从大到小);
  • 多序索引:多个字段组成的索引,但其中一部分字段为升序,一部分字段为降序;

从功能维度划分:

  • 主键索引:MongoDB中默认为_id字段且不能更改,用于维护聚簇索引树;
  • 普通索引:一或多个字段组成的索引,没有任何特殊性,只为提升检索效率;
  • 唯一索引:一或多个字段组成的索引,值不能重复,且只允许一个文档不插入索引字段;
  • 全文索引:一或多个字段组成的索引,集合内只能有一个,可以基于索引字段进行全文检索;
  • 空间索引:基于地理空间数据字段创建的索引,支持平面几何的2D索引和球形几何的2D sphere索引;

从索引性质维度划分:

  • 稀疏索引:结合唯一索引使用,允许一个唯一索引字段,出现多个null值;
  • TTL索引:类似于Redis的过期时间,为一个字段创建TTL索引后,超时会自动删除整个文档;
  • 隐藏索引:在不删除索引的情况下,把索引隐藏起来,语句执行时不再走索引,相当于关闭索引;
  • 通配符索引:给内嵌文档、且会发生动态变化的字段创建的索引;

从存储方式维度划分:

  • 聚簇索引:索引数据和文档数据存储在一起的索引;
  • 非聚簇索引:索引数据和文档数据分开存储的索引;

从数据结构维度划分:

  • B+树索引:索引底层采用B+Tree结构存储;
  • 哈希索引:索引底层采用Hash结构存储;

显而易见,这里又出现了一堆索引相关的名词,不过好在其中大部分概念,和MySQL索引的概念相同,下面展开聊聊。

3.2、MongoDB索引详解

先来看看MongoDB中创建索引的命令,如下:

JavaScript 复制代码
db.collection.createIndex(<key and index type specification>, <options>);

前面提到的所有索引,都是通过这一个方法创建,不同类型的索引,通过里面的参数和选项来区分,下面说明一下参数和可选项。

第一个参数主要是传字段,以及索引类型,这里可以传一或多个字段,用于表示单列/复合索引。

第二个参数表示可选项,如下:

  • background:是否以后台形式创建索引,因为创建索引会导致其他操作阻塞;
  • unique:是否创建成唯一索引;
  • name:指定索引的名称;
  • sparse:是否对集合中不存在的索引字段的文档不启用索引;
  • expireAfterSeconds:指定存活时间,超时后会自动删除文档;
  • v:指定索引的版本号;
  • weights:指定索引的权重值,权值范围是1~99999,当一条语句命中多个索引时,会根据该值来选择;

接着还是以之前的xiong_mao集合为例,来阐述索引相关内容,数据如下:

JavaScript 复制代码
db.xiong_mao.insert([
    {_id:1, name:"肥肥", age:3, hobby:"竹子", color:"黑白色"},
    {_id:2, name:"花花", color:"黑白色"},
    {_id:4, name:"黑熊", age:3, food:{name:"黄金竹", grade:"S"}},
    {_id:5, name:"白熊", age:4, food:{name:"翠绿竹", grade:"B"}},
    {_id:6, name:"棕熊", age:3, food:{name:"明月竹", grade:"A"}},
    {_id:7, name:"红熊", age:2, food:{name:"白玉竹", grade:"S"}},
    {_id:8, name:"粉熊", age:6, food:{name:"翡翠竹", grade:"A"}},
    {_id:9, name:"紫熊", age:3, food:{name:"烈日竹", grade:"S"}},
    {_id:10, name:"金熊", age:6, food:{name:"黄金竹", grade:"S"}}
]);

3.2.1、单列索引

JavaScript 复制代码
db.xiong_mao.createIndex({name: -1}, {name: "idx_name"});

这代表着基于name字段,创建一个名为idx_name的单列普通索引,-1代表降序,不过对于单字段的索引而言,排序方式并不重要,因为索引底层默认是B+Tree,每个文档之间会有双向指针,为此,MongoDB基于单字段索引查询时,既可以向前、也可以向后查找数据,示意图如下:

创建完成后,可以通过db.xiong_mao.getIndexes()命令查询索引,如下:

json 复制代码
[
  {v: 2, key: { _id: 1 }, name: '_id_'},
  {v: 2, key: { name: -1 }, name: 'idx_name'}
]

这里可以看到,MongoDB默认给_id字段创建的索引,第二个则是咱们手动创建的索引,索引的版本为2(如果不指定索引名,会按"字段名+下划线+排序方式"规则默认生成)。

再来看一种情况,例如现在将集合中的爱好字段,变为一个数组:

Json 复制代码
{_id:1, name:"肥肥", age:3, hobby:["竹子", "睡觉"], color:"黑白色"}

现在给hobby字段创建一个索引,这时叫啥索引?多键索引!因为这里是基于单个数组类型的字段在建立索引,所以MongoDB会为数组中的每个元素,都生成索引的条目(即索引键),由于一个文档的数组字段,拥有多个元素,因此会创建多个索引键,这也是"多键索引"的名字由来。

3.2.2、复合索引

复合索引是指基于多个字段创建的索引,例如:

JavaScript 复制代码
db.xiong_mao.createIndex({name:-1, age:1}, {name:"idx_name_age"});

这里基于name、age字段两个字段,创建了一个复合索引,其中指定了按name降序、age升序,这个排序就有意义了,MongoDB生成索引键时,会按照指定的顺序,来将索引键插入到树中。

说明:索引键=索引字段的值,比如现在一个文档的name=zhuzi、age=3,索引键为zhuzi3

注意:由于这里的顺序是{name:-1, age:1},所以当排序查询时,支持sort({name:-1,age:1})、sort({name:1,age:-1}),因为这两个顺序和树的组成顺序要么完全相同、相反,而当执行sort({name:-1,age:-1})、sort({name:1,age:1})排序查询时,将不会使用索引,因为这时和树的顺序冲突。

这里咱们用explain命令浅浅分析一下:

JavaScript 复制代码
db.xiong_mao.find({}).sort({name:1,age:1}).explain("executionStats");
db.xiong_mao.find({}).sort({name:-1,age:1}).explain("executionStats");

执行结果信息比较多,这里只贴出关键信息,如下:

从图中能明显看出,第一条与索引顺序冲突的语句,走的是COLLSCAN全集合扫描;而第二条复合索引顺序的语句,走的是IXSCAN索引扫描,使用了name_-1_age_1这个索引检索到了数据,因此这点在使用复合索引时一定要注意!

同时,因为刚刚又给name、age字段建立了一个复合索引,所以最开始建立name单列索引,属于冗余的重复索引,这时可以通过dropIndex命令删除掉,如下:

JavaScript 复制代码
// 自定义名称创建的索引,需要传入名称删除
db.xiong_mao.dropIndex("name_-1");
// 使用默认名称创建的索引,可以直接传入创建时的字段+顺序
db.xiong_mao.dropIndex({name:-1});
// 这个方法可以删除一个集合的所有索引(除开默认的_id索引外)
db.xiong_mao.dropIndexes();

顺便这里也提一句,如果索引在创建时未指定名称,中途是不可以重命名的,只能先删再建时重新命名。

3.2.3、唯一索引

唯一索引相信大家都熟悉,它必须创建在不会出现重复值的字段上,基于唯一索引查找数据时,找到第一个满足条件的数据,就会立马停止匹配,毕竟该字段的值在集合中是唯一的,创建的方式如下:

JavaScript 复制代码
db.xiong_mao.createIndex({name:1}, {unique: true});

只需要将unique设置为true即可,现在再尝试向集合中插入一条name重复的文档:

JavaScript 复制代码
db.xiong_mao.insertOne({name: "肥肥"});
[Error] index 0: 11000 - E11000 duplicate key error collection: 
    zhuzi.xiong_mao index: name_1 dup key: { name: "肥肥" }

这时就会因为name值违反唯一约束而报错。OK,再来看一个特殊情况:

JavaScript 复制代码
db.xiong_mao.insertOne({_id:66, age: 1});
db.xiong_mao.insertOne({_id:77, age: 2});

这时大家查询一下该集合,会发现_id=66这条数据会插入进去,而77这条数据会报错,这是为什么?

因为MongoDB中,可以向集合中动态插入不同的字段,上面两条语句都没有指定name字段,这时MongoDB默认会将这两条语句的name字段置空,而name=null的情况,也会被当作一个索引键,插入到索引树中。由于name字段建立了唯一索引,因此空值情况也只能出现一次。

那这个问题能不能解决呢?当然可以,这里需要用到稀疏索引,"稀疏"是索引的一种特性,如下:

JavaScript 复制代码
// 先删除索引
db.xiong_mao.dropIndex("name_1");
// 再重新建立一次,并将sparse选项置为true
db.xiong_mao.createIndex({name:1}, {unique:true, sparse:true});

这时再执行前面的两条语句,就能同时插入了!后续可以通过null值,来查询没有唯一索引字段的文档:

JavaScript 复制代码
db.xiong_mao.find({name:null});

3.2.4、部分索引

部分索引即使用字段的一部分开创建索引,但必须要结合partialFilterExpression选项来实现,如下:

JavaScript 复制代码
db.xiong_mao.createIndex(
  // 给hobby字段创建索引
  {hobby:1},
  {partialFilterExpression: {
      hobby: {
        // 只为存在hobby字段的文档创建索引
        $exists: true,
        // 通过$substr操作符,截取前1个字节作为索引键
        $expr: {$eq: [{$substr:["$hobby", 0, 1] },"prefix"]}
      }
    }}
);

其实这就类似于MySQL中的前缀索引,不过MongoDB的中的部分索引功能更强大,还可以只为集合中的一部分文档创建索引,例如:

JavaScript 复制代码
db.xiong_mao.createIndex(
   {age: -1},
   // 只为集合中年龄大于2岁的文档创建索引
   {partialFilterExpression: {age: {$gt: 2}}}
);

为此,这里纠正一下:在最开始给出的索引分类概念中,将部分索引解释成了前缀索引的概念,实则不然,其实还可以为集合里的一部分数据创建索引!

3.2.5、TTL索引

TTL索引这个类型比较有趣,可以基于它实现过期自动删除的效果,主要依靠expireAfterSeconds选项来创建,不过只能在Date、ISODate类型的字段上,建立TTL索引,如下:

JavaScript 复制代码
db.test_ttl.insertMany([
    // new Date()表示插入当前时间
    {_id:1, time:new Date()},
    {_id:2, time:new Date()},
    {_id:3, time:new Date()},
    {_id:4, time:new Date()},
    {_id:5, notes:"这条数据用于观察TTL删除特性"}
]);
db.test_ttl.find();
db.test_ttl.createIndex({time: 1}, {expireAfterSeconds: 10});

上面新建了一个ttl_test集合,并向其中插入了5条数据,接着对time字段创建了一个TTL索引,给定的过期时间为10s,这里咱们稍等12s左右再去查询:

JavaScript 复制代码
db.test_ttl.find();
[{_id: 5, notes: '这条数据用于观察TTL删除特性'}]

此时会发现,只剩下了_id=5这条没有time字段的数据,从这点就可以观察出TTL索引的特性,不过大家在使用时需要注意:TTL索引只能建在单字段上,不支持建立TTL复合索引;同时,TTL索引只能建立在类型为Date、ISODate的字段上,在其他类型的字段上建立TTL索引,文档永远不会过期。

这里说明一下,为什么TTL索引必须基于Date类型的字段创建呢?因为MongoDB会使用该字段的值,作为计算的起始时间,如果在一个Date数组类型的字段上建立TTL索引,MongoDB会使用其中最早的时间来计算过期时间。

3.2.6、全文索引

MySQL中想实现模糊查询,一般会采用like关键字;而在MongoDB中想实现模糊查询,官方并没有提供相关方法与操作符,只能通过自己写正则的形式,实现模糊查找的功能,那有没有更好的方法呢?答案是有,为相应字段创建全文索引即可。

全文索引在之前讲MySQL索引时也聊到过,在数据量不大不小(几百万左右)、查询又不是特别复杂的情况下,直接上ElasticSearch、Solr等中间件,显得有点大材小用,此时全文索引就是这类搜索引擎的平替。不过相较于MySQLMongoDB提供的全文索引,功能方面会更加强大。

创建的语法如下:

JavaScript 复制代码
db.xiong_mao.createIndex({name: "text"}, {name:"ft_idx_name"});

这里对name字段建立了一个全文索引,和创建普通索引的区别在于:在字段后面加了一个text

不过要注意,MongoDB全文索引停用词、词干和词器的规则,默认为英语,想要更改,这里涉及到创建索引时的两个可选项:

  • default_language:指定全文索引停用词、词干和词器的规则,默认为english
  • language_override:指定全文索引语言覆盖的范围,默认为language

不过注意,不管任何技术栈的全文索引,对中文的支持都不太友好,分词方面总会有点不完善,所以MongoDB很鸡贼,全文索引直接不支持中文,当你试图通过default_language:"chinese"去将语言改为中文时,会直接给你返回报错~

当然,正是由于MongoDB的全文索引不支持中文,因此就算你给一个字符串字段,建立了全文索引后,也无法实现全文搜索,如下:

JavaScript 复制代码
db.xiong_mao.find({$text: {$search: "熊"}});

这段语句的含义是:通过全文索引搜索含"熊"这个关键字的数据,在咱们前面给出的集合数据中,name包含"熊"的数据有好几条,但这条语句执行之后的结果为null。想要解决这个问题,必须要手动安装第三方的中文分词插件,如mmseg、jieba等。

当然,如果你字段中的值是英文,这自然是支持的,例如:

JavaScript 复制代码
// 先向集合中插入三个name为英文的文档
db.xiong_mao.insertMany([
    {_id:21, name:"jack bear", age: 1},
    {_id:22, name:"coly bear", age: 2},
    {_id:23, name:"alan bear", age: 3}
]);

// 再使用英文作为关键字进行全文搜索(多个关键字用空格分隔)
db.xiong_mao.find({$text: {$search: "jack coly"}});
// =============结果=============
[
  { _id: 22, name: 'coly bear', age: 2 },
  { _id: 21, name: 'jack bear', age: 1 }
]

结果很明显,全文索引对英文完全支持,不过关于全文索引更多的搜索方法,大家可自行查阅官方文档,或寻找相关资料,这里不再过多阐述。

3.2.7、空间索引

空间索引,这玩意儿很多人用不到,除非涉及到地图、出行类的业务,或者其他涉及到坐标的场景,毕竟这时才会存储经纬度,MongoDB的空间索引,必须建立在类型为Point的字段上,总共有三种空间索引:

  • 2D:适用于平面上的二维几何数据;
  • 2Dsphere:适用于球面上的二维几何数据;
  • geoHaystack2D索引的升级版,适用于平面的特定坐标数据(查询性能更高);

这里来演示一下,先创建一个名为"熊猫馆"的集合,并插入几条坐标数据:

JavaScript 复制代码
db.panda_house.insertMany([
	{_id:1, house_name:"熊猫高级会所", location:{type: "Point", coordinates:[-66.66, 22.22]}},
	{_id:2, house_name:"竹子高级会所", location:{type: "Point", coordinates:[-88.88, 55.55]}},
	{_id:3, house_name:"竹子爱熊猫会所", location:{type: "Point", coordinates:[-77.88, 11.22]}}
]);

接着我们为其location字段,创建一个2dsphere空间索引:

JavaScript 复制代码
db.panda_house.createIndex({location:"2dsphere"});

接着可以使用坐标来进行查询,例如查询某个坐标附近五公里内的熊猫馆:

JavaScript 复制代码
db.panda_house.find(
    // 基于location字段进行查询
	{location: {
	    // $near用来实现坐标附近的数据检索,返回结果会按距离排序
		$near: {
		    // $geometry操作符用于指定Geo对象(地理空间对象)
			$geometry: {
				type:"Point", coordinates: [-66.66, 22.21]}, 
				// $maxDistance限制了最大搜索距离为5000m(五公里)
				$maxDistance: 5000
		}}
});

上述语句执行后,最终会把"熊猫高级会所"这条数据搜索出来,是不是和你点外卖时的搜索场景很像?

3.2.8、哈希索引

哈希索引是等值查询时最快的索引,在之前MySQL篇章也提及过它,只不过MySQL-InnoDB引擎中,不支持手动创建哈希索引,只能由InnoDB运行时自动生成,即自适应哈希索引技术。反观MongoDB中,默认使用的索引结构为B+Tree,但它也支持手动创建哈希结构的索引,语法如下:

JavaScript 复制代码
db.xiong_mao.createIndex({name:'hashed'});

我们只需要在创建索引时,在字段后显式指明hashed即可,这样在做等值查询时,就会自动使用哈希索引。

不过注意,哈希索引只支持等值查询的场景,如果一个字段还需要参与范围查询、排序等场景,那么并不建议在该字段上建立哈希索引。同时,创建哈希索引的字段,其值必须具备分散性,如年龄、性别这类字段,显然并不适合,大量重复值会导致哈希冲突十分严重,像手机号、身份证号这类属性的字段,就特别适合建立哈希索引。

3.2.9、通配符索引

在前面提到过"内嵌文档"这个概念,这是指将另一个文档,以字段值的形式嵌入到一个文档中。

结合MongoDB可以动态插入各种字段的特性,每个内嵌文档的字段,也可以灵活变化,例如前面给出的数据:

JavaScript 复制代码
{_id:4, name:"黑熊", age:3, food:{name:"黄金竹", grade:"S"}},
{_id:5, name:"白熊", age:4, food:{name:"翠绿竹", grade:"B"}},
......

这些数据中都内嵌了一个food文档,虽然现在插入的都是固定的name、grade字段,但我们可以随时插入新的字段,例如:

JavaScript 复制代码
db.xiong_mao.insertOne({
    _id:99, name:"星熊", age: 1,
    food: {name:"星光竹", grade:"S", quality_inspector: ["竹大","竹二"]}
});

这时新插入的文档,其food字段又多了一个quality_inspector质检员的属性,对于这种动态变化的字段,可不可以建立索引呢?答案是可以,MongoDB4.2中引入了"通配符索引"来支持对未知或任意字段的查询操作,创建的语法如下:

JavaScript 复制代码
db.xiong_mao.createIndex({"food.$**": 1});

这时再通过food字段来进行查询,看看explain执行计划:

JavaScript 复制代码
db.xiong_mao.find({"food.grade":"S"}).explain();

执行计划中很明显,通过food字段作为查询条件时,用到了刚刚创建的通配符索引。

最后,如果你想观察一个索引到底有没有效果,可以对比有索引、没索引时的查询耗时,但咱们需要把索引删掉吗?这是不需要的,MongoDB支持隐藏索引,可以把一个已有索引"藏"起来,相关命令如下:

JavaScript 复制代码
// 创建隐藏索引
db.<集合名>.createIndex({<字段名>:<排序方式>}, {hidden: true});

// 隐藏已有索引
db.<集合名>.hideIndex({<字段名>:<排序方式>});
db.<集合名>.hideIndex("索引名称");

// 取消隐藏索引
db.<集合名>.unhideIndex({<字段名>:<排序方式>});
db.<集合名>.unhideIndex("索引名称");

OK,MongoDB基于索引查询时,同样也支持索引覆盖,也就是需要返回的结果字段,在索引字段中有时,会直接返回索引键的值,并不会再次回表查询整个文档。

3.3、explain执行计划

在之前的《SQL优化篇》中,曾详细说到过MySQLexplain工具,通过该命令,能有效帮咱们分析语句的执行情况,而MongoDB同样也提供了这个命令,在上面的索引阶段,也简单使用过,命令格式如下:

JavaScript 复制代码
db.<collection>.find().explain(<verbose>);

explain方法同样有三个模式可选,这里简单列出来:

  • queryPlanner:返回执行计划的详细信息,包括查询计划、集合信息、查询条件、最佳计划、查询方式、服务信息等(默认模式);
  • exectionStats:列出最佳执行计划的执行情况和被拒绝的计划等信息(即语句最终执行的方案);
  • allPlansExecution:选择并执行最佳执行计划,同时输出其他所有执行计划的信息;

一般排查find()查询缓慢问题时,可以先指定第二个模式,查看最佳执行计划的信息;如果怀疑MongoDB没选择好索引,则可以再指定第三个模式,查看其他执行计划,如果的确是因为走错了索引,这时你可以通过hint强制指定要使用的索引,如下:

JavaScript 复制代码
db.集合名.find(查询条件).hint(索引名);

OK,接着来了解一下explain命令输出的信息含义,大家可以自行去mongosh里执行一下explain命令,会发现它输出了特别多的信息,咱们主要关注stage这个值,这是最重要的字段,就类似于MySQL-explaintype字段,代表着本次语句的查询类型,该字段可能会出现以下值:

  • COLLSCAN:扫描整个集合进行查询;
  • IXSCAN:通过索引进行查询;
  • COUNT_SCAN:使用索引在进行count操作;
  • COUNTSCAN:没使用索引在进行count操作;
  • FETCH:根据索引键去磁盘拿具体的数据;
  • SORT:执行了sort排序查询;
  • LIMIT:使用了limit限制返回行数;
  • SKIP:使用了skip跳过了某些数据;
  • IDHACK:通过_id主键查询数据;
  • SHARD_MERGE:从多个分片中查询、合并数据;
  • SHARDING_FILTER:通过mongos对分片集群执行查询操作;
  • SUBPLA:未使用索引的$or查询;
  • TEXT:使用全文索引进行查询;
  • PROJECTION:本次查询指定了返回的结果集字段(投影查询);

这里咱们只需要带SCAN后缀的,因为其他都属于命令执行的"阶段",并不属于具体的类型,explain会将一条语句执行的每个阶段,都详细列出来,每个阶段都会有stage字段,我们要做的,就是确保每个阶段都能用上索引即可,如果某一阶段出现COLLSCAN,在数据量较大的情况下,都有可能导致查询缓慢。

其次,咱们需要关心keysExamined、docsExamined两个字段的值(exectionStats模式下才能看到),前者代表扫描的索引键数量,后者代表扫描的文档数量,前者越大,代表索引字段值的离散性太差,后者的值越大,一般代表着没建立索引。

OK,这里暂时了解这么多,关于explain其余的输出信息,很多跟集群有关系,为此,等后续把MongoDB集群过了之后,有机会再详细讲述~

四、MongoDB安全与权限管理

通常为了保证数据安全性,Redis、MySQL、MQ、ES......,通常都会配置账号/密码,一来可以提高安全等级,二来还可以针对不同库、操作设置权限,极大程度上降低了数据的安全风险。同样,在MongoDB中也支持创建账号、密码,以及分配权限,并且还支持角色的概念,可以先为角色分配权限,再为用户绑定角色,从而节省大量重复的权限分配工作。

同时,MongoDB中还内置了大量常用角色,方便于咱们快速分配权限,不过并没有默认的账号,所以想要启用MongoDB的访问控制,还需要先创建一个账号:

JavaScript 复制代码
// 切换到admin数据库
use admin;
// 创建一个用户,并赋予root角色(针对于zhuzi库有效,不写db代表对所有库生效)
db.createUser({"user":"zhuzi", "pwd":"123456", "roles":[{"role":"root", "db":"zhuzi"}]});
// 退出mongosh客户端
quit;

接着再关闭MongoDB服务,重新启动时开启访问控制,必须使用账号密码连接才允许操作:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -shutdown -f /soft/mongodb/conf/standalone/mongodb.conf
[root@~]# /soft/mongodb/mongosh/bin/mongosh 192.168.229.135:27017

接着先切换到咱们前面创建的zhuzi库,查询一下xiong_mao集合试试看:

JavaScript 复制代码
use zhuzi;
db.xiong_mao.find({_id:1});
MongoServerError: command find requires authentication

此时就会看到对应报错,提示目前未授权,所以无法执行命令,因此这里需要登录一下,不过登录必须要切换到admin库下才可以,否则会提示认证失败:

JavaScript 复制代码
db.auth("zhuzi", "123456");
{ ok: 1 }

use zhuzi;
db.xiong_mao.find({_id:1});
[ { _id: 1, name: '肥肥', age: 3, hobby: '竹子', color: '黑白色' } ]

认证成功后,再次切回zhuzi库查询,此时会发现数据依旧可以查询出来。

4.1、MongoDB内置角色

前面大致了解了一下如何启用访问控制后,接着来看看MongoDB中的内置角色,毕竟前面只使用了root角色,可以通过下述命令来查询MongoDB所有内置角色:

JavaScript 复制代码
use admin;
db.runCommand({rolesInfo: 1, showBuiltinRoles: true});

返回结果较长,这里就不贴了,大家可以自己执行一下,这里归类一下:

  • root:超级管理员权限,可以执行任何操作;
  • read:只读用户,不允许对数据库执行写入操作;
  • readWrite:读写用户,允许对数据执行读写操作;
  • dbAdmin:数据库管理员(如创建和删除数据库),不允许读写数据;
  • userAdmin:用户管理员(如创建和删除用户),不允许读写数据;
  • dbOwner:同时拥有dbAdmin、userAdmin两个角色的权限,且允许读写数据;
  • backup:具有备份和恢复权限,不允许读写数据;
  • restore:只具有数据恢复权限,不允许读写数据;
  • clusterAdmin:集群超级管理员,可以执行集群中任意操作,允许读写数据;
  • clusterManager:集群管理员,只可以管理集群节点、配置等;
  • clusterMonitor:集群监视员,允许监控集群的状态和性能,不允许读写数据;

上面是一些常用的角色,而对于其他使用频率较少的则不再列出,而这些内置角色,可以在创建用户的时候分配,一个用户同时可以绑定多个角色。但如果你想要的权限,内置角色并不提供,也可以自定义角色,下面来看看。

4.2、MongoDB自定义角色

先来看看自定义角色的语法:

JavaScript 复制代码
use zhuzi;

db.createRole(
    {
    // 自定义角色的名称
    role:"zhuziRole",
    // 自定义角色拥有的权限集
    privileges: [
        {
           // 自定义角色可操作的资源 
           resource:
            {
                // 当前角色可操作zhuzi库
                db:"zhuzi",
                // 当前角色具体可操作的集合(多个传数组,所有写"")
                collection:""
            },
            // 当前角色拥有的权限
            actions: ["find", "update", "insert", "remove"]
        }
        ],
        // 当前角色是否继承其他角色,如果指定了其他角色,当前角色自动继承父亲的所有权限
        roles: []
    }
);

关于该命令中的具体含义,大家可以参考上面的注释,通过该方式,诸位可以灵活的创建出各种适用于业务的角色,最后再附上一些相关命令:

JavaScript 复制代码
// 给指定角色增加权限
db.grantPrivilegesToRole(
    "角色名称",
    [{
        resource: {
          db: "库名",
          collection: ""
        }, 
        actions: ["权限1","......"]
    }]
);

// 回收指定角色的权限
db.revokePrivilegesFromRole(
    "角色名称",
    [{
        resource: {
          db: "库名",
          collection: ""
        }, 
        actions: ["权限1","......"]
    }]
);

// 删除角色(要先进入角色所在的库)
use zhuzi;
db.dropRole("角色名称");

// 查看当前库的所有角色
show roles;

// 查看当前库中所有用户
show users;

OK,掌握上述这些命令完全能满足日常需求了,至于其他更多的,则可以参考官网或自行查阅资料。

五、MongoDB入门篇总结

到这里,MongoDB入门篇内容就结束了,原本打算一篇写完,结果发现这篇就有好几万字了,由于平台字数限制,所以拆成了三篇来写,每一篇分别对应不同的学习阶段,上篇写入门、中篇写进阶、下篇写Java应用(如果是其他语言的读者,需要自行寻找对应客户端的应用文)。

当然,虽然这篇字数最多,但大多数在讲API应用,因此阅读难度并不算大,更多考验的是耐心,如若你有耐心阅读至此,那么恭喜你!又掌握了一项新的技术栈~

最后,第二篇文章已经同步更新,这里附上链接:《MongoDB保姆级指南(中):从副本集群、分片集群起航,探索分布式存储的趋势!》

相关推荐
Mr.131 小时前
数据库的三范式是什么?
数据库
Cachel wood1 小时前
python round四舍五入和decimal库精确四舍五入
java·linux·前端·数据库·vue.js·python·前端框架
Python之栈1 小时前
【无标题】
数据库·python·mysql
风_流沙1 小时前
java 对ElasticSearch数据库操作封装工具类(对你是否适用嘞)
java·数据库·elasticsearch
亽仒凣凣1 小时前
Windows安装Redis图文教程
数据库·windows·redis
亦世凡华、2 小时前
MySQL--》如何在MySQL中打造高效优化索引
数据库·经验分享·mysql·索引·性能分析
YashanDB2 小时前
【YashanDB知识库】Mybatis-Plus调用YashanDB怎么设置分页
数据库·yashandb·崖山数据库
ProtonBase2 小时前
如何从 0 到 1 ,打造全新一代分布式数据架构
java·网络·数据库·数据仓库·分布式·云原生·架构
云和数据.ChenGuang7 小时前
Django 应用安装脚本 – 如何将应用添加到 INSTALLED_APPS 设置中 原创
数据库·django·sqlite
woshilys7 小时前
sql server 查询对象的修改时间
运维·数据库·sqlserver