在处理大量数据时,关系数据库存在很多问题。 无论是速度,高效处理,有效并行化,可扩展性还是成本,当数据量开始增长时,关系数据库都会失败。该关系数据库的另一个挑战是必须预先定义关系和模式。Elasticsearch 也是一个 NoSQL 文档数据存储。 但是,尽管是一个 NoSQL 数据存储,Elasticsearch 在一定程度上提供了很多帮助来管理关系数据。 它支持类似 SQL 的连接,并且在嵌套和相关的数据实体上工作得非常棒。

比如,对于一个像下面的 blog 形式的文档:

一个 blog 可能对应于很多的 comments,或者一个员工对应于很多的经验。这种数据就是关系数据。使用 Elasticsearch,你可以通过保留轻松地工作与不同实体的关联以及强大的全文搜索和分析。 Elasticsearch 通过引入两种类型的文档关系模型使这成为可能:

  • nested 关系: 在这种关系中,这种一对多的关系存在于同一个文档之中
  • parent-child 关系:在这种关系中,它们存在于不同的文档之中。我将在另外一个文章中描述

这两种关系在同一个模式下工作,即一对多个的关系。一个 root 或 parent 可以有一个及多个子 object。

如上图所示,在嵌套关系中,有一个根对象(object),它是我们拥有的主文档,它包含一个称为嵌套文档的子文档数组。 根对象内的文档嵌套级别没有限制。 例如,查看以下 JSON 以进行多级嵌套:

 {
     "location_id": "axdbyu",
     "location_name": "gurgaon",
     "company": [
       {
         "name": "honda",
         "modelName": [
           { "name": "honda cr-v", "price": "2 million" }
         ]
       }, 
       {
         "name": "bmw",
         "modelName": [
           { "name": "BMW 3 Series", "price": "2 million"},
           { "name": "BMW 1 Series", "price": "3 million" }
         ]
      } 
    ]
}

下面,我们来做一个例子来展示一下为什么 nested 对象可以解决我们的问题。

Object 数据类型

JSON 文档本质上是分层的:文档可能包含内部对象,而内部对象又可能包含内部对象本身:

1)外部文档也是一个 JSON 对象。

2)它包含一个名为 manager 的内部对象。

3)其中又包含一个名为 name 的内部对象。

在 Elasticsearch 内部,该文档被索引为一个简单的键值对列表,如下所示:

{
  "region":             "US",
  "manager.age":        30,
  "manager.name.first": "John",
  "manager.name.last":  "Smith"
}

 接下来,我们首先创建一个叫做 developer 的索引,并输入如下的两个数据:

POST developer/_doc/101
{
  "name": "zhang san",
  "skills": [
    {
      "language": "ruby",
      "level": "expert"
    },
    {
      "language": "javascript",
      "level": "beginner"
    }
   ]
}

POST developer/_doc/102
{
  "name": "li si",
  "skills": [
    {
      "language": "ruby",
      "level": "beginner"
    }
   ]
}

上面显示是一对多的一个 index。

Object Query

这个时候我们想搜一个 skills: language 是 ruby,并且 level 是 biginner 的文档。我们可能想到的方法是:

GET developer/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "match": {
            "skills.language": "ruby"
          }
        },
        {
          "match": {
            "skills.level": "beginner"
          }
        }
      ]
    }
  }
}

通过上面的搜寻,我们得到的结果是:

{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 0.0,
    "hits" : [
      {
        "_index" : "developer",
        "_type" : "_doc",
        "_id" : "101",
        "_score" : 0.0,
        "_source" : {
          "name" : "zhang san",
          "skills" : [
            {
              "language" : "ruby",
              "level" : "expert"
            },
            {
              "language" : "javascript",
              "level" : "beginner"
            }
          ]
        }
      },
      {
        "_index" : "developer",
        "_type" : "_doc",
        "_id" : "102",
        "_score" : 0.0,
        "_source" : {
          "name" : "li si",
          "skills" : [
            {
              "language" : "ruby",
              "level" : "beginner"
            }
          ]
        }
      }
    ]
  }
}

我们可以看到,我们得到两个结果。但是我们仔细查看一下发现得到的结果并不是我们想得到的。从我们的原意来说,我们想得到的是 li si,因为只有 li si 这个人的 language 是 ruby,并且他的 level 是 beginner。zhang san 这个文档,应该不在搜寻之列。这是为什么呢?

原来,langauge 及 level 是 skills 的 JSON 内部数组项。当 JSON 对象被 Lucene 扁平化后,我们失去了 language 和 level 之间的对应关系。取而代之的是如下的这种关系:

{
  "name": "zhang san",
  "skills.language" :["ruby", "javascript"],
  "skills.level": ["expert", "beginner"]
}

如上所示,我们看到的是一个扁平化的数组。之前的那种 language 和 level 之间的那种对应关系已经不存在了。

Object aggregation

同样的问题也存在于 aggregation 中,比如我们想做一下的 aggregation:

GET developer/_search
{
  "size": 0,
  "aggs": {
    "languages": {
      "terms": {
        "field": "skills.language.keyword"
      },
      "aggs": {
        "level": {
          "terms": {"field": "skills.level.keyword"}
        }
      }
    }
  }
}

显示的结果是:

{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "languages" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "ruby",
          "doc_count" : 2,
          "level" : {
            "doc_count_error_upper_bound" : 0,
            "sum_other_doc_count" : 0,
            "buckets" : [
              {
                "key" : "beginner",
                "doc_count" : 2
              },
              {
                "key" : "expert",
                "doc_count" : 1
              }
            ]
          }
        },
        {
          "key" : "javascript",
          "doc_count" : 1,
          "level" : {
            "doc_count_error_upper_bound" : 0,
            "sum_other_doc_count" : 0,
            "buckets" : [
              {
                "key" : "beginner",
                "doc_count" : 1
              },
              {
                "key" : "expert",
                "doc_count" : 1
              }
            ]
          }
        }
      ]
    }
  }
}

显然,对于 key javascript 来说,它并没有 expert 对应的 level,但是在我们的 aggregation 里显示出来了。这个结果显然是错误的。

nested 数据类型

Nested 类型是 object 数据类型的特殊版本,它允许对象数组以一种可以彼此独立查询的方式进行索引。在内部,嵌套对象将数组中的每个对象索引为单独的隐藏文档,这意味着每个嵌套对象都可以使用 nested query 独立于其他对象进行查询。每个 nested 对象都被索引为一个单独的 Lucene 文档。nested 数据类型能够让我们对 object 数组建立索引,并且分别进行查询。

如果需要维护数组中每个对象的关系,请使用 nested 数据类型

为了能够把我们的数据定义为nested,我们必须修改之前的索引 mapping 为:

DELETE developer

PUT developer
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text"
      },
      "skills": {
        "type": "nested",
        "properties": {
          "language": {
            "type": "keyword"
          },
          "level": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

经过这样的改造之后,重新把我们之前的数据输入到 index 里:

POST developer/_doc/101
{
  "name": "zhang san",
  "skills": [
    {
      "language": "ruby",
      "level": "expert"
    },
    {
      "language": "javascript",
      "level": "beginner"
    }
   ]
}

POST developer/_doc/102
{
  "name": "li si",
  "skills": [
    {
      "language": "ruby",
      "level": "beginner"
    }
   ]
}

针对 101,在 Lucence 中的数据结构变为:

{
  "name": "zhang san",
  {
    "skills.language": "ruby",
    "skills.level": "expert"
  },
  {
    "skills.language": "javascript",
    "skills.level", "beginner"
  }
}

nested query

我们来重新做我们之前的搜索:

GET developer/_search
{
  "query": {
    "nested": {
      "path": "skills",
      "query": {
        "bool": {
          "filter": [
            {
              "match": {
                "skills.language": "ruby"
              }
            },
            {
              "match": {
                "skills.level": "beginner"
              }
            }
          ]
        }
      }
    }
  }
}

注意上面的 nested 字段。显示的结果是:

{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.0,
    "hits" : [
      {
        "_index" : "developer",
        "_type" : "_doc",
        "_id" : "102",
        "_score" : 0.0,
        "_source" : {
          "name" : "li si",
          "skills" : [
            {
              "language" : "ruby",
              "level" : "beginner"
            }
          ]
        }
      }
    ]
  }
}

显然,我们只得到了一个我们想要的结果。

获取 inner hits

parent-joinnested 功能允许返回在不同范围内具有匹配项的文档。 在父/子情况下,根据子文档中的匹配返回父文档,或者根据父文档中的匹配返回子文档。 在 nested 的情况下,基于嵌套内部对象中的匹配返回文档。

在许多情况下,了解哪些内部嵌套对象(对于嵌套的情况)或子/父文档(对于父/子的情况)导致某些信息返回非常有用。 内部点击功能可用于此目的。 此功能会在搜索响应中为每个搜索命中返回附加的嵌套命中,这些嵌套命中会导致搜索命中在不同范围内匹配。

我们可以尝试如下的查询:

GET developer/_search
{
  "query": {
    "nested": {
      "path": "skills",
      "query": {
        "bool": {
          "filter": [
            {
              "match": {
                "skills.language": "ruby"
              }
            },
            {
              "match": {
                "skills.level": "beginner"
              }
            }
          ]
        }
      },
      "inner_hits": {}
    }
  }
}

在上面,我们添加了 inner_hits。上面写的命令返回的结果为:

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.0,
    "hits" : [
      {
        "_index" : "developer",
        "_type" : "_doc",
        "_id" : "102",
        "_score" : 0.0,
        "_source" : {
          "name" : "li si",
          "skills" : [
            {
              "language" : "ruby",
              "level" : "beginner"
            }
          ]
        },
        "inner_hits" : {
          "skills" : {
            "hits" : {
              "total" : {
                "value" : 1,
                "relation" : "eq"
              },
              "max_score" : 0.0,
              "hits" : [
                {
                  "_index" : "developer",
                  "_type" : "_doc",
                  "_id" : "102",
                  "_nested" : {
                    "field" : "skills",
                    "offset" : 0
                  },
                  "_score" : 0.0,
                  "_source" : {
                    "language" : "ruby",
                    "level" : "beginner"
                  }
                }
              ]
            }
          }
        }
      }
    ]
  }
}

nested aggregation

同样,我们可以对我们的索引来做一个 aggregation:

GET developer/_search
{
  "size": 0,
  "aggs": {
    "nested_skills": {
      "nested": {
        "path": "skills"
      },
      "aggs": {
        "languages": {
          "terms": {
            "field": "skills.language"
          },
          "aggs": {
            "levels": {
              "terms": {
                "field": "skills.level"
              }
            }
          }
        }
      }
    }
  }
}

显示的结果是:

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "nested_skills" : {
      "doc_count" : 3,
      "languages" : {
        "doc_count_error_upper_bound" : 0,
        "sum_other_doc_count" : 0,
        "buckets" : [
          {
            "key" : "ruby",
            "doc_count" : 2,
            "levels" : {
              "doc_count_error_upper_bound" : 0,
              "sum_other_doc_count" : 0,
              "buckets" : [
                {
                  "key" : "beginner",
                  "doc_count" : 1
                },
                {
                  "key" : "expert",
                  "doc_count" : 1
                }
              ]
            }
          },
          {
            "key" : "javascript",
            "doc_count" : 1,
            "levels" : {
              "doc_count_error_upper_bound" : 0,
              "sum_other_doc_count" : 0,
              "buckets" : [
                {
                  "key" : "beginner",
                  "doc_count" : 1
                }
              ]
            }
          }
        ]
      }
    }
  }
}

从上面显示的结果,可以看出来对于 ruby 来说,它分别对应于一个 bigginer 及一个 expert。这个和我们之前的数据是一样的。对于 javascript 来说,它只有一个 beginner 的 level。

Nested 数据类型更新

在使用 Elasticsearch 时,为了系统的效率,我们并不建议经常修改文档,但是在有些时候,我们还必须对已经索引过的文档进行修改。针对 nested 类型的字段,我该如何进行更新及删除呢?请阅读我的另外一篇文章 “Elasticsearch:如何修改 nested 字段的值”。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