Elasticsearch搜索服务学习之十一——数据建模(一)——关联关系

高新技术,ElasticSearch

2017-07-28

329

0

目录


关系型数据库和ES

对于关系型数据库而言,有数据库三范式来规范我们的数据建模,在表结构上,我们考虑的无非是这么几种关系:一对一、多对一、多对多,然后在数据查询时进行各种连接查询,以获得期望的结果。关系型数据库中,一般的建模要求如下:

  • 每个实体可以被主键唯一标识,实体规范符合数据库三范式
  • 唯一实体的数据只存储一次,而相关实体只存储它的主键(建立外键关系)。只能在一个具体位置修改这个实体的数据。
  • 实体可以进行关联查询,可以跨实体搜索。
  • 单个实体的变化是原子的一致的隔离的,和持久的。 (可以在 ACID Transactions 中查看更多细节。)
  • 大多数关系数据库支持跨多个实体的 ACID 事务。

关系型数据库的这种规范化的建模方式,有其局限性,例如:对全文搜索的支持能力有限;实体关联查询的消耗很高,关联实体越多,这个消耗就会越高。因此,一个好的关系型数据库建模,对性能的影响至关重要。

然而,与关系型数据库不通,在elasticsearch或者其他NoSql数据库中,提供了扁平化的结构,索引是独立文档的集合体,文档是否匹配搜索请求取决于它是否包含所有的所需信息。扁平化结构具有很多优势:

  • 索引过程是快速和无锁的。
  • 搜索过程是快速和无锁的。
  • 因为每个文档相互都是独立的,大规模数据可以在多个节点上进行分布。

那么,我么需要在ES中处理类似关系型数据库的这种关联关系的时候,应该如何考虑?

ES的数据模型

考虑这样一个例子:我们正在做一个酒店预订系统,设计的重要信息包括酒店基本信息、酒店的房型信息、房型的价格信息,在关系型数据库中,很好处理这几个之间的关系,然后,我们需要在ES中除了按照酒店的基本信息进行全文检索外,还需要按照价格范围进行酒店过滤。实体间的关系如下图:

 

在这样一个比较复杂的关系中,我们怎么设计我们的数据模型呢?

在ES中如何处理这样的关系型数据呢?ES提供了以下几种模型:

通常都需要结合其中的某几个方法来得到最终的解决方案,这里我们首先来实践一下关联关系处理的几种方式。

应用层链接

与关系型数据库采取相同的方式,将数据进行标准化处理,数据间的关系通过关联id来实现。

例如,对用户和他们的博客文章进行索引,数据标准化存储如下:

PUT /my_index/user/1 ①
{
"name": "John Smith",
"email": "john@smith.com",
"dob": "1970/10/24"
}

PUT /my_indexpost/2 ②
{
"title": "Relationships",
"body": "It's complicated...",
"user": 1 ③
}

每个文档的 index, type, 和 id 一起构造成主键。

blogpost 通过用户的 id 链接到用户。index 和 type 并不需要因为在我们的应用程序中已经硬编码。

要查询用户id为1的用户的博客文章,很容易:

GET /my_indexpost/_search
{
  "query": {
    "filtered": {
      "filter": {
        "term": { "user": 1 }
      }
    }
  }
}

 现在我们需要查询用户名为John的所有博客文章,这是我们需要运行两次查询: 第一次会查找所有叫做John的用户从而获取他们的ID集合,接着第二次会将这些ID集合放到类似于前面一个例子的查询中:

GET /my_index/user/_search
{
  "query": {
    "match": {
      "name": "John"
    }
  }
}

GET /my_indexpost/_search
{
  "query": {
    "filtered": {
      "filter": {
        "terms": { "user": [1] }  ①
      }
    }
  }
}

执行第一个查询得到的结果将填充到 terms 过滤器中。

 如上所示,为了查询特定用户名的博客,我们需要运行额外的查询,通常情况下,我们搜索的用户名为John的用户数量会非常多,此时得到的用户ID数量会非常大,查询的性能影响较大。

所以,在非关系型数据库来将数据规范化存储,以实现跟关系型数据库相同的处理方式并非是一个很好的选择,因为必须为查询付出额外的代价。

非规范化数据

使用Elasticsearch得到最好的搜索性能的方法是有目的的通过在索引时进行非规范化denormalizing)。对每个文档保持一定数量的冗余副本可以在需要访问时避免进行关联。

同样以上边的例子为例,如果我们希望能够通过某个用户姓名找到他写的博客文章,可以在博客文档中包含这个用户的姓名:

PUT /my_index/user/1
{
  "name":     "John Smith",
  "email":    "john@smith.com",
  "dob":      "1970/10/24"
}

PUT /my_indexpost/2
{
  "title":    "Relationships",
  "body":     "It's complicated...",
  "user":     {
    "id":       1,
    "name":     "John Smith" ①
  }
}

这部分用户的字段数据已被冗余到 blogpost 文档中。

 可以看出,这有点类似在关系型数据库中存储冗余字段来避免表之间的关联查询。此时查询用户的博客文章会比较简单:

GET /my_indexpost/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title":     "relationships" }},
        { "match": { "user.name": "John"          }}
      ]
    }
  }
}

虽然我们在查询速度上得到非常大的提升,但是带来了另一个问题:我们在更新用户信息时,不得不将user和logpost下的用户信息同步更新,以保证数据的一致性,如果用户信息更新的频率非常高,那么索引的写入操作将对搜索服务的性能造成很大影响。

 因此,这种情况适用于冗余数据更新频率不高的情况。

字段折叠

考虑这样一个需求:我们既要按照用户名模糊查询,又要按照用户名分别查询每个用户相关的博客文章,即对整个用户名进行聚合统计,该如何实现?一方面,我们需要对name字段进行分词从而进行全文检索,另一方面,我们希望对name整个内容进行聚合分析。此时,我们需要在name上设置一个子字段来实现聚合分析:

PUT /my_index/_mappingpost
{
  "properties": {
    "user": {
      "properties": {
        "name": { ①
          "type": "string",
          "fields": {
            "raw": { ② 
              "type":  "string",
              "index": "not_analyzed"
            }
          }
        }
      }
    }
  }
}

user.name 字段将分词存储,用来进行全文检索。

user.name.raw 字段为user.name的子字段,存储时不进行分词,将用来通过 terms 聚合进行分组。

添加数据:

PUT /my_index/user/1
{
  "name": "John Smith",
  "email": "john@smith.com",
  "dob": "1970/10/24"
}

PUT /my_indexpost/2
{
  "title": "Relationships",
  "body": "It's complicated...",
  "user": {
    "id": 1,
    "name": "John Smith"
  }
}

PUT /my_index/user/3
{
  "name": "Alice John",
  "email": "alice@john.com",
  "dob": "1979/01/04"
}

PUT /my_indexpost/4
{
  "title": "Relationships are cool",
  "body": "It's not complicated at all...",
  "user": {
    "id": 3,
    "name": "Alice John"
  }
}

现在我们来查询标题包含relationships并且作者名包含 John 的博客,查询结果再按作者名分组:

GET /my_indexpost/_search
{
  "size" : 0, ①
  "query": { ②
    "bool": {
      "must": [
        { "match": { "title":     "relationships" }},
        { "match": { "user.name": "John"          }}
      ]
    }
  },
  "aggs": {
    "users": {
      "terms": {
        "field":   "user.name.raw",      ③
        "order": { "top_score": "desc" } ④
      },
      "aggs": {
        "top_score": { "max":      { "script":  "_score"           }}, ⑤
        "blogposts": { "top_hits": { "_source": "title", "size": 5 }}  ⑥
      }
    }
  }
}

我们感兴趣的博客文章是通过 blogposts 聚合返回的,所以我们可以通过将 size 设置成 0 来禁止 hits 常规搜索。

query 返回通过 relationships 查找名称为 John 的用户的博客文章。

terms 聚合为每一个 user.name.raw 创建一个桶。

top_score 聚合对通过 users 聚合得到的每一个桶按照文档评分对词项进行排序。

top_hits 聚合仅为每个用户返回五个最相关的博客文章的 title 字段。

你可以点击这里 top_hits aggregation查询分组聚合的相信信息。

 响应结果如下:

...
"hits": {
  "total":     2,
  "max_score": 0,
  "hits":      [] ①
},
"aggregations": {
  "users": {
     "buckets": [
        {
           "key":       "John Smith", ②
           "doc_count": 1,
           "blogposts": {
              "hits": { 
                 "total":     1,
                 "max_score": 0.35258877,
                 "hits": [ ③
                    {
                       "_index": "my_index",
                       "_type":  "blogpost",
                       "_id":    "2",
                       "_score": 0.35258877,
                       "_source": {
                          "title": "Relationships"
                       }
                    }
                 ]
              }
           },
           "top_score": { 
              "value": 0.3525887727737427 ④
           }
        },
...

 

因为我们设置 size 为 0 ,所以 hits 数组是空的。

在顶层查询结果中出现的每一个用户都会有一个对应的桶。

在每个用户桶下面都会有一个 blogposts.hits 数组包含针对这个用户的顶层查询结果。

用户桶按照每个用户最相关的博客文章进行排序。


总结 

 本文简单的说明了关联关系处理的几种方式,及其各自的优缺点:

  • 应用层连接,类似于关系数据库的外键关系,数据只能在一端更新,需要为查询付出额外的开销,基础数据结果集大的时候查询的性能低;
  • 数据非规范化,相当于关系数据库简历冗余数据,除了查询的_source中有额外的数据、索引数据较大之外,另一个明显的缺点就是,必须考虑冗余数据的更新,不适用于冗余数据更新频繁的情况。

接下来,我们再来尝试了解嵌套对象和父子关系模型。


前一篇:Elasticsearch搜索服务学习之十——索引映射管理
后一篇:ubuntu下ssh关闭密码登陆,采用公钥认证登陆

belonk

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