白日梦的Elasticsearch实战笔记,ES账号免费借用、32个查询案例、15个聚合案例、7个查询优化技巧。

一、导读

Hi!大家久等了!时隔10天,白日梦的Elasticsearch笔记进阶篇终于甘完了!本次更新依然是干货满满!

image

下面会和大家分享 32种查询方法、15中聚合方式、7种优化后的查询技巧。欢迎大家转发支持!

如果对ES中的各种概念不太清楚可以去看上一篇文章,白日梦的ES笔记-基础篇,并且有些概念不理解并不会影响你看懂本文中为大家介绍的各种查询方式。

下一篇(白日梦的ES系列笔记第三篇)文章会跟大家一起杀回到基础部分,系统的做一次概念上的扫盲!

最后一篇(ES系列笔记第四篇)以编程语言实战为主,不出意外的话会以视频的方式和大家见面。

欢迎关注白日梦!第一时间追更新!

二、福利:账号借用

好消息!!!如果你嫌安装ES麻烦,想使用现成的ES学习,可以免费白嫖白日梦的搭建在公网上的ES实例(有效期还有340多天,预计到2022年初才过期哦)。关注此公号后台回复:白嫖 可得到账号密码。

image

Notice!!!我不能保证它一定是安全可用哦,毕竟IP直接暴露在公网上是极有可能被黑的。如果你发现服务不可用,可以跟我说一下。我提前做好了镜像,可快速将系统回复如初。(为了安全,我也会不定期更新IP、账号密码)所以大家拿它用来学习还行,不要往上面放重要的数据哈!

关注白日梦(一个专注于技术的百度后端研发)后台回复:白嫖 ,即可领取账号密码。
关注白日梦(一个专注于技术的百度后端研发)后台回复:白嫖 ,即可领取账号密码。
关注白日梦(一个专注于技术的百度后端研发)后台回复:白嫖 ,即可领取账号密码。


点击链接阅读原文:可以找到我公众号的二维码。


另外我也推荐大家阅读原文,json的格式会好看很多!




三、_search api 搜索api

search api也是我们最需要了解和掌握的APi。因为绝大部分时间你使用ES就是为了检索嘛,所以下面一起看一下ES有哪些检索API,当然最终的目的是大家有拥有选择出一种适合自己业务的检索方式的能力。

我又来吹牛了!

image

如果你不学白日梦跟你介绍的这些查询方式、技巧。我敢说你八成不懂别人用Java或者Golang写出来的代码。

相反如果你看懂了下面的几十个Case后,我敢说你自己可以分分钟独立的用熟悉的编程语言写出对应的查询代码!




3.1、什么是query string search?

所谓的query string search其实就是ES为我们提供的一种检索方式。下面这行请求就是典型的通过 query string search的方式进行检索。

其实这种检索方式很少用。直观上看 query string search 这种检索方式的特点就是它的请求参数全部写在URI中。

GET /your_index/your_type/_search?q=*&sort=account_number:asc&pretty

解读一下上面的 query string search: q=* ,表示匹配index=bank的下的所有doc,sort=account_number:asc表示告诉ES,结果按照account_number字段升序排序,pretty是告诉ES,返回一个漂亮的json格式的数据。

上面的q还可以写成下面这样:

GET /your_index/your_type/_search?q=自定义field:期望的值
GET /your_index/your_type/_search?q=+自定义field:期望的值
GET /your_index/your_type/_search?q=-自定义field:期望的值

解读ES返回的响应如下(包括后面的query dsl的几十种查询案例的返回值也长这样,并且下面不再重复分析这个返回值都有啥字段了,所以推荐你好好看下这个返回值再去浏览本文的重头戏:query dsl 和 查询优化技巧哈):

{
  "took" : 63,// 耗费的时间
  // 是否超时了,默认情况下不存在time_out,比如你的搜索耗时1分钟,它就等1分钟,但是不超时
  // 在发送搜索请求时可以指定超时时间
  // 比如你指定了10ms超时,它就会把这10ms内获得的数据返回给你
  "timed_out" : false,
  "_shards" : { // 你的搜索请求打到了几个shard上面去。
    // Primary Shard可以承接读、写流量。Replica Shard会承接读流量。
    // 因为我是默认配置,有五个primary shard。
    // 所以它的搜索请求会被打到5个分片上去,并且都成功了
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,// 跳过了0个
    "failed" : 0 // 失败了0个
  },
  "hits" : {//命中的情况
    "total" : 1000,// 命中率 1000个
    // _score 全文检索时使用,这个相关性得分越高,说明doc和检索的内容的越相关、越匹配
    // max_score就是最大的 _score
    "max_score" : null,
    // 默认查询前10条,直接返回每个doc的完整数据
    "hits" : [ {   
      "_index" : "bank",// 索引
      "_type" : "_doc",// type
      "_id" : "0",// id 
      "sort": [0],
      "_score" : null,// 相关性得分
      // _source里面存放的是doc的具体数据
      "_source" :       {"account_number":0,
                       "balance":16623,
                       "firstname":"Bradshaw",
                       "lastname":"Mckenzie",
                       "age":29,
                       "gender":"F",
                       "address":"244 Columbus Place",
                       "employer":"Euron",
                       "email":"bradshawmckenzie@euron.com",
                       "city":"Hobucken",
                       "state":"CO"}
            },
         {
      "_index" : "bank",
      "_type" : "_doc",
      "_id" : "1",
      "sort": [1],
      "_score" : null,
      "_source" : {"account_number":1,
                   "balance":39225,
                   "firstname":"Amber",
                   "lastname":"Duke",
                   "age":32,
                   "gender":"M",
                   "address":"880 Holmes Lane",
                   "employer":"Pyrami",
                   "email":"amberduke@pyrami.com",
                   "city":"Brogan",
                   "state":"IL"}
    }, ...
    ]
  }
}

