Elasticsearch:在 Elasticsearch 中的 join 数据类型父子关系

在关系数据库中,子表使用外键引用父表,这种关系称为join。 设计通常涉及 规范化数据
Elasticsearch不是关系数据库,它全与搜索效率而不是存储效率有关。 存储的数据已被去规范化并且几乎是平坦的。 这意味着 join不能跨索引,Elasticsearch的重点在于速度,而传统 join 的运行速度太慢。 因此,子文档和父文档都必须位于相同的索引和相同的分片中。

 

父-子关系例子

让我们考虑下图1所示的家谱。该树有3个父母和9个孩子。 每个角色都有 “gender” 和 “isAlive” 状态。

图1 family tree 例子

 

在上面的示例中,我们探索以下场景:

  • 亲子关系
  • 每个父母有多个孩子
  • 多个层次的亲子关系

创建 family_tree 索引

以下代码有为上述关系创建索引:

PUT family_tree
{
  "settings": {
    "index": {
      "number_of_shards": 1,
      "number_of_replicas": 1
    }
  },
  "mappings": {
    "properties": {
      "firstName": {
        "type": "text"
      },
      "lastName": {
        "type": "text"
      },
      "gender": {
        "type": "text"
      },
      "isAlive": {
        "type": "boolean"
      },
      "relation_type": {
        "type": "join",
        "eager_global_ordinals": true,
        "relations": {
          "parent": "child"
        }
      }
    }
  }
}

在上面:

  • relation_type: 是 join 数据类型的名字
  • eager_global_ordinals: 亲子使用 Global Ordinals 加快导入速度
  • relations: 定义了一组可能的关系,每个关系都是 parent 名称和 child 名称。在这个例子里,为了方便,我们使用了 “parent” 及 “child” 作为父子关系的名称。在实际的例子中,我们可以使用你们所喜欢的名字

插入 parent 数据

在运行脚本以插入上图1所示的其他父级数据之前,让我们逐步了解一个父级插入的代码。

PUT family_tree/_doc/1?routing=Darren
{
  "firstName": "Darren",
  "lastName": "Ford",
  "gender": "Male",
  "isAlive": false,
  "relation_type": {
    "name": "parent"
  }
}

上面的代码为 Darren Ford 创建了一个新文档,并使用 related_type 字段将其标记为父文档。 将值 “parent” 分配给关系的名称。 除了关系之外,它还添加了所需的字段,例如 “firstName”,“lastName”,“gender” 和 “isAlive”。
这里要注意的一件事是 routing 查询参数。 每个父对象都为其参数分配自己的名称。 路由字段可帮助我们控制文档将在哪个分片上建立索引。 分片使用以下公式标识:

shard = hash(routing_value) % number_of_primary_shards

我们通过如果的方法来插入余下的 parent 文档:

PUT family_tree/_doc/1?routing=Darren
{
  "firstName": "Darren",
  "lastName": "Ford",
  "gender": "Male",
  "isAlive": false,
  "relation_type": {
    "name": "parent"
  }
}

PUT family_tree/_doc/2?routing=Sienna
{
  "firstName": "Sienna",
  "lastName": "Evans",
  "gender": "Female",
  "isAlive": false,
  "relation_type": {
    "name": "parent"
  }
}

PUT family_tree/_doc/3?routing=Ryan
{
  "firstName": "Ryan",
  "lastName": "Turner",
  "gender": "Male",
  "isAlive": false,
  "relation_type": {
    "name": "parent"
  }
}

 

插入 child 数据

同样,让我们先遍历一个子插件,然后运行上图所示的9个子插件的批量插入。

PUT family_tree/_doc/5?routing=Darren
{
  "firstName": "Pearl",
  "lastName": "Ford",
  "gender": "Female",
  "isAlive": true,
  "relation_type": {
    "name": "child",
    "parent": "1"
  }
}

在我们的示例中,“Pearl Ford” 是 “Darren Ford” 的子代,请注意,我们使用与创建 Darren 记录相同的 routing 查询参数。 这是因为子文档和父文档必须位于同一分片上的限制。
该记录与 Darren 的记录之间的关联是由 related_type 字段进行的,在该字段中,我们将关系的名称添加为 “child”,从而使 Pearl Ford 成为 ID 为“1”的 parent 的子代(我们创建父代 Darren 的同一个 ID 与)。

我们可以运用如下的代码来创建如下的子文档:

PUT family_tree/_bulk?routing=Darren
{"index": {"_id": "4"}}
{"firstName":"Otis", "lastName":"Ford", "gender":"Male", "isAlive":false, "relation_type":{ "name":"child", "parent":"1" }}
{"index": {"_id": "5"}}
{"firstName":"Pearl", "lastName":"Ford", "gender":"Female", "isAlive":true, "relation_type": { "name":"child", "parent":"1" }}
{"index": {"_id": "6"}}
{"firstName":"Ava", "lastName":"Ford", "gender":"Female", "isAlive":true, "relation_type": { "name":"child", "parent":"1" }}
{"index": {"_id": "7"}}
{"firstName":"Tyler", "lastName":"Ford", "gender":"Male", "isAlive":true, "relation_type": { "name":"child", "parent":"1" }}
{"index": {"_id": "8"}}
{"firstName":"Xavier", "lastName":"Ford", "gender":"Male", "isAlive":true, "relation_type": { "name":"child", "parent":"1"}}


PUT family_tree/_bulk?routing=Sienna
{"index": {"_id": "9"}}
{"firstName":"Ralph", "lastName":"Evans", "gender":"Male", "isAlive":true, "relation_type":{ "name":"child", "parent":"2" }}

PUT family_tree/_bulk?routing=Ryan
{"index": {"_id": "10"}}
{"firstName":"Fred", "lastName":"Turner", "gender":"Male", "isAlive":true, "relation_type":{ "name":"child", "parent":"3" }}
{"index": {"_id": "11"}}
{"firstName":"Scarlet", "lastName":"Turner", "gender":"Female", "isAlive":false, "relation_type":{ "name":"child", "parent":"3" }}
{"index": {"_id": "12"}}
{"firstName":"Wayne", "lastName":"Turner", "gender":"Male", "isAlive":true, "relation_type":{ "name":"child", "parent":"3" }}

我们使用上面 bulk API 来把我们的 child 数据导入到 Elasticsearch 中。

 

查询数据

现在是执行和理解的有趣部分,我们可以在刚刚创建的关系上运行查询。

搜索和过滤特定的 parent

  • 得到 Sienna Evans 的 child:parent_id 查询可用于查找属于特定 parent 的 child 文档
GET family_tree/_search
{
  "query": {
    "parent_id": {
      "type": "child",
      "id": "2"
    }
  }
}

返回的结果是:

    "hits" : [
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "9",
        "_score" : 1.7917595,
        "_routing" : "Sienna",
        "_source" : {
          "firstName" : "Ralph",
          "lastName" : "Evans",
          "gender" : "Male",
          "isAlive" : true,
          "relation_type" : {
            "name" : "child",
            "parent" : "2"
          }
        }
      }
    ]

上查询出 parent_id 为2的所有 child 文档。

  • 获取所有 isAlive 为 true 的所有 Darren Ford 的孩子:bool 和 must 关键字可用于获取记录。
GET family_tree/_search
{
  "query": {
    "bool": {
      "filter": {
        "term": {
          "isAlive": true
        }
      },
      "must": {
        "parent_id": {
          "type": "child",
          "id": "1"
        }
      }
    }
  }
}

返回的结果为:

    "hits" : [
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "5",
        "_score" : 0.6931472,
        "_routing" : "Darren",
        "_source" : {
          "firstName" : "Pearl",
          "lastName" : "Ford",
          "gender" : "Female",
          "isAlive" : true,
          "relation_type" : {
            "name" : "child",
            "parent" : "1"
          }
        }
      },
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "6",
        "_score" : 0.6931472,
        "_routing" : "Darren",
        "_source" : {
          "firstName" : "Ava",
          "lastName" : "Ford",
          "gender" : "Female",
          "isAlive" : true,
          "relation_type" : {
            "name" : "child",
            "parent" : "1"
          }
        }
      },
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "7",
        "_score" : 0.6931472,
        "_routing" : "Darren",
        "_source" : {
          "firstName" : "Tyler",
          "lastName" : "Ford",
          "gender" : "Male",
          "isAlive" : true,
          "relation_type" : {
            "name" : "child",
            "parent" : "1"
          }
        }
      },
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "8",
        "_score" : 0.6931472,
        "_routing" : "Darren",
        "_source" : {
          "firstName" : "Xavier",
          "lastName" : "Ford",
          "gender" : "Male",
          "isAlive" : true,
          "relation_type" : {
            "name" : "child",
            "parent" : "1"
          }
        }
      }
    ]

