Elasticsearch搜索服务学习之十二——数据建模(二)——嵌套对象

2017-08-18

222

0

目录


嵌套对象

由于在Elasticsearch中单个文档的增删改都是原子性操作,那么将相关实体数据都存储在同一文档中也就理所当然。比如说,我们可以将订单及其明细数据存储在一个文档中。又比如,我们可以将一篇博客文章的评论以一个comments数组的形式和博客文章放在一起:
PUT /my_indexpost/1
{
    "title": "Nest eggs",
    "body": "Making your money work...",
    "tags": [
        "cash",
        "shares"
    ],
    "comments": [
        {
            "name": "John Smith",
            "comment": "Great article",
            "age": 28,
            "stars": 4,
            "date": "2014-09-01"
        },
        {
            "name": "Alice White",
            "comment": "More like this please",
            "age": 31,
            "stars": 5,
            "date": "2014-10-22"
        }
    ]
}

可以看到,comments是一个JSON数组,如果添加索引时未特别设置,它会被映射为一个object类型。

由于所有的信息都在一个文档中,当我们查询时就没有必要去联合文章和评论文档,查询效率就很高。但是当我们使用如下查询时,上面的文档也会被当做是符合条件的结果:
GET /_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "Alice" }},
        { "match": { "age":  28      }} 
      ]
    }
  }
}

Alice实际是31岁,不是28!

这个查询的结果不是我们期望的and的关系,我们希望查询名称是Alice而且年龄是28岁的文档,但是结果并非是我们期望的,会包括所有包含Alice或者年龄是28岁的文档。

出现上面这种问题的原因是 JSON 格式的文档被处理成如下的扁平式键值对的结构:
{
"title": [ eggs, nest ],
"body": [ making, money, work, your ],
"tags": [ cash, shares ],
"comments.name": [ alice, john, smith, white ],
"comments.comment": [ article, great, like, more, please, this ],
"comments.age": [ 28, 31 ],
"comments.stars": [ 4, 5 ],
"comments.date": [ 2014-09-01, 2014-10-22 ]
}
Alice 和 31 、 John 和 2014-09-01 之间的相关性信息不再存在。虽然object类型在存储单一对象时非常有用,但对于对象数组的搜索而言,毫无用处。
 
嵌套对象就是来解决这个问题的。将comments字段类型设置为 nested而不是object后, 每一个嵌套对象都会被索引为一个隐藏的独立文档,举例如下:
第一个对象
{
"comments.name": [ john, smith ],
"comments.comment": [ article, great ],
"comments.age": [ 28 ],
"comments.stars": [ 4 ],
"comments.date": [ 2014-09-01 ]
}
第二个对象
{
"comments.name": [ alice, white ],
"comments.comment": [ like, more, please, this ],
"comments.age": [ 31 ],
"comments.stars": [ 5 ],
"comments.date": [ 2014-10-22 ]
}
根文档(父文档)
{
"title": [ eggs, nest ],
"body": [ making, money, work, your ],
"tags": [ cash, shares ]
}
在独立索引每一个嵌套对象后,对象中每个字段的相关性得以保留。我们查询时,也仅仅返回那些真正符合条件的文档。不仅如此,由于嵌套文档直接存储在文档内部,查询时嵌套文档和根文档联合成本很低,速度和单独存储几乎一样。
 
嵌套文档是隐藏存储的,我们不能直接获取。如果要增删改一个嵌套对象,我们必须把整个文档重新索引才可以。值得注意的是,查询的时候返回的是整个文档,而不是嵌套文档本身。

嵌套对象映射

映射一个嵌套对象类型很简单,将字段的type变为nested,而非object:

PUT /my_index
{
  "mappings": {
    "blogpost": {
      "properties": {
        "comments": {
          "type": "nested",  // 映射为嵌套对象类型
          "properties": {
            "name":    { "type": "string"  },
            "comment": { "type": "string"  },
            "age":     { "type": "short"   },
            "stars":   { "type": "short"   },
            "date":    { "type": "date"    }
          }
        }
      }
    }
  }
}
这就是需要设置的一切。至此,所有 comments 对象会被索引在独立的嵌套文档中。可以查看 nested类型参考文档 获取更多详细信息。

嵌套对象查询

