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 的好处在于:
- parent 文档可以不需要重新索引 children 进行更新。
- child 文档可以被添加、修改或者删除,而不影响 parent 或者其他 children。这在 child 文档很多和增改频率很高的时候尤其有用。
- 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
}
}
}
}
- 类型为
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"
}
-
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
的分值,不过这里也可以使用 avg
,min
,max
和 sum
。
下面的查询将会返回 london
和 liverpool
,但是 london
会有更高的分数,因为 Alice Smith
比 Barry Smith
更好地匹配:
GET /company/branch/_search
{
"query": {
"has_child": {
"type": "employee",
"score_mode": "max",
"query": {
"match": {
"name": "Alice Smith"
}
}
}
}
}
默认
score_mode
为none
,该设置明显快于其他模式,因为 Elasticsearch 不需要计算每个 child 文档的分值。只有在你在乎分值的时候才需要根据需要设置模式。
min_children 和 max_children
has_child
查询和过滤器都接受 min_children
和 max_children
参数,仅当匹配 children 的数量在指定的范围内会返回 parent 文档。
这个查询将会匹配有至少两位员工的分部:
GET /company/branch/_search
{
"query": {
"has_child": {
"type": "employee",
"min_children": 2, ...1
"query": {
"match_all": {}
}
}
}
}
- 分部必须有至少两位员工才能匹配
有 min_children
和 max_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"
}
}
}
}
}
- 返回有类型为
branch
的 children
has_children
查询也支持 score_mode
,但是仅仅会接受两个设置:none
(默认)和 score
。每个 child 仅仅有 1 个 parent,所以没有必要去将多个分数化归为单个的分数。选择就是 score
和 none
这两者。
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"
}
}
}
}
}
}
}
}
- 在
branch
文档中的country
字段。 -
children
聚合联结了 parent 文档和相关联的 children 类型employee
。 - 来自
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
}
}
}
}
-
branch
是country
的 child -
employee
是branch
的 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"
}
-
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
- 以比较友好的格式按节点返回内存使用 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
}
}
}
}
}
-
_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 关系前考虑其他类型的关系技术
网友评论