20

#1:速度优先使用嵌入数据,完整性优先使用引用数据

多个文档使用的数据可以使用嵌入(非规范化)或引用(规范化)。非规范化并不一定比规范化更好,反之亦然:每种方式都有自己的权衡,你应该选择最适合你的应用程序的方式。

非规范化可能导致数据不一致:假设您想要将图1-1中的苹果更改为梨。如果更新完第一个文档中的值但应用程序崩溃未能及时更新其他文档,则数据库针对同一个对象将会产生两个不同的值。

A normalized schema. The fruit field is stored in the food collection and referenced by the documents in the meals collection.

图1-1。规范化架构。fruit的字段在food中定义,在meals中引用。

不一致的问题并不是很大,但“不大”的程度取决于你所存储的内容。对于许多应用程序来说,短暂的时间不一致是可以允许的:如果有人更改了他的用户名,那么旧帖子用他的旧用户名显示几个小时可能并不重要。如果即使短暂的不一致的值也不允许,那么您应该进行规范化的方式来存储。

但是,如果采用规范化,则在每次要查找的内容时,应用程序都必须执行额外的查询fruit图1-2)。如果您的应用程序无法承受这种性能损失,并且以后可以解决不一致,那么您应该进行非规范化。

A denormalized schema. The value for fruit is stored in both the food and meals collections.

图1-2。非规范化架构。fruit同时存储在food和meals中

这是一种权衡:您不能同时拥有最快的性能又能保持实时一致性。您必须确定哪个对您的应用程序更重要。

示例:购物车订单

假设我们正在为购物车应用程序设计架构。我们的应用程序在MongoDB中存储订单,订单应包含哪些信息?

规范化架构

Product:

 {
     "_id" : productId,
     "name" : name,
     "price" : price,
     "desc" : description
 }

Order:

   {
    "_id" : orderId,
    "user" : userInfo,
    "items" : [
        productId1,
        productId2,
        productId3
     ]
   }

我们将每个商品的_id存储在订单中。然后,当我们显示订单的内容时,我们查询orders集合以获得正确的订单,然后查询商品集合以获得与我们的_ids 列表相关联的商品。 无法在此模型中使用单个查询获取完整订单信息

如果更新了有关商品的信息,则引用此商品的所有文档都将“更改”,因为这些文档仅指向最终文档。

标准化为我们提供了较慢的读取和保证所有订单的一致性视图; 多个文档可以自动更改(因为实际上只有引用文档在变化)。

非规范化架构

非规范化架构

Product(与之前相同):

{
  "_id" : productId,
  "name" : name,
  "price" : price,
  "desc" : description
}

Order:

{
​    "_id" : orderId,
​    "user" : userInfo,
​    "items" : [
​        {
​            "_id" : productId1,
​            "name" : name1,
​            "price" : price1
​        },
​        {
​            "_id" : productId2,
​            "name" : name2,
​            "price" : price2
​        },
​        {
​            "_id" : productId3,
​            "name" : name3,
​            "price" : price3
​        }
​    ]
}

我们将商品信息作为嵌入式文档存储在订单中。然后,当我们显示订单时,我们只需要进行一次查询。

如果有关产品的信息已更新,并且我们希望将更改关联到订单,我们必须单独更新每个购物车。

非规范化使我们在所有订单中的读取速度更快,但在处理所有订单一致性上会不是很方便; 不能跨多个文档自动更改产品详细信息。

所以,应该如何决定是规范化还是非规范化?

决策因素

