ElasticSearch【有与无】【搜索引擎】【ES28】父-子关系文档

目录

1.简介

1.1.父-子关系文档映射

1.2.构建父-子文档索引

1.3.通过子文档查询父文档

min_children 和 max_children

1.4.通过父文档查询子文档

1.5.子文档聚合

1.6.祖辈与孙辈关系

1.7.实际使用中的一些建议

1.8.全局序号和延迟

1.9.多代使用和结语


1.简介

父-子关系文档 在实质上类似于 nested model :允许将一个对象实体和另外一个对象实体关联起来。而这两种类型的主要区别是:在 nested objects 文档中,所有对象都是在同一个文档中,而在父-子关系文档中,父对象和子对象都是完全独立的文档。

父-子关系的主要作用是允许把一个 type 的文档和另外一个 type 的文档关联起来,构成一对多的关系:一个父文档可以对应多个子文档 。与 nested objects 相比,父-子关系的主要优势有:

  • 更新父文档时,不会重新索引子文档。
  • 创建,修改或删除子文档时,不会影响父文档或其他子文档。这一点在这种场景下尤其有用:子文档数量较多,并且子文档创建和修改的频率高时。
  • 子文档可以作为搜索结果独立返回。

Elasticsearch 维护了一个父文档和子文档的映射关系,得益于这个映射,父-子文档关联查询操作非常快。但是这个映射也对父-子文档关系有个限制条件:父文档和其所有子文档,都必须要存储在同一个分片中。

父-子文档ID映射存储在 [docvalues] 中。当映射完全在内存中时, [docvalues] 提供对映射的快速处理能力,另一方面当映射非常大时,可以通过溢出到磁盘提供足够的扩展能力

 

1.1.父-子关系文档映射

建立父-子文档映射关系时只需要指定某一个文档 type 是另一个文档 type 的父亲。

该关系可以在如下两个时间点设置:

  1. 创建索引时;
  2. 在子文档 type 创建之前更新父文档的 mapping。

【举例】

有一个公司在多个城市有分公司,并且每一个分公司下面都有很多员工。有这样的需求:按照分公司、员工的维度去搜索,并且把员工和他们工作的分公司联系起来。针对该需求,用嵌套模型是无法实现的。当然,如果使用 application-side-joins 或者 data denormalization 也是可以实现的,但是为了演示的目的,在这里我们使用父-子文档。

在创建员工 employee 文档 type 时,指定分公司 branch 的文档 type 为其父亲。

PUT /company
{
  "mappings": {
    "branch": {},
    "employee": {
      "_parent": {
        "type": "branch"  // employee 文档 是 branch 文档的子文档。
      }
    }
  }
}

1.2.构建父-子文档索引

为父文档创建索引与为普通文档创建索引没有区别。父文档并不需要知道它有哪些子文档。

POST /company/branch/_bulk
{ "index": { "_id": "london" }}
{ "name": "London Westminster", "city": "London", "country": "UK" }
{ "index": { "_id": "liverpool" }}
{ "name": "Liverpool Central", "city": "Liverpool", "country": "UK" }
{ "index": { "_id": "paris" }}
{ "name": "Champs Élysées", "city": "Paris", "country": "France" }

创建子文档时,用户必须要通过 parent 参数来指定该子文档的父文档 ID

PUT /company/employee/1?parent=london 
{
  "name":  "Alice Smith",
  "dob":   "1970-10-24",
  "hobby": "hiking"
}

父文档 ID 有两个作用:创建了父文档和子文档之间的关系,并且保证了父文档和子文档都在同一个分片上。

在 [routing-value] 中,解释了 Elasticsearch 如何通过路由值来决定该文档属于哪一个分片,路由值默认为该文档的 _id 。
分片路由的计算公式如下:

shard = hash(routing) % number_of_primary_shards

如果指定了父文档的 ID,那么就会使用父文档的 ID 进行路由,而不会使用当前文档 _id 。也就是说,如果父文档和子文档都使用相同的值进行路由,那么父文档和子文档都会确定分布在同一个分片上

在执行单文档的请求时需要指定父文档的 ID,单文档请求包括:通过 GET 请求获取一个子文档;创建、更新或删除一个子文档。而执行搜索请求时是不需要指定父文档的ID,这是因为搜索请求是向一个索引中的所有分片发起请求,而单文档的操作是只会向存储该文档的分片发送请求。因此,如果操作单个子文档时不指定父文档的 ID,那么很有可能会把请求发送到错误的分片上。

父文档的 ID 应该在 bulk API 中指定