执行上面的查询将获取 “Pearl”,“ Ava”,“ Tyler” 和 “Xavier” Ford 的记录。

 

查询拥有 child 及 parent 查询

查询关键字 has_child 和 has_parent,有助于查询具有父子关系的数据。

获取所有育有女儿并且女儿已死的父母:has_child,关键字可帮助我们获取所有父母记录,其中孩子有过滤器。

GET family_tree/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "gender": "Female"
              }
            },
            {
              "match": {
                "isAlive": false
              }
            }
          ]
        }
      }
    }
  }
}

查询的返回结果是:

    "hits" : [
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.0,
        "_routing" : "Ryan",
        "_source" : {
          "firstName" : "Ryan",
          "lastName" : "Turner",
          "gender" : "Male",
          "isAlive" : false,
          "relation_type" : {
            "name" : "parent"
          }
        }
      }
    ]

执行上面的查询,将获得 “Ryan Turner” 的记录,Ryan Turner 是唯一一个死去的女儿 “Scarlet Turner” 的父母。

  • 获取所有父母性别为 “female” 的 child:has_parent 关键字可帮助我们获取所有有父母过滤条件的孩子记录。
GET family_tree/_search
{
  "query": {
    "has_parent": {
      "parent_type": "parent",
      "query": {
        "match": {
          "gender": "Female"
        }
      }
    }
  }
}

返回的结果:

    "hits" : [
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "9",
        "_score" : 1.0,
        "_routing" : "Sienna",
        "_source" : {
          "firstName" : "Ralph",
          "lastName" : "Evans",
          "gender" : "Male",
          "isAlive" : true,
          "relation_type" : {
            "name" : "child",
            "parent" : "2"
          }
        }
      }
    ]

执行上述查询,获取 “Ralph Evans” 的记录,其父母是 “Sienna Evans”,所有其他父母均为 Male。

 

Parent 拥有多个children

让我们将 “Melissa Ford” 作为 wife 添加到 “Darren Ford”中,如下图所示。“Darren” 现在已附加了 “children 和 wife” 文件。

可以使用以下代码更改索引:

PUT family_tree/_mapping
{
  "properties": {
    "relation_type": {
      "type": "join",
      "eager_global_ordinals": true,
      "relations": {
        "parent": [
          "child",
          "wife"
        ]
      }
    }
  }
}

我们修改了上面的 mapping,从而使得我们的 relation_type 具有一个 “parent” 及两个 children: “child” 及 “wife”。

插入 “Melissa Ford” 文档与我们之前创建的子记录类似,这将使用与父路径 “Darren” 相同的路径参数,并使用 “wife” 作为 relationship_type 名称。

PUT family_tree/_doc/13?routing=Darren
{
  "firstName": "Melissa",
  "lastName": "Ford",
  "gender": "Female",
  "isAlive": false,
  "relation_type": {
    "name": "wife",
    "parent": "1"
  }
}

查询 wife 数据

获取有 wife 的 “parent” (请注意这里的 parent 不是指的是妻子的父母,而是我们的数据关系的父母,也就是 Daren。我们可以从上面的图中可以看出来):查询使用 has_child 关键字并按 “wife” 类型进行过滤。

GET family_tree/_search
{
  "query": {
    "has_child": {
      "type": "wife",
      "query": {
        "match_all": {}
      }
    }
  }
}

返回数据:

    "hits" : [
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_routing" : "Darren",
        "_source" : {
          "firstName" : "Darren",
          "lastName" : "Ford",
          "gender" : "Male",
          "isAlive" : false,
          "relation_type" : {
            "name" : "parent"
          }
        }
      }
    ]

执行以上查询,获取 “Darren Ford” 的记录。

 

多级关系(孙子关系)

让我们将 Grand Children 添加到家谱中,如下图所示:

索引需要在这里重新创建! 这是由于另一个限制,只有在元素已经是父元素的情况下,才可以在现有元素中添加子元素。 由于在较早创建索引时 “child” 类型不是父类型,因此我们需要删除较早的索引,使用下面的代码创建一个新的索引,然后重新插入所有数据。

我们首先重新修改 family_tree 的 mapping:

DELETE family_tree

