资讯专栏INFORMATION COLUMN

MongoDB指南---6、更新文档

zero / 1497人阅读

摘要:所以,两个需要同时进行的更新会迅速接连完成,此过程不会破坏文档最新的更新会取得胜利。可以使用原子性的更新修改器,指定对文档中的某些字段进行更新。

上一篇文章:MongoDB指南---5、创建、删除文档
下一篇文章:MongoDB指南---7、find简介与查询条件

文档存入数据库以后,就可以使用update方法来更新它。update有两个参数,一个是查询文档,用于定位需要更新的目标文档;另一个是修改器(modifier)文档,用于说明要对找到的文档进行哪些修改。
更新操作是不可分割的:若是两个更新同时发生,先到达服务器的先执行,接着执行另外一个。所以,两个需要同时进行的更新会迅速接连完成,此过程不会破坏文档:最新的更新会取得“胜利”。

3.3.1 文档替换

最简单的更新就是用一个新文档完全替换匹配的文档。这适用于进行大规模模式迁移的情况。例如,要对下面的用户文档做一个比较大的调整:

{
    "_id" : ObjectId("4b2b9f67a1f631733d917a7a"),
    "name" : "joe",
    "friends" : 32,
    "enemies" : 2
}

我们希望将"friends"和"enemies"两个字段移到"relationships"子文档中。可以在shell中改变文档的结构,然后使用update替换数据库中的当前文档:

> var joe = db.users.findOne({"name" : "joe"}); 
> joe.relationships = {"friends" : joe.friends, "enemies" : joe.enemies};
{
    "friends" : 32,
    "enemies" : 2
}> joe.username = joe.name;
"joe"
> delete joe.friends;
true
> delete joe.enemies;
true
> delete joe.name;
true
> db.users.update({"name" : "joe"}, joe);

现在,用findOne查看更新后的文档结构。

{
    "_id" : ObjectId("4b2b9f67a1f631733d917a7a"),
    "username" : "joe",
    "relationships" : {
        "friends" : 32,
        "enemies" : 2
    }
}

一个常见的错误是查询条件匹配到了多个文档,然后更新时由于第二个参数的存在就产生重复的"_id"值。数据库会抛出错误,任何文档都不会更新。
例如,有好几个文档都有相同的"name"值,但是我们没有意识到:

> db.people.find()
{"_id" : ObjectId("4b2b9f67a1f631733d917a7b"), "name" : "joe", "age" : 65},
{"_id" : ObjectId("4b2b9f67a1f631733d917a7c"), "name" : "joe", "age" : 20},
{"_id" : ObjectId("4b2b9f67a1f631733d917a7d"), "name" : "joe", "age" : 49},·

现在如果第二个Joe过生日,要增加"age"的值,我们可能会这么做:

> joe = db.people.findOne({"name" : "joe", "age" : 20});
{
    "_id" : ObjectId("4b2b9f67a1f631733d917a7c"),
    "name" : "joe",
    "age" : 20
}
> joe.age++;
> db.people.update({"name" : "joe"}, joe);
E11001 duplicate key on update

到底怎么了?调用update时,数据库会查找一个"name"值为"Joe"的文档。找到的第一个是65岁的Joe。然后数据库试着用变量joe中的内容替换找到的文档,但是会发现集合里面已经有一个具有同样"_id"的文档。所以,更新就会失败,因为"_id"值必须唯一。为了避免这种情况,最好确保更新时总是指定一个唯一文档,例如使用"_id"这样的键来匹配。对于上面的例子,这才是正确的更新方法:

> db.people.update({"_id" : ObjectId("4b2b9f67a1f631733d917a7c")}, joe)

使用"_id"作为查询条件比使用随机字段速度更快,因为是通过"_id"建立的索引。第5章会介绍索引对更新和其他操作的影响。

3.3.2 使用修改器

通常文档只会有一部分要更新。可以使用原子性的更新修改器(update modifier),指定对文档中的某些字段进行更新。更新修改器是种特殊的键,用来指定复杂的更新操作,比如修改、增加或者删除键,还可能是操作数组或者内嵌文档。
假设要在一个集合中放置网站的分析数据,只要有人访问页面,就增加计数器。可以使用更新修改器原子性地完成这个增加。每个URL及对应的访问次数都以如下方式存储在文档中:

{
    "_id" : ObjectId("4b253b067525f35f94b60a31"),
    "url" : "www.example.com",
    "pageviews" : 52
}

每次有人访问页面,就通过URL找到该页面,并用"$inc"修改器增加"pageviews"的值。

> db.analytics.update({"url" : "www.example.com"},
... {"$inc" : {"pageviews" : 1}})

现在,执行一个find操作,会发现"pageviews"的值增加了1。

> db.analytics.find()
{
    "_id" : ObjectId("4b253b067525f35f94b60a31"),
    "url" : "www.example.com",
    "pageviews" : 53
}

使用修改器时,"_id"的值不能改变。(注意,整个文档替换时可以改变"_id"。)其他键值,包括其他唯一索引的键,都是可以更改的。

1. "$set"修改器入门