由于嵌套对象被索引在独立隐藏的文档中,我们无法直接查询它们。相应地,我们必须使用 nested查询去获取它们:
GET /my_indexpost/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "title": "eggs" // title子句是查询根文档的。
          }
        },
        {
          "nested": {
            "path": "comments", // nested子句作用于嵌套字段comments。在此查询中,既不能查询根文档字段,也不能查询其他嵌套文档。
            "query": {
              "bool": {
                "must": [ // comments.name和comments.age子句操作在同一个嵌套文档中。
                  {
                    "match": {
                      "comments.name": "john"
                    }
                  },
                  {
                    "match": {
                      "comments.age": 28
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}
  • nested字段可以包含其他的nested字段。同样地,nested查询也可以包含其他的nested查询。而嵌套的层次会按照你所期待的被应用。
  • nested查询肯定可以匹配到多个嵌套的文档。每一个匹配的嵌套文档都有自己的相关度得分,但是这众多的分数最终需要汇聚为可供根文档使用的一个分数。
默认情况下,根文档的分数是这些嵌套文档分数的平均值。可以通过设置 score_mode 参数来控制这个得分策略,相关策略有 avg (平均值), max (最大值), sum (加和) 和 none (直接返回 1.0 常数值分数)。
GET /my_indexpost/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "title": "eggs"
          }
        },
        {
          "nested": {
            "path": "comments",
            "score_mode": "max",  // 返回最优匹配嵌套文档的 _score 给根文档使用。
            "query": {
              "bool": {
                "must": [
                  {
                    "match": {
                      "comments.name": "john"
                    }
                  },
                  {
                    "match": {
                      "comments.age": 28
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}
如果nested查询放在一个布尔查询的filter子句中,其表现就像一个nested查询,只是score_mode参数不再生效。因为它被用于不打分的查询中 — 只是符合或不符合条件,不必打分 — 那么 score_mode 就没有任何意义,因为根本就没有要打分的地方。 filter仅对结果进行过滤,而不影响结果的任何评分。

嵌套字段排序

尽管嵌套字段的值存储于独立的嵌套文档中,但依然有方法按照嵌套字段的值排序。 让我们添加另一个记录,以使得结果更有意思:
PUT /my_indexpost/2
{
  "title": "Investment secrets",
  "body":  "What they don't tell you ...",
  "tags":  [ "shares", "equities" ],
  "comments": [
    {
      "name":    "Mary Brown",
      "comment": "Lies, lies, lies",
      "age":     42,
      "stars":   1,
      "date":    "2014-10-18"
    },
    {
      "name":    "John Smith",
      "comment": "You're making it up!",
      "age":     28,
      "stars":   2,
      "date":    "2014-10-16"
    }
  ]
}
假如我们想要查询在10月份收到评论的博客文章,并且按照 stars 数的最小值来由小到大排序,那么查询语句如下:
GET /_search
{
  "query": {
    "nested": {  // 此处的 nested 查询将结果限定为在10月份收到过评论的博客文章。
      "path": "comments",
      "filter": {
        "range": {
          "comments.date": {
            "gte": "2014-10-01",
            "lt":  "2014-11-01"
          }
        }
      }
    }
  },
  "sort": {
    "comments.stars": {  // 结果按照匹配的评论中 comment.stars 字段的最小值 (min) 来由小到大 (asc) 排序。
      "order": "asc",   
      "mode":  "min",   
      "nested_path": "comments",  // 排序子句中的nested_path和nested_filter和query子句中的 nested 查询相同,原因在下面有解释。
      "nested_filter": {
        "range": {
          "comments.date": {
            "gte": "2014-10-01",
            "lt":  "2014-11-01"
          }
        }
      }
    }
  }
}
我们为什么要用 nested_path 和 nested_filter 重复查询条件呢?
 
原因在于,排序发生在查询执行之后。 查询条件限定了只在10月份收到评论的博客文档,但返回整个博客文档。 如果我们不在排序子句中加入 nested_filter , 那么我们对博客文档的排序将基于博客文档的所有评论,而不是仅仅在10月份接收到的评论。

嵌套聚合

在查询的时候,我们使用nested查询就可以获取嵌套对象的信息。同理, nested聚合允许我们对嵌套对象里的字段进行聚合操作。
GET /my_indexpost/_search
{
  "size" : 0,
  "aggs": {
    "comments": {  // nested 聚合 “进入” 嵌套的 comments 对象。
      "nested": {
        "path": "comments"
      },
      "aggs": {
        "by_month": {
          "date_histogram": { // comment对象根据 comments.date 字段的月份值被分到不同的桶。
            "field":    "comments.date",
            "interval": "month",
            "format":   "yyyy-MM"
          },
          "aggs": {
            "avg_stars": {
              "avg": { // 计算每个桶内star的平均数量。
                "field": "comments.stars"
              }
            }
          }
        }
      }
    }
  }
}
从下面的结果可以看出聚合是在嵌套文档层面进行的:
...
"aggregations": {
  "comments": {
     "doc_count": 4, // 总共有4个 comments 对象 :1个对象在9月的桶里,3个对象在10月的桶里。
     "by_month": {
        "buckets": [
           {
              "key_as_string": "2014-09",
              "key": 1409529600000,
              "doc_count": 1, 
              "avg_stars": {
                 "value": 4
              }
           },
           {
              "key_as_string": "2014-10",
              "key": 1412121600000,
              "doc_count": 3, 
              "avg_stars": {
                 "value": 2.6666666666666665
              }
           }
        ]
     }
  }
}
...

逆向嵌套聚合

nested 聚合 只能对嵌套文档的字段进行操作。 根文档或者其他嵌套文档的字段对它是不可见的。 然而,通过  reverse_nested 聚合,我们可以 走出 嵌套层级,回到父级文档进行操作。
 
例如,我们要基于评论者的年龄找出评论者感兴趣 tags 的分布。 comment.age 是一个嵌套字段,但 tags在根文档中:
GET /my_indexpost/_search
{
  "size" : 0,
  "aggs": {
    "comments": {
      "nested": { // nested 聚合进入 comments 对象。
        "path": "comments"
      },
      "aggs": {
        "age_group": {
          "histogram": { 
            "field":    "comments.age", // histogram 聚合基于 comments.age 做分组,每10年一个分组。
            "interval": 10
          },
          "aggs": {
            "blogposts": {
              "reverse_nested": {},  // reverse_nested 聚合退回根文档。
              "aggs": {
                "tags": {
                  "terms": { 
                    "field": "tags" // terms 聚合计算每个分组年龄段的评论者最常用的标签词。
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
简略结果如下所示:
..
"aggregations": {
  "comments": {
     "doc_count": 4,  // 一共有4条评论。
     "age_group": {
        "buckets": [
           {
              "key": 20,  // 在20岁到30岁之间总共有两条评论。
              "doc_count": 2,  // 这些评论包含在两篇博客文章中。
              "blogposts": {
                 "doc_count": 2,
                 "tags": {
                    "doc_count_error_upper_bound": 0,
                    "buckets": [ // 在这些博客文章中最热门的标签是 `shares`、 `cash`、`equities`。 
                       { "key": "shares",   "doc_count": 2 },
                       { "key": "cash",     "doc_count": 1 },
                       { "key": "equities", "doc_count": 1 }
                    ]
                 }
              }
           },
...

嵌套对象的使用时机

嵌套对象 在只有一个主要实体时非常有用,这个主要实体包含有限个紧密关联但又不是很重要的实体,例如我们的 blogpost 对象包含评论对象。 在基于评论的内容查找博客文章时, nested 查询有很大的用处,并且可以提供更快的查询效率。
嵌套模型的缺点如下:
  • 当对嵌套文档做增加、修改或者删除时,整个文档都要重新被索引。嵌套文档越多,这带来的成本就越大。
  • 查询结果返回的是整个文档,而不仅仅是匹配的嵌套文档。尽管目前有计划支持只返回根文档中最佳匹配的嵌套文档,但目前还不支持。
有时你需要在主文档和其关联实体之间做一个完整的隔离设计。这个隔离是由 父子关联 提供的。

总结

elasticsearch中,使用object类型的数组数据时,在执行查询时可能会不按照原意图从而导致意料之外的结果,原因在于JSON格式的文档被处理成扁平式键值对的结构,object数据间的相关不再存在。nested对象解决了该问题, 每一个嵌套对象都会被索引为一个隐藏的独立文档,在独立索引每一个嵌套对象后,对象中每个字段的相关性得以保留。

但是,使用嵌入对象时考需要考虑一个问题:由于嵌入对象查询结果是返回整个文档,不能返回仅仅符合条件的文档(目前还不支持,我用的5.4.0),所以如果嵌入对象太大或者需要仅返回符合条件部分的文档时,嵌入对象就不太适用了。

例如,前边的例子中,我们要求只返回comments中age大于30的文档,而且comments返回的时候只包括age大于30的数据,此时nested对象是无法实现的,只能返回整个文档。

那么,此时有没有别的建模方式可以采用呢,答案是肯定的。接下来,我们就来看看另一种建模方式:父——子关系模型


前一篇:ubuntu下ssh关闭密码登陆,采用公钥认证登陆
后一篇:Elasticsearch搜索服务学习之十二——数据建模(三)——父子关系模型

belonk

轻轻地我走了,正如我轻轻地来,我挥一挥衣袖,不带走一片云彩