Elasticsearch 中 Parent-Child 关系

Neil Zhu,简书ID Not_GOD,University AI 创始人 & Chief Scientist,致力于推进世界人工智能化进程。制定并实施 UAI 中长期增长战略和目标,带领团队快速成长为人工智能领域最专业的力量。
作为行业领导者,他和UAI一起在2014年创建了TASA(中国最早的人工智能社团), DL Center(深度学习知识中心全球价值网络),AI growth(行业智库培训)等,为中国的人工智能人才建设输送了大量的血液和养分。此外,他还参与或者举办过各类国际性的人工智能峰会和活动,产生了巨大的影响力,书写了60万字的人工智能精品技术内容,生产翻译了全球第一本深度学习入门书《神经网络与深度学习》,生产的内容被大量的专业垂直公众号和媒体转载与连载。曾经受邀为国内顶尖大学制定人工智能学习规划和教授人工智能前沿课程,均受学生和老师好评。

parent-child 关系

类似于 nested model:可以关联两个实体。不同在于,nested object 中所有的实体必须存在同一个文档中,而在 parent-child 中,parent 和 children 可以是完全分开的文档。

parent-child 功能让我们可以将一种文档类型以一对多的关系关联到另一个上。相比 nested object 的好处在于:

  1. parent 文档可以不需要重新索引 children 进行更新。
  2. child 文档可以被添加、修改或者删除,而不影响 parent 或者其他 children。这在 child 文档很多和增改频率很高的时候尤其有用。
  3. child 文档可以被作为搜索请求的结果返回。

Elasticsearch 维护了一个 parent 到 children 的映射。所以在查询时刻的连接(join)会很快,但是这样也给 parent-child 关系带来了限制:parent 和所有 children 必须处在一个分片上。

parent-child ID 映射作为字段数据存放在内存中。后期会有计划将这个默认设置改成使用 doc values。

parent-child 映射

为了建立 parent-child 关系的需求是指定哪种文档类型应该是 child 类型的 parent。这个必须在�索引创建时刻�指定,或者使用 update-mapping API 在 child 类型被创建前指定。

假设,我们一家公司在很多城市都有自己的分部。我们希望将员工和他们工作的地址关联。我们需要搜索分部、员工个人,和为特定分部工作的员工,所以 nested 模型就没有作用了。当然,我们是可以使用 application-side-join 或者 data denormalization,但这里我们试试 parent-child。

现在我们必须要做的是告诉 Elasticsearch employee 类型将 branch 文档类型作为其 _parent,这个我们可以在创建索引的时候指定:

PUT /company
{
  "mappings": {
    "branch": {},
    "employee": {
      "_parent": {
        "type": "branch"  ...1
      }
    }
  }
}
  1. 类型为 employee 的文档是 类型 branch 的 children。

索引 parents 和 children

索引 parent 文档跟以前一样。parents 不需要知道任何关于其 children 的信息:

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" }

在索引 children 文档时,你必须指定关联的 parent 文档的 ID:

PUT /company/employee/1?parent=london  ...1
{
  "name":  "Alice Smith",
  "dob":   "1970-10-24",
  "hobby": "hiking"
}
  1. employee 文档是 london 分部的 child

parent ID 有两个作用:创建了 parent 和 child 之间的关联,确保 child 文档存在同一个分片上。在 Routing a Document to a Shard 中,我们解释了 Elasticsearch 如何使用一个路由值,默认是文档的 _id 来确定文档应该属于哪个分片。路由值插入到下面的公式中:

shard = hash(routing) % number_of_primary_shards

然而,如果 parent ID 指定了,路由值就是 parent ID 而不再是 _id 了。换言之,parent 和 child 使用了同样的路由值——parent 的_id —— 所以他们会同样存在一个分片上。

在使用 GET 请求检索 child 文档,或者索引、更新或者删除 child 文档时parent ID 需要根据所有单个文档请求指定。不像搜索请求,会被转发给一个索引中的所有分片,这些单个文档的请求只会转发给那个包含对应文档的分片——如果 parent ID 没有指定,这些请求可能就会被转发到错误的分片上。

parent 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" }