"$set"用来指定一个字段的值。如果这个字段不存在,则创建它。这对更新模式或者增加用户定义的键来说非常方便。例如,用户资料存储在下面这样的文档里:

> db.users.findOne()
{
    "_id" : ObjectId("4b253b067525f35f94b60a31"),
    "name" : "joe",
    "age" : 30,
    "sex" : "male",
    "location" : "Wisconsin"
}

非常简要的一段用户信息。要想添加喜欢的书籍进去,可以使用"$set":

> db.users.update({"_id" : ObjectId("4b253b067525f35f94b60a31")},
... {"$set" : {"favorite book" : "War and Peace"}})

之后文档就有了"favorite book"键。

> db.users.findOne()
{
    "_id" : ObjectId("4b253b067525f35f94b60a31"),
    "name" : "joe",
    "age" : 30,
    "sex" : "male",
    "location" : "Wisconsin",
    "favorite book" : "War and Peace"
}

要是用户觉得喜欢的其实是另外一本书,"$set"又能帮上忙了:

> db.users.update({"name" : "joe"},
... {"$set" : {"favorite book" : "Green Eggs and Ham"}})

用"$set"甚至可以修改键的类型。例如,如果用户觉得喜欢很多本书,就可以将"favorit ebook"键的值变成一个数组:

> db.users.update({"name" : "joe"},
... {"$set" : {"favorite book" :
...     ["Cat"s Cradle", "Foundation Trilogy", "Ender"s Game"]}})

如果用户突然发现自己其实不爱读书,可以用"$unset"将这个键完全删除:

> db.users.update({"name" : "joe"},
... {"$unset" : {"favorite book" : 1}})

现在这个文档就和刚开始时一样了。
也可以用"$set"修改内嵌文档:

> db.blog.posts.findOne()
{
    "_id" : ObjectId("4b253b067525f35f94b60a31"),
    "title" : "A Blog Post",
    "content" : "...",
    "author" : {
        "name" : "joe",
        "email" : "joe@example.com"
    }
}
> db.blog.posts.update({"author.name" : "joe"},
... {"$set" : {"author.name" : "joe schmoe"}})
> db.blog.posts.findOne()
{
    "_id" : ObjectId("4b253b067525f35f94b60a31"),
    "title" : "A Blog Post",
    "content" : "...",
    "author" : {
        "name" : "joe schmoe",
        "email" : "joe@example.com"
    }
}

增加、修改或删除键时,应该使用$修改器。要把"foo"的值设为"bar",常见的错误做法如下:

> db.coll.update(criteria, {"foo" : "bar"})

这会事与愿违。实际上这会将整个文档用{"foo":"bar"}替换掉。一定要使用以$开头的修改器来修改键/值对。

2. 增加和减少

"$inc"修改器用来增加已有键的值,或者该键不存在那就创建一个。对于更新分析数据、因果关系、投票或者其他有变化数值的地方,使用这个都会非常方便。
假如建立了一个游戏集合,将游戏和变化的分数都存储在里面。比如用户玩弹球(pinball)游戏,可以插入一个包含游戏名和玩家的文档来标识不同的游戏:

> db.games.insert({"game" : "pinball", "user" : "joe"})

要是小球撞到了砖块,就会给玩家加分。分数可以随便给,这里就把玩家得分基数约定成50好了。使用"$inc"修改器给玩家加50分:

> db.games.update({"game" : "pinball", "user" : "joe"},
... {"$inc" : {"score" : 50}})

更新后,可以看到:

> db.games.findOne()
{
     "_id" : ObjectId("4b2d75476cc613d5ee930164"),
     "game" : "pinball",
     "user" : "joe",
     "score" : 50
 }

分数(score)键原来并不存在,所以"$inc"创建了这个键,并把值设定成增加量:50。
如果小球落入加分区,要加10 000分。只要给"$inc"传递一个不同的值就好了:

> db.games.update({"game" : "pinball", "user" : "joe"},
... {"$inc" : {"score" : 10000}})

现在来看看结果:

> db.games.find()
{
     "_id" : ObjectId("4b2d75476cc613d5ee930164"),
     "game" : "pinball",
     "user" : "joe",
     "score" : 10050
}

"score"键已经有了,而且有一个数字类型的值,所以服务器就给这个值增加了10 000。
"$inc"与"$set"的用法类似,就是专门来增加(和减少)数字的。"$inc"只能用于整型、长整型或双精度浮点型的值。要是用在其他类型的数据上就会导致操作失败,例如null、布尔类型以及数字构成的字符串,而在其他很多语言中,这些类型都会自动转换为数值类型。

> db.foo.insert({"count" : "1"})
> db.foo.update({}, {"$inc" : {"count" : 1}})
Cannot apply $inc modifier to non-number

另外,"$inc"键的值必须为数字。不能使用字符串、数组或其他非数字的值。否则就会提示“Modifier"$inc"allowed for numbers only”(修改器"$inc"只允许使用数值类型)这样的错误。要修改其他类型,应该使用"$set"或者一会儿要讲到的数组修改器。

3. 数组修改器

有一大类很重要的修改器可用于操作数组。数组是常用且非常有用的数据结构:它们不 仅是可通过索引进行引用的列表,而且还可以作为数据集(set)来用。

