Mongo进阶 - 系统设计:模式构建

MongoDB使用文档数据模型具有内在的灵活性,允许数据模型支持你的应用程序需求, 灵活性也可能导致模式比它们应有样子的更复杂。这涉及到如何在MongoDB中设计数据库模式(schema),有一个严峻的现实,大多数性能问题都可以追溯到糟糕的模式设计。@pdai

前言

当涉及MongoDB时,一个经常被问到的问题是“我如何在MongoDB中为我的应用程序构造模式(schema)?”老实说,这要看情况而定。你的应用程序读操作比写操作多吗?从数据库中读取时需要将哪些数据放在一起?有哪些性能因素需要考虑?文档有多大?它们今后会变成多大?你预计数据会如何增长和扩展?

所有这些以及更多的问题,都涉及到如何在MongoDB中设计数据库模式(schema)。有人说MongoDB是无模式的,而实际上模式设计在MongoDB中非常重要。有一个严峻的现实,我们发现的大多数性能问题都可以追溯到糟糕的模式设计。

MongoDB使用文档数据模型。此模型具有内在的灵活性,允许数据模型支持你的应用程序需求。灵活性也可能导致模式比它们应有样子的更复杂。在考虑模式设计时,我们应该考虑性能、可伸缩性和简单性。

模式构建

在本系列文章“使用模式构建”中,我们将了解在MongoDB中行之有效的十二种常见的模式设计方式(Schema Design Patterns)。我们希望本系列文章能够建立一种在设计模式时可以使用的通用方法和词汇表。利用这些模式(patterns)可以在模式(schema)规划中使用“构建基块(building blocks)”,从而使这个过程更多地成为一种方法论而不是艺术。

多态模式

当集合中的所有文档都具有相似但不相同的结构时,我们将其称为多态模式。如前所述,当我们希望从单个集合中访问(查询)信息时,多态模式非常有用。根据我们要运行的查询将文档分组在一起(而不是将其分散在多个表或集合中)有助于提高性能。

假设我们有一个应用程序用来跟踪所有不同运动项目的专业运动员。

我们仍然希望能够在应用程序中访问所有的运动员,但每个运动员的属性都不尽相同,这就是多态模式可以发挥作用的地方。在下面的示例中,我们将来自两个不同项目运动员的数据存储在同一个集合中。即使文档在同一集合中,存储的关于每个运动员的数据也不必须是相同的。

对于职业运动员的记录既有相似之处也有不同之处。使用多态模式,我们可以很容易地适应这些差异。如果不使用多态模式,我们可能会有一个保龄球运动员的集合和一个网球运动员的集合。当我们想询问所有运动员时,我们需要进行耗时且复杂的连接操作(join)。相反,由于我们使用了多态模式,我们所有的数据都存储在一个运动员集合中,通过一个简单的语句就可以完成对所有运动员的查询。

这种设计模式也可以使用在嵌入式子文档中。在上面的例子中,Martina Navratilova不仅仅是作为一名单独的选手参加比赛,所以我们可能希望她的记录结构如下:

从应用程序开发的角度来看,当使用多态模式时,我们将查看文档或子文档中的特定字段,以便能够跟踪差异。例如,我们知道一个网球运动员可能参加不同的项目,而另一个运动员可能不参加。这通常需要应用程序基于给定文档中的信息选择不同的代码路径。或者,可能会编写不同的类或子类来处理网球、保龄球、足球和橄榄球运动员之间的差异。

  • 应用场景示例

多态模式的一个示例用例是单一视图 (opens new window)应用程序。假设你在一家公司工作,随着时间的推移,这家公司以其技术和数据模式收购了其它公司。假如每家公司都有许多数据库,每个都以不同的方式为“向客户提供的保险”建模。然后你购买了这些公司,并希望将所有这些系统集成到一起。而将这些不同的系统合并到一个统一的SQL模式中是一项既昂贵又费时的工作。

Metlife (opens new window)能够在几个月内利用MongoDB和多态模式构建他们的单一视图应用程序。他们的单一视图应用程序将来自多个来源的数据聚合到一个中央存储库中,从而使客户服务、保险代理、计费还有其它部门能够360°了解一个客户。这使得他们能够以较低的成本为客户提供更好的服务。此外,利用MongoDB的灵活数据模型和多态模式,开发团队能够快速创新,使其产品上线。

单一视图 (opens new window)应用程序是多态模式的一个用例。它也适用于产品目录,例如自行车和鱼竿具有不同的属性。我们的运动员示例可以很容易地扩展到一个更完善的内容管理系统中,并在其中使用多态模式。

  • 结论

当文档具有更多的相似性而不是差异性时,就会使用多态模式。这种模式设计的典型用例是:

  • 单一视图应用程序
  • 内容管理
  • 移动应用程序
  • 产品目录

多态模式提供了一个易于实现的设计,允许在单个集合中进行查询,并且它还是我们接下来文章中探讨的许多设计模式的起点。

属性模式

出于性能原因考虑,为了优化搜索我们可能需要许多索引以照顾到所有子集。创建所有这些索引可能会降低性能。属性模式为这种情况提供了一个很好的解决方案。

假设现在有一个关于电影的集合。其中所有文档中可能都有类似的字段:标题、导演、制片人、演员等等。假如我们希望在上映日期这个字段进行搜索,这时面临的挑战是“哪个上映日期”?在不同的国家,电影通常在不同的日期上映。

{
    title: "Star Wars",
    director: "George Lucas",
    ...
    release_US: ISODate("1977-05-20T01:00:00+01:00"),
    release_France: ISODate("1977-10-19T01:00:00+01:00"),
    release_Italy: ISODate("1977-10-20T01:00:00+01:00"),
    release_UK: ISODate("1977-12-27T01:00:00+01:00"),
    ...
}
1
2
3
4
5
6
7
8
9
10