有三个主要因素需要考虑:

  • 对于非常罕见的数据更改,您是否为每次读取付出了额外的代价?您可能会每次读取商品10,000次其详细信息才会发生变化。您是否要为10,000次读取中的每次读取进行额外的查询,以使该写入更快或保证一致?大多数应用程序读比写更重要,所以先要了解您的比例是多少。

    您想要引用的数据实际上经常发生变化的频率是多少?变化越小,非规范化的论证就越有效。在大多数场景下,几乎不值得引用很少变化的数据,如姓名,出生日期,股票代码和地址。

  • 一致性有多重要?如果一致性很重要,那么您应该进行规范化。例如,假设多个文档需要自动地看到更改。如果我们设计的交易应用程序某些证券只能在某些时间进行交易,我们希望在它们无法交易时立即“锁定”它们。然后我们可以使用单个锁定文档作为相关证券模型的引用。但是,在应用程序级别执行此类操作可能会更好,因为应用程序需要知道何时锁定和解锁的规则。

    另一个时间点的一致性很重要,对于难以协调不一致的应用程序。例如在在订单示例中,我们有严格的层次结构:订单从商品中获取信息,商品永远不会从订单中获取信息。如果有多个“源”文档,则很难确定应该选取哪个。

    但是,在这种订单管理中,一致性实际上可能是有害的。假设我们想以20%的折扣出售商品。我们不想更改现有订单中的任何信息,我们只想更新商品说明。因此在这种情况下,我们实际上需要一个时间点的快照数据(参见技巧#5:嵌入“时间点”数据)。

  • 需要很快的读取速度吗?如果读取需要尽可能快,则应该进行非规范化。实时应用程序通常应尽可能地进行非规范化存储。

对订单模型进行非规范化有一个很好的场景:信息不会发生太大变化,及时变化了我们也不希望订单反映这些变化。归一化并没有给我们任何特别的优势。

在这种情况下,最好的选择是对订单模式进行非规范化。

进一步阅读:

提示#2:如果您需要面向未来的数据,请进行标准化

规范化“面向未来”的数据:您应该能够将标准化数据用于将来以不同方式查询数据的不同应用程序。

这假定您有一些数据集,应用程序会有较多迭代,将需要使用多年。有这样的数据集,但大多数人的数据不断发展,旧数据要么被更新,要么被丢弃。大多数人希望他们的数据库在他们现在正在进行的查询上尽可能快地执行,如果他们将来更改这些查询,他们将针对新查询优化他们的数据库。

此外,如果应用程序发展比较成功,其数据集通常会变得非常特定于应用程序。这并不是说它不能用于更多的应用程序; 通常你至少会想要对它进行元分析。但这与“面向未来”不同,它能够经得起10年来人们想要运行的任何疑问。

提示#3:尝试在单个查询中获取数据

注意
在本节中,应用程序单元用作某些应用程序工作的通用术语。如果您有Web或移动应用程序,则可以将应用程序单元视为对后端的请求。其他一些例子:

对于桌面应用程序,这可能是用户交互。

对于分析系统,这可能是一个图表加载。

它基本上是一个独立的工作单元,您的应用程序可能会涉及访问数据库。

应该将MongoDB模式设计为按应用程序单元进行查询。

示例:博客

如果我们正在设计博客应用程序,请求博客文章可能是一个应用程序单元。当我们显示帖子时,我们想要内容,标签,关于作者的一些信息(虽然可能不是她的整个个人资料),以及帖子的评论。因此,我们将所有这些信息嵌入到post文档中,我们可以在一个查询中获取该视图所需的所有内容。

请记住,目标是每页一个查询,而不是一个文档:有时我们可能会返回多个文档或部分文档(而不是每个字段)。例如,主页面可能包含来自posts集合的最新十个帖子,但只有他们的标题,作者和摘要:

> db.posts.find({}, {"title" : 1, "author" : 1, "slug" : 1, "_id" : 0}).sort(
... {"date" : -1}).limit(10)

每个标记可能有一个页面,其中包含具有给定标记的最后20个帖子的列表:

> db.posts.find({"tag" : someTag}, {"title" : 1, "author" : 1, 
... "slug" : 1, "_id" : 0}).sort({"date" : -1}).limit(20)

将有一个单独的authro集合,其中包含每个作者的完整配置文件。作者页面很简单,它只是author集合中的文档 :

> db.authors.findOne({"name" : authorName})

posts集合中的文档可能包含作者文档中出现的信息的子集:可能是作者的姓名和缩略图个人资料图片。

请注意,应用程序单元不必与单个文档对应,尽管在某些先前描述的情况中会发生这种情况(博客文章和作者的页面都包含在单个文档中)。但是,在很多情况下,应用程序单元将是多个文档,但可通过单个查询访问。

示例:图片墙

假设我们有一个图片墙,用户在新线程或现有线程中发布由图像和一些文本组成的消息。然后,一个应用程序单元正在线程上查看20条消息,因此我们将每个人的帖子作为posts集合中的单独文档。当我们想要显示页面时,我们将执行查询:

> db.posts.find({"threadId" : id}).sort({"date" : 1}).limit(20)

然后,当我们想要获取下一页消息时,我们将在该线程上查询接下来的20条消息,然后查询20之后的消息,等等:

> db.posts.find({"threadId" : id, "date" : {"$gt" : latestDateSeen}}).sort(
... {"date" : 1}).limit(20)

然后我们可以放置索引{threadId : 1, date : 1}以获得这些查询的良好性能。

注意

我们不使用skip(20),因为范围更适合分页

随着您的应用程序变得更加复杂,用户和管理员请求更多功能,您需要为每个应用程序单元生成多个查询。对于任何足够复杂的应用程序,您可能最终会为您的应用程序的一个更荒谬的功能进行多个查询。

提示#4:嵌入依赖字段

在考虑是否嵌入或引用文档时,请问自己是否要单独查询此字段中的信息,或仅在较大文档的框架中查询。例如,您可能想要查询标记,但只想链接回带有该标记的帖子,而不是链接回自己的标记。与评论类似,您可能会有最近评论的列表,但人们有兴趣访问发起评论的帖子(除非评论是您的应用程序中的一等公民)。

如果您一直在使用关系数据库并且正在将现有模式迁移到MongoDB,则连接表是嵌入的绝佳候选者。基本上是键和值的表(例如标签,权限或地址)几乎总是在MongoDB中更好地嵌入。

最后,如果只有一个文档关注某些信息,则将信息嵌入该文档中。

提示#5:嵌入“时间点”数据

正如提示#1中的订单示例中所提到的速度优先使用嵌入数据,完整性优先使用引用数据,如果产品(例如,销售)或获得新缩略图,您实际上并不希望订单中的信息发生变化。应嵌入任何类型的信息,您希望在特定时间对数据进行快照。

订单文档中的另一个示例:地址字段也属于“时间点”类别的数据。如果他更新了他的个人资料,您不希望用户的历史订单发生变化。

提示#6:不要嵌入具有未绑定增长的字段

由于MongoDB存储数据的方式,不断地将信息附加到数组的末尾是相当低效的。在正常使用期间,您希望数组和对象的大小相当稳定。

因此,嵌入20个子文档,或100或1,000,000是可以的,但事先要预防事情的发生。允许文档在使用时增长很多最后查询速度可能比你想要的要慢。

评论通常是一个特殊的情况,因应用程序而异。对于大多数应用程序,评论应嵌入其父文档中。但是,对于评论是其自己的实体或通常有数百个或更多的应用程序,它们应存储为单独的文档。

作为另一个例子,假设我们仅为了评论而创建一个应用程序。提示#3中的图像板示例尝试在单个查询中获取数据是这样的; 主要内容是评论。在这种情况下,我们希望评论是单独的文档。

提示#7:预先填充任何可能的内容

如果您知道您的文档可能需要某些字段,那么在您第一次插入文档时填充它们比在您创建字段时更有效。例如,假设您要为站点分析创建应用程序,以查看一天中每分钟访问不同页面的用户数量。我们将有一个页面集合,其中每个文档代表一个页面的6小时片段。我们希望每分钟和每小时存储信息:

{
​    "_id" : pageId,
​    "start" : time,
​    "visits" : {
​        "minutes" : [
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59]
​        ],
​        "hours" : [num0, ..., num5] 
​    }
}