4. 添加元素

如果数组已经存在,"$push"会向已有的数组末尾加入一个元素,要是没有就创建一个新的数组。例如,假设要存储博客文章,要添加一个用于保存数组的"comments"(评论)键。可以向还不存在的"comments"数组添加一条评论,这个数组会被自动创建,并加入一条评论:

> db.blog.posts.findOne()
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "title" : "A blog post",
    "content" : "..."
}
> db.blog.posts.update({"title" : "A blog post"},
... {"$push" : {"comments" :
...    {"name" : "joe", "email" : "joe@example.com",
...    "content" : "nice post."}}})
> db.blog.posts.findOne()
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "title" : "A blog post",
    "content" : "...",
    "comments" : [
        {
            "name" : "joe",
            "email" : "joe@example.com",
            "content" : "nice post."
        }
    ]
}

要是还想添加一条评论,继续使用"$push":

> db.blog.posts.update({"title" : "A blog post"},
... {"$push" : {"comments" :
...     {"name" : "bob", "email" : "bob@example.com",
...     "content" : "good post."}}})
> db.blog.posts.findOne()
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "title" : "A blog post",
    "content" : "...",
    "comments" : [
        {
            "name" : "joe",
            "email" : "joe@example.com",
            "content" : "nice post."
        },
        {
            "name" : "bob",
            "email" : "bob@example.com",
            "content" : "good post."
        }
    ]
}

这是一种比较简单的"$push"使用形式,也可以将它应用在一些比较复杂的数组操作中。使用"$each"子操作符,可以通过一次"$push"操作添加多个值。

> db.stock.ticker.update({"_id" : "GOOG"},
... {"$push" : {"hourly" : {"$each" : [562.776, 562.790, 559.123]}}})

这样就可以将三个新元素添加到数组中。如果指定的数组中只含有一个元素,那这个操作就等同于没有使用"$each"的普通"$push"操作。
如果希望数组的最大长度是固定的,那么可以将"$slice"和"$push"组合在一起使用,这样就可以保证数组不会超出设定好的最大长度,这实际上就得到了一个最多包含N个元素的数组:

> db.movies.find({"genre" : "horror"},
... {"$push" : {"top10" : {
...     "$each" : ["Nightmare on Elm Street", "Saw"],
...     "$slice" : -10}}})

这个例子会限制数组只包含最后加入的10个元素。"$slice"的值必须是负整数。
如果数组的元素数量小于10("$push"之后),那么所有元素都会保留。如果数组的元素数量大于10,那么只有最后10个元素会保留。因此,"$slice"可以用来在文档中创建一个队列。
最后,可以在清理元素之前使用"$sort",只要向数组中添加子对象就需要清理:

> db.movies.find({"genre" : "horror"},
... {"$push" : {"top10" : {
...     "$each" : [{"name" : "Nightmare on Elm Street", "rating" : 6.6},
...                {"name" : "Saw", "rating" : 4.3}],
...     "$slice" : -10,
...     "$sort" : {"rating" : -1}}}})

这样会根据"rating"字段的值对数组中的所有对象进行排序,然后保留前10个。注意,不能只将"$slice"或者"$sort"与"$push"配合使用,且必须使用"$each"。

5. 将数组作为数据集使用

你可能想将数组作为集合使用,保证数组内的元素不会重复。可以在查询文档中用"$ne"来实现。例如,要是作者不在引文列表中,就添加进去,可以这么做:

> db.papers.update({"authors cited" : {"$ne" : "Richie"}},
... {$push : {"authors cited" : "Richie"}})

也可以用"$addToSet"来实现,要知道有些情况"$ne"根本行不通,有些时候更适合用"$addToSet"。
例如,有一个表示用户的文档,已经有了电子邮件地址的数据集:

> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "username" : "joe",
    "emails" : [
        "joe@example.com",
        "joe@gmail.com",
        "joe@yahoo.com"
    ]
}

添加新地址时,用"$addToSet"可以避免插入重复地址:

> db.users.update({"_id" : ObjectId("4b2d75476cc613d5ee930164")},
... {"$addToSet" : {"emails" : "joe@gmail.com"}})
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "username" : "joe",
    "emails" : [
        "joe@example.com",
        "joe@gmail.com",
        "joe@yahoo.com",
    ]
}
> db.users.update({"_id" : ObjectId("4b2d75476cc613d5ee930164")},
... {"$addToSet" : {"emails" : "joe@hotmail.com"}})
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "username" : "joe",
    "emails" : [
        "joe@example.com",
        "joe@gmail.com",
        "joe@yahoo.com",
        "joe@hotmail.com"
    ]
}

将"$addToSet"和"$each"组合起来,可以添加多个不同的值,而用"$ne"和"$push"组合就不能实现。例如,想一次添加多个邮件地址,就可以使用这些修改器:

> db.users.update({"_id" : ObjectId("4b2d75476cc613d5ee930164")}, {"$addToSet" :
... {"emails" : {"$each" :
...     ["joe@php.net", "joe@example.com", "joe@python.org"]}}})
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "username" : "joe",
    "emails" : [
        "joe@example.com",
        "joe@gmail.com",
        "joe@yahoo.com",
        "joe@hotmail.com"
        "joe@php.net"
        "joe@python.org"
    ]
}
6. 删除元素