搜索上映日期需要同时查看多个字段。为了快速进行搜索,我们需要在电影集合中使用多个索引:

{release_US: 1}
{release_France: 1}
{release_Italy: 1}
...
1
2
3
4

使用属性模式,我们可以将此信息移至数组中并减少对索引需求。我们将这些信息转换成一个包含键值对的数组:

{
    title: "Star Wars",
    director: "George Lucas",
    …
    releases: [
        {
        location: "USA",
        date: ISODate("1977-05-20T01:00:00+01:00")
        },
        {
        location: "France",
        date: ISODate("1977-10-19T01:00:00+01:00")
        },
        {
        location: "Italy",
        date: ISODate("1977-10-20T01:00:00+01:00")
        },
        {
        location: "UK",
        date: ISODate("1977-12-27T01:00:00+01:00")
        },],}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

通过在数组中的元素上创建一个这样的索引,索引变得更易于管理:{ “releases.location”: 1, “releases.date”: 1}

使用属性模式,我们可以将组织信息添加到文档中,在获取通用特征的同时以应对罕见的/不可预测的字段,比如在一个新节日或小节日里上映的电影。此外,使用键/值约定允许非确定性命名(non-deterministic naming)并且可以很容易地添加限定符(qualifiers)。假如我们有一个关于瓶装水的数据集合,那么它们的属性可能看起来是这样:

"specs": [
    { k: "volume", v: "500", u: "ml" },
    { k: "volume", v: "12", u: "ounces" }
]
1
2
3
4

这里我们将信息分为键和值“k”和“v”,并添加第三个字段“u”,允许度量单位单独存储。

  • 应用场景示例

属性模式非常适合具有相同值类型的字段集(如日期列表)。它在处理产品特性时也能很好地工作。有些产品,如服装,可能具有以小、中、大来表示的尺码,同一集合中的其他产品可以用体积表示,其它的可以用实际尺寸或重量来表示。

一个资产管理领域的客户最近使用属性模式部署了他们的解决方案。客户使用该模式存储给定资产的所有特征。这些特征在资产中很少常见,或者在设计时很难预见到。关系模型通常使用复杂的设计过程以用户定义字段 (opens new window)的形式表达这样的思想。

虽然产品目录中的许多字段类似,例如名称、供应商、制造商、原产地等,但产品的规格或属性可能有所不同。如果应用程序和数据访问模式依赖于需要同时搜索这些不同字段,那么属性模式为数据提供了一个良好的结构。

  • 结论

属性模式针对每个文档中许多类似字段提供了更简单的文档索引。通过将这个数据子集移动到一个键值子文档中,我们可以使用不确定的字段名,为信息添加额外的限定符,并更清楚地说明原始字段和值的关系。当我们使用属性模式时,由于需要的索引更少,查询变得更简单更快。

属性模式特别适用于以下情况:

  • 我们有一些大文档,它们有很多相似的字段,而这些字段的一个子集具有共同的特征,我们希望对该子集字段进行排序或查询;
  • 我们需要排序的字段只能在一小部分文档中找到;
  • 上述两个条件均满足。

桶模式

这种模式在处理物联网(IOT)、实时分析或通用时间序列数据时特别有效。通过将数据放在一起,我们可以更容易地将数据组织成特定的组,提高发现历史趋势或提供未来预测的能力,同时还能对存储进行优化。

随着数据在一段时间内持续流入(时间序列数据),我们可能倾向于将每个测量值存储在自己的文档中。然而,这种倾向是一种非常偏向于关系型数据处理的方式。如果我们有一个传感器每分钟测量温度并将其保存到数据库中,我们的数据流可能看起来像这样:

{
   sensor_id: 12345,
   timestamp: ISODate("2019-01-31T10:00:00.000Z"),
   temperature: 40
}

{
   sensor_id: 12345,
   timestamp: ISODate("2019-01-31T10:01:00.000Z"),
   temperature: 40
}

