要真正掌握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)
如果不存在_id
是324
的文档,会用该_id
创建一个新文档,文档中temp
的值就是$inc
的2.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
,允许提供一个要删除值的列表。如果要删除dirt
和garden
标签,可以这样使用$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
以及update
或remove
是必选的。1
1. update
与remove
二选一。——译者注
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,即不会分配额外空间。
现在,有个小小的忠告。此处讨论到的注意事项适用于数据大小超过内存总数,或者写负载极重的情况。因此如果正在为一个高流量网站构建分析系统,请适当参考本节的内容。