指定超时时间: GET /_search?timeout=10ms 在进行优化时,可以考虑使用timeout, 比如: 正常来说我们可以在10s内获取2000条数据,但是指定了timeout,发生超时后我们可以获取10ms中获取到的 100条数据。




3.2、什么是query dsl?

dsl 全程 domain specified language

不论是query string search 还是这小节的query specified language它们本质上都是在发送Resutful类型的网络请求。相对于 query string search 的将所有的请求参数都写在URI中,query dsl 一般长下面这样:

GET /yourIndex/yourType/_search
{
  // 很多请求参数
}

说的直白一点,query string search 更像是http中的GET请求,因为它没有请求体。而本小节的query dsl 更像是http 中的POST请求。




3.3、干货!32个查询案例!

下面一起看一下有哪些query dsl的使用方式。(查询的返回值和上面我们一起看的那个是一样的,所以下面的重点是怎么查,而不是怎么看返回值哈)

1、查询指定index下的全部doc

# _search是关键字,下文基本每个查询都会有它,不再赘述了哈
GET /your_index/your_type/_search
{
  "query": { "match_all": {} }
}

2、针对name字段进行全文检索(match查询)

ES会将用户将输入的字符串通过分词器拆解开,然后去倒排索引中扫描匹配(下一篇文章白日梦的笔记会重新杀回ES涉及的核心概念,包括这个倒排索引)。在倒排索引中哪怕匹配上了一个也会将结果返回。

GET /yourIndex/yourType/_search
{
   "query": { 
     # match表示全文检索,所以白日梦会被分词成 白日、梦、白日梦
     # 也就是说当前的match会匹配出name中有“白日” 或者“梦” 或者“白日梦”的doc
     "match": {
       "name":"白日梦"
     } 
   }
}
# 实际上,match query底层会被转换成下面的格式进行检索
#
# {
#    "bool":{
#        "should":[
#         {"term":{"title":"白日"}},
#                   {"term":{"title":"白日梦"}},
#         {"term":{"title":"梦"}}
#     ]
#  }
# }
#

3、全文检索:手动控制全文检索的精度

GET /your_index/your_type/_search
{
   "query": { 
     "match": {
        "name":{
            "query":"bairi meng",
            # and表示,只有同时出现bairi meng两个词的doc才会被命中
            # 如果不加and限制,则bairi和meng之间是或的关系,只要出现一个就行
            "operator":"and",  
        }
     }
    }
}
# 添加上operator 操作会被ES转换成下面的格式,将上面的should转换成must
#
# {
#    "bool":{
#        "must":[
#         {"term":{"title":"bairi"}},
#         {"term":{"title":"meng"}}
#     ]
#  }
# }

4、去掉全文检索的长尾

# 去长尾
GET /your_index/your_type/_search
{
   "query": { 
     "match": {
        "name":{
            "query":"欢迎关注白日梦!",
            "operator":"and",  
            # 上面的query可能被分词成: 欢迎、关注、白日梦、欢迎关注、关注白日梦这五个词。
            # 默认来说只要命中其中的一个词,那个doc就会被返回,所以有长尾现象。
            # 去长尾:控制至少命中3/4个词的doc才算是真正命中。
            "minimum_should_match":"75%" 
        }
     }
    }
}
# 添加上 minimum_should_match 操作会被ES转换成下面的格式 
#
# {
#    "bool":{
#        "should":[
#         {"term":{"title":"白日"}},
#         {"term":{"title":"梦"}}
#     ],
#       "minimum_should_match":3
#  }
# }
#  

5、全文检索:通过boost控制权重。

如下Case:要求doc的name字段必须包含:“关注”,于此同时,如果doc的name字段中包含:“白日梦”,则将这个doc的权重提高为3,如果name字段中包含了“公众号” 再提高它的权重2。经过这样的处理,name字段中包含:“关注白日梦公众号” 的doc的权重就最高,它在搜索结果中的排名就越靠前。

GET /your_index/your_type/_search
{
   "query": { 
     "bool":{
            "must":{
                "match": {
                 "name":{
                        # 默认情况下,所有字段的权重都是样的,都是1
                    "query":"关注",
                    }
                    }
             },
             "should":[
                    {
                    "match": {
                 "name":{
                    "query":"白日梦",
                    # 将name字段的权重提升成3
                    "boost":3 
                    }
                    }
                    },
                    {
                    "match": {
                 "name":{
                    "query":"公众号",
                    # 将name字段的权重提升成3
                    # 默认情况下,所有字段的权重都是样的,都是1
                    "boost":2  
                    }
                    }
                }
            ]
      }
   }
}   

6、稍微复杂一点的多条件查询:bool查询

GET /your_index/your_type/_search
{ 
  "query": {
    # 比如你的查询比较复杂,涉及到很多的子查询,那你可以考虑通过bool查询包裹这些子查询
    # 每一个子查询都会计算出这个doc针对于它这种查询得到的相关性得分。
    # 最终由bool查询将这些得分合并为一个最终的得分
    "bool": {
      # 必须匹配到XXX, 并且会得出相关性得分
      # address中必须包含mill 
      "must": [ {"match": { "address": "mill" } }, 
      ],
      # 在满足must的基础上,should条件不满足也可以,但是如果也匹配上了,相关性得分会增加
      # 如果没有must的话,should中的条件必须满足一个
      "should": [{ "match": { "address": "lane" } }],
      "must_not": [ # 一定不包含谁
        { "match": { "address": "mill" } },
      ]
        }
    }
}

7、bool查询+去长尾。

# bool查询+去长尾
GET /your_index/your_type/_search
{ 
  "query": {
    "bool":{
      "should":[
        "match":{"name":"白日梦1"},
            "match":{"name":"白日梦2"},
            "match":{"name":"白日梦3"},
      ],
        "minimum_should_match":3
    }
  }
}

8、best fields策略:取多个query中得分最高的得分作为doc的最终得分。

