1
本文翻译自:Managing Relations Inside Elasticsearch | Elastic

在现实世界中,数据很少是简单的,通常情况都存在着混乱的交错关系。

我们该如何在 Elasticsearch 中表现文档( 数据 )间的关系呢?这里有一些机制,能够为我们提供文档间关系的支持。这些机制有着各自的优势和劣势,请务必根据不同的场景妥善使用。

Inner Objects

最简单的机制被称为「内部对象」。下面是一个嵌入到父级对象中的 JSON 对象:

{
    "name":"Zach",
    "car":{
        "make":"Saturn",
        "model":"SL"
    }
}

很简单吧。car 字段是一个拥有 makemodel 两个属性的内部对象。当根对象和内部对象间属于一对一关系时,这种内部对象的映射关系是有效的。比如:每个人最多含有一个 car

但是,当 Zach 拥有两个 car,而 Bob 只有一个 car 时呢?

{
    "name" : "Zach",
    "car" : [
        {
            "make" : "Saturn",
            "model" : "SL"
        },
        {
            "make" : "Subaru",
            "model" : "Imprezza"
        }
    ]
}
{
    "name" : "Bob",
    "car" : [
        {
          "make" : "Saturn",
          "model" : "Imprezza"
        }
    ]
}

请忽略 Saturn 公司从未生产过 Imprezza 型汽车的问题,考虑一下,当我们试图在 ES 中检索的时候会发生什么呢?由于只有 Bob 拥有 Saturn Imprezza,所以我们可以创建一个查询:

query: car.make=Saturn AND car.model=Imprezza

这样对吗?好吧,这样的查询结果并不会如我们所愿。如果执行这条查询语句,我们将会得到全部两条文档。这是由于 Elasticsearch 在内部将内部对象降维成了单个对象。因而 Zach 这条文档实际上是这样的:

{
    "name" : "Zach",
    "car.make" : ["Saturn", "Subaru"]
    "car.model" : ["SL", "Imprezza"]
}

这就解释了为什么上述查询会返回那样的结果。ELasticsearch 从根本上就是扁平处理的,所以文档在内部都会被当做扁平的字段。

Nested

作为内部对象的另一个选择,Elasticsearch 提供了「嵌套类型」的概念。嵌套文档在文档层面和内部对象是相同的,但是它提供了内部对象没有的功能( 也包括一些限制 )。

嵌套文档的例子如下:

{
    "name" : "Zach",
    "car" : [
        {
            "make" : "Saturn",
            "model" : "SL"
        },
        {
            "make" : "Subaru",
            "model" : "Imprezza"
        }
    ]
}

在映射层面,嵌套类型必须显式的声明( 不同于内部对象,可以自动检测 ):

{
    "person":{
        "properties":{
            "name" : {
                "type" : "string"
            },
            "car":{
                "type" : "nested"
            }
        }
    }
}

Inner Objects 的问题在于,每一个嵌套的 JSON 对象并不会被认为是文档中的单独组件。相反的,它们会与其他 Inner Objects 合并,并共享相同的属性名。

而这一问题并不会在 Nested 文档中出现。每一个 Nested 文档都会保持独立,因而我们可以使用 car.make=Saturn AND car.model=Imprezza 而不会遇到意外问题。

Elasticsearch 从根本上仍是扁平的,但它在内部管理着 Nested 关系,使其能够表现嵌套层次。当我们创建一个 Nested 文档时,Elasticsearch 实际上添加了两个独立的文档( 根对象和嵌套对象 ),然后再内部将其关联。上述两个文档均被存储在同一 Shard 上的同一个 Lucene 块中,因而读取性能仍旧非常迅速。

这种安排也同时带来了一些弊端。最明显的在于,我们只能通过特殊的「嵌套查询」才能访问嵌套文档。另一个问题会在我们试图对文档的根对象或其子对象的更新操作时出现。

因为 Nested 文档被存储在同一个 Lucene 块中,而 Lucene 不允许在段上的随机写操作,所以对 Nested 文档中某个字段的鞥更新操作将会导致整个文档的索引重建。

索引重建的目标包括根及其嵌套的子对象,即使它们并没有被修改。在内部,Elasticsearch 会将就文档标记为删除,更新字段,并将文档的全部内容重建索引值新的 Lucene 块中。如果 Nested 文档数据频繁更新的话,因索引重建而导致的性能消耗便不能被忽视。

此外,在 Nested 文档间使用交叉引用是不可行的。一个 Nested 对象的属性对另一个 Nested 对象是不可见的。例如,我们不能使用 A.nameB.age 同时作为过滤条件进行查询。可行的做法是使用 include_in_root,这会高效的将嵌套文档拷贝到根中,但如此一来,问题又回到了 Inner Objects 的情况。

Parent/Child