PUT family_tree
{
  "settings": {
    "index": {
      "number_of_shards": 1,
      "number_of_replicas": 1
    }
  },
  "mappings": {
    "properties": {
      "firstName": {
        "type": "text"
      },
      "lastName": {
        "type": "text"
      },
      "gender": {
        "type": "text"
      },
      "isAlive": {
        "type": "boolean"
      },
      "relation_type": {
        "type": "join",
        "eager_global_ordinals": true,
        "relations": {
          "parent": [
            "child",
            "wife"
          ],
          "child": "grandchild"
        }
      }
    }
  }
}

在这里,“child” 也成为 “grandchild” 类型的父母。 这使我们具有关系 parent→child→grandchild。

我们先按照上面的顺序,把我们之前的数据导入到 family_tree 中。然后再导入我们的 grandchild 数据。插入 “grandchild” 文档与插入“child”记录非常相似。

PUT family_tree/_doc/14?routing=Darren
{
  "firstName": "Douglas",
  "lastName": "Ford",
  "gender": "Male",
  "isAlive": true,
  "relation_type": {
    "name": "grandchild",
    "parent": "5"
  }
}

在我们的示例中,“Douglas Ford” 是 “Pearl Ford” 的子代,也是 “Darren Ford” 的孙代,请注意,我们使用与创建 Darren 记录相同的路由查询参数。 这样可以确保与超级父代 “Darren” 关联的所有子代都在同一分片上建立索引。

此记录与 “Pearl Ford” 之间的 join 由 relation_type 字段进行,在该字段中,我们将关系的名称添加为 “grandchild”,使 “Douglas Ford” 成为其 ID 为“5”的父代的孙代(同一个 ID  我们创建了 Pearl Ford)。

我们可以使用如下的代码把其它的 grandchild 的文档写入到 Elasticsearch 中:

PUT family_tree/_bulk?routing=Darren
{"index": {"_id": "14"}}
{"firstName":"Douglas", "lastName":"Ford", "gender":"Male", "isAlive":true, "relation_type":{ "name":"grandchild", "parent":"5" }}

PUT family_tree/_bulk?routing=Ryan
{"index": {"_id": "15"}}
{"firstName":"Frederick", "lastName":"Turner", "gender":"Male", "isAlive":false, "relation_type":{ "name":"grandchild", "parent":"11" }}
{"index": {"_id": "16"}}
{"firstName":"Eleanor", "lastName":"Turner", "gender":"Female", "isAlive":false, "relation_type":{ "name":"grandchild", "parent":"11" }}
{"index": {"_id": "17"}}
{"firstName":"Troy", "lastName":"Turner", "gender":"Male", "isAlive":false, "relation_type":{ "name":"grandchild", "parent":"11" }}

请求 grandparent 数据

获取所有拥有孙女的祖父母:

GET family_tree/_search
{
  "query": {
    "has_child": {
      "type": "child",
      "query": {
        "has_child": {
          "type": "grandchild",
          "query": {
            "match": {
              "gender": "Female"
            }
          }
        }
      }
    }
  }
}

返回的结果:

    "hits" : [
      {
        "_index" : "family_tree",
        "_type" : "_doc",
        "_id" : "3",
        "_score" : 1.0,
        "_routing" : "Ryan",
        "_source" : {
          "firstName" : "Ryan",
          "lastName" : "Turner",
          "gender" : "Male",
          "isAlive" : false,
          "relation_type" : {
            "name" : "parent"
          }
        }
      }
    ]

执行此查询将获得 “Ryan Turner” 记录,因为他是唯一拥有孙女 “Eleanor Turner” 的祖父母,如上面的图所示。

在这里我们必须指出的是:

不建议使用多个级别的关系来复制关系模型。 每个关系级别都会在查询时增加内存和计算方面的开销。 如果您关心性能,则应该对数据进行非规范化。 — elastic.co

 

Elasticsearch 中的 join 限制

现在,我们已经看到了加入功能的作用,让我们回顾一下上面注意到的限制。

  • 父文档和子文档必须在同一分片上建立索引。
  • 每个索引仅允许一个join字段映射。
  • 一个元素可以有多个子级,但只能有一个父级。
  • 可以向现有join字段添加新关系。
  • 也可以将子元素添加到现有元素中,但前提是该元素已经是父元素。

 

总结

当索引时间性能比搜索时间性能更重要时,父子联接可能是管理关系的有用技术,但代价是很高的。 必须意识到折衷方案,例如父子文档的物理存储约束和增加的复杂性。 另一个预防措施是避免多层父子关系,因为这将消耗更多的内存和计算量。