一个query中是存在多个match的(我们称它为多字段查询),而且每个match都会贡献自己的相关性得分,也就是说doc最终的相关性得分是通过这多个match贡献的相关性得分通过一定的机制计算出来的。而且相关性得分越高,文档在搜索结果中就越靠前。

这时,如果你不希望让doc的最终得分是通过综合所有的match计算得出的,可以使用dis_max查询。它会取所有match中得分最高的match当作doc的最终得分。

GET /your_index/your_type/_search
{
   "query": { 
     # 这种用法不容忽略
     # 直接取下面多个query中得分最高的query当成最终得分
     "dis_max": {
        "queries":[
           {"match":{"name":"白日梦"}},
           {"match":{"content":"关注白日梦!"}}
        ]
     }
   }
}

9、基于 tie_breaker 优化dis_max

上面的Case中有提到这个dis_max查询,这个dis_max也是实现best field的关键,即:它会取所有match中得分最高的match当作doc的最终得分。

而这个例子中的tie_breaker会重新让dis_max考虑到其他field的得分影响,比如下面的0.4,表示最终的doc得分会考虑其他match的影响,但是它的影响会被弱化成原来的0.4。

GET /your_index/your_type/_search
{   
    # 基于 tie_breaker 优化dis_max
    # tie_breaker可以使dis_max考虑其它field的得分影响
    "query": { 
     # 直接取下面多个query中得分最高的query当成最终得分
     # 这也是best field策略
     "dis_max": { 
        "queries":[
           {"match":{"name":"关注"}},
           {"match":{"content":"白日梦"}}
        ],
        "tie_breaker":0.4
     }
    }
}   

10、同时在你指定的多个字段中进行检索:multi_match

GET /your_index/your_type/_search
{    
  # 查询多个,在下面指定的两个字段中检索含有 “this is a test“ 的doc
  "query": { 
    "multi_match" : {
      "query":    "this is a test", 
      "fields": [ "subject", "message" ] 
    }
  }
}

11、使用multi_match query简化dis_max

# 还是这个dis_max query,如下:
GET /your_index/your_type/_search
{   
    # 基于 tie_breaker 优化dis_max
    # tie_breaker可以使dis_max考虑其它field的得分影响
    "query": { 
     # 直接取下面多个query中得分最高的query当成最终得分
     # 这也是best field策略
     "dis_max": { 
        "queries":[
           {"match":{"name":"关注"}},
           {"match":{"content":"白日梦"}}
        ],
        "tie_breaker":0.4
     }
    }
} 

# 使用multi_match query简化写法如下:
GET /your_index/your_type/_search
{    
    "query": { 
       "multi_match":{
           "query":"关注 白日梦",
                      # 指定检索的策略 best_fields(因为dis_max就是best field策略)
           "type":"best_fields",
                    # content^2 表示增加权重,相当于:boost2
           "fields":["name","content^2"],
                     "tie_breaker":0.4,
                     "minimum_should_match":3
       }
    }
}

12、most field策略和上面说的best field策略是不同的,因为best field策略说的是:优先返回某个field匹配到更多关键字的doc。

优先返回有更多的field匹配到你给定的关键字的doc。而不是优先返回某个field完全匹配你给定关键字的doc

另外most_fields不支持使用minimum_should_match去长尾。

GET /your_index/your_type/_search
{    
    # most_fields策略、优先返回命中更多关键词的doc
    # 如下从title、name、content中搜索包含“赐我白日梦”的doc
    "query": { 
       "multi_match":{
           "query":"赐我白日梦",
                      # 指定检索的策略most_fields
           "type":"most_fields",
           "fields":["title","name","content"]
       }
    }
}

13、cross_fields策略:如下Case

GET /your_index/your_type/_search
{    
    "query": { 
       "multi_match":{
           "query":"golang java",
                # cross_fields 要求golang:必须在title或者在content中出现
            # cross_fields 要求java:必须在title或者在content中出现
           "type":"cross_fields",
           "fields":["title","content"]
       }
    }
}

14、查询空

GET /your_index/your_type/_search
{   
  "query": { 
    "match_none": {}
  }
}

15、精确匹配

# 使用trem指定单个字段进行精确匹配
GET /your_index/your_type/_search
{   
  # 精确匹配name字段为白日梦的doc
  "query": { 
    "constant_score":{
            "filter":{
                    "term": {
                    "name":"白日梦"
                 } 
            }
        }
    } 
}

# 使用terms指定在多个字段中进行精确匹配
# 下面的例子相当于SQL: where name in ('tom','jerry')
GET /your_index/your_type/_search
{
   # 精确匹配
  "query": { 
    "constant_score":{
            "filter":{
                    "terms": {
                    "想搜索的字段名":[
                                "tom",
                            "jerry"
                    ]
                 } 
            }
        }
    } 
} 

16、短语检索:要求doc的该字段的值和你给定的值完全相同,顺序也不能变,所以它的精确度很高,但是召回率低。

GET /your_index/your_type/_search
{   
  # 短语检索 
  # 顺序的保证是通过 term position来保证的
  # 精准度很高,但是召回率低
  "query": {  
                # 只有name字段中包含了完整的 白日梦 这个doc才算命中
              # 不能是单个 ”白日“,也不能是单个的 “梦”,也不能是“白日xxx梦”
              # 要求 短语相连,且顺序也不能变
         "match_phrase": { 
             "name": "白日梦"
                    }
        }
}

17、提高短语检索的召回率

如果使用match_phase进行短语检索,本质上就是要求doc中的字段值和给定的值完全相同,即使是顺序不同也不行。但是为了提高召回率如你又想容忍短语匹配可以存在一定的误差,比如你希望搜索 “i love world” 时,能够搜索出''world love i"

这时可以通过slop来实现这个功能,slop可以帮你让指定短语中的词最多经过slop次移动后如果能匹配某个doc,也把这个doc当作结果返回给用户。

