MongoDB数据操作


二、MongoDB渐悉

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

2.1、新增操作

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

// 向集合插入单条或多条数据(需要用[]来包裹多个文档)
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方法都有两个可选项,如下:

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

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

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

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两类方法:

// 根据指定条件删除集合中的数据
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操作:

// 根据条件修改集合中的单条数据(多条数据满足条件时,只修改第一条)
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在做等值条件修改,后面讲完查询操作后,大家可以自行套入各种复杂条件进行修改。

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

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

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

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

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

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

2.4、查询操作

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

2.4.1、基本查询语句

// 查询集合所有数据
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中文网-运算符。

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

// 对指定字段去重,并返回去重后的字段值列表
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一样,不同类型的函数,执行的优先级不一样,例如:

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提供的聚合管道来完成,语法如下:

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

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

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

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

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

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

/* 按年龄进行分组,并统计各组的数量(没有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中文网-聚合管道操作符。

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

/* 按年龄进行分组,并统计各组的数量 */
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}统计得出的,这段逻辑相当于:

Map<Integer,Integer> groupMap =newHashMap<>();
for(XiongMao xm : xiongMaoList){
    Integerage= xm.getAge();
    if(groupMap.get(age)==null){
        groupMap.put(age,1);
}else{
    Integercount= groupMap.get(age)+1;
        groupMap.put(age, count);
    }
}

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

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

/* 多字段分组:按食物等级、颜色字段分组,并求出每组的年龄总和 */
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格式表达出来的数据,都可以放到集合中存储,比如:

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

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

/* 以下数据仅为示例,不要纠结订单数据的不完整性 */
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:newDate(),
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格式的订单详情。

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

/* 新增单条订单详情:通过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]等各种数组操作符,来实现嵌入式文档的增删改查。不过如若只嵌入了一个文档,而不是一个文档数组时,操作的方法有有些不同,如下:

// 以之前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两个集合,数据如下:

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字段来关联商品所在的商店,查询时又该如何处理呢?如下:

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,下面来看看事务怎么使用:

// 开启一个会话
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:指定事务的读取模式

  • local:读取最近的数据,可能包含未提交的事务更改; - available:读取已提交的数据,可能包含尚未持久化的事务更改; - snapshot:读取事务开始时的一致快照,不包含未提交的事务更改; - level:指定一致性级别

  • writeConcern:指定事务的写入模式

  • [number]:写操作在写入指定数量的节点后,返回写入成功; - majority:写操作在写入大多数节点(半数以上)后,返回写入成功; - tagSetName:写操作在写入指定标签的节点后,返回写入成功; - w:指定写操作的确认级别(同步模式) - j:写入是否应被持久化到磁盘; - wtimeout:指定写入确认的超时时间;

  • readPreference:定义读操作的节点优先级和模式

  • primary:只从主节点读取数据; - secondary:只在从节点上读取数据; - primaryPreferred:优先从主节点读取,主节点不可用,转到从节点读取; - secondaryPreferred:优先在从节点读取,从节点不可用,转到主节点读取; - nearest:从可用节点中选择最近的节点进行读取; - mode:指定读取模式 - tagSets:在带有指定的标签的节点上执行读取操作

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

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

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

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

# 获取锁
db.collection.fsyncLock();
# 释放锁
db.collection.fsyncUnlock();