POST /company/employee/_bulk
{ "index": { "_id": 2, "parent": "london" }}
{ "name": "Mark Thomas", "dob": "1982-05-16", "hobby": "diving" }
{ "index": { "_id": 3, "parent": "liverpool" }}
{ "name": "Barry Smith", "dob": "1979-04-01", "hobby": "hiking" }
{ "index": { "_id": 4, "parent": "paris" }}
{ "name": "Adrien Grand", "dob": "1987-05-11", "hobby": "horses" }

如果你想要改变一个子文档的 parent 值,仅通过更新这个子文档是不够的,因为新的父文档有可能在另外一个分片上。
因此,你必须要先把子文档删除,然后再重新索引这个子文档。

1.3.通过子文档查询父文档

has_child 的查询和过滤可以通过子文档的内容来查询父文档。

【例如】根据如下查询,可查出所有80后员工所在的分公司

GET /company/branch/_search
{
  "query": {
    "has_child": {
      "type": "employee",
      "query": {
        "range": {
          "dob": {
            "gte": "1980-01-01"
          }
        }
      }
    }
  }
}

has_child 查询可以匹配多个子文档,并且每一个子文档的评分都不同。但是由于每一个子文档都带有评分,这些评分如何规约成父文档的总得分取决于 score_mode 这个参数。该参数有多种取值策略:默认为 none ,会忽略子文档的评分,并且会给父文档评分设置为 1.0 ; 除此以外还可以设置成 avg 、 min 、 max 和 sum

GET /company/branch/_search
{
  "query": {
    "has_child": {
      "type":       "employee",
      "score_mode": "max",
      "query": {
        "match": {
          "name": "Alice Smith"
        }
      }
    }
  }
}

score_mode 为默认的 none 时,会显著地比其模式要快,这是因为Elasticsearch不需要计算每一个子文档的评分。
只有当真正需要关心评分结果时,才需要为 score_mode 设值,例如设成 avg 、 min 、 max 或 sum 。

min_children 和 max_children

has_child 的查询和过滤都可以接受这两个参数:min_children 和 max_children 。 使用这两个参数时,只有当子文档数量在指定范围内时,才会返回父文档。

【举例】查询只会返回至少有两个雇员的分公司

GET /company/branch/_search
{
  "query": {
    "has_child": {
      "type":         "employee",
      "min_children": 2,  // 至少有两个雇员的分公司才会符合查询条件。
      "query": {
        "match_all": {}
      }
    }
  }
}

带有 min_children 和 max_children 参数的 has_child 查询或过滤,和允许评分的 has_child 查询的性能非常接近。

has_child Filter
has_child 查询和过滤在运行机制上类似,区别是 has_child 过滤不支持 score_mode 参数。
has_child 过滤仅用于筛选内容--如内部的一个 filtered 查询--和其他过滤行为类似:包含或者排除,但没有进行评分。

has_child 过滤的结果没有被缓存,但是 has_child 过滤内部的过滤方法适用于通常的缓存规则。

1.4.通过父文档查询子文档

虽然 nested 查询只能返回最顶层的文档 ,但是父文档和子文档本身是彼此独立并且可被单独查询的。使用 has_child 语句可以基于子文档来查询父文档,使用 has_parent 语句可以基于父文档来查询子文档。

GET /company/employee/_search
{
  "query": {
    "has_parent": {
      "type": "branch",  // 返回父文档 type 是 branch 的所有子文档
      "query": {
        "match": {
          "country": "UK"
        }
      }
    }
  }
}

has_parent 查询也支持 score_mode 这个参数,但是该参数只支持两种值: none (默认)和 score 。每个子文档都只有一个父文档,因此这里不存在将多个评分规约为一个的情况, score_mode 的取值仅为 score 和 none 。

不带评分的 has_parent 查询
当 has_parent 查询用于非评分模式(比如 filter 查询语句)时, score_mode 参数就不再起作用了。
因为这种模式只是简单地包含或排除文档,没有评分,那么 score_mode 参数也就没有意义了。

1.5.子文档聚合

在父-子文档中支持 子文档聚合,这一点和 [nested-aggregation] 类似。但是,对于父文档的聚合查询是不支持的(和 reverse_nested 类似)。

【举例】按照国家维度查看最受雇员欢迎的业余爱好

GET /company/branch/_search
{
  "size" : 0,
  "aggs": {
    "country": {
      "terms": { // country 是 branch 文档的一个字段
        "field": "country"
      },
      "aggs": {
        "employees": {
          "children": {  // 子文档聚合查询通过 employee type 的子文档将其父文档聚合在一起
            "type": "employee"
          },
          "aggs": {
            "hobby": {
              "terms": { 
                "field": "hobby" // hobby 是 employee 子文档的一个字段
              }
            }
          }
        }
      }
    }
  }
}