如果你想改变 child 文档的 parent 值,仅仅重新索引或者更新 child 文档是不够的——新的 parent 文档可能会在不同的分片上。所以,你必须删除旧的 child 文档,然后索引新的 child。

通过 children 找到 parents

has_child 查询和过滤器可以用来根据 children 的内容找到 parent 文档。例如,我们可以找到所有包含出生在 1980 后的员工的分部:

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

如同 nested query,has_child 查询可以匹配多个 child 文档,每个都有相应的相关分数。这些分数如何化归为针对 parent 文档的单独分数取决于 score_mode 参数。默认设置是 none,这会忽视 child 分数并给 parents 分配了 1.0 的分值,不过这里也可以使用 avgminmaxsum

下面的查询将会返回 londonliverpool,但是 london 会有更高的分数,因为 Alice SmithBarry Smith 更好地匹配:

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

默认 score_modenone,该设置明显快于其他模式,因为 Elasticsearch 不需要计算每个 child 文档的分值。只有在你在乎分值的时候才需要根据需要设置模式。

min_children 和 max_children

has_child 查询和过滤器都接受 min_childrenmax_children 参数,仅当匹配 children 的数量在指定的范围内会返回 parent 文档。

这个查询将会匹配有至少两位员工的分部:

GET /company/branch/_search
{
  "query": {
    "has_child": {
      "type":         "employee",
      "min_children": 2,  ...1
      "query": {
        "match_all": {}
      }
    }
  }
}
  1. 分部必须有至少两位员工才能匹配

min_childrenmax_children 参数的 has_child 查询或者过滤器的性能和启用计分的 has_child 查询相同。

has_child 过滤器


has_child 过滤器和 has_child 查询工作机制差不多,只是这里不会支持 score_mode 参数。就和其他的过滤器类似:包含或者不包含,并不计分。
has_child 过滤器的结果并不缓存,通常的缓存规则应用在 has_child 过滤器内部的 filter 上。

通过 parents 寻找 children

nested 查询只会返回根文档作为结果,parent-child 文档本身是独立的,每个可以独立地进行查询。has_child 查询允许我们返回基于在其 children 的数据上 parents 文档,has_parent 查询则是基于 parents 的数据返回 children。

看起来和 has_child 很像。这个例子返回了在 UK 工作的员工:

GET /company/employee/_search
{
  "query": {
    "has_parent": {
      "type": "branch", 
      "query": {
        "match": {
          "country": "UK"
        }
      }
    }
  }
}
  1. 返回有类型为 branch 的 children

has_children 查询也支持 score_mode,但是仅仅会接受两个设置:none(默认)和 score。每个 child 仅仅有 1 个 parent,所以没有必要去将多个分数化归为单个的分数。选择就是 scorenone 这两者。

has_parent 过滤器


has_parent 过滤器和 has_parent 查询工作机制相同,除了它不支持 score_mode 参数。仅仅可以用在过滤器中。
has_parent 过滤器的结果并不缓存,通常的缓存机制用在 has_parent 过滤器的内部 filter 上。

children 聚合

parent-child 支持 children 聚合作为 nested 聚合直接的类似。parent 聚合不支持。

下面的例子展示了我们如何根据国家来确定员工最爱的兴趣爱好:

GET /company/branch/_search?search_type=count
{
  "aggs": {
    "country": {
      "terms": { ...1
        "field": "country"
      },
      "aggs": {
        "employees": {
          "children": {  ...2
            "type": "employee"
          },
          "aggs": {
            "hobby": {
              "terms": {  ...3
                "field": "employee.hobby"
              }
            }
          }
        }
      }
    }
  }
}
  1. branch 文档中的country 字段。
  2. children 聚合联结了 parent 文档和相关联的 children 类型 employee
  3. 来自 employee child 文档的 hobby 字段。

Grandparents 和 Grandchildren

parent-child 关系可以扩展超过一代——grandchildren 可以有 grandparents——但是需要额外步骤来确保来自所有代的文档索引在同一个分片上。
让我们改变前面的例子来让 country 类型是 branch 类型的 parent:

PUT /company
{
  "mappings": {
    "country": {},
    "branch": {
      "_parent": {
        "type": "country"  ...1
      }
    },
    "employee": {
      "_parent": {
        "type": "branch"  ...2
      }
    }
  }
}
  1. branchcountry 的 child
  2. employeebranch 的 child

国家和分部有一个简单的 parent-child 关系,所以我们使用和之前同样的过程:

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 文档被路由到和 parent country 文档同样的分片上。然而,看看使用同样的技术在 employee grandchildren上:

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

这儿员工文档的路由分片会被 parent ID London 确定,但是 london 文档会根据其 parent ID ——uk 确定。很可能 grandchild 会得到和它 parent 和 grandparent 不同的分片,最终会导致 grandparent grandchild 关系失效。

于是我们重新设计,增加一个额外的 routing 参数,将这个设置为 grandparent ID 来保证所有三代都索引在同一个分片上。索引请求应该像这样:

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

parent 值仍然会用来连接员工文档和其parent,但是 routing 值需要对所有单个文档请求设置。

查询和聚合,只要你一步一步通过每一代文档。例如,为了找到有员工喜欢滑雪的国家,我们需要将国家和分部、分部和员工进行联结:

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

实战建议

parent-child 连接是在管理关系时有用的技术,其前提是索引性能比搜索性能更加重要,也带来了一个显著的代价。parent-child 查询时间可能是等价的 nested query 五到十倍。

内存使用

parent-child ID 映射仍旧是存在内存中的。有计划将这个映射使用 doc value 替代,这肯定是较大的内存节约。在进行了这个更新前,你需要注意下面的事:每个 parent 文档的字符串 _id 字段需要存放在内存中,每个 child 文档需要 8 字节(long value)的内存。实际上,这个可以有压缩技术的支持,但这是一个解决方向。

你可以检查使用 indices-stats API 来追踪 parent-child 缓存使用了多少内存,或者 node-stats API(在节点层的总结):

GET /_nodes/stats/indices/id_cache?human ...1
  1. 以比较友好的格式按节点返回内存使用 ID 缓存的情况

global ordinals 和 延时

parent-child 使用 global ordinals 来加速联结。不管 parent-child 映射使用 in-memory 缓存或者磁盘 doc value,global ordinals 仍然需要在每次索引变动后进行重建。

在分片中的 parent 越多,就需要更长的 global ordinals 来构建。parent-child 是最适合对每个 parent 有很多 children的情况,而不是很多的 parent 少量的 children。

global ordinals 默认是 lazily 构建的:在每个刷新 refresh 后的第一个 parent-child 查询或者聚合将会触发 global ordinal 的构建。这会引入一个明显延迟增加。你可以使用 eager_global_ordinals 来从查询时刻到刷新时刻的变动构建 global ordinal 的代价,通过将 _parent 字段映射按照如下修改:

PUT /company
{
  "mappings": {
    "branch": {},
    "employee": {
      "_parent": {
        "type": "branch",
        "fielddata": {
          "loading": "eager_global_ordinals"  ...1
        }
      }
    }
  }
}
  1. _parent 字段的 Global ordinals 将会在新的 segment 在搜索可见前构建。

有很多 parent 时,global ordinals 需要几秒钟进行构建。这样的话,增加 refresh_interval 来让刷新减少并让 global ordinals 留存更长就比较合理了。这会大幅降低每秒钟都重建 global ordinals CPU 代价。

多代关系总结性思考

连接多代的能力看起来很诱人,但是你会发现它带来的代价:

  • 更多的连接,更差的性能
  • 每代 parents 需要让他们的字符串 _id 字段存储在内存中,这样也会消耗大量内存

所以在你考虑需要处理的关系,考量 parent-child 是不是合适的选择是,可以看看下面的一些建议:

  • 谨慎地使用 parent-child 关系,�仅仅在有更多的 children 时采用
  • 避免在一个单独的查询中使用多个 parent-child 联结
  • 避免在使用 has_child 过滤器中使用计分,或者在 has_child 查询中将 score_mode 设置为 none
  • 尽量让 parent ID 短,以减少内存使用量

综上所述:在尝试 parent-child 关系前考虑其他类型的关系技术

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容