有几个从数组中删除元素的方法。若是把数组看成队列或者栈,可以用"$pop",这个修改器可以从数组任何一端删除元素。{"$pop":{"key":1}}从数组末尾删除一个元素,{"$pop":{"key":-1}}则从头部删除。
有时需要基于特定条件来删除元素,而不仅仅是依据元素位置,这时可以使用"$pull"。例如,有一个无序的待完成事项列表:

> db.lists.insert({"todo" : ["dishes", "laundry", "dry cleaning"]})

要是想把洗衣服(laundry)放到第一位,可以从列表中先把它删掉:

> db.lists.update({}, {"$pull" : {"todo" : "laundry"}})

通过查找,会发现只有两个元素了:

> db.lists.find()
{
    "_id" : ObjectId("4b2d75476cc613d5ee930164"),
    "todo" : [
        "dishes",
        "dry cleaning"
    ]
}

"$pull"会将所有匹配的文档删除,而不是只删除一个。对数组[1,1,2,1]执行pull 1,结果得到 只有一个元素的数组2。
数组操作符只能用于包含数组值的键。例如,不能将一个整数插入数组,也不能将一个字符串从数组中弹出。要修改标量值,使用"$set"或者"$inc"。

7. 基于位置的数组修改器

若是数组有多个值,而我们只想对其中的一部分进行操作,就需要一些技巧。有两种方法操作数组中的值:通过位置或者定位操作符("$")。
数组下标都是以0开头的,可以将下标直接作为键来选择元素。例如,这里有个文档,其中包含由内嵌文档组成的数组,比如包含评论的博客文章。

> db.blog.posts.findOne()
{
    "_id" : ObjectId("4b329a216cc613d5ee930192"),
    "content" : "...",
    "comments" : [
        {
            "comment" : "good post",
            "author" : "John",
            "votes" : 0
        },
        {
            "comment" : "i thought it was too short",
            "author" : "Claire",
            "votes" : 3
        },
        {
            "comment" : "free watches",
            "author" : "Alice",
            "votes" : -1
        }
    ]
}

如果想增加第一个评论的投票数量,可以这么做:

> db.blog.update({"post" : post_id},
... {"$inc" : {"comments.0.votes" : 1}})

但是很多情况下,不预先查询文档就不能知道要修改的数组的下标。为了克服这个困难,MongoDB提供了定位操作符"$",用来定位查询文档已经匹配的数组元素,并进行更新。例如,要是用户John把名字改成了Jim,就可以用定位符替换他在评论中的名字:

db.blog.update({"comments.author" : "John"},
... {"$set" : {"comments.$.author" : "Jim"}})

定位符只更新第一个匹配的元素。所以,如果John发表了多条评论,那么他的名字只在第一条评论中改变。

8. 修改器速度

有的修改器运行比较快。$inc能就地修改,因为不需要改变文档的大小,只需要将 键的值修改一下(对文档大小的改变非常小),所以非常快。而数组修改器可能会改变文档的大小,就会慢一些("$set"能在文档大小不发生变化时立即修改它,否则性能也会有所下降)。
将文档插入到MongoDB中时,依次插入的文档在磁盘上的位置是相邻的。因此,如果一个文档变大了,原先的位置就放不下这个文档了,这个文档就会被移动到集合中的另一个位置。
可以在实际操作中看到这种变化。创建一个包含几个文档的集合,对某个位于中间的文档进行修改,使其尺寸变大。然后会发现这个文档被移动到了集合的尾部:

> db.coll.insert({"x" :"a"})
> db.coll.insert({"x" :"b"})
> db.coll.insert({"x" :"c"})
> db.coll.find()
{ "_id" : ObjectId("507c3581d87d6a342e1c81d3"), "x" : "a" }
{ "_id" : ObjectId("507c3583d87d6a342e1c81d4"), "x" : "b" }
{ "_id" : ObjectId("507c3585d87d6a342e1c81d5"), "x" : "c" }
> db.coll.update({"x" : "b"}, {$set: {"x" : "bbb"}})
> db.coll.find()
{ "_id" : ObjectId("507c3581d87d6a342e1c81d3"), "x" : "a" }
{ "_id" : ObjectId("507c3585d87d6a342e1c81d5"), "x" : "c" }
{ "_id" : ObjectId("507c3583d87d6a342e1c81d4"), "x" : "bbb" }

MongoDB不得不移动一个文档时,它会修改集合的填充因子(padding factor)。填充因子是MongoDB为每个新文档预留的增长空间。可以运行db.coll.stats()查看填充因子。执行上面的更新之前,"paddingFactor"字段的值是1:根据实际的文档大小,为每个新文档分配精确的空间,不预留任何增长空间,如图3-1所示。让其中一个文档增大之后,再次运行这个命令(如图3-2所示),会发现填充因子增加到了1.5:为每个新文档预留其一半大小的空间作为增长空间,如图3-2所示。如果随后的更新导致了更多次的文档移动,填充因子会持续变大(虽然不会像第一次移动时的变化那么大)。如果不再有文档移动,填充因子的值会缓慢降低,如图3-3所示。