我们在这里有一个较大的优势:我们知道从现在到结束时这些文件会是什么样子。现在将有一个开始时间在接下来的六个小时内每分钟都有一个条目。然后会有很多类似的文档。

因此,我们可以有一个批处理作业,可以在非繁忙时间插入这些“模板”文档,也可以在一天中稳定地插入。此脚本可以插入看起来像这样的文档,替换 someTime为下一个6小时间隔应该是如下的内容:

{
​    "_id" : pageId,
​    "start" : someTime,
​    "visits" : {
​        "minutes" : [
​            [0, 0, ..., 0],
​            [0, 0, ..., 0],
​            [0, 0, ..., 0],
​            [0, 0, ..., 0],
​            [0, 0, ..., 0],
​            [0, 0, ..., 0]
​        ],
​        "hours" : [0, 0, 0, 0, 0, 0]
​    }
}

现在,当您增加或设置这些计数器时,MongoDB不需要为它们找到空间。它只是更新您已经输入的值,这要快得多。

例如,在小时开始时,您的程序可能会执行以下操作:

> db.pages.update({"_id" : pageId, "start" : thisHour}, 
... {"$inc" : {"visits.0.0" : 3}})

这个想法可以扩展到其他类型的数据,甚至集合和数据库本身。如果您每天使用新的集合,也可以提前创建它们。

