乐趣区

MongoDB指南6更新文档

上一篇文章:MongoDB 指南 —5、创建、删除文档
下一篇文章:

文档存入数据库以后,就可以使用 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” 必须有一个,也只能有一个。要是没有匹配的文档,这个命令会返回一个错误。

退出移动版