MongoDB中是否有查找或插入的选项?

7
我有一个MongoDB文档,希望只在不存在的情况下将其添加到集合中,但不更改现有文档。换句话说,我正在寻找一种原子方式来:
1. find if a document exists (based on a given key criteria)
2. if it exists: 
2.1   return it
   otherwise:
2.1   add a new one

这类似于 upsert选项,但它更倾向于现有文档而不是新文档。
附注:如果可能的话,我希望不使用唯一索引
提前感谢大家。

请再次阅读upsert部分。它确实就是你所要求的。 - Neil Lunn
@NeilLunn 我再次阅读了一遍,恐怕我们对此有不同意见。upsert是插入或更新,而我正在寻找的是插入或不更新。 - akiva
2
实际上并没有查找或插入命令,逻辑上它们是不同的操作。最好的选择是进行两个操作,先插入再查找。 - Alex Theedom
我同意这是两个不同的操作,但是将新的(upsert)标记为收藏与将现有的标记为收藏一样合理。 - akiva
2个回答

2

看一下 MongoDB 的 findAndModify 方法。

它几乎符合你的所有标准。

  1. 在单个文档上,它是原子性的。
  2. 它有一个 upsert 选项。
  3. 默认情况下,它返回 修改前 的文档。
  4. 如果需要,还可以删除文档。

引用findAndModify的upsert参数文档: "为了避免多次插入,请确保查询字段拥有唯一索引。"此外,findAndModify是上面提出的解决方案之一(“如果不存在,则插入”)。 我承认,这有点隐藏,哈哈。很可能,PyMongo的find_one_and_update函数是使用findAndModify实现的,因为它们具有相同的参数和返回类型。我对PyMongo最大的抱怨(也是唯一的一个)是它们缺乏坚持MongoDB API。 - John Crawford

1

我最近遇到了这个问题,并使用了一些提示中提到的upsert标志。在选择我的推荐解决方案之前,我尝试了许多方法,这是本答案中描述的最后一个选项。请原谅我使用PyMongo代码,希望它不难转换为您的项目。

首先, MongoDB文档明确警告不要在没有唯一索引的情况下使用upsert。看起来该命令本身是使用标准的“查找/插入”方法实现的,并且不是原子性的。2个并发客户端可能会失败其查找,然后每个插入自己的文档副本。如果没有唯一索引来强制执行无重复项,则MongoDB将允许发生这种事件!在实施解决方案时,请记住这一点。

如果不存在于现有集合中则插入

from pymongo import ReturnDocument
objID = db.collection.find_one_and_update(
    myDoc,
    {"$unset": {"<<<IHopeThisIsNeverInTheDB>>>": ""}},  #There is no NOOP...
    {},  #We only want the "_id".
    return_document=ReturnDocument.AFTER,  #IIRC an upsert would return a null without this.
    upsert=True,
)["_id"]

使用伪NOOP,我成功将update调用转换为带有upsert功能的find调用,成功地实现了在单个MongoDB调用中进行“插入新数据”的操作。这大致相当于MongoDB客户端操作:
db.collection.findAndModify({
    query: <your doc>,
    update: {$unset: {"<<<IHopeThisIsNeverInTheDatabase>>>": ""}},  // There is no NOOP...
    new: true,  // IIRC an upsert would return a null without this.
    fields: {},  // Only want the ObjectId
    upsert: true,  // Create if no matches.
})

这段代码存在一个问题/特性,即它将匹配包含来自<your doc>的数据超集的文档,而不仅仅是精确匹配。例如,考虑一个集合:
{"foo": "bar", "apples": "oranges"}

上述代码将会把已存在于集合中的一份文档与正在上传的任意一份以下文档进行匹配:
{"foo": "bar"}
{"apples": "oranges"}
{"foo": "bar", "apples", "oranges"}

因此,这并不是真正的“如果新插入”(insert if new),因为它未能忽略超集文档,但对于某些应用程序而言,这可能已经足够,并且与蛮力方法相比速度非常快。
如果仅匹配子文档就足够好:
q = {k: {"$eq": v} for k, v in myDoc.items()}  #Insert "$eq" operator on root's subdocuments to require exact matches.
objID = db.collection.find_one_and_update(
    q,
    {"$unset": {"<<<IHopeThisIsNeverInTheDB>>>": ""}},  #There is no NOOP...
    {},  #We only want the "_id".
    return_document=ReturnDocument.AFTER,  #IIRC an upsert would return a null without this.
    upsert=True,
)["_id"]

请注意,$eq 是有序的,因此如果您处理的数据不是有序的(例如 Python dict 对象),则此方法将无法工作。

如果整个文档不完全匹配,则插入

我可以想到4种方法来解决这个问题,最后一种方法是我推荐的方法。

优化Upsert查找和插入

您可以通过

q = {k: {"$eq": v} for k, v in myDoc.items()}  #Insert "$eq" operator on root's subdocuments to require exact matches.
resp = collection.update_many(
    q,
    {"$unset": {"<<<IHopeThisIsNeverInTheDB>>>": ""}},  #There is no NOOP...
    True,
)
objID = resp.upserted_id
if objID is None:
    #No upsert occurred.  If you must, use a find to get the direct match:
    docs = collection.find(q, {k: 0 for k in myDoc.keys()}, limit=resp.matched_count)
    for doc in docs:
        if len(doc) == 1:  #Only match documents that have the "_id" field and nothing else.
            objID = doc["_id"]
            break
    else:  #No direct matches were found.
        objID = collection.insert_one(myDoc, {}).inserted_id

请注意,在find的结果中过滤已知字段以减少数据使用量并简化等价性检查的使用。我还加入了resp.matched_count来限制查询,以便我们不浪费时间查找我们已经知道不存在的文档。
请注意,这种方法针对于upsert(在单个插入函数中进行2次插入调用...yuk!!!!)进行了优化,其中您更经常创建文档而不是查找现有文档。在我遇到的大多数“如果新建则插入”情况下,更常见的事件是文档已经存在,此时您想要采用“首先查找,然后插入缺失”的方法。这导致其他选项。

顺序相关的查找和插入

执行$eq样式的查询以匹配子文档,然后使用客户端代码检查根并在没有匹配项时插入:
q = {k: {"$eq": v} for k, v in myDoc.items()}  #Insert "$eq" operator on root's subdocuments to require exact matches.
docs = collection.find(q, {k: 0 for k in myDoc.keys()})  #Filter known fields so we isolate the mismatches.
for doc in docs:
    if len(doc) == 1:  #Only match documents that have the "_id" field and nothing else.
        objID = doc["_id"]
        break
else:  #No direct matches were found.
    objID = collection.insert_one(myDoc, {}).inserted_id

再次提醒,$eq 是有序的,这可能会根据您的情况引起问题。

无序查找和插入

如果您希望实现无序操作,可以通过简单地展开 JSON 文档来构建查询。这会使映射树中的父项重复,但根据您的用例,这可能是可以接受的。

myDoc = {"llama": {"duck": "cake", "ate": "rake"}}
q = {"llama.duck": "cake", "llama.ate": "rake"}
docs = collection.find(q, {k: 0 for k in q.keys()})  #Filter known fields so we isolate the mismatches.
for doc in docs:
    if len(doc) == 1:  #Only match documents that have the "_id" field and nothing else.
        objID = doc["_id"]
        break
else:  #No direct matches were found.
    objID = collection.insert_one(myDoc, {}).inserted_id

很可能有一种使用JavaScript在服务器端完成所有操作的方法。不幸的是,我目前的JavaScript技能有所欠缺。

哈希作为唯一索引(建议)

创建唯一索引要求,可以通过对文档信息的哈希值创建此索引,如在另一个SO问题的答案中建议的那样:https://dev59.com/GIbca4cB1Zd3GeqPc_w5#27993841。理想情况下,这个哈希值可以仅从数据生成,而无需与MongoDB交互即可创建该哈希值。链接答案的作者对JSON文档的字符串表示进行了SHA-256哈希。对于这个项目,我已经使用了xxHash,因此选择对bson.json_util.dumps(myDoc)输出进行xxHash,其中myDoc是我要上传的dictcollections.OrderedDictbson.son.SON对象。由于我正在使用Python进行鸭子类型等操作,使用json_util可以给我后转换状态的SON文档,从而确保哈希生成在其他程序/语言中是平台无关的。请注意,哈希通常是有序相关的,因此使用像Python的dict这样的无序结构将导致重复数据的不同哈希。如果用户向我提供一个dict,我编写了一个简单的实用程序函数,递归地将dict对象转换为使用Python的sorted函数排序的bson.son.SON对象。
一旦您拥有代表数据的哈希或其他唯一值,并且 为该键在MongoDB中创建了唯一索引, 您可以使用简单的 upsert 方法来实现“插入新数据”的功能。
from pymongo import ReturnDocument
myDoc["xxHash"] = xxHashValue  #32-bit signed integer generated from xxHash of "bson.json_util.dumps(myDoc)"
objID = db.collection.find_one_and_update(
    myDoc,
    {"$unset": {"<<<IHopeThisIsNeverInTheDB>>>": ""}},  #There is no NOOP...
    {},  #We only want the "_id".
    return_document=ReturnDocument.AFTER,  #IIRC an upsert would return a null without this.
    upsert=True,
)["_id"]

所有的数据库工作都在一个简短的命令中完成,并且使用索引非常快速。难点只是生成哈希值。
所以,这里有几种方法可能适合您的特定情况。当然,如果MongoDB支持根级别的等价测试,这将会更加容易,但哈希方法是一个很好的替代方案,并且很可能提供最佳的整体速度。

哇,@john-crawford!这是一个很棒的答案,非常感谢。它不仅解释了我所问的问题为什么是错误的,还提供了几种处理方法。真希望我能给这个答案超过+1的评价。 - akiva

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接