提示#8:尽可能预先分配空间

这与提示#6密切相关不嵌入具有未绑定增长的字段提示#7:预先填充任何可能的内容。这是一种优化,一旦您知道您的文档通常会增长到一定的大小,但它们的起始尺寸较小。当您最初插入文档时,添加一个包含文档将(最终)大小的字符串的垃圾字段,然后立即取消设置该字段:

> collection.insert({"_id" : 123, /* other fields */, "garbage" : someLongString})
> collection.update({"_id" : 123}, {"$unset" : {"garbage" : 1}})

这样,MongoDB最初会将文档放在某个位置,以便为其提供足够的增长空间(图1-3)。

提示#9:将嵌入信息存储在数组中以进行匿名访问

经常出现的问题是是否将信息嵌入数组或子文档中。当你总是知道你将要查询什么时,应该使用子文档。如果您有可能无法确切知道要查询的内容,请使用数组。当你知道关于你要查询的元素的一些标准时,通常应该使用数组。

If you store a document with the amount of room it will need in the future, it will not need to be moved later.

图1-3。如果您存储的文档具有将来需要的空间量,则无需稍后移动。

假设我们正在编写一个玩家选择各种物品的游戏。我们可能会将角色文档建模为:

{
​    "_id" : "fred",
​    "items" : {
​        "slingshot" : {
​            "type" : "weapon",
​            "damage" : 23,
​            "ranged" : true
​        },
​        "jar" : {
​             "type" : "container",
​             "contains" : "fairy"
​        },
​        "sword" : {
​             "type" : "weapon",
​             "damage" : 50,
​             "ranged" : false
​        }
​     }
}

现在,假设我们想要找到damage大于20的所有武器。我们不能!子文档不允许您进入items并说“给我任何damage超过20的项目。”您只能询问 具体项目:“ items.slingshot.damage大于20?items.sword.damage?“等等。

如果您希望能够在不知道其标识符的情况下访问任何项目,则应该安排架构以将项目存储在数组中:

{
​    "_id" : "fred",
​    "items" : [
​        {
​            "id" : "slingshot",
​            "type" : "weapon",
​            "damage" : 23,
​            "ranged" : true
​        },
​        {
​             "id" : "jar",
​             "type" : "container",
​             "contains" : "fairy"
​        },
​        {
​             "id" : "sword",
​             "type" : "weapon",
​             "damage" : 50,
​             "ranged" : false
​        }
​     ]
}

现在您可以使用简单的查询,例如{"items.damage" : {"$gt" : 20}}。如果您需要匹配(例如damageranged)的给定项目的多个条件,则可以使用$elemMatch