Elasticsearch 提供的最后一种方式是使用 Parent/Child 类型。这种模式相比 Nested 嵌套类,属于更为松散的耦合,并且给我们提供了更多强大的查询方式。看我们来看个例子,在这个例子中,一个人具有多个家庭( 在不同的情况下 )。父元素像通常一样具有 mapping 如下:

{
    "mappings":{
        "person":{
            "name":{
                "type":"string"
            }
        }
    }
}

子元素在父元素之外,有着自己的 mapping,且含有特殊的 _parent 属性集

{
    "homes":{
        "_parent":{
            "type" : "person"
        },
        "state" : {
            "type" : "string"
        }
    }
}

_parent 字段向 Elasticsearch 声明了 Employers 类型文档是 Person 类型的自雷。我们可以非常容易的向文档中加入此类型的数据。我们可以像通常情况一样添加父类型文档:

$ curl -XPUT localhost:9200/test/person/zach/ -d'
{
   "name" : "Zach"
}

添加子类型文档与通常略有不同,我们需要在在请求参数中指定该子文档所属于的父文档( 在这个例子中是 zach,这个值是我们在上面添加父文档时所指定的文档 ID ):

$ curl -XPOST localhost:9200/homes?parent=zach -d'
{
    "state" : "Ohio"
}
$ curl -XPOST localhost:9200/test/homes?parent=zach -d'
{
    "state" : "South Carolina"
}

上述两个文档现在都以与 zach 父文档建立了关联,这使得我们可以使用如下的查询:

  • Has Parent 过滤 / Has Parent 查询,可在父文档中起作用,并返回子文档
  • Has Child 过滤 / Has Child 查询,可在子文档中起作用,并返回父文档
  • Top Children 查询,能够返回匹配的前 X 个文档

由于子元素或父元素都是第一等类型,我们可以像通常情况一样单独请求它们( 只是不能使用关系值 )。

Nested 的最大问题在于其存储:同一元素的所有内容军备存储在同一个 Lucene 块中。Parent/Child 方式通过分离二者并使其松耦合在一起移除了这一限制。这种方式有利有弊。松耦合方式使得我们可以自由的更新或删除父文档,因为这些操作并不会对父文档或其他子文档产生影响。

Parent/Child 的缺点在于,其表现性能比 Nested 稍差。子文档被定位到与父文档相同的 Shard,因而它们仍能得益于分片级的缓存和内存过滤。但因为它们没有被放在同一个 Lucene 块中,因而比起 Nested 方式,Parent/Child 方式仍会稍慢。此外,这种方式还会增加一定的内存负载,因为 Elasticsearch 需要在内存中保存管理关系的「join table」。

最后,我们会发现排序和评分计算是相对困难的。例如,我们很难得到究竟是哪个文档匹配了 Has_child 过滤条件,而仅能够得到一个父文档符合条件的文档。在某些情况下,这个问题会相当棘手。

逆规范化

有时,最好的做法是在合适的时候简单地进行数据的逆规范化。Elasticsearch 的确提供了在特定情况下有效的关系结构支持,但这并不意味着我们能够使用类似关系型数据库管理系统所提供的强关系特性。

Elasticsearch 在本质上是扁平的数据架构,因而尝试去使用关系型数据是有风险的。在部分情况下,选择将数据进行逆规范化( 反范式 ),并采用二次查询的方式获得数据,是最为明智的选择。逆规范化可以说是最为强大和灵活的。

当然,这会带来管理成本。我们需要手动管理数据间的关系,并使用必要的查询或过滤条件去关联多样的类型。

结论和回顾

本文内容相对冗长,以下是简短的回顾:

Inner Object

  • 简单、快速、高性能
  • 仅对一对一关系起作用
  • 无需额外的查询

Nested

  • Nested 文档被存储在同一个 Lucene 块中,因而在同等条件下,Nested 的读取性能要高于 Parent/Child 类型
  • 对 Nested 文档中的根元素或子元素进行更新会导致 ES 对整个文档的更新。对于内容较多的文档而言,这一过程的代价很大
  • 无法使用「交叉引用」
  • 对不经常变更的文档非常适用

Parent/Child

  • 子文档与父文档单独存储,但仍在同一个分片中。因而其查询效率比 Nested 稍差
  • 由于 ES 需要在内存中管理 「join」列表,因而会增加一些内存负载
  • 更新子文档不会影响父文档或其他子文档,这会在大文档中潜在地节省大量的索引消耗
  • 因为 Has Child/Has Parent 操作有时是不可见的,因而排序和评分操作时难以进行的

逆规范化

  • 需要自己管理所有的关系
  • 更为灵活,但也会有最大的管理成本
  • 基于具体的设置,性能会或多或少的有所损耗

dailybird
1.1k 声望73 粉丝

I wanna.