{
   sensor_id: 12345,
   timestamp: ISODate("2019-01-31T10:02:00.000Z"),
   temperature: 41
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

随着我们的应用程序在数据和索引大小上的扩展,这可能会带来一些问题。例如,我们可能最终不得不对每次测量的sensor_id和timestamp进行索引,实现以内存为代价的快速访问。但利用文档数据模型,我们可以按时间将这些数据“以桶的方式”储存到特定时间片测量值的文档中。我们还可以通过编程方式向每一个“桶”中添加附加信息。

通过将桶模式应用于数据模型,我们可以在节省索引大小、简化潜在的查询以及在文档中使用预聚合数据的能力等方面获得一些收益。获取上面的数据流并对其应用桶模式,我们可以得到:

{
    sensor_id: 12345,
    start_date: ISODate("2019-01-31T10:00:00.000Z"),
    end_date: ISODate("2019-01-31T10:59:59.000Z"),
    measurements: [
       {
       timestamp: ISODate("2019-01-31T10:00:00.000Z"),
       temperature: 40
       },
       {
       timestamp: ISODate("2019-01-31T10:01:00.000Z"),
       temperature: 40
       },{
       timestamp: ISODate("2019-01-31T10:42:00.000Z"),
       temperature: 42
       }
    ],
   transaction_count: 42,
   sum_temperature: 2413
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

使用桶模式,我们将数据“封装”到一个小时的桶中。这个特定的数据流仍然在增长,因为它目前只有42个测量值;这个小时还有更多的测量值要添加到“桶”中。当它们添加到measurements数组中时,transaction_count将增加,并且sum_temperature也将更新。

有了预先聚合的sum_temperature值,就可以很容易拉出一个特定的存储桶并确定该桶的平均温度(sum_temperature / transaction_count)。在处理时间序列数据时,知道2018年7月13日加利福尼亚州康宁市下午2:00至3:00的平均温度通常比知道下午2:03那一时刻的温度更有意义也更重要。通过用桶组织数据并进行预聚合,我们可以更轻松地提供这些信息。

此外,随着我们收集的信息越来越多,为了更高效我们可能决定将源数据进行归档。你想我们多久才会需要访问从1948年开始康宁市的温度?能够将这些数据桶移动到数据存档中是一项很大的收益。

  • 应用场景示例

有一个Bosch的物联网实现可以成为时间序列数据在现实世界中体现价值的一个例子。他们将MongoDB和时间序列数据应用于一个汽车业的数据程序中。该应用程序从整个车辆的各种传感器中获取数据,从而提高车辆本身的诊断能力和部件性能。

其它一些例子还包括在银行的金融程序中使用这种模式将交易进行分组。

  • 结论

处理时间序列数据时,在MongoDB中使用桶模式是一个很好的选择它减少了集合中的文档总数,提高了索引性能,并且通过预聚合简化了数据访问

桶模式在许多情况下都非常有效,但是如果我们的数据中有异常值呢?这就是我们要讨论的下一个模式——异常值设计模式(Outlier Design Pattern)的作用所在。

异常值模式

我们已经研究了多态模式、属性模式和桶模式。其中,尽管文档的模式略有不同,但从应用程序和查询的角度来看,文档的结构基本上是一致的。然而,如果情况并非如此会怎么样?当有数据不属于“正常”模式时会发生什么?如果有异常值怎么办?

使用异常值模式就是在防止一些少数的查询或文档将我们推向对大多数用例来说都不佳的解决方案。

一个存有user_id的典型book文档可能看起来像这样:

{
    "_id": ObjectID("507f1f77bcf86cd799439011")
    "title": "A Genealogical Record of a Line of Alger",
    "author": "Ken W. Alger",,
    "customers_purchased": ["user00", "user01", "user02"]

}
1
2
3
4
5
6
7
8

对于绝大多数不太可能登上“畅销书”排行榜的书来说,这可以工作得很好。尽管将异常值考虑进来后导致了customers_purchased数组超出了我们设置的1000个条目的限制,但我们可以添加一个新字段将这本书“标记”为异常值。

{
    "_id": ObjectID("507f191e810c19729de860ea"),
    "title": "Harry Potter, the Next Chapter",
    "author": "J.K. Rowling",,
   "customers_purchased": ["user00", "user01", "user02",, "user999"],
   "has_extras": "true"
}
1
2
3
4
5
6
7
8

然后,我们将多出的信息移动到与书籍的id相关联的单独文档中。在应用程序中,我们可以看文档是否有值为true的has_extras字段。如果是,那么应用程序将会检索额外的信息。这样处理可以使其对大多数应用程序代码来说是基本透明的。

许多设计决策都基于应用程序的工作负载,因此这个解决方案旨在展示一个异常值模式的示例。这里要理解的重要概念是,异常值在其数据中有足够大的差异,如果它们被当作“正常值”对待,那么为它们更改应用程序设计将降低其它更典型查询和文档的性能。

  • 应用场景示例

异常模式是一种高级模式, 但可以带来较大的性能改进。它经常在受欢迎程度可以作为一个因素的情况下使用,例如社交网络关系、图书销售、电影评论等。互联网已经大幅缩小了我们的世界,当某个东西变得受欢迎时,它改变了我们需要对数据建模的方式。

一个例子是拥有视频会议产品的客户。大多数视频会议的被授权的与会者列表可以和会议保存在同一文档中。然而,也有一些活动预计会有数千的参加者,比如一家公司的全体员工。对于那些“异常”会议,这个客户使用“overflow”文档来记录那些长长的与会者列表。

  • 结论

异常值模式所要解决的问题是防止以少量文档或查询来确定应用程序的解决方案,尤其是当该解决方案对大多数用例来说不是最佳的时候。我们可以利用MongoDB的灵活数据模型在文档中添加一个字段来将其标记为异常值。然后在应用程序内部,我们对异常值的处理会略有不同。通过为典型的文档或查询定制模式,应用程序的性能将会针对那些正常的用例进行优化,而那些异常值仍将得到处理。

这个模式需要考虑的一点是,它通常是为特定的查询和情况而定制的。因此,一些临时产生的查询可能会导致性能不理想。此外,由于大部分工作是在应用程序代码本身内完成的,因此随着时间的推移可能需要进行额外的代码维护。

计算模式

它可以用于对集合中的数据进行计算或操作,并将结果存储在文档中,以避免重复进行相同的计算。当你的系统在重复执行相同的计算,并且具有较高的读写比时,请考虑使用计算模式。

假设现在有一个关于电影信息的Web应用程序。每次我们访问应用查找电影时,页面都会提供有关播放这部电影的影院数量、观看电影的总人数以及总收入的信息。如果应用必须不断地为每次页面访问计算这些值,那么当碰上那些很受欢迎的电影时会使用掉大量的处理资源。

然而,大多数时候我们不需要知道确切的数字。我们可以在后台进行计算,然后每隔一段时间更新一次电影信息的主文档。这些计算允许我们在显示有效数据的同时无需给CPU带来额外的负担。

当有在应用程序中需要重复计算的数据时,我们可以使用计算模式。当数据访问模式为读取密集型时,也会使用计算模式;例如,如果每小时有1000000次读取而只有1000次写入,则在写入时进行计算会使计算次数减少1000倍。

在我们的电影数据库示例中,我们可以根据特定电影上的所有放映信息进行计算,并将计算结果与电影本身的信息存储在一起。在低写负载的环境中,这个计算可以与源数据的任意更新一起完成。如果有更多的常规写入,则可以按定义好的时间间隔(例如每小时)进行计算。因为不会对上映信息中的源数据做任何修改,所以我们可以继续运行现有的计算,或者在任何时间点运行新的计算,并且确定将得到正确的结果。

一些执行计算的其它策略可能会涉及例如向文档添加时间戳以指示文档上次的更新时间。之后,应用程序可以确定何时需要进行计算。另一种选择是可以生成一个需要完成的计算队列。使用何种更新策略最好留给应用开发人员去选择。

  • 应用场景示例

只要有对数据进行计算的需求,就可以使用计算模式。一个很好的例子是需要求和的数据集(如收入或观影者),但时间序列数据、产品目录、单视图应用程序和事件源也同样很适合这种模式。

这是许多客户已经实现的模式。例如,一个客户对车辆数据进行了大量的聚合查询,并将结果存储在服务器上,以在接下来的几个小时显示这些信息。

一家出版公司将所有类型的数据进行编制来创建像“100个最佳的……”这样的有序列表。这些列表一段时间只需要重新生成一次,而底层数据可能在其它时间更新。

  • 结论

这一强大的设计模式可以减少CPU工作负载并提高应用程序性能。它可以用于对集合中的数据进行计算或操作,并将结果存储在文档中,以避免重复进行相同的计算。当你的系统在重复执行相同的计算,并且具有较高的读写比时,请考虑使用计算模式。

子集模式

当我们的文档拥有大量数据而其并不常用时,子集模式就非常有用。产品评论、文章评论、电影中的演员信息都是这个模式的应用场景案例。每当文档大小对工作集的大小产生压力并导致工作集超过计算机的RAM容量时,子集模式便成为一个可以考虑的选项。

在多年前,第一代PC拥有高达256KB的RAM和两个5.25英寸的软盘驱动器。没有硬盘,因为在当时它们极为昂贵。这些限制导致在处理大量(对那时来说)数据时由于内存不足,必须在物理上交换软盘。如果当时有办法只把我经常使用的数据(如同整体数据的一个子集)放入内存就好了。

现代应用程序也无法幸免于资源消耗的影响。MongoDB将频繁访问的数据(称为工作集)保存在RAM中。当数据和索引的工作集超过分配的物理RAM时,随着磁盘访问的发生以及数据从RAM中转出,性能会开始下降。

我们如何解决这个问题?首先,我们可以向服务器添加更多的RAM,不过也就只能扩展这么多。我们也可以考虑对集合进行分片,但这会带来额外的成本和复杂性,而我们的应用程序可能还没有准备好来应对这些。另一种选择是减小工作集的大小,这就是我们可以利用子集模式的地方。 子集模式

此模式用来解决工作集超出RAM,从而导致信息从内存中被删除的问题。这通常是由拥有大量数据的大型文档引起的,这些数据实际上并没有被应用程序使用。我这么说到底是什么意思呢?

假设一个电子商务网站有一个产品评论列表。当访问该产品的数据时,我们很可能只需要最近10个左右的评论。将整个产品数据与所有评论一起读入,很容易导致工作集的膨胀。

相比于将所有的评论与产品存储在一起,我们可以将其分为两个集合。一个集合具有最常用的数据,例如当前的评论;另一个集合具有不太常用的数据,例如旧的评论、产品历史记录等。我们可以复制在一对多或多对多关系中最常用的那部分数据。

在Product集合中,我们只保留最近十次的评论。这允许通过只引入整体数据的一部分或子集来减少工作集。附加信息(本例中的reviews)存储在单独的Reviews集合中,如果用户希望查看更多的评论,则可以访问该集合。在考虑将数据拆分到何处时,文档中使用最多的部分应放入“主”集合,而使用频率较低的数据应放入另一个集合。对于我们例子中的评论,这个分割点可能是产品页面上可见的评论数。

  • 应用场景示例

当我们的文档拥有大量数据而其并不常用时,子集模式就非常有用。产品评论、文章评论、电影中的演员信息都是这个模式的应用场景案例。每当文档大小对工作集的大小产生压力并导致工作集超过计算机的RAM容量时,子集模式便成为一个可以考虑的选项。

  • 结论

通过使用包含有频繁访问数据的较小文档,我们减少了工作集的总体大小。这使得应用程序所需要的最常用信息的磁盘访问时间更短。在使用子集模式时必须做的一个权衡是,我们必须管理子集,而且如果我们需要引入更旧的评论或所有信息,则需要额外的数据库访问才能做到这一点。

扩展引用模式

如果需要大量的JOIN操作来聚集那些需要频繁访问的数据,这时要怎么办呢?这就是我们可以使用扩展引用模式的地方。

有时将数据放置在一个单独的集合中是有道理的。如果一个实体可以被认为是一个单独的“事物”,那么使其拥有单独的集合通常是有意义的。例如在一个电子商务应用中,存在订单的概念,就像客户和库存一样,它们都是独立的逻辑实体。

然而从性能的角度来看,这就成了问题,因为我们需要为特定的订单将信息拼凑起来。一个客户可以有N个订单,创建一个1-N关系。如果我们反过来从订单的角度看,它们与客户之间有一种N-1的关系。仅仅是为了减少JOIN操作而为每个订单嵌入关于客户的所有信息,会导致大量的信息重复。此外,对于订单来说,并非所有的客户信息都是必须的。

扩展引用模式提供了一种很好的方法来处理这类情况。我们只复制经常访问的字段,而不是复制全部的客户信息。我们只嵌入那些优先级最高、访问最频率的字段,例如名称和地址,而不是嵌入所有信息或包含一个引用来JOIN信息。

使用此模式时需要考虑的一点是,数据是重复的。因此最好存储在主文档中的数据是不会经常更改的字段。像user_id和人名之类的东西是不错的选择,这些很少改变。

此外,要注意只引入和复制所需的数据。想象一下订单发票,如果我们在发票上输入客户的姓名,我们是否在那个时间点会需要他们的第二个电话号码和非送货地址?可能不会,因此我们可以将该数据从invoice集合中删除,并添加一个custormer集合的引用。

当信息被更新时,我们同样需要考虑如何处理。哪些扩展引用发生了更改?应该什么时候进行更新?如果该信息是账单地址,我们是否需要出于历史目的维护该地址,还是可以直接更新?有时使数据重复会更好,因为你可以保留历史值,这可能更有意义。我们发货时客户所居住的地址在订单文档中更有意义,然后可以通过客户集合来获取现在的地址。

  • 应用场景示例

订单管理应用是此模式的经典用例。在考虑订单到客户的N-1关系时,我们希望减少信息的连接以提高性能。通过包含对需要频繁连接数据的一个简单引用,我们在处理过程中省掉了一个步骤。

我们继续使用订单管理系统的作为例子。在发票上,Acme公司可能被列为一个铁砧的供应商。从发票的角度来看,拥有Acme公司的联系信息可能并不重要。例如,这些信息最好保存在单独的supplier集合中。在invoice集合中,我们会保留有关供应商的必要信息,作为对供应商信息的扩展引用。

  • 结论

当应用程序中有许多重复的JOIN操作时,扩展引用模式是一个很好的解决方案。通过识别查找端(lookup side)的字段并将那些经常访问的字段引入主文档,可以提高性能。这是通过更快的读取和减少JOIN的总数来实现的。但是请注意,重复数据是这种设计模式的一个副作用。

近似值模式

在所需要的计算非常有挑战性或消耗的资源昂贵(时间、内存、CPU周期)时,如果精度不是首要考虑因素时,那么我们就可以使用近似值模式。

假设现在有一个相当规模的城市,大约有3.9万人。人口的确切数字是相当不稳定的,人们会搬入搬出、有婴儿会出生、有人会死亡。我们也许要花上整天的时间来得到每天确切的居民数量。但在大多数情况下,39,000这个数字已经“足够好”了。同样,在许多我们开发的应用程序中,知道“足够好”程度的数字就可以了。如果一个“足够好”的数字就够了,那么这就是一个应用近似值模式的好机会。

人口问题,精确计算这个数字的成本是多少?从我开始计算起,它将会改变还是可能会改变?如果这个数字被报告为39,000,而实际上是39,012,这会对这个城市的规划战略产生什么影响?

从应用程序的角度看,我们可以构建一个近似因子,它允许对数据库进行更少写入的同时仍然提供统计上有效的数字。例如,假设我们的城市规划是基于每10000人需要一台消防车,那么用100人作为这个计划的“更新”周期看起来就不错。“我们正接近下一个阈值了,最好现在开始做预算吧。”

在应用程序中,我们不需要每次更改都去更新数据库中的人口数。我们可以构建一个计数器,只在每达到100的时候才去更新数据库,这样只用原来1%的时间。在这个例子里,我们的写操作显著减少了99%。还有一种做法是创建一个返回随机数的函数。比如该函数返回一个0到100之间的数字,它在大约1%的时间会返回0。当这个条件满足时,我们就把计数器增加100。

我们为什么需要关心这个?当数据量很大或用户量很多时,对写操作性能的影响也会变得很明显。规模越大,影响也越大,而当数据有一定规模时,这通常是你最需要关心的。通过减少写操作以及不必要的“完美”,可以极大地提高性能。

  • 应用场景示例

人口统计的方式是近似值模式的一个示例。另一个可以应用此模式的用例是网站视图。一般来说,知道访问过该网站的人数是700,000还是699,983并不重要。因此,我们可以在应用程序中构建一个计数器,并在满足阈值时再更新数据库。

这可能会极大地降低网站的性能。在关键业务数据的写入上花费时间和资源才是有意义的,而把它们全部花在一个页面计数器上似乎并不是对资源很好的利用。

在上图中,我们看到了如何使用近似值模式,这不仅可以减少计数操作的写入,还可以通过减少这些写入来降低架构的复杂性和开销。这可以带来更多的收益,而不仅仅是写操作时间的减少。与前面讨论过的计算模式(The Computed Pattern)类似,它通过降低计算的频率,从而在总体上节约了CPU的使用。

  • 结论

近似值模式对于处理难以计算和/或计算成本高昂的数据,并且这些数字的精确度不太关键的应用程序是一个很好的解决方案。我们可以减少对数据库的写入,从而提高性能,并且保持数字仍然在统计上是有效的。然而,使用这种模式的代价是精确的数字无法被表示出来,并且必须在应用程序本身中实现。

树形模式

在使用对于许多模式时,通常需要在易用性和性能之间进行权衡。对于树形模式来说,它通过避免多次连接操作可以获得更好的性能,但是你需要自己管理图的更新。

到目前为止,我们讨论的许多设计模式都强调省去JOIN操作的时间是有好处的。那些会被一起访问的数据也应该存储在一起,即便导致了一些数据重复也是可以的。像扩展引用(Extended Reference)这样的设计模式就是一个很好的例子。但是,如果要联接的数据是分层的呢?例如,你想找出从某个员工到CEO的汇报路径?MongoDB提供了$graphlookup运算符,以图的方式去浏览数据,这可能是一种解决方案。但如果需要对这种分层数据结构进行大量查询,你可能还是需要应用相同的规则,将那些会被一起访问的数据存储在一起。这里我们就可以使用树形模式。 树形模式

在以前的表格式数据库中,有许多方法可以表示一个树。最常见的是,让图中的每个节点列出其父节点,还有一种是让每个节点列出其子节点。这两种表示方式可能都需要多次访问来构建出节点链。

由父节点构建的公司架构

由子节点构建的公司架构

还有一种做法,我们可以将一个节点到层级顶部的完整路径储存起来。在本例中,我们将存储每个节点的“父节点”。这在一个表格式数据库中很可能是通过对一个父节点的列表进行编码来完成的。而在MongoDB中,可以简单地将其表示为一个数组。

如图所示,在这种表示中会有一些重复数据。如果信息是相对静态的,比如在家谱中你的父母和祖先是不变的,从而使这个数组易于管理。然而,在我们的公司架构示例中,当变化发生并且架构进行重组时,你需要根据需要更新层次结构。与不用每次计算树所带来的好处相比,这仍然是一个很小的成本。

  • 应用场景示例

产品目录是另一个使用树形模式的好例子。产品通常属于某个类别,而这个类别是其它类别的一部分。例如,一个固态硬盘(Solid State Drive)可能位于硬盘驱动器(Hard Drives)下,而硬盘驱动器又属于存储(Storage)类别,存储又在计算机配件(Computer Parts)下。这些类别的组织方式可能偶尔会改变,但不会太频繁。

注意在上面这个文档中的ancestor_categories字段跟踪了整个层次结构。我们还使用了一个字段parent_category。在这两个字段中重复储存直接父级节点是我们与许多客户合作后发现的使用树形模式的一种最佳实践。包含“parent”字段通常很方便,特别是当你需要保留在文档上使用$graphLookup的能力时。

将祖先节点保存在数组中可以提供对这些值创建多键索引(multi-key index)的能力。这允许轻松找到给定类别的所有子代。至于直接子代,可以通过查看将给定类别作为其直接“父母”的文档来访问。我们刚刚说过有这个字段会很方便。

  • 结论

在使用对于许多模式时,通常需要在易用性和性能之间进行权衡。对于树形模式来说,它通过避免多次连接操作可以获得更好的性能,但是你需要自己管理图的更新。

预分配模式

为避免性能问题,内存通常以块的形式进行分配。在MongoDB的早期(MongoDB 3.2版之前),当它使用MMAPv1存储引擎时,一个常见的优化是提前分配所需的内存,以满足不断增长的文档未来会达到的大小。MMAPv1中不断增长的文档需要由服务端以相当昂贵的成本进行位置的迁移。WiredTiger的无锁机制(lock-free)和重写(rewrite)更新算法不需要这种处理。随着MMAPv1在MongoDB 4.0中的弃用,预分配模式似乎失去了一些吸引力和必要性。然而,仍然会有一些用例需要WiredTiger的预分配模式。

这个模式只要求创建一个初始的空结构,稍后再进行填充。这听起来似乎很简单,但你需要在简化预期的结果和解决方案可能会消耗的额外资源中取得平衡。大文档会产生比较大的工作集,也就需要更多的RAM来包含此工作集。

如果应用程序的代码在使用未完全填充的结构时更容易编写和维护, 则这种方案带来的收益很容易超过RAM消耗所带来的成本。假设现在有一个需求要将剧院的空间表示为一个二维数组,其中每个座位都有一个“行”和一个“数字”,例如,座位“C7”。有一些行可能会有比较少的座位,但是在二维数组中查找座位“B3”会比用复杂的公式在一个只存储实际座位的一维数组中查找更快、更简洁。这样,找出可使用的座位也更容易,因为可以为这些座位创建一个单独的数组。

场所的二维表示,绿色为有效座位,可用座位以蓝色框作为标记

场所的一维表示,可用座位以蓝色框作为标记

  • 应用场景示例

如前所述,二维结构的表示(比如场地)是一个很好的用例。另一个例子是预约系统,按照每天作为粒度,其中资源会被冻结或者预订。针对每个有效天使用一个单元格可能比保存一个范围的列表可以更快地进行计算和检查。

2019年4月美国的工作日数组

2019年4月美国的工作日范围列表

  • 结论

在使用MongoDB的MMAPv1存储引擎时,此模式可能是最常用的模式之一。然而,由于这个存储引擎的弃用,它失去了一些通常的使用场景,但在某些情况下仍然有用。和其它模式一样,你需要在“简单”和“性能”之间做出权衡。

文档版本控制模式

数据库,例如MongoDB,非常擅长查询大量数据并进行频繁更新。然而,在大多数情况下,我们只针对数据的最新状态执行查询。那如果有些场景下我们需要查询数据的以前状态呢?如果我们需要一些文档的版本控制功能怎么办?这就是我们可以使用文档版本控制模式的地方。

这个模式的关键是保持文档的版本历史记录处于可用状态。我们可以构建一个专用的版本控制系统和MongoDB配合使用。这个系统用于处理少数文档的更改,而MongoDB用于处理其它文档。这可能看起来有些笨。但是通过使用文档版本控制模式,我们可以避免使用多个系统来管理当前文档及其历史,方法是将它们保存在同一个数据库中。

这种模式解决了这样一个问题:希望可以在不引入第二个管理系统的情况下保留MongoDB中某些文档的旧版本。为此,我们在每个文档中添加一个字段,以便跟踪文档版本。然后,数据库将会有两个集合:一个集合具有最新的(和查询最多的数据),另一个具有所有数据的修订版本。

文档版本控制模式对数据库以及应用程序中的数据访问模式做了一些假设。

  • 每个文档不会有太多的修订版本。
  • 需要做版本控制的文档不会太多。
  • 大多数的查询都是基于文档的最新版本。

如果你发现这些假设不适用于你的场景,那么这个模式也许不太合适。这需要你更改对于这一模式中版本的实现,或者你的用例可能需要换一个解决方案。

  • 应用场景示例

文档版本控制模式在高度规范化的行业中非常有用,这些行业会要求一组数据的特定时间点版本。金融和医疗行业就是很好的例子,保险业和法律相关的行业也同样如此。有许多场景需要跟踪数据某些部分的历史记录。

我们来看看一个保险公司可能会如何使用这种模式。每个客户都有一个“标准”保单和一个(根据客户意愿增加的)该客户特有的保单附加条款。这附加的第二部分包括保险单附加条款列表和正在投保的特定项目列表。当客户更改了受保的具体项目时,这一信息需要随之更新,而同时之前的历史记录也需要保留。这在业主或承租人这样的保单中相当常见。例如,有人想要投保的特定项目超出了所提供的典型保险范围,那么这部分会作为附加条款单独列出。保险公司的另一个用例可能是保留他们随时间邮寄给客户的“标准保单”的所有版本。

根据文档版本控制模式的需求,这看起来是一个非常好的用例。保险公司可能有几百万个客户,对“附加”列表的修改可能不会太频繁,而且对保单的大多数搜索针对的都是最新版本。

在我们的数据库中,每个客户可能在current_policies集合中有一个包含客户特定信息的current_policy文档,以及在policy_revisions集合中有一个policy_revision文档。此外,还会有一个对于大多数客户来说相同的standard_policy集合。当客户购买新项目并希望将其添加到其保单中时,将使用current_policy文档创建一个新的policy_revision文档。随后,文档中的版本字段将会递增以标识其为最新版本,并将客户的更改添至其中。

最新版本存储在current_policies集合中,而旧版本将写入policy_revisions集合。通过在current_policy集合中保留最新版本,查询请求可以保持简单。根据对数据的需求,policy_revisions集合可能也只保留几个版本。

在这个例子中,中土(Middle-earth)保险公司为其客户制定了一个standard_policy。夏尔(Shire)的所有居民都将共享这个保单文档。现在比尔博(Bilbo)还想在他正常的保险范围之外添加一些特别的保项:他的精灵宝剑(Elven Sword)以及,当然,还有至尊魔戒(the One Ring)。这些将保存在current_policies集合中,并且在进行更改时,policy_revisions集合将保留更改的历史记录。

文档版本控制模式相对容易实现。它可以在现有系统上实现,而不会对应用程序或现有文档进行太多的更改。此外,访问文档最新版本的查询仍然可以执行。

这种模式的一个缺点是对于历史信息需要访问不同的集合。此外,这种模式对数据库的总体写入量会更高。这就是为什么使用此模式的要求之一是数据的更改不会太频繁。

  • 结论

当你需要跟踪文档的更改时,文档版本控制模式是一个很好的选择。它相对容易实现,并且可以应用于现有的一组文档。另一个好处是,对最新版本数据的查询仍然可以很好地执行。但是,它不能取代专用的版本控制系统。

模式版本控制模式

有一种说法,生命中唯一不变的东西就是变化。这同样适用于数据库模式。我们会想要获取我们曾经认为不需要的信息。或者一些新上线的服务需要包含在数据库记录中。不管变更背后的原因是什么,一段时间之后,我们不可避免地需要对应用程序中的底层模式设计进行更改。虽然这经常会在传统的表格数据库系统中带来一些挑战甚至是麻烦,但在MongoDB中,我们可以使用模式版本控制来简化这一过程。

如前所述,在一个表格式数据库中更新数据模式是很有挑战性的。通常需要停止应用程序,迁移数据库以支持新模式,然后重新启动。这种停机时间会导致糟糕的用户体验。此外,如果迁移没有完全成功,会发生什么?恢复到先前的状态通常是一个更大的挑战。

在MongoDB中,存在于同一数据库集合中的文档可以有不同的形式,模式版本控制模式利用了这一特性。MongoDB的这种多态性非常强大,它允许具有不同字段甚至不同类型的同一字段这样的文档同时存在。

这一模式的实现相对容易。我们的应用程序最初会使用一种模式,而这个模式最终会需要修改。当这种情况发生时,我们可以使用schema_version字段创建新模式并将其保存到数据库中。这个字段允许我们的应用程序知道如何处理这个特定的文档。或者,我们可以让应用程序根据某些给定字段的存在或不存在来推断版本,但最好还是使用前一种方法。我们可以假定没有此字段的文档是版本1,然后每个新的模式版本都会增加schema_version字段的值,并可以在应用程序中进行相应的处理。

当保存新信息时,我们使用最新的模式版本。我们可以根据应用程序和使用场景来决定是否需要将所有文档更新为新设计,或是在访问时再更新,又或者根本不进行更新。在应用程序内部,我们会为每个模式版本创建相应的处理函数。

  • 应用场景示例

如前所述,几乎每个数据库在其生命周期中的某个时刻都会产生变更,因此这个模式在许多情况下都非常有用。让我们来看一个客户档案的例子。我们从前就开始保存客户信息,那时还没有这么多的联系方式,要找一个人只能在他在家或者工作的时候:

{
    "_id": "",
    "name": "Anakin Skywalker",
    "home": "503-555-0000",
    "work": "503-555-0010"
}
1
2
3
4
5
6

随着时间的推移,越来越多的客户记录被保存起来,有一天我们发现手机号码也是需要保存的。可以很容易地添加这一字段。

{
    "_id": "",
    "name": "Darth Vader",
    "home": "503-555-0100",
    "work": "503-555-0110",
    "mobile": "503-555-0120"
}
1
2
3
4
5
6
7

又过了一段时间,我们发现越来越少的人拥有家庭电话,而其它的联系方式正变得越来越重要。像Twitter、Skype和Google Hangouts这样的东西正变得越来越流行,这些甚至在我们第一次相互保存联系信息时可能都还不存在。我们还希望尽可能地使我们的应用程序具有前瞻性。在阅读了使用模式构建系列文章之后,我们了解了属性模式,并将其实现到一个contact_method数组中。为此,我们创建了一个新的模式版本。

{
    "_id": "",
    "schema_version": "2",
    "name": "Anakin Skywalker (Retired)",
    "contact_method": [
        { "work": "503-555-0210" },
        { "mobile": "503-555-0220" },
        { "twitter": "@anakinskywalker" },
        { "skype": "AlwaysWithYou" }
    ]
}
1
2
3
4
5
6
7
8
9
10
11

MongoDB文档模型的灵活性允许在数据库不停机的情况下进行所有这些操作。从应用的角度来看,可以设计成同时读取模式的两个版本。即使涉及的应用服务器不止一个,应用程序对于如何处理模式差异的更改也是不需要停机的。

  • 结论

模式版本控制非常适合于这样的情况:不允许应用程序停机、更新文档可能需要数小时、数天或数周才能完成、不需要将文档更新到新版本,也不是这些要求的组合。它可以轻松添加新的schema_version字段,并允许应用程序根据这些更改进行调整。此外,它还为我们开发人员提供了更好地决定何时以及如何进行数据迁移的机会。所有这些都会帮助减少未来的技术债务,这是这个模式的另一大优势。

与本系列中提到的其它模式一样,使用模式版本控制模式也需要一些考量。如果文档中某个字段的索引不在同一级别,则在迁移文档时可能需要2个索引。

这种模式的主要好处之一是数据模型本身的简单性。只需添加schema_version字段,然后允许应用程序处理不同的文档版本。

此外,正如应用场景示例中所看到的,我们可以将设计模式组合在一起以获得额外的性能提升。在本例中,将模式版本控制和属性模式一起使用,允许在不停机的情况下进行模式升级,这使得模式版本控制这一模式在MongoDB中特别强大。这很可能为你提供了一个充分的理由,让你在应用程序中使用MongoDB的文档模型而不是老式的表格数据库。

模式构建小结

  • 近似值

近似值模式适用于当昂贵的计算很频繁,而这些计算的精度要求通常不是首要考虑的时候。

  • 优点:

    • 对数据库更少的写入
    • 保持在统计学上有效的数字
  • 缺点

    • 无法展示精确的数字
    • 需要在应用层实现
  • 属性

属性模式适用于解决这样一类问题:我们有一些大文档,它们有很多相似的字段,而这些字段的一个子集具有共同的特征,我们希望对该子集字段进行排序或查询。当需要排序的字段只能在一小部分文档中找到。或者在文档中同时满足这两个条件时。

  • 优点

    • 需要更少的索引
    • 查询变得更容易编写,而且通常更快
  • 分桶

当需要管理流式数据,如时间序列、实时分析或物联网(IOT)应用程序时,分桶模式是一个很好的解决方案。

  • 优点

    • 减少了集合中的文档总数
    • 提高了索引性能
    • 可以通过预聚合简化数据的访问
  • 计算

当数据访问模式为读取密集型并且应用程序需要重复计算这些数据时,计算模式是一个很好的选项。

优点

  • 对于频繁的计算可以减少CPU的工作负载

  • 查询变得更容易编写,而且通常更快

  • 缺点

    • 识别出需要使用此模式的的场景可能比较困难
    • 除非必要,请勿过度使用此模式
  • 文档版本控制

当你需要在MongoDB中维护以前版本的文档时,文档版本控制模式是一种可行的解决方案。

  • 优点

    • 容易实现,即使是在现存的系统中
    • 在最新版本上进行请求时,没有性能上的影响
  • 缺点

    • 写操作的数量会翻倍
    • 请求需要被定位到正确的集合
  • 扩展引用

当你的应用程序使用了大量的JOIN操作来将频繁访问的数据集中在一起时,你会发现扩展引用模式非常有用。

  • 优点

    • 当有大量的JOIN操作时可以提升性能
    • 读操作会更快,并且可以减少JOIN操作的数量
  • 缺点

    • 会有重复数据
  • 异常值

你是否发现有一些查询或文档和其它典型数据的模式不一样?这些例外情况是否驱动了你应用程序的解决方案?如果是这样,那么异常值模式就是解决这种情况的一个很好的方法。

  • 优点

    • 防止整个应用的解决方案被某些个别的文档或请求所左右
    • 请求会针对那些典型的用例进行优化,而异常值仍将得到处理
  • 缺点

    • 通常会为特定的查询而进行定制,因此一些临时产生的查询可能性能不太理想
    • 此模式的大部分工作是在应用程序代码中完成的
  • 预分配

当你事先知道文档的结构,而应用程序只需要用数据填充它时,预分配模式是正确的选择。

  • 优点

    • 当预先知道文档结构时,可以简化设计
  • 缺点

    • 简单和性能之间的权衡
  • 多态

当有多种文档它们的相似性比差异更多,并且需要将这些文档保存在同一个集合中时,多态模式是一种解决方案。

  • 优点

    • 实现简单
    • 查询可以在单个集合中运行
  • 模式版本控制

几乎每个应用程序都可以从模式版本控制模式中获益,因为数据模式的更改经常发生在应用程序的生命周期中。此模式允许历史版本和当前版本的文档在集合中同时存在。

  • 优点

    • 不需要停机时间
    • 模式迁移可控
    • 减少未来的技术债务
  • 缺点

    • 在迁移过程中,对相同的字段可能需要两个索引
  • 子集

子集模式解决了有大量数据的大文档没有被应用程序使用而导致的工作集超过RAM容量的问题。

  • 优点

    • 在总体上减小了工作集的大小
    • 缩短了最常用数据的磁盘访问时间
  • 缺点

    • 必须管理子集
    • 请求附加的数据需要额外的数据库访问
  • 树形

当数据是分层结构并且经常被查询时,树形模式就是你要使用的。

  • 优点

    • 通过避免多次JOIN操作提高了性能
  • 缺点

    • 需要在应用层管理图的更新
  • 结论

正如我们希望你在本系列文章中看到的,MongoDB文档模型在如何建模数据方面提供了很大的灵活性。这种灵活性是非常强大的,但是这种能力需要根据应用程序的数据访问模式去驾驭利用。MongoDB中的模式设计对应用程序的性能有着巨大的影响。我们发现性能问题常常可以追溯到糟糕的模式设计。

请记住,为了进一步增强文档模型的能力,这些设计模式在合理的情况下可以一起使用。例如,随着应用程序的发展,模式版本控制可以与任何其它模式一起使用。学习完已经介绍的十二种设计模式,你已经拥有了利用文档模型强大的灵活性所需的工具和知识。

参考文章

作者:

MongoDB官网博客 - Building with Patterns系列 (opens new window)

联系我

添加@pdai微信

PS:添加时请备注Java全栈,谢谢!