那么,什么时候应该使用子文档而不是数组?当您知道并且始终知道您正在访问的字段的名称时。

例如,假设我们跟踪玩家的能力:她的力量,智力,智慧,灵巧,体质和魅力。我们将始终知道我们正在寻找哪种具体能力,因此我们可以将其存储为:

{
​    "_id" : "fred",
​    "race" : "gnome",
​    "class" : "illusionist",
​    "abilities" : {
​        "str" : 20,
​        "int" : 12,
​        "wis" : 18,
​        "dex" : 24,
​        "con" : 23,
​        "cha" : 22
​    }
}

当我们想要找到一个特定的技能,我们可以看一下abilities.str,或者abilities.con,或者别的什么东西。我们永远不会想要找到一个超过20的能力,因为我们总会知道我们在寻找什么。

提示#10:设计文档应该是充分考虑的

MongoDB应该是一个庞大而笨重的数据存储。也就是说,它几乎不进行任何处理,只是存储和检索数据。您应该尊重这一目标并尽量避免强制MongoDB执行可在客户端上执行的任何计算。即使是“微不足道的”任务,例如寻找平均值或求和字段,通常也应该推送给客户端进行。

如果要查询必须计算且未在文档中明确显示的信息,您有两种选择:

通常,您应该只在文档中明确显示信息。

假设你要查询的文档,其中 applesoranges的总和为30。也就是说,你的文档看起来是这样的:

{
​    "_id" : 123,
​    "apples" : 10,
​    "oranges" : 5
}

鉴于上述文档,查询总数将需要使用JavaScript,因此效率非常低。而是total在文档中添加一个字段:

{
​    "_id" : 123,
​    "apples" : 10,
​    "oranges" : 5,
​    "total" : 15
}