1.6.祖辈与孙辈关系

PUT /company
{
  "mappings": {
    "country": {},
    "branch": {
      "_parent": {
        "type": "country"  // branch 是 country 的子辈
      }
    },
    "employee": {
      "_parent": {
        "type": "branch"  // employee 是 branch 的子辈
      }
    }
  }
}

POST /company/country/_bulk
{ "index": { "_id": "uk" }}
{ "name": "UK" }
{ "index": { "_id": "france" }}
{ "name": "France" }

POST /company/branch/_bulk
{ "index": { "_id": "london", "parent": "uk" }}
{ "name": "London Westmintster" }
{ "index": { "_id": "liverpool", "parent": "uk" }}
{ "name": "Liverpool Central" }
{ "index": { "_id": "paris", "parent": "france" }}
{ "name": "Champs Élysées" }

parent ID 使得每一个 branch 文档被路由到与其父文档 country 相同的分片上进行操作。然而,当我们使用相同的方法来操作 employee 这个孙辈文档时,会发生什么呢?

PUT /company/employee/1?parent=london
{
  "name":  "Alice Smith",
  "dob":   "1970-10-24",
  "hobby": "hiking"
}

employee 文档的路由依赖其父文档 ID — 也就是 london — 但是 london 文档的路由却依赖 其本身的 父文档 ID — 也就是 uk 。此种情况下,孙辈文档很有可能最终和父辈、祖辈文档不在同一分片上,导致不满足祖辈和孙辈文档必须在同一个分片上被索引的要求。

解决方案是添加一个额外的 routing 参数,将其设置为祖辈的文档 ID ,以此来保证三代文档路由到同一个分片上。

 

PUT /company/employee/1?parent=london&routing=uk 
{
  "name":  "Alice Smith",
  "dob":   "1970-10-24",
  "hobby": "hiking"
}

routing 的值会取代 parent 的值作为路由选择。

parent 参数的值仍然可以标识 employee 文档与其父文档的关系,但是 routing 参数保证该文档被存储到其父辈和祖辈的分片上。routing 值在所有的文档请求中都要添加。

联合多代文档进行查询和聚合是可行的,只需要一代代的进行设定即可。

GET /company/country/_search
{
  "query": {
    "has_child": {
      "type": "branch",
      "query": {
        "has_child": {
          "type": "employee",
          "query": {
            "match": {
              "hobby": "hiking"
            }
          }
        }
      }
    }
  }
}

1.7.实际使用中的一些建议

当文档索引性能远比查询性能重要的时候,父子关系是非常有用的,但是它也是有巨大代价的。其查询速度会比同等的嵌套查询慢5到10倍!

 

1.8.全局序号和延迟

父子关系使用了全局序数 来加速文档间的联合。不管父子关系映射是否使用了内存缓存或基于硬盘的 doc values,当索引变更时,全局序数要重建。

一个分片中父文档越多,那么全局序数的重建就需要更多的时间。父子关系更适合于父文档少、子文档多的情况。

全局序数默认情况下是延迟构建的:在refresh后的第一个父子查询会触发全局序数的构建。而这个构建会导致用户使用时感受到明显的迟缓。

PUT /company
{
  "mappings": {
    "branch": {},
    "employee": {
      "_parent": {
        "type": "branch",
        "fielddata": {
          "loading": "eager_global_ordinals"  // 在一个新的段可搜索前,_parent 字段的全局序数会被构建
        }
      }
    }
  }
}

当父文档过多时,全局序数的构建会耗费很多时间。此时可以通过增加 refresh_interval 来减少 refresh 的次数,延长全局序数的有效时间,这也很大程度上减小了全局序数每秒重建的cpu消耗。

 

1.9.多代使用和结语

多代文档的联合查询(查看 祖辈与孙辈关系)虽然看起来很吸引人,但必须考虑如下的代价:

  • 联合越多,性能越差。
  • 每一代的父文档都要将其字符串类型的 _id 字段存储在内存中,这会占用大量内存。

当你考虑父子关系是否适合你现有关系模型时,请考虑下面这些建议:

  • 尽量少地使用父子关系,仅在子文档远多于父文档时使用。
  • 避免在一个查询中使用多个父子联合语句。
  • 在 has_child 查询中使用 filter 上下文,或者设置 score_mode 为 none 来避免计算文档得分。
  • 保证父 IDs 尽量短,以便在 doc values 中更好地压缩,被临时载入时占用更少的内存。

最重要的是: 先考虑下我们之前讨论过的其他方式来达到父子关系的效果。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

琴 韵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值