图3-1 最初,文档之间没有多余的空间

图3-2 如果一个文档因为体积变大而不得不进行移动,它原先占用的空间就闲置了,而且填充因子会增加

图3-3 之后插入的新文档都会拥有填充因子指定大小的增长空间。如果在之后的插入中不再发生文档移动,填充因子会逐渐变小
移动文档是非常慢的。MongoDB必须将文档原先所占的空间释放掉,然后将文档写入另一片空间。因此,应该尽量让填充因子的值接近1。无法手动设定填充因子的值(除非是要对集合进行压缩,参见18.4节),但是可以设计一种不依赖于文档、可以任意增长的模式。第8章会详细介绍模式设计的相关内容。
下面用一个简单的程序来展示原地更新和文档移动的速度差别。下面的程序插入了一个只包含一个键的文档,并且对这个键的值进行了100 000次增加:

> db.tester.insert({"x" : 1})
> var timeInc = function() {
... var start = (new Date()).getTime();
...
... for (var i=0; i<100000; i++) {
...     db.tester.update({}, {"$inc" : {"x" : 1}});
...     db.getLastError();
... }
...
... var timeDiff = (new Date()).getTime() - start;
... print("Updates took: "+timeDiff+"ms");
... }
> timeInc()

在MacBook Air上,总共花费了7.33秒。也就是每秒超过13 000次更新。现在,使用"$push"向一个只有一个键的数组中插入新数据,重复100 000次。将上面例子中用于更新文档的代码修改为:

...   db.tester.update({}, {"$push" : {"x" : 1}})

这个程序运行时间为67.58秒,每秒少于1500次更新。
使用"$push"以及其他一些数组修改器是非常好的,而且通常是必要的,但是,在进行类似的更新时,需要好好权衡一下。如果"$push"成为了瓶颈,那么将一个内嵌文档取出放入一个多带带的集合中,手动填充,或者使用第8章将要介绍的其他某项技术,都很值得。
写作本书时,MongoDB仍然不能很好地重用空白空间,因此频繁移动文档会产生大量空的数据文件。如果有太多不能重用的空白空间,你会经常在日志中看到如下信息:

Thu Apr 5 01:12:28 [conn124727] info DFM::findAll(): extent a:7f18dc00 was empty, skipping ahead

这就是说,执行查询时,MongoDB会在整个范围(entire extent,可以在附录B中查看相关定义。简单来说,它就是集合的一个子集)内进行查找,却找不到任何文档:这只是个空白空间。这个消息提示本身没什么影响,但是它指出你当前拥有太多的碎片,可能需要进行压缩。
如果你的模式在进行插入和删除时会进行大量的移动或者是经常打乱数据,可以使用usePowerOf2Sizes选项以提高磁盘复用率。可以通过collMod命令来设定这个选项:

> db.runCommand({"collMod" : collectionName, "usePowerOf2Sizes" : true})

这个集合之后进行的所有空间分配,得到的块大小都是2的幂。由于这个选项会导致初始空间分配不再那么高效,所以应该只在需要经常打乱数据的集合上使用。在一个只进行插入或者原地更新的集合上使用这个选项,会导致写入速度变慢。
如果在这个命令中指定"usePowerOf2Sizes"选项的值为false,就会关闭这种特殊分配机制。这个选项只会影响之后新分配的记录,因此,在已有的集合上运行这个命令或者是更改这个选项的值,不会对现有数据产生影响。

3.3.3 upsert

upsert是一种特殊的更新。要是没有找到符合更新条件的文档,就会以这个条件和更新文档为基础创建一个新的文档。如果找到了匹配的文档,则正常更新。upsert非常方便,不必预置集合,同一套代码既可以用于创建文档又可以用于更新文档。
我们回过头看看那个记录网站页面访问次数的例子。要是没有upsert,就得试着查询URL,没有找到就得新建一个文档,找到的话就增加访问次数。要是把这个写成JavaScript程序,会是下面这样的:

// 检查这个页面是否有一个文档
blog = db.analytics.findOne({url : "/blog"})

// 如果有,就将视图数加/并保存
if (blog) {
    blog.pageviews++;
    db.analytics.save(blog);
}
// 否则为这个页面创建一个新文档
else {
    db.analytics.save({url : "/blog", pageviews : 1})
}

这就是说如果有人访问页面,我们得先对数据库进行查询,然后选择更新或者插入。要是多个进程同时运行这段代码,还会遇到同时对给定URL插入多个文档这样的竞态条件。
要是使用upsert,既可以避免竞态问题,又可以缩减代码量(update的第3个参数表示这是个upsert):

db.analytics.update({"url" : "/blog"}, {"$inc" : {"pageviews" : 1}}, true)

这行代码和之前的代码作用完全一样,但它更高效,并且是原子性的!创建新文档会将条件文档作为基础,然后对它应用修改器文档。
例如,要是执行一个匹配键并增加对应键值的upsert操作,会在匹配的文档上进行增加:

