首页 » MongoDB实战 » MongoDB实战全文在线阅读

《MongoDB实战》6.4 具体细节:MongoDB的更新与删除

关灯直达底部

要真正掌握MongoDB中的更新,需要彻底理解MongoDB的文档模型和查询语言,前几节里的例子对此很有帮助。不过,与全书的“具体细节”部分一样,我们会讨论实质性的问题。此处不仅会囊括MongoDB更新接口中每个特性的简要概述,还有多项与性能相关的说明。简单起见,后续的示例都使用JavaScript。

6.4.1 更新类型与选项

MongoDB支持针对性更新与替换更新。前者使用一个或多个更新操作符来定义,后者使用一个文档来替换匹配更新查询选择器的文档。

语法说明:更新与查询

刚接触MongoDB的用户有时会分不清更新与查询的语法。针对性更新总是由更新操作符开始的,这些操作符几乎全是动词形式的。以$addToSet操作符为例:

db.products.update({}, {$addToSet: {tags: 'green'}})  

如果要为该更新增加一个查询选择器,请注意这个查询操作符在语义上起着形容词的作用,紧跟在要查询的字段名之后:

db.products.update({'price' => {$lte => 10}},   {$addToSet: {tags: 'cheap'}})  

基本上,更新操作符是前缀,而查询操作符通常是中缀。

请注意,如果更新文档含糊不清,更新将会失败。此处,我们将更新操作符$addToSet和替换风格的{name: "Pitchfork"}结合在一起:

db.products.update({}, {name: "Pitchfork", $addToSet: {tags: 'cheap'}})  

如果目的是改变文档的名字,必须使用$set操作符:

db.products.update({},  {$set: {name: "Pitchfork"}, $addToSet: {tags: 'cheap'}})  

1. 多文档更新

默认情况下,更新操作只会更新查询选择器匹配到的第一个文档。要更新匹配到的所有文档,需要明确指定多文档更新(multidocument update)。在Shell里,要实现这一点,可以将update方法的第四个参数设置为true。下面展示如何为产品集合里的所有文档添加cheap标签:

db.products.update({}, {$addToSet: {tags: 'cheap'}}, false, true)  

使用Ruby驱动(和大多数其他驱动)时,可以更清楚地表示多文档更新:

@products.update({}, {'$addToSet' => {'tags' => 'cheap'}}, :multi => true)  

2. upsert

某项内容不存在时进行插入,存在则进行更新,这是很常见的需求。可以使用MongoDB的upsert轻松实现这一模式。如果查询选择器匹配到文档,进行普通的更新操作。如果没有匹配到文档,将会插入一个新文档。新文档的属性合并自查询选择器与针对性更新的文档。1

1. 请注意,upsert无法用于替换风格的更新操作。

以下是在Shell中使用upsert的简单示例:

db.products.update({slug: 'hammer'}, {$addToSet: {tags: 'cheap'}}, true)  

这是Ruby中等效的upsert示例:

@products.update({'slug' => 'hammer'},   {'$addToSet' => {'tags' => 'cheap'}}, :upsert => true)  

你应该已经猜到了,upsert一次只能插入或更新一个文档。在需要原子性地更新文档,以及无法确定文档是否存在时,upsert能发挥巨大的作用。6.2.3节中有一个实际的例子,描述了如何向购物车中添加产品。

6.4.2 更新操作符

MongoDB支持很多更新操作符,此处我会为每个更新操作符提供一个简单的示例。

1. 标准更新操作符

第一组是最常用的操作符,几乎能用于任意数据类型。

  • $inc

可以使用$inc操作符递增或递减数值:

db.products.update({slug: "shovel"}, {$inc: {review_count: 1}})db.users.update({username: "moe"}, {$inc: {password_retires: -1})  

也可以用它加或减任意数字:

db.readings.update({_id: 324}, {$inc: {temp: 2.7435}})  

$inc既方便又高效,因为它很少会改变文档的大小,$inc通常原地作用在数据的磁盘位置上,所以只会影响到指定的数据对。2

2. 当数字类型发生改变时,情况会有所不同。如果$inc造成32位整数被转换为64位整数,那么整个BSON文档会原地重写。

正如添加产品到购物车的示例中演示的那样,$inc能用于upsert中。例如,可以将之前的更新改为upsert:

db.readings.update({_id: 324}, {$inc: {temp: 2.7435}}, true)  

如果不存在_id324的文档,会用该_id创建一个新文档,文档中temp的值就是$inc2.7435

  • $set$unset

如果需要为文档中的特定键赋值,可以使用$set。为键赋值时,可以使用任意合法的BSON类型。也就是说以下更新都是正确的:

db.readings.update({_id: 324}, {$set: {temp: 97.6}})db.readings.update({_id: 325}, {$set: {temp: {f: 212, c: 100} })db.readings.update({_id: 326}, {$set: {temps: [97.6, 98.4, 99.1]}})  

如果键已存在,其值会被覆盖;否则会创建一个新的键。

$unset能删除文档中特定的键。下面展示如何删除文档中的temp键:

db.readings.update({_id: 324}, {$unset: {temp: 1}})  

还可以在内嵌文档和数组之上使用$unset。这两种情况都要用点符号指定内部对象。如果集合中有两个文档:

{_id: 325, 'temp': {f: 212, c: 100}}{_id: 326, temps: [97.6, 98.4, 99.1]}  

我们可以用下面的语句删除第一个文档里的华氏温标读数,以及第二个文档中的第0个元素:

db.readings.update({_id: 325},  {$unset: {'temp.f': 1}})db.readings.update({_id: 236},  {$pop: {temps: -1}})  

$set也能使用访问子文档和数组元素的点符号。

  • $rename

如果要更改键名,请使用$rename

db.readings.update({_id: 324}, {$rename: {'temp': 'temperature'}})  

还可以重命名子文档:

db.readings.update({_id: 325}, {$rename: {'temp.f': 'temp.farenheit'}})  

对数组使用$unset

请注意,在单个数组元素上使用$unset的结果可能与你设想的不一样。其结果只是将元素的值设置为null,而非删除整个元素。要彻底删除某个数组元素,可以用$pull$pop操作符。

db.readings.update({_id: 325}, {$unset: {'temp.f': 1}})db.readings.update({_id: 326}, {$unset: {'temp.0': 1}})  

2. 数组更新操作符

数组在MongoDB文档模型中的重要性是显而易见的,因此MongoDB理所当然地提供了很多专门用于数组的更新操作符。

  • $push$pushAll

如果需要为数组追加一些值,可以考虑$push$pushAll,前者能向数组中添加一个值,而后者则支持添加一个值列表。例如,可以很方便地为铲子添加新标签:

db.products.update({slug: 'shovel'}, {$push: {'tags': 'tools'}})  

如果需要在一个更新里添加多个标签,同样不成问题:

db.products.update({slug: 'shovel'},  {$pushAll: {'tags': ['tools', 'dirt', 'garden']}})  

注意,可以往数组里添加各种类型的值,不局限于标量(scalar)。上一节里,向购物车的明细条目数组里添加产品的代码就是一个很好的例子。

  • $addToSet$each

$addToSet也能为数组追加值,不过它的做法更细致:要添加的值如果不存在才执行添加操作。因此,如果铲子已经有了tools标签,那么以下更新不会修改文档:

db.products.update({slug: 'shovel'}, {$addToSet: {'tags': 'tools'}})  

如果想在一个操作里向数组添加多个唯一的值,必须结合$each操作符来使用$addToSet。下面是一个示例:

db.products.update({slug: 'shovel'},{$addToSet: {'tags': {$each: ['tools', 'dirt', 'steel']}}})  

仅当$each中的值不在tags里时,才会进行添加。

  • $pop

要从数组中删除元素,最简单的方法就是使用$pop操作符。如果用$push向数组中追加了一个元素,那么随后的$pop会删除最后添加的内容。虽然$pop常和$push一起出现,但也可以单独使用。如果tags数组里包含['tools', 'dirt', 'garden', 'steel']这四个值,那么下面的$pop会删除steel标签:

db.products.update({slug: 'shovel'}, {$pop: {'tags': 1}})  

$pop的语法和$unset类似,即{$pop: {'elementToRemove': 1}},不同的是$pop还能接受-1来删除数组的第一个元素。下面展示如何从数组中删除tools标签:

db.products.update({slug: 'shovel'}, {$pop: {'tags': -1}})  

可能有一个地方会让你不太满意,即无法返回$pop从数组中删除的值。尽管它的名字叫$pop,但其结果和你所熟知的栈式操作不太一样,请注意这一点。

  • $pull$pullAll

$pull的作用与$pop类似,但更高级一点。使用$pull时可以明确用值来指定要删除哪个数组元素,而不是位置。再来看看标签示例,如果要删除dirt标签,无需知道它在数组中的位置,只需告诉$pull操作符删除它就可以了:

db.products.update({slug: 'shovel'}, {$pull {'tags': 'dirt'}})  

$pullAll类似于$pushAll,允许提供一个要删除值的列表。如果要删除dirtgarden标签,可以这样使用$pushAll

db.products.update({slug: 'shovel'}, {$pullAll {'tags': ['dirt', 'garden']}})  

3. 位置更新

在MongoDB中建模数据时通常会使用子文档数组,但在位置操作符出现之前,要操作那些子文档并非易事。位置操作符允许更新数组里的子文档,我们可以在查询选择器中用点符号指明要修改的子文档。若没有示例,理解起来比较麻烦,因此此处假设有一个订单文档,其中一部分内容是这样的:

{ _id: new ObjectId("6a5b1476238d3b4dd5000048"),  line_items: [    { _id: ObjectId("4c4b1476238d3b4dd5003981"),      sku: "9092",      name: "Extra Large Wheel Barrow",      quantity: 1,      pricing: {        retail: 5897,        sale: 4897,      }    },    { _id: ObjectId("4c4b1476238d3b4dd5003981"),      sku: "10027",      name: "Rubberized Work Glove, Black",      quantity: 2,      pricing: {       retail: 1499,        sale: 1299,      }    }  ]}  

假设想设置第二个明细条目的数量,把SKU为10027那条的数量设置为5。问题是你不清楚这个特定的子文档在line_items数组里的位置,甚至都不知道它是否存在。只需一个简单的查询选择器,以及一个使用了位置操作符的更新文档,这些问题就都迎刃而解了:

query = {_id: ObjectId("4c4b1476238d3b4dd5003981"),        'line_items.sku': "10027"}update = {$set: {'line_items.$.quantity': 5}}db.orders.update(query, update)  

'line_items.$.quantity'字符串里看到的$就是位置操作符。如果查询选择器匹配到了文档,那么有10027这个SKU的文档的下标就会替换位置操作符,从而更新正确的文档。

如果数据模型中包含子文档,那么你会发现在执行精细的文档更新操作时,位置操作符实在太有用了。

6.4.3 findAndModify命令

本章已经出现了很多findAndModify命令的鲜活示例,就差罗列它的选项了。在以下选项中,只有query以及updateremove是必选的。1

1. updateremove二选一。——译者注

  • query,文档查询选择器,默认为{}

  • update,描述更新的文档,默认为{}

  • remove,布尔值,为true时删除对象并返回,默认为false

  • new,布尔值,为true时返回修改后的文档,默认为false

  • sort,指定排序方向的文档,因为findAndModify一次只修改一个文档,sort选项能用来控制处理哪个文档。例如,可以按照{created_at: -1}来排序,处理最近创建的匹配文档。

  • fields,如果只需要返回字段的子集,可以通过该选项指定。当文档很大时,这个选项很有用。能像在其他查询里一样指定字段,请查看第5章中与字段相关的示例。

  • upsert,布尔值,为true时将findAndModify当做upsert对待。如果文档不存在,则创建之。请注意,如果希望返回新创建的文档,还需要指定{new: true}

6.4.4 删除

得知删除文档的操作毫无挑战之后,你一定非常宽慰。我们可以删除整个集合,也可以向remove方法传递一个查询选择器,删除集合的子集。删除全部评论是很容易的:

db.reviews.remove({})  

但更常见的做法是删除特定用户的评论:

db.reviews.remove({user_id: ObjectId('4c4b1476238d3b4dd5000001')})  

所有对remove的调用都接受一个可选的查询选择器,用于指定要删除的文档。正如API所示,没有其他要说明的内容了。也许你会对这些操作的并发性和原子性心存疑问,下一节里我会对此做出解释。

6.4.5 并发性、原子性与隔离性

理解MongoDB中如何保证并发性是很重要的。自MongoDB v2.0起,锁策略非常粗放,靠一个全局读写锁来控制整个mongod实例。2这也就意味着,任何时刻,数据库只允许存在一个写线程或多个读线程(两者不能并存)。实际情况比听上去要好得多,因为这个锁策略还有一些并发优化措施。其中之一是,数据库持有一个内部映射,知道哪些文档在内存里。对于那些不在内存里的文档的读写,数据库会让步于其他操作,直到文档被载入内存。

2. 本书翻译过程后期,MongoDB推出了2.2版本,去掉了全局的写锁。——译者注

第二个优化是写锁让步。任何写操作都可能耗时很久,所有其他的读写操作在此期间都会被阻塞。所有的插入、更新和删除都要持有写锁。插入的耗时一般不长,但更新就不一样了,比方说更新整个集合需要很久,涉及很多文档的删除操作也是如此。当前的解决方案是允许这些耗时很久的操作周期性地暂停,以便执行其他的读和写。在操作暂停时,它会自己停下来,释放锁,稍后再恢复。3

3. 当然,暂停和恢复通常发生在几毫秒内,因此我们这里不讨论极端的中断。

但在更新和删除文档时,这种暂停行为可能好坏掺半。很容易想到这种情况:希望在其他操作发生前更新或删除所有文档。在这些情况下,可以使用名为$atomic的特殊选项来避免暂停。简单地在查询选择器中添加$atomic操作符即可:

db.reviews.remove({user_id: ObjectId('4c4b1476238d3b4dd5000001'),{$atomic: true}})  

对于多文档更新,也可以做同样的处理。这迫使整个多文档更新在隔离的情况下执行完毕:

db.reviews.update({$atomic: true}, {$set: {rating: 0}}, false, true)  

这个更新操作将所有评论的评分设为0。因为操作是隔离执行的,所以不会暂停,保证系统始终是一致的。4

4. 注意,如果使用了$atomic的操作中途失败,并不会自动回滚。只有一半文档被更新,而另一半还是保持原来的值。

6.4.6 更新性能说明

经验表明,对更新是如何作用于磁盘上的文档能有一个基本认识,有助于设计出性能更好的系统。你应该理解的第一件事是何种程度的更新能被称为“原地”更新。理想情况下,在磁盘上,更新对一个BSON文档的影响只是极小一部分,这样的性能是最好的,但事实并非总是如此。我来解释一下其中的缘由。

磁盘上的文档更新本质上分三种。第一种,也是最高效的,只发生在单值修改并且整个BSON文档的大小不改变的情况下。这通常会发生在$inc操作符上,因为$inc只会增加一个整数,该值在磁盘上的大小并不改变。如果这个整数是由int表示的,那么它会占用四个字节;长整数和双精度浮点数会占用八个字节。更改这些数字的值并不需要更多空间,因此磁盘上就只需重写该文档的一个值。

第二种更新会改变文档的大小和结构。BSON文档会表示为字节数组,文档的头四个字节总是存储文档的大小。因此,当在文档上使用$push操作符时,不仅增加整个文档的大小,还改变它的结构。这要求在磁盘上重写整个文档,这么做的效率还不算太差,但还是应该注意一下。如果在一个更新中使用了多个更新操作符,那么每个操作符都会重写一次文档。这也通常不算什么大问题,尤其是写操作发生在内存里时。但如果文档特别大,比如有4 MB左右,而你又在用$push向那些文档里添加值,那么服务器端就可能要做很多事情了。5

5. 如果你打算执行很多更新操作,那么保持较小的文档是理所应当的事。

最后一种更新是重写文档的结果。如果文档扩大了,无法放入之前分配的磁盘空间里,那么该文档不仅要重写,而且还必须移到新的空间里。这种移动操作如果经常发生,代价还是很大的。为了降低此类开销,MongoDB会根据每个集合的情况动态调整填充因子(padding factor)。也就是说,如果有一个集合会发生很多要重新分配空间的更新,则会增加其内部填充因子。填充因子乘上插入文档的大小后就能得到要额外创建的空间。这能减少未来重新分配文档的数量。

要查看指定集合的填充因子,可以运行stats命令:

db.tweets.stats{  "ns" : "twitter.tweets",  "count" : 53641,  "size" : 85794884,  "avgObjSize" : 1599.4273783113663,  "storageSize" : 100375552,  "numExtents" : 12,  "nindexes" : 3,  "lastExtentSize" : 21368832,  "paddingFactor" : 1.2,  "flags" : 0,  "totalIndexSize" : 7946240,  "indexSizes" : {  "_id_" : 2236416,  "user.friends_count_1" : 1564672,  "user.screen_name_1_user.created_at_-1" : 4145152},"ok" : 1 }  

这一推文集合的填充因子是1.2,即插入100 B的文档时,MongoDB会在磁盘上分配120 B。默认的填充因子是1,即不会分配额外空间。

现在,有个小小的忠告。此处讨论到的注意事项适用于数据大小超过内存总数,或者写负载极重的情况。因此如果正在为一个高流量网站构建分析系统,请适当参考本节的内容。