然后总数可以在applesoranges`改变时同时改变:

> db.food.update(criteria, 
... {"$inc" : {"apples" : 10, "oranges" : -2, "total" : 8}})
> db.food.findOne()
{
    "_id" : 123,
    "apples" : 20,
    "oranges" : 3,
    "total" : 23
}

如果您不确定更新是否会改变任何内容,这将变得更加棘手。例如,假设您希望能够查询水果的数字类型,但您不知道您的更新是否会添加新类型。

因此,假设您的文档看起来像这样:

{
​    "_id" : 123,
​    "apples" : 20,
​    "oranges : 3,
​    "total" : 2
}  

现在,如果您执行的更新可能会或可能不会创建新字段,您是否增加total?如果更新最终创建新字段,则应更新总计:

> db.food.update({"_id" : 123}, {"$inc" : {"banana" : 3, "total" : 1}})

相反,如果香蕉田已经存在,我们不应该增加总数。但是从客户端来看,我们不知道它是否存在!

有两种方法可以解决这个问题:快速,不一致的方式,以及缓慢,一致的方式。

快速的方法是选择total添加或不添加1并使我们的应用程序意识到它需要检查客户端的实际总数。我们可以进行持续的批处理作业,以纠正我们最终遇到的任何不一致。

如果我们的应用程序可以立即花费额外的时间,我们可以执行findAndModify“锁定”文档(设置其他写入将手动检查的“锁定”字段),返回文档,然后发出更新解锁文档并更新字段和total正确:

> var result = db.runCommand({"findAndModify" : "food", 
... "query" : {/* other criteria */, "locked" : false},
... "update" : {"$set" : {"locked" : true}}})
>
> if ("banana" in result.value) {
...   db.fruit.update(criteria, {"$set" : {"locked" : false}, 
...       "$inc" : {"banana" : 3}})
... } else {
...   // increment total if banana field doesn't exist yet
...   db.fruit.update(criteria, {"$set" : {"locked" : false}, 
...       "$inc" : {"banana" : 3, "total" : 1}})
... } 

正确的选择取决于您的应用。

提示#11:首选$ -operators到JavaScript

某些操作无法使用$-operators 完成 。对于大多数应用程序而言,使文档自给自足可以最大限度地降低必须执行的查询的复杂性。但是,有时您将不得不查询无法用$-operators 表达的内容。在这种情况下,JavaScript可以解决您的问题:您可以使用$where子句在查询中执行任意JavaScript。

$where在查询中使用,请编写一个返回true 或返回的JavaScript函数false(无论该文档是否匹配$where)。所以,假设我们只想返回值member[0].agemember[1].age等于的记录。我们可以这样做:

> db.members.find({"$where" : function() { 
... return this.member[0].age == this.member[1].age;
... }})

正如您可能想象的那样,$where 为您的查询提供相当多的能力。但是,它也很慢。

在幕后

$where由于MongoDB在幕后所做的事情需要很长时间:当您执行普通(非$where)查询时,您的客户端将该查询转换为BSON并将其发送到数据库。MongoDB也将数据存储在BSON中,因此它基本上可以将您的查询直接与数据进行比较。这非常快速有效。

现在假设您有一个$where 必须作为查询的一部分执行的子句。MongoDB必须为集合中的每个文档创建一个JavaScript对象,解析文档的BSON并将其所有字段添加到JavaScript对象中。然后它会执行您针对文档发送的JavaScript,然后再次将其全部删除。这是非常耗费时间和资源的。

获得更好的表现

$where必要时是一个很好的能力,但应尽可能避免。实际上,如果您注意到您的查询需要大量的$wheres,那么这是一个很好的迹象,表明您应该重新考虑您的架构。

如果需要$where查询,您可以通过最小化创建它的文档数量来减少性能损失。尝试提出可以在没有$where的情况下检查的其他标准,并首先列出该标准; 到查询到达时“运行中”的文档越少,所需的时间$where就越少 。

例如,假设我们有$where上面给出的例子,并且我们意识到,当我们检查两个成员的年龄时,我们仅适用于至少具有联合成员资格的成员,可能是家庭成员:

> db.members.find({'type' : {$in : ['joint', 'family']}, 
... "$where" : function() {
...     return this.member[0].age == this.member[1].age;
... }})

现在,所有单个成员资格文档将在查询到达时排除$where

提示#12:随时计算聚合

只要有可能,随着时间的推移计算聚合$inc。例如,在提示#7:预先填充任何可能的内容,我们有一个分析应用程序,其中包含按分钟和小时分列的统计信息。我们可以在递增分钟数的同时递增小时统计数据。

如果您的聚合需要更多调整(例如,查找一小时内的平均查询数),请将数据存储在分钟字段中,然后进行持续的批处理,以计算最新分钟的平均值。由于计算聚合所需的所有信息都存储在一个文档中,因此甚至可以将此处理传递给客户端以获取更新的(未聚合的)文档。批处理作业已经记录了较旧的文档。

提示#13:编写代码来处理数据完整性问题

鉴于MongoDB的无模式特性以及非规范化的优势,您需要在应用程序中保持数据的一致性。

许多ODM都有各种方法来强制执行一致的模式以达到各种严格程度。但是,还存在上面提到的一致性问题:由系统故障引起的数据不一致(提示#1:速度重复数据,完整性参考数据)和MongoDB更新的限制(提示#10:设计文档是充分考虑的)。对于这些类型的不一致,您需要实际编写一个用于检查数据的脚本。

如果您按照本章中的提示操作,最终可能会有相当多的cron作业,具体取决于您的应用程序。例如,您可能有:

  • 一致性修复程序

    检查计算和重复数据以确保每个人都具有一致的值。

  • 预填充器

    创建将来需要的文档。

  • 聚合

    保持内联聚合为最新。

其他有用的脚本(与本章不严格相关)可能是:

  • 架构检查器

    确保当前使用的文档集都具有一组字段,可以自动更正它们,也可以通知您不正确的字段。

  • 备份工作

    fsync,定期锁定和转储数据库。

在后台运行检查和保护数据的作业会让您更加轻松地使用它。


原来就是你
91 声望1 粉丝

记录学习过程~