> db.users.update({"rep" : 25}, {"$inc" : {"rep" : 3}}, true)
> db.users.findOne()
{
    "_id" : ObjectId("4b3295f26cc613d5ee93018f"),
    "rep" : 28
}

upsert创建一个"rep"值为25的文档,随后将这个值加3,最后得到"rep"为28的文档。要是不指定upsert选项,{"rep":25}不会匹配任何文档,也就不会对集合进行任何更新。
要是再次运行这个upsert(条件为{"rep":25}),还会创建一个新文档。这是因为没有文档满足匹配条件(唯一一个文档的"rep"值是28)。
有时,需要在创建文档的同时创建字段并为它赋值,但是在之后的所有更新操作中,这个字段的值都不再改变。这就是"$setOnInsert"的作用。"$setOnInsert"只会在文档插入时设置字段的值。因此,实际使用中可以这么做:

> db.users.update({}, {"$setOnInsert" : {"createdAt" : new Date()}}, true)
> db.users.findOne()
{
    "_id" : ObjectId("512b8aefae74c67969e404ca"),
    "createdAt" : ISODate("2013-02-25T16:01:50.742Z")
}

如果再次运行这个更新,会匹配到这个已存在的文档,所以不会再插入文档,因此"createdAt"字段的值也不会改变:

> db.users.update({}, {"$setOnInsert" : {"createdAt" : new Date()}}, true)
> db.users.findOne()
{
    "_id" : ObjectId("512b8aefae74c67969e404ca"),
    "createdAt" : ISODate("2013-02-25T16:01:50.742Z")
}

注意,通常不需要保留"createdAt"这样的字段,因为ObjectIds里包含了一个用于标明文档创建时间的时间戳。但是,在预置或者初始化计数器时,或者是对于不使用ObjectIds的集合来说,"$setOnInsert"是非常有用的。

save shell帮助程序

save是一个shell函数,如果文档不存在,它会自动创建文档;如果文档存在,它就更新这个文档。它只有一个参数:文档。要是这个文档含有"_id"键,save会调用upsert。否则,会调用insert。如果在Shell中使用这个函数,就可以非常方便地对文档进行快速修改。

> var x = db.foo.findOne()
> x.num = 42
42
> db.foo.save(x)

要是不用save的话,最后一行代码看起来就会比较繁琐了,比如db.foo.up date({"_id" : x._id}, x)。

3.3.4 更新多个文档

默认情况下,更新只能对符合匹配条件的第一个文档执行操作。要是有多个文档符合条件,只有第一个文档会被更新,其他文档不会发生变化。要更新所有匹配的文档,可以将update的第4个参数设置为true。
update的行为以后可能会发生变化(服务器可能默认会更新所有匹配的文档,只有第4个参数为false才会只更新一个),所以建议每次都显式表明要不要做多文档更新。
这样不但更明确地指定了update的行为,而且可以在默认行为发生变化时正常运行。
多文档更新对模式迁移非常有用,还可以在对特定用户发布新功能时使用。例如,要送给在个指定日期过生日的所有用户一份礼物,就可以使用多文档更新,将"gift"增加到他们的账号:

> db.users.update({"birthday" : "10/13/1978"},
... {"$set" : {"gift" : "Happy Birthday!"}}, false, true)

这样就给生日为1978年10月13日的所有用户文档添加了"gift"键。
想要知道多文档更新到底更新了多少文档,可以运行getLastError命令(可以理解为“返回最后一次操作的相关信息”)。键"n"的值就是被更新文档的数量。

> db.count.update({x : 1}, {$inc : {x : 1}}, false, true)
> db.runCommand({getLastError : 1})
{
    "err" : null,
    "updatedExisting" : true,
    "n" : 5,
    "ok" : true
}

这里"n"为5,说明有5个文档被更新了。"updatedExisting"为true,说明是对已有的文档进行更新。

3.3.5 返回被更新的文档

调用getLastError仅能获得关于更新的有限信息,并不能返回被更新的文档。可以通过findAndModify命令得到被更新的文档。这对于操作队列以及执行其他需要进行原子性取值和赋值的操作来说,十分方便。
假设我们有一个集合,其中包含以一定顺序运行的进程。其中每个进程都用如下形式的文档表示:

{
    "_id" : ObjectId(),
    "status" : state,
    "priority" : N
}

"status"是一个字符串,它的值可以是"READY"、"RUNNING"或"DONE"。需要找到状态为"READY"具有最高优先级的任务,运行相应的进程函数,然后将其状态更新为"DONE"。也可能需要查询已经就绪的进程,按照优先级排序,然后将优先级最高的进程的状态更新为"RUNNING"。完成了以后,就把状态改为"DONE"。就像下面这样:

var cursor = db.processes.find({"status" : "READY"});
ps = cursor.sort({"priority" : -1}).limit(1).next();
db.processes.update({"_id" : ps._id}, {"$set" : {"status" : "RUNNING"}});
do_something(ps);
db.processes.update({"_id" : ps._id}, {"$set" : {"status" : "DONE"}});

这个算法不是很好,可能会导致竞态条件。假设有两个线程正在运行。A线程读取了文档,B线程在A将文档状态改为"RUNNING"之前也读取了同一个文档,这样两个线程会运行相同的处理过程。虽然可以在更新查询中进行状态检查来避免这一问题,但是十分复杂:

var cursor = db.processes.find({"status" : "READY"});
cursor.sort({"priority" : -1}).limit(1);
while ((ps = cursor.next()) != null) {
    ps.update({"_id" : ps._id, "status" : "READY"},
              {"$set" : {"status" : "RUNNING"}});
    var lastOp = db.runCommand({getlasterror : 1});
    if (lastOp.n == 1) {
        do_something(ps);
        db.processes.update({"_id" : ps._id}, {"$set" : {"status" : "DONE"}})
        break;
    }
    cursor = db.processes.find({"status" : "READY"});
    cursor.sort({"priority" : -1}).limit(1);
}

这样也有问题。因为有先有后,很可能一个线程处理了所有任务,而另外一个就傻傻地呆在那里。A线程可能会一直占用着进程,B线程试着抢占失败后,就让A线程自己处理所有任务了。
遇到类似这样的情况时,findAndModify就可大显身手了。findAndModify能够在一个操作中返回匹配结果并且进行更新。在本例中,处理过程如下所示:

> ps = db.runCommand({"findAndModify" : "processes",
... "query" : {"status" : "READY"},
... "sort" : {"priority" : -1},
... "update" : {"$set" : {"status" : "RUNNING"}})
{
    "ok" : 1,
    "value" : {
        "_id" : ObjectId("4b3e7a18005cab32be6291f7"),
        "priority" : 1,
        "status" : "READY"
    }
}

注意,返回文档中的状态仍然为"READY",因为findAndModify返回的是修改之前的文档。要是再在集合上进行一次查询,会发现这个文档的"status"已经更新成了"RUNNING":

> db.processes.findOne({"_id" : ps.value._id})
{
    "_id" : ObjectId("4b3e7a18005cab32be6291f7"),
    "priority" : 1,
    "status" : "RUNNING"
}

这样的话,程序就变成了下面这样:

ps = db.runCommand({"findAndModify" : "processes",
    "query" : {"status" : "READY"},
    "sort" : {"priority" : -1},
    "update" : {"$set" : {"status" : "RUNNING"}}}).value 
do_something(ps)
db.process.update({"_id" : ps._id}, {"$set" : {"status" : "DONE"}})

findAndModify可以使用"update"键也可以使用"remove"键。"remove"键表示将匹配的文档从集合里面删除。例如,现在不用更新状态了,而是直接删掉,就可以像下面这样:

ps = db.runCommand({"findAndModify" : "processes",
    "query" : {"status" : "READY"},
    "sort" : {"priority" : -1},
    "remove" : true}).value
do_something(ps)

findAndModify命令有很多可以使用的字段。

findAndModify

字符串,集合名。

query

查询文档,用于检索文档的条件。

sort

排序结果的条件。

update

修改器文档,用于对匹配的文档进行更新(update和remove必须指定一个)。

remove

布尔类型,表示是否删除文档(remove和update必须指定一个)。

new

布尔类型,表示返回更新前的文档还是更新后的文档。默认是更新前的文档。

fields

文档中需要返回的字段(可选)。

upsert

布尔类型,值为true时表示这是一个upsert。默认为false。

"update"和"remove"必须有一个,也只能有一个。要是没有匹配的文档,这个命令会返回一个错误。

3.4 写入安全机制

写入安全(Write Concern)是一种客户端设置,用于控制写入的安全级别。默认情况下,插入、删除和更新都会一直等待数据库响应(写入是否成功),然后才会继续执行。通常,遇到错误时,客户端会抛出一个异常(有些语言中可能不叫“异常”,不过实质上都是类似的东西)。
有一些选项可以用于精确控制需要应用程序等待的内容。两种最基本的写入安全机制是应答式写入(acknowledged wirte)和非应答式写入(unacknowledged write)。应答式写入是默认的方式:数据库会给出响应,告诉你写入操作是否成功执行。非应答式写入不返回任何响应,所以无法知道写入是否成功。
通常来说,应用程序应该使用应答式写入。但是,对于一些不是特别重要的数据(比如日志或者是批量加载数据),你可能不愿意为了自己不关心的数据而等待数据库响应。在这种情况下,可以使用非应答式写入。
尽管非应答式写入不返回数据库错误,但是这不代表应用程序不需要做错误检查。如果尝试向已经关闭的套接字(socket)执行写入,或者写入套接字时发生了错误,都会引起异常。
使用非应答式写入时,一种经常被忽视的错误是插入无效数据。比如,如果试图插入两个具有相同"_id"字段的文档,shell就会抛出异常:

> db.foo.insert({"_id" : 1})
> db.foo.insert({"_id" : 1})
E11000 duplicate key error index: test.foo.$_id_ dup key: { : 1.0 }

如果第二次插入时使用的是非应答式写入,那么第二次插入就不会抛出异常。键重复异常是一种非常常见的错误,还有其他很多类似的错误,比如无效的修改器或者是磁盘空间不足等。
shell与客户端程序对非应答式写入的实际支持并不一样:shell在执行非应答式写入后,会检查最后一个操作是否成功,然后才会向用户输出提示信息。因此,如果在集合上执行了一系列无效操作,最后又执行了一个有效操作,shell并不会提示有错误发生。

> db.foo.insert({"_id" : 1}); db.foo.insert({"_id" : 1}); db.foo.count()

可以调用getLastError 手动强制在shell 中进行检查,这一操作会检查最后一次 操作中的错误。

> db.foo.insert({"_id" : 1}); db.foo.insert({"_id" : 1}); print(
... db.getLastError()); db.foo.count()
E11000 duplicate key error index: test.foo.$_id_ dup key: { : 1.0 }
1

编写需要在shell中执行的脚本时,这是非常有用的。
事实上,还有其他一些写入安全机制,第11章会讲述多台服务器之间的写入安全,第19章会讲述写入提交。
2012年,默认的写入安全机制改变了,所以,遗留代码的行为可能会与预期不一致。在此之前,默认的写入是非应答式的。
幸好,很容易得知当前代码是在默认的写入安全机制发生变化之前写的还是之后写的:默认的写入机制变为安全写入之后,所有驱动程序都开始使用MongoClient这个类。如果程序使用的连接对象是Mongo或者Connection或者其他内容,那么这段程序使用的就是旧的、默认不安全的API。在默认写入安全机制发生变化之前,任何语言都没有使用MongoClient作为类名,所以,如果你的代码使用了这个类名,说明你的代码是写入安全的。 如果使用的连接不是MongoClient,应在必要时将旧代码中的非应答式写入改成应答式写入.

上一篇文章:MongoDB指南---5、创建、删除文档
下一篇文章:MongoDB指南---7、find简介与查询条件

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/44012.html

相关文章

  • MongoDB指南---3、MongoDB基础知识-数据类型

    摘要:如将构造函数作为函数进行调用即不包括的方式,返回的是日期的字符串表示,而非日期对象。如果不注意这一点,没有始终使用日期构造函数,将得到一堆混乱的日期对象和日期的字符串。关于日期类的完整解释,以及构造函数的参数格式,参见规范节。 上一篇文章:MongoDB指南---2、MongoDB基础知识-文档、集合、数据库、客户端下一篇文章:MongoDB指南---4、MongoDB基础知识-使用M...

    aervon 评论0 收藏0
  • MongoDB指南---3、MongoDB基础知识-数据类型

    摘要:如将构造函数作为函数进行调用即不包括的方式,返回的是日期的字符串表示,而非日期对象。如果不注意这一点,没有始终使用日期构造函数,将得到一堆混乱的日期对象和日期的字符串。关于日期类的完整解释,以及构造函数的参数格式,参见规范节。 上一篇文章:MongoDB指南---2、MongoDB基础知识-文档、集合、数据库、客户端下一篇文章:MongoDB指南---4、MongoDB基础知识-使用M...

    tunny 评论0 收藏0
  • MongoDB指南---5、创建、删除文档

    摘要:例如,假设要删除集合中所有为的人删除数据是永久性的,不能撤销,也不能恢复。删除速度删除文档通常很快,但是如果要清空整个集合,那么使用直接删除集合会更快然后在这个空集合上重建各项索引。上一篇文章指南基础知识使用下一篇文章指南更新文档 上一篇文章:MongoDB指南---4、MongoDB基础知识-使用MongoDB Shell下一篇文章:MongoDB指南---6、更新文档 本章会介绍...

    jas0n 评论0 收藏0
  • MongoDB指南---5、创建、删除文档

    摘要:例如,假设要删除集合中所有为的人删除数据是永久性的,不能撤销,也不能恢复。删除速度删除文档通常很快,但是如果要清空整个集合,那么使用直接删除集合会更快然后在这个空集合上重建各项索引。上一篇文章指南基础知识使用下一篇文章指南更新文档 上一篇文章:MongoDB指南---4、MongoDB基础知识-使用MongoDB Shell下一篇文章:MongoDB指南---6、更新文档 本章会介绍...

    int64 评论0 收藏0
  • MongoDB指南---7、find简介与查询条件

    摘要:上一篇文章指南更新文档下一篇文章指南特定类型的查询本章将详细介绍查询。查询条件和就是全部的比较操作符,分别对应和。如果查询优化器可以更高效地处理,那就选择使用它。注意,查询优化器不会对进行优化,这与其他操作符不同。 上一篇文章:MongoDB指南---6、更新文档下一篇文章:MongoDB指南---8、特定类型的查询 本章将详细介绍查询。主要会涵盖以下几个方面: 使用find或者f...

    denson 评论0 收藏0
  • MongoDB指南---7、find简介与查询条件

    摘要:上一篇文章指南更新文档下一篇文章指南特定类型的查询本章将详细介绍查询。查询条件和就是全部的比较操作符,分别对应和。如果查询优化器可以更高效地处理,那就选择使用它。注意,查询优化器不会对进行优化,这与其他操作符不同。 上一篇文章:MongoDB指南---6、更新文档下一篇文章:MongoDB指南---8、特定类型的查询 本章将详细介绍查询。主要会涵盖以下几个方面: 使用find或者f...

    tylin 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<