GET /your_index/your_type/_search
{    
   # 短语检索
   "query": {
           # 指定了slop就不再要求搜索term之间必须相邻,而是可以最多间隔slop距离。
         # 在指定了slop参数的情况下,离关键词越近,移动的次数越少, relevance score 越高。
         # match_phrase +  slop 和 proximity match 近似匹配作用类似。
         # 平衡精准度和召回率。
         "match_phrase": { 
             "address": "mill lane",
                         # 指定搜索文本中的几个term经过几次移动后可以匹配到一个doc
             "slop":2
          } 
  }
}

18、混合使用match和match_phrase 平衡精准度和召回率

GET /your_index/your_type/_search
{    
   # 混合使用match和match_phrase 平衡精准度和召回率
   "query": { 
      "bool": {  
        "must":  {
            # 全文检索虽然可以匹配到大量的文档,但是它不能控制词条之间的距离
            # 可能i love world在doc1中距离很近,但是它却被ES排在结果集的后面
            # 它的性能比match_phrase和proximity高
                "match": {
                "title": "i love world" 
            } 
             },
        "should": {
            # 因为slop有个特性:词条之间间隔的越近,移动的次数越少 最终的得分就越高
            # 于是可以借助match_phrase+slop感知term position的功能
            # 实现为距离相近的doc贡献分数,让它们靠前排列
            "match_phrase":{
                "title":{
                    "query":"i love world",
                    "slop":15
                }
            }
        }
    }
}

19、使用rescore_query重打分。提高精准度和召回率。

GET /your_index/your_type/_search
{    
   # 重打分机制
   "query": { 
       "match":{
           "title":{
               "query":"i love world",
               "minimum_should_match":"50%"
           }
       },
       # 对全文检索的结果进行重新打分
       "rescore":{
             # 对全文检索的前50条进行重新打分
           "window_size":50,  
           "query": { 
               # 关键字
               "rescore_query":{ 
                      # match_phrase + slop 感知 term persition,贡献分数
                    "match_phrase":{ 
                       "title":{
                           "query":"i love world",
                           "slop":50
                     }
                }
          }
       } 
   }
}

20、前缀匹配:搜索 user字段以"白日梦"开头的 doc

GET /your_index/your_type/_search
{    
  # 前缀匹配,相对于全文检索,前缀匹配是不会对前缀进行分词的。
  # 而且每次匹配都会扫描整个倒排索引,直到扫描完一遍才会停下来
  # 前缀搜索不会计算相关性得分所有的doc的得分都是1
  # 前缀越短能匹配到的doc就越多,性能越不好
  "query": { 
    "prefix" : { "user" : "白日梦" }
  }
}

21、前缀搜索 + 添加权重

GET /your_index/your_type/_search
{    
  # 前缀搜索 + 添加权重
  "query": { 
    "prefix" : { 
        "name" :  { 
            "value" : "白日梦", 
            "boost" : 2.0 
            }
        }
  }
}

22、通配符搜索

GET /your_index/your_type/_search
{    
  # 通配符搜索
  "query": {
        "wildcard" : { 
                    "title" : "白日梦的*笔记"
                }
   }
}


GET /your_index/your_type/_search
{    
  # 通配符搜索
  "query": {
        "wildcard" : {
                "title" : { 
                    "value" : "白日梦的*笔记", 
                    "boost" : 2.0 
                    } 
            }
   }
}

23、正则搜索

GET /your_index/your_type/_search
{    
   # 正则搜索  
   "query": {
        "regexp":{
            "name.first":{
                "value":"s.*y",
                "boost":1.2
            }
        }
    }
}

24、搜索推荐:match_phrase_prefix,最终实现的效果类似于百度搜索,当用户输入一个词条后,将其它符合条件的词条的选项推送出来。

match_phrase_prefix和match_phrase相似,但是区别是它会将最后一个term当作前缀,发起一次搜索。因此它也叫search time 搜索推荐,因为它是在你搜索的时候又发起了一次新的请求来拿到推荐的内容,它的效率整体也是比较低的。

GET /your_index/your_type/_search
{    
   "query": {
      # 前缀匹配(关键字)
      "match_phrase_prefix" : {
        "message" : {
                            # 比如你搜索关注白日梦,经过分词器处理后会得到最后一个词是:“白日梦”
                          # 然后他会拿着白日梦再发起一次搜索,于是你就可能搜到下面的内容:
                # “关注白日梦的微信公众号”
                            # ”关注白日梦的圈子“
                "query" : "关注白日梦",
                # 指定前缀最多匹配多少个term,超过这个数量就不在倒排索引中检索了,提升性能
                "max_expansions" : 10,
                # 提高召回率,使用slop调整term persition,贡献得分
                "slop":10
            }
       } 
  }
}

25、Function Score Query

Function Score Query 实际上是一种让用户可以自定义实现一种对doc得分进行增强的手段。比如:用户可以自定义一个function_secore 函数,然后指定将这个field的值和ES计算出来的分数相乘,作为doc的最终得分。

# Case1
GET /your_index/your_type/_search
{    
  "query": {
        "function_score": {
            # 正常写一个query
            "query": { 
                "match": {
                    "query":"es"
                } 
                    },
                  # 自定义增强策略
                    “field_value_factor”:{
                        # 对检索出的doc的最终得分都要multiply上star字段的值
              "field":"star",
            }
            "boost_mode":"multiply",
                        # 限制最大的得分不能超过maxboost指定的值。
                        "maxboost":3
        }
    }
}

# Case2
GET /your_index/your_type/_search
{    
  "query": {
        "function_score": {
            "query": { 
                "match": {
                    "query":"es"
                } 
                    },
                    “field_value_factor”:{
                        # 对检索出的doc的最终得分都要multiply上star字段的值
                        # 这时有个问题,假如说star字段的值为0,那最终结果岂不是都为0?
              "field":"star",
              # 所以考虑使用modifier优化一下
              # newScore = oldScore + log(1+star)
              "modifier":"log1p",
            }
            "boost_mode":"multiply",
                        "maxboost":3
        }
    }
}

# Case3
GET /your_index/your_type/_search
{    
  "query": {
        "function_score": {
            "query": { 
                "match": {
                    "query":"es"
                } 
                    },
                    “field_value_factor”:{
              "field":"star",
              "modifier":"log1p",
              # 使用factor将star字段对权重的影响降低成1/10
              # newScore = oldScore + log( 1 + star*factor )
                        "factor":0.1
            }
            "boost_mode":"multiply",
                        "maxboost":3
        }
    }
}

# 补充boost_mode有哪些中选项
multiply、sum、min、max、replace

26、Fuzzy Query 模糊查询会提供容错的处理

GET /your_index/your_type/_search
{    
   # Fuzzy Query 模糊查询会提供容错的处理
   "query": {
        "fuzzy" : {
            "user" : {
                "value": "白日梦",
                "boost": 1.0,
                # 最大的纠错次数,一般设为之AUTO
                "fuzziness": 2,
                # 不会被“模糊化”的初始字符数。这有助于减少必须检查的术语的数量。默认值为0。
                "prefix_length": 0,
                # 模糊查询将扩展到的最大项数。默认值为50
                "max_expansions": 100 
                # 是否支持模糊变换(ab→ba)。默认的是false
                transpositions:true 
            }
        }
    }
}

27、解读一个实用的案例

GET /your_index/your_type/_search
{ 
  "query": {
    # 比如你的查询比较复杂,涉及到很多的子查询,那你可以考虑通过bool查询包裹这些子查询
    # 每一个子查询都会计算出这个doc针对于它这种查询得到的相关性得分。
    # 最终由bool查询将这些得分合并为一个最终的得分
    "bool": {
      # 必须匹配到XXX, 并且会得出相关性得分
      # address中必须包含mill 
      "must": [ {
            "match": {
          "address": "mill" 
           } 
        }, 
      ],
      # 在满足must的基础上,should条件不满足也可以,但是如果也匹配上了,相关性得分会增加
      # 如果没有must的话,should中的条件必须满足一个
      "should": [
        { "match": { "address": "lane" } }
      ],
      "must_not": [ # 一定不包含谁
        { "match": { "address": "mill" } },
      ],
            # filter中的表达式仅仅对数据进行过滤,但是不会影响搜索结果的相关度得分。
            # 所以你如果不希望添加的过滤条件影响最终的doc排序的话,可以将条件放在filter中。
            # query是会计算doc的相关度得分的,得分越高,越靠前。
      "filter": { 
        "range": { # 按照范围过滤
          "balance": { # 指定过滤的字段
            "gte": 20000s # 高于20000
            "lte": 30000  # 低于30000
          }
        }
      }
    }
  }

默认的排序规则是按照_score降序排序,但像上面说的那样,如果全部都是filter的话它就不会计算得分,也就是说所有的得分全是1,这时候就需要定制排序规则,定义的语法我在上面写了

28、查询名称中包含“白日梦”的doc,并且按照star排序

高亮、排序、分页以及_source 指定需要的字段都可以进一步作用在query的结果上。

# ES默认的排序规则是按照 _score 字段降序排序的

# 但是ES允许你像下面这样定制排序规则
GET /your_index/your_type/_search
{
   "query": { 
     "match": {"name":"白日 梦"}
   },
  # 指定排序条件
  "sort":[
    # 指定排序字段为 star
    {"star":"desc"}
  ]
}   

29、分页查询

如:从第一条doc开启查,查10条。(如果你不使用from、to搜索的话,默认就搜索前10条)

GET /your_index/your_type/_search
{
   "query": { "match_all": {} },
      "from": 0, # 0:是第一个doc
    "size": 10
}   

# 还可以像这样发起分页请求
GET /your_index/your_type/_search?size=10
GET /your_index/your_type/_search?size=10&from=20

# deep paging 问题
比如系统中只有3个primary shard,1个replica shard,共有6W条数据。
用户希望查询第1000页,每页10条数据。也就是1000*10 = 10001 ~ 10010 条数据
假如说用户将这个分页请求会打向ES集群中的replica shard,接下来会发生什么?
回答:
接收到请求的shard 我们称它为coordinate node(协调节点),它会将请求转发到三个primary,
每个primary shard都会取出它们的第1~10010条数据id,返回给coordinate node,
也就是说coordinate node总共会接收到30030个id,然后coordinate node再拿着这些id发起mget请求获取数据
对获取到的结果30030排序处理,最后取相关性得分最高的10条返回给用户。

所以当分页过深的时候是非常消耗内存、网络带宽、CPU的。

30、指定要查询出来的doc的某几个字段。如下:

# 假设白日梦对应的json长下面这样:
{
  "name":"白日梦",
  “address”:"beijing",
  "gender":"man"
}

# 然后我只想检索出name字段,其他的不想知道,可以像下面这样通过_sorce限制
GET /your_index/your_type/_search
{
   "query": { "match_all": {} },
   # ES会返回全文JSON,通过_source可以指定返回的字段
     "_source": ["name"],
}  

31、filter过滤,查询name中包含白日梦,且star大于100的doc。

GET /your_index/your_type/_search
{
   "query": { 
     # 可以使用bool封装包括多个查询条件
     “bool":{
        "must":{"match": {"name":"白日 梦"}}
                # 指定按照star的范围进行filter
              "filter":{
            # range既能放在query中,也能放在filter中。
            # 如果放在filter中,range过滤的动作不会影响最终的得分。
            # 但是放在query中,range动作会影响最终的得分。
            "range":{
                            “star”:{"gt":100}
                 }
         }
      }
   }
}  

# 拓展:
# 关于range还可以像这样过滤时间
"range":{
  # 指定birthday范围为最近一个月的doc
  "birthday":{
    "gt":"2021-01-20||-30d"
  }
}

# 或者使用now语法
  # 指定birthday范围为最近一个月的doc
  "birthday":{
    "gt":"now-30d"
  }
}

32、指定对返回的doc中指定字段中的指定单词高亮显示。

GET /your_index/your_type/_search
{
   "query": { 
        "match": {"name":"白日 梦"}    
    },
    "highlight":{ # 高亮显示
         "fields":{  # 指定高亮的字段为 firstname
             "firstname":{}
     }
} 

# 最终得到的返回值类似下面这样
  ... 
  "hits" : {
    "total" : 1000,# 1000个
    "max_score" : null,
    "hits" : [ {   
      "_index" : "bank",
      "_type" : "_doc",
      "_id" : "0",
      "sort": [0],
      "_score" : 0.777777,
      "_source" :       {"account_number":0,
                       "balance":16623,
                       "firstname":"我是白",
                       "lastname":"日梦",
                       "state":"CO"}
        }],
            "highlight":{
         "firstname":[
         "我是<em>白</em>"
       ]
 }
 ...

参考:https://www.elastic.co/guide/en/elasticsearch/reference/6.2/query-dsl.html




四、聚合分析




4.1、什么是聚合分析?

聚合分析有点类似于SQL语句中的那种group by、where age > 20 and age < 30、这种操作。常见的聚合分析就是根据某一个字段进行分组分析,要求这个字段是不能被分词的,如果被聚合的字段被分词,按照倒排索引的方式去索引的话,就不得不去扫描整个倒排索引(才可能将被聚合的字段找全,效率很低)。

聚合分析是基于doc value的数据结果集进行操作的,这个doc value 其实就是正排索引(现在了解就好,下一篇文章统一扫盲),

关于聚合分析有三个重要的概念:

  • bucket

    特别是你去使用一下java、golang中的es相关的api,就会看到这个bucket关键字,bucket就是聚合操作得到的结果集。

  • metric

    metric就是对bucket进行分析,比如取最大值、最小值、平均值。

  • 下钻

    下钻就是在现有的分好组的bucket继续分组,比如可以先按性别分组、下钻再按年龄分组。




4.2、干货!15个聚合分析案例

1、比如我们公司人很多,其中不泛有很多重名的人,现在我的需求是:我想知道我们公司中有多个人叫tom、多少个人叫jerry,也就是说,我想知道:重名的人分别有多少个。于是我们需要像下面这样根据名字聚合。

聚合的结果中天然存在一个metric,它就是当前bucket的count,也就是我们想要的结果:

GET /your_index/your_type/_search
{   
  # 表示只要聚合的结果,而不要参与聚合的原始数据
  “size”:0,
  # 使用聚合时,天然存在一个metric,就是当前bucket的count
  "aggs": {
    "group_by_name": { # 自定义的名字
      "term": {
        "field": "name" # 指定聚合的字段, 意思是 group by name
      }
    }
  }
} 

GET /your_index/your_type/_search
{   
  “size”:0,
   # 使用聚合时,天然存在一个metric,就是当前bucket的count
  "aggs": {
    "group_by_xxx": { # 自定义的名字
     # 除了使用term还可以使用terms
     # trems允许你指定多个字段
     "terms": {
         # 指定聚合的字段, 意思是 group by v1、v2、v3
        "field": {"value1","value2","value3"} 
      }
    }
  }
} 

2、先搜索,再对搜索结果聚合。比如我想知道在所有的男生中的重名情况

GET /your_index/your_type/_search
{   
  # 先查询
  “query”:{
        "term":{
        "gender":"man"
      }
  },
   # 再聚合
  "aggs": {
    "group_by_name": { 
      "term": {
        "field": "name" # 指定聚合的字段, 意思是 group by name
      }
    }
  }
} 

3、我想把重名的人分成一组,然后我想了解每组人的平均年龄。可以像下面这样干

GET /your_index/your_type/_search
{   
  "size":0,
     # 聚合中嵌套聚合,意思是先 group by avg age,再 group by field1。
  "aggs": {
    "group_by_name": {
      "terms": {
        "field": "name"
      },
             # 在上面的name分组的结果之上再按照age聚合
      "aggs": { 
        "average_age": {
          # 指定聚合函数为avg
          "avg": {
            "field": "age"
          }
        }
      }
    }
  }
} 

4、我想了解我们公司不同年龄段:20岁~25岁有多少人、25岁~30岁有多少人、30岁~35岁、35岁~40岁有多少人,以及每个年龄段有多少女生,多少男生。

GET /your_index/your_type/_search
{   
   "size":0,
   # 先按照年龄分组,在按照性别分组
   "aggs": {
    "group_by_age": {
      "range": {
        "field": "age",
        "ranges": [
          {
            "from": 20,
            "to": 25
          },{
            "from": 25,
            "to": 30
          },{
            "from": 30,
            "to": 35
          },{
            "from": 35,
            "to": 40
          }
        ]
      },
      "aggs": {
        "group_by_gender": {
          "terms": {
            # gender.keyword一般是ES自动为我们创建的类型
            # keyword类型的field不会分词、默认长度256字符
            # 这里大家初步了解有这个东西,知道怎么回事就行,下一篇文章扫盲
            "field": "gender.keyword"
          }
        }
         }
        }
} 

5、我想知道我们公司每个年龄段,每个性别的平均账户余额。

GET /your_index/your_type/_search
{       
  "size":0,
   # 先按照年龄分组,在按照性别分组,再按照平均工资聚合
   # 最终的结果就得到了每个年龄段,每个性别的平均账户余额
   "aggs": {
    "group_by_age": {
      "range": {
        "field": "age",
        "ranges": [
          {
            "from": 20,
            "to": 30
          }
        ]
      },
      "aggs": {
        "group_by_gender": {
          "term": {
            "field": "gender.keyword"
          },
          # 在上一层根据gender聚合的基础上再基于avg balance聚合
          "aggs": {
            "average_balance": {
              "avg": {
                "field": "balance"
              }
            }
          }
        }
      }
        }
} 

6、嵌套聚合,并且使用内部聚合的结果集

GET /your_index/your_type/_search
{       
  "size":0,    
   # 嵌套聚合,并且使用内部聚合的结果集
   "aggs": { 
    "group_by_state": {
      "term": {
        "field": "state.keyword",
        "order": {
          # average_balance是下面内部聚合的结果集合,在此基础上做desc
          "average_balance": "desc" 
        }
      },
            # 如下的agg会产出多个bucket如:
      # bucket1 => {state=1,acg=xxx、min=xxx、max=xxx、sum=xxx}
        # bucket2 => {state=2,acg=xxx、min=xxx、max=xxx、sum=xxx}
      "aggs": {
        "average_balance": {
          "avg": {  # avg 求平均值  metric
            "field": "balance"
          }
        },
         "min_price": {
          "min": {  # metric 求最小值
            "field": "price"
          }
        },
         "max_price": {
          "max": {  # metric 求最大值
            "field": "price"
          }
        },
         "sum_price": {
          "sum": {  # metric 计算总和
            "field": "price"
          }
        },
      }
    }
  }
}

8、除了前面说的按照值分组聚合,比如男、女,还可以使用histogram按区间聚合分析。

GET /your_index/your_type/_search
{
  "size":0,   
  # histogram,类似于terms,同样会进行bucket分组操作。
  # 使用histogram需要执行一个field,比如下例中的age,表示按照age的范围进行分组聚合
  "aggs": { # 聚合中嵌套聚合
      "group_by_price": {
            "histogram": {
                 "field": "age",
                              # interval为10,它会划分成这样 0-10  10-20  20-30 ...
                                # 那age为21的记录就会被分进20-30的区间中
                 "interval":10
             },
       "aggs": { # 聚合中嵌套聚合
            "average_price": {
               "avg": {
                  "field": "price"
               }
            }
        }
     }
  }
}

9、根据日期进行聚合

GET /your_index/your_type/_search
{       
  "size":0, 
  "aggs": {
     "agg_by_time" : { 
            # 关键字
          "date_histogram" : {
                "field" : "age",
                        # 间隔,一个月为一个跨度
                "interval" : "1M",
                "format" : "yyyy-MM-dd",
                        # 即使这个区间中一条数据都没有,这个区间也要返回
                "min_doc_count":0 
                        # 指定区间
                        “extended_bounds”:{
                            "min":"2021-01-01",
                          "max":"2021-01-01",
                            }
            } 
        }
    }
}

# 补充
"interval":“quarter”按照季度划分

10、filter aggregate 过滤、聚合。

# Case1
# 如下例子:我想先过滤出年龄大于20的人,然后聚合他们的平均工资
GET /your_index/your_type/_search
{
  "size":0,
  "query":{
    "consitant_score":{
      # 这个filter会针对ES中全局的数据进行filter
      "filter":{
        "range":{"age":{"gte":20}}
      }
    }
  },
  "aggs":{
    "avg_salary":{
      "avg":{
        "field":"salary"
      }
    }
  }
}

# Case2
# bucket filter
POST /sales/_search
{
    "aggs" : {
        # T恤bucket的agg
        "agg_t_shirts" : {
            "filter" : { 
              "term": {
                "type": "t-shirt" 
              }
            },
            "aggs" : {
                "avg_price" : { "avg" : { "field" : "price" } }
            }
        },
            # 毛衣bucket的agg
      "agg_sweater" : {
            "filter" : { 
              "term": {
                "type": "sweater" 
              }
            },
            "aggs" : {
                "avg_price" : { "avg" : { "field" : "price" } }
            }
        }
    }
}

11、嵌套聚合-广度优先

说一个应用于场景: 我们检索电影的评论, 但是我们先按照演员分组聚合,再按照评论的数量进行聚合。且我们假设每个演员都出演了10部电影。

分析: 如果我们选择深度优先的话, ES在构建演员电影相关信息时,会顺道计算出电影下面评论数的信息,假如说有10万个演员的filter aggregate话, 10万*10=100万个电影 每个电影下又有很多影评,接着处理影评, 就这样内存中可能会存在几百万条数据,但是我们最终就需要50条,这种开销是很大的。

广度优先的话,是我们先处理电影数,而不管电影的评论数的聚合情况,先从10万演员中干掉99990条数据,剩下10个演员再聚合。

        "aggs":{
            "target_actors":{
                "terms":{
                    "field":"actors",
                    "size":10,
                    "collect_mode":"breadth_first" # 广度优先
                }
            }
        }

12、global aggregation

全局聚合,下面先使用query进行全文检索,然后进行聚合, 下面的聚合实际上是针对两个不同的结果进行聚合。

  • 第一个聚合添加了global关键字,意思是ES中存在的所有doc进行聚合计算得出t-shirt的平均价格

  • 第二个聚合针对全文检索的结果进行聚合

POST /sales/_search?size=0
{
    "query" : {
        # 全文检索 type = t-shirt的商品
        "match" : { "type" : "t-shirt" }
    },
    "aggs" : {
        "all_products" : {
            "global" : {}, # 表示让 all_products 对ES中所有数据进行聚合
            "aggs" : {
                # 没有global关键字,表示针对全文检索的结果进行聚合
                "avg_price" : { "avg" : { "field" : "price" } }
            }
        },
        "t_shirts": { "avg" : { "field" : "price" } }
    }
}

13、Cardinality Aggregate 基数聚合

在ES中聚合时去重一般选用cardinality metric,它可以实现对每一个bucket中指定的field进行去重,最终得到去重后的count值。

虽然她会存在5%左右的错误率,但是性能特别好

POST /sales/_search?size=0
{
    "aggs" : {
        # 先按照月份聚合得到不同月的bucket
        "agg_by_month" : {
            "date_histogram":{
              "field" : "my_month",
              "internal":"month"
            }
        },
                # 在上一步得到的以月份为维护划分的bucket基础上,再按照品牌求基数去重。
              # 于是最终我们就得到了每个月、每种品牌的销售量。
        "aggs" : {
            "dis_by_brand" : {
                "cardinality" : { 
                 "field" : "brand"
            }
        }
    }
}

对Cardinality Aggregate的性能优化, 添加 precision_threshold 优化准确率和内存的开销。

还是下面的例子,如果将precision_threshold的值调整到100意思是:当品牌的总数量小于100时,去重的精准度为100%, 此时内存的占用情况为 100*8=800字节。

加入我们将这个值调整为1000,意思是当品台的种类在1000个以内时,去重的精准度100%,内存的占用率为1000*8=80KB。

官方给出的指标是:将precision_threshold设置为5时,错误率会被控制在5%以内。

POST /sales/_search?size=0
{
    "aggs" : {
        "type_count" : {
            "cardinality" : { # 关键字
                "field" : "brand"
                "precision_threshold":100
            }
        }
    }
}

进一步优化,Cardinality底层使用的算法是 HyperLogLog++。

因为这个算法的底层会对所有的 unique value取hash值,利用这个hash值去近似的求distcint count, 因此我们可以在创建mapping时,将这个hash的求法设置好,添加doc时,一并计算出这个hash值,这样 HyperLogLog++ 就无需再计算hash值,而是直接使用。从而达到优化速度的效果。

PUT /index/
{
    "mappings":{
        "my_type":{
            "properties":{
                "my_field":{
                    "type":"text",
                    "fields":{
                        "hash":{
                            "type":"murmu3"
                        }
                    }
                }
            }
        }
    }
}

14、控制聚合的升降序

比如我想知道每种颜色item的平均价格,并且我希望按照价格的从小到大升序展示给我看。

于是就像下面这样,先按照颜色聚合可以将相同颜色的item聚合成1组,在聚合的结果上再根据价格进行聚合。期望在最终的结果中,通过order控制按照价格聚合的分组中升序排序, 这算是个在下钻分析时的排序技巧。

GET /index/type/_search
{
     "size":0,
     "aggs":{
         "group_by_color":{
             "term":{
                 "field":"color",
                 "order":{ #
                     "avg_price":"asc"
                 }
             }
         },
         "aggs":{
             # 在上一层按color聚合的基础上,再针对price进行聚合
             "avg_price":{
                 "avg":{
                     "field":"price"
                 }
             }
         }
     }
}

15、Percentiles Aggregation

计算百分比, 常用它计算:在200ms内成功访问网站的比率、在500ms内成功访问网站的比例、在1000ms内成功访问网站的比例,或者是销售价为1000元的商品占总销售量的比例、销售价为2000元的商品占总销售量的比例等等。

示例: 针对doc中的 load_time字段, 计算出在不同百分比下面的 load_time_outliner情况。

GET latency/_search
{
    "size": 0,
    "aggs" : {
        "load_time_outlier" : {
                # 关键字
            "percentiles" : {
                "field" : "load_time" 
            }
        }
    }
}

响应解读:在百分之50的加载请求中,平均load_time的时间是在445.0。 在99%的请求中,平均加载时间980.1。

{
    ...

   "aggregations": {
      "load_time_outlier": {
         "values" : {
            "1.0": 9.9,
            "5.0": 29.500000000000004,
            "25.0": 167.5,
            "50.0": 445.0,
            "75.0": 722.5,
            "95.0": 940.5,
            "99.0": 980.1000000000001
         }
      }
   }
}

还可以自己指定百分比跨度间隔。

GET latency/_search
{
    "size": 0,
    "aggs" : {
        "load_time_outlier" : {
            "percentiles" : {
                "field" : "load_time",
                "percents" : [95,99,99.9] 
            }
        }
    }
}

优化: percentile底层使用的是 TDigest算法。用很多个节点执行百分比计算,近似估计,有误差,节点越多,越精准。

可以设置compression的值, 默认是100 , ES限制节点的最多是 compression*20 =2000个node去计算 , 因为节点越多,性能就越差。

一个节点占用 32字节, 1002032 = 64KB。

GET latency/_search
{
    "size": 0,
    "aggs" : {
        "load_time_outlier" : {
            "percentiles" : {
                "field" : "load_time",
                "percents" : [95,99,99.9],
                "compression":100 # 默认值100
            }
        }
    }
}

参考:https://www.elastic.co/guide/en/elasticsearch/reference/6.2/search-aggregations.html




五、7个查询优化技巧




  • 第一种:多字段检索,巧妙控制权重
  • 第一种: 更换写法,改变占用的权重比例。
  • 第三种: 如果不希望使用相关性得分,使用下面的语法。
  • 第四种: 灵活的查询
  • 第五种: 比如我对title字段进行检索,我希望检索结果中包含"java",并且我允许检索结果中包含:”golang“ ,但是!如果检索结果中包含”golang“,我希望这个title中包含”golang“的doc的排名能靠后一些。
  • 第六种: 重打分机制
  • 第七种: 提高召回率和精准度的技巧:混用match和match_phrase+slop提高召回率。注意下面的嵌套查询层级 bool、must、should




上面的七种优化相关性得分的方式的具体实现代码,在公众号原文中可以查看到,推荐阅读原文,json的格式会好看很多,ES专题依然在连载中~,欢迎关注。

点击阅读原文,查看7种优化方式的具体实现代码
点击阅读原文,查看7种优化方式的具体实现代码
点击阅读原文,查看7种优化方式的具体实现代码


参考:

官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/6.0

query dsl:https://www.elastic.co/guide/en/elasticsearch/reference/6.2/query-dsl.html

聚合分析:https://www.elastic.co/guide/en/elasticsearch/reference/6.2/search-aggregations.html




欢迎关注

点击阅读原文,怎么关注我你懂的~

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

推荐阅读更多精彩内容