Elasticsearch权威指南 阅读笔记(5) 排序 分布式搜索 深入分片

时间:Nov. 4, 2018 分类:

目录:

排序

默认情况下,会按照相关性越高,排名越靠前的方式

相关性排序

相关性排序是根据搜索后的_score字段进行倒叙排序,及时过滤语句,隐含条件也是match_all,所以获取到的数据的_score字段都是1而已

字段值排序

GET /_search
{
    "query" : {
        "filtered" : {
            "filter" : { "term" : { "user_id" : 1 }}
        }
    },
    "sort": { "date": { "order": "desc" }}
}

响应结果

"hits" : {
    "total" :           6,
    "max_score" :       null, 
    "hits" : [ {
        "_index" :      "us",
        "_type" :       "tweet",
        "_id" :         "14",
        "_score" :      null, 
        "_source" :     {
             "date":    "2014-09-24",
             ...
        },
        "sort" :        [ 1411516800000 ] 
    },
    ...
}
  • _score并没有被计算
  • date被转化为毫秒作为排序依据
  • 增加sort字段,包含的值是用来排序的,长整型数字1411516800000等同于日期字符串2014-09-24 00:00:00 UTC

计算_score是比较消耗性能的, 而且通常主要用作排序,我们不是用相关性进行排序的时候,就不需要统计其相关性。 如果你想强制计算其相关性,可以设置track_scores为 true

字段值默认以顺序排列,而_score默认以倒序排列

多级排序

合并一个查询语句,并且展示所有匹配的结果集使用第一排序是date,第二排序是_score

GET /_search
{
    "query" : {
        "filtered" : {
            "query":   { "match": { "tweet": "manage text search" }},
            "filter" : { "term" : { "user_id" : 2 }}
        }
    },
    "sort": [
        { "date":   { "order": "desc" }},
        { "_score": { "order": "desc" }}
    ]
}

排序是很重要的。结果集会先用第一排序字段来排序,当用用作第一字段排序的值相同的时候, 然后再用第二字段对第一排序值相同的文档进行排序,以此类推。

多值字段排序

在为一个字段的多个值进行排序的时候, 其实这些值本来是没有固定的排序的-- 一个拥有多值的字段就是一个集合, 你准备以哪一个作为排序依据呢?

对于数字和日期,你可以从多个值中取出一个来进行排序,你可以使用min, max, avg 或 sum这些模式。 比说你可以在 dates 字段中用最早的日期来进行排序:

"sort": {
    "dates": {
        "order": "asc",
        "mode":  "min"
    }
}

多值字段字符串的排序

多值字段是指同一个字段在ES索引中可以有多个含义,即可使用多个分析器(analyser)进行分词与排序,也可以不添加分析器,保留原值

被分析器处理之后字符为analyzed字段,默认情况下Es就是直接进行analyzed处理的,analyzed字符串字段同时也是多值字段,但是排序的话可能会得不到想要的值

例如你分析一个字符"fine old art",它最终会得到三个值。例如我们想要按照第一个词首字母排序, 如果第一个单词相同的话,再用第二个词的首字母排序,以此类推,可惜 ElasticSearch 在进行排序时 是得不到这些信息的。

使一个string字段可以进行排序,它必须只包含一个词:即完整的not_analyzed字符串

_source下相同的字符串上排序两次会造成不必要的资源浪费。 而我们想要的是同一个字段中同时包含这两种索引方式,我们只需要改变索引(index)的mapping即可。 方法是在所有核心字段类型上,使用通用参数 fields对mapping进行修改。 比如,我们原有mapping如下:

"tweet": {
    "type":     "string",
    "analyzer": "english"
}

改变后的多值字段mapping如下:

"tweet": { 
    "type":     "string",
    "analyzer": "english",
    "fields": {
        "raw": { 
            "type":  "string",
            "index": "not_analyzed"
        }
    }
}
  • tweet字段用于全文本的analyzed索引方式不变。
  • 新增的 tweet.raw 子字段索引方式是 not_analyzed。

现在,在给数据重建索引后,我们既可以使用 tweet 字段进行全文本搜索,也可以用tweet.raw字段进行排序:

GET /_search
{
    "query": {
        "match": {
            "tweet": "elasticsearch"
        }
    },
    "sort": "tweet.raw"
}

对 analyzed 字段进行强制排序会消耗大量内存。

相关性简介

_score的计算方式取决于不同的查询类型,或者说查询语句,例如fuzzy查询会计算与关键词的拼写相似程度,terms查询会计算找到的内容与关键词组成部分匹配的百分比,但是一般意义上我们说的全文本搜索是指计算内容与关键词的类似程度

ElasticSearch的相似度算法被定义为 TF/IDF,即检索词频率/反向文档频率

  • 检索词频率 出现频率越高,相关性也越高。
  • 反向文档频率 每个检索词在索引中出现的频率,频率越高,相关性越低。
  • 字段长度准则 长度越长,相关性越低
  • 另外对于bool查询,匹配的字句越多,相关性越高,每个查询子句计算得出的评分会被合并到总的相关性评分中的

理解评分标准

explain设为true就可以得到更详细的信息,返回值中的_explanation会包含在每一个入口,告诉你采用了哪种计算方式,并让你知道计算的结果以及其他详情

可以加到文档上,

GET /us/tweet/12/_explain
{
   "query" : {
      "filtered" : {
         "filter" : { "term" :  { "user_id" : 2           }},
         "query" :  { "match" : { "tweet" :   "honeymoon" }}
      }
   }
}

可以获取到该文档为何会被匹配或者为何没有被匹配

数据字段

对一个字段进行排序时,ElasticSearch 需要进入每个匹配到的文档得到相关的值。 倒排索引在用于搜索时是非常卓越的,但却不是理想的排序结构。

  • 当搜索的时候,我们需要用检索词去遍历所有的文档
  • 当排序的时候,我们需要遍历文档中所有的值,我们需要做反倒序排列操作

为了提高效率,Es会将其都放到内存中,加载的是索引下的所有文档,包括所有类型,因为从硬盘反向倒排索引是非常缓慢的

这会消耗掉很多内存,尤其是大量的字符串数据,内存不足是可以通过横向扩展解决的,我们可以增加更多的节点到集群。

分布式搜索

分布式搜索的执行方式

在各个分片上找到匹配的结果放到有序列表中,返回到请求节点

查询阶段

在初始化查询阶段(query phase),查询被向索引中的每个分片副本(原本或副本)广播。每个分片在本地执行搜索并且建立了匹配document的优先队列(priority queue)。

优先队列只是一个存有前n个(top到n)匹配document的有序列表,这个优先队列的大小由分页参数from和size决定

示例

GET /_search
{
    "from": 90,
    "size": 10
}

优先队列的长度为100

  1. 客户端发送一个search(搜索)请求给Node 3,Node 3创建了一个长度为from+size的空优先级队列。
  2. Node 3 转发这个搜索请求到索引中每个分片的原本或副本。每个分片在本地执行这个查询并且结果将结果到一个大小为from+size的有序本地优先队列里去。
  3. 3.每个分片返回document的ID和它优先队列里的所有document的排序值给协调节点Node 3。Node 3把这些值合并到自己的优先队列里产生全局排序结果。

当一个搜索请求被发送到一个节点Node,这个节点就变成了协调节点。这个节点的工作是向所有相关的分片广播搜索请求并且把它们的响应整合成一个全局的有序结果集。这个结果集会被返回给客户端。

第一步是向索引里的每个节点的分片副本广播请求。就像document的GET请求一样,搜索请求可以被每个分片的原本或任意副本处理。这就是更多的副本(当结合更多的硬件时)如何提高搜索的吞吐量的方法。对于后续请求,协调节点会轮询所有的分片副本以分摊负载。

每一个分片在本地执行查询和建立一个长度为from+size的有序优先队列——这个长度意味着它自己的结果数量就足够满足全局的请求要求。分片返回一个轻量级的结果列表给协调节点。只包含documentID值和排序需要用到的值,例如_score。

协调节点将这些分片级的结果合并到自己的有序优先队列里。这个就代表了最终的全局有序结果集。到这里,查询阶段结束。

整个过程类似于归并排序算法,先分组排序再归并到一起,对于这种分布式场景非常适用。

一个索引可以由一个或多个原始分片组成,所以一个对于单个索引的搜索请求也需要能够把来自多个分片的结果组合起来。一个对于 多(multiple)或全部(all)索引的搜索的工作机制和这完全一致——仅仅是多了一些分片而已。

取回阶段

查询阶段辨别出那些满足搜索请求的document,但我们仍然需要取回那些document本身。这就是取回阶段的工作。

分发阶段由以下步骤构成:

  1. 协调节点辨别出哪个document需要取回,并且向相关分片发出GET请求。
  2. 每个分片加载document并且根据需要丰富(enrich)它们,然后再将document返回协调节点。
  3. 一旦所有的document都被取回,协调节点会将结果返回给客户端。

协调节点先决定哪些document是实际(actually)需要取回的。例如,我们指定查询{ "from": 90, "size": 10 },那么前90条将会被丢弃,只有之后的10条会需要取回。这些document可能来自与原始查询请求相关的某个、某些或者全部分片。

协调节点为每个持有相关document的分片建立多点get请求然后发送请求到处理查询阶段的分片副本。

分片加载document主体——_source field。如果需要,还会根据元数据丰富结果和高亮搜索片断。一旦协调节点收到所有结果,会将它们汇集到单一的回答响应里,这个响应将会返回给客户端。

搜索选项

preference(偏爱)

preference参数允许你控制使用哪个分片或节点来处理搜索请求。接受如下一些参数_primary_primary_first_local_only_node:xyz_prefer_node:xyz_shards:2,3

可以参考

按照timestamp字段来对你的结果排序,并且有两个document有相同的timestamp。由于搜索请求是在所有有效的分片副本间轮询的,这两个document可能在原始分片里是一种顺序,在副本分片里是另一种顺序,这种情况被称为Bouncing Results(结果震荡)

避免这个问题方法是对于同一个用户总是使用同一个分片。方法就是使用一个随机字符串例如用户的会话ID(session ID)来设置preference参数

timeout(超时)

协调节点会等待接收所有分片的回答。如果有一个节点遇到问题,它会拖慢整个搜索请求。

timeout参数告诉协调节点最多等待多久,就可以放弃等待而将已有结果返回。返回部分结果总比什么都没有好。

routing(路由选择)

建立索引时提供一个自定义的routing参数来保证所有相关的document(如属于单个用户的document)被存放在一个单独的分片中。

在搜索时,你可以指定一个或多个routing 值来限制只搜索那些分片而不是搜索index里的全部分片

GET /_search?routing=user_1,user2

搜索请求的返回将会指出这个搜索是否超时,以及有多少分片成功答复了:

    "timed_out":     true,  (1)
    "_shards": {
       "total":      5,
       "successful": 4,
       "failed":     1     (2)
    },

search_type

默认的搜索类型为query_then_fetch,可以进行指定

GET /_search?search_type=count
  • count(计数)搜索类型只有一个query(查询)的阶段。当不需要搜索结果只需要知道满足查询的document的数量时,可以使用这个查询类型。
  • query_and_fetch(查询并且取回)搜索类型将查询和取回阶段合并成一个步骤。这是一个内部优化选项,当搜索请求的目标只是一个分片时可以使用,例如指定了routing(路由选择)值时。虽然你可以手动选择使用这个搜索类型,但是这么做基本上不会有什么效果。
  • dfs_query_then_fetchdfs_query_and_fetch dfs搜索类型有一个预查询的阶段,它会从全部相关的分片里取回项目频数来计算全局的项目频数。
  • scan(扫描)搜索类型是和scroll(滚屏)API连在一起使用的,可以高效地取回巨大数量的结果。它是通过禁用排序来实现的。我们将在下一节scan-and-scroll(扫描和滚屏)里讨论它。

扫描和滚屏

scan(扫描)搜索类型是和scroll(滚屏)API一起使用来从Elasticsearch里高效地取回巨大数量的结果而不需要付出深分页的代价。

scroll(滚屏)

一个滚屏搜索允许我们做一个初始阶段搜索并且持续批量从Elasticsearch里拉取结果直到没有结果,采用的类似数据库中的游标的形式。

滚屏搜索会及时制作快照。这个快照不会包含任何在初始阶段搜索请求后对index做的修改。它通过将旧的数据文件保存在手边,所以可以保护index的样子看起来像搜索开始时的样子。

scan(扫描)

深度分页代价最高的部分是对结果的全局排序,但如果禁用排序,就能以很低的代价获得全部返回结果。

可以采用scan(扫描)搜索模式。扫描模式让Elasticsearch不排序,只要分片里还有结果可以返回,就返回一批结果

search_type设置成scan,并且传递一个scroll参数来告诉Elasticsearch滚屏应该持续多长时间,设置size大小,就是每次回传几笔数据,当回传到没有数据时,仍会返回200成功,只是hits裡的hits会是空list

初始化

GET /old_index/_search?search_type=scan&scroll=1m
{
    "query": { "match_all": {}},
    "size":  1000
}

这个请求的应答没有包含任何命中的结果,但是包含了一个Base-64编码的_scroll_id(滚屏id)字符串。可以将_scroll_id传递给_search/scroll末端来获取第一批结果

{
    "_scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAfv5-FjNOamF0Mk1aUUhpUnU5ZWNMaHJocWcAAAAAAH7-gBYzTmphdDJNWlFIaVJ1OWVjTGhyaHFnAAAAAAB-_n8WM05qYXQyTVpRSGlSdTllY0xocmhxZwAAAAAAdsJxFmVkZTBJalJWUmp5UmI3V0FYc2lQbVEAAAAAAHbCcBZlZGUwSWpSVlJqeVJiN1dBWHNpUG1R",
    "took": 2,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": 84,
        "max_score": 1,
        "hits": [
            {
                "_index": "video1522821719",
                "_type": "doc",
                "_id": "84056",
                "_score": 1,
                "_source": {
                    "title": "三个院子",
                    "createTime": 1522239744000
                }
            }
            ....99 data
        ]
    }
}

再次请求

POST 127.0.0.1:9200/_search/scroll?scroll=1m
{
    "scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAdsMqFmVkZTBJalJWUmp5UmI3V0FYc2lQbVEAAAAAAHbDKRZlZGUwSWpSVlJqeVJiN1dBWHNpUG1RAAAAAABpX2sWclBEekhiRVpSRktHWXFudnVaQ3dIQQAAAAAAaV9qFnJQRHpIYkVaUkZLR1lxbnZ1WkN3SEEAAAAAAGlfaRZyUER6SGJFWlJGS0dZcW52dVpDd0hB"
}

如果没有数据了,就会回传空的hits

{
    "_scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAdsMqFmVkZTBJalJWUmp5UmI3V0FYc2lQbVEAAAAAAHbDKRZlZGUwSWpSVlJqeVJiN1dBWHNpUG1RAAAAAABpX2sWclBEekhiRVpSRktHWXFudnVaQ3dIQQAAAAAAaV9qFnJQRHpIYkVaUkZLR1lxbnZ1WkN3SEEAAAAAAGlfaRZyUER6SGJFWlJGS0dZcW52dVpDd0hB",
    "took": 2,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": 84,
        "max_score": null,
        "hits": []
    }
}

清除scroll

虽然我们在设置开启scroll时,设置了一个scroll的存活时间,但是如果能够在使用完顺手关闭,可以提早释放资源,降低ES的负担

DELETE 127.0.0.1:9200/_search/scroll
{
    "scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAdsMqFmVkZTBJalJWUmp5UmI3V0FYc2lQbVEAAAAAAHbDKRZlZGUwSWpSVlJqeVJiN1dBWHNpUG1RAAAAAABpX2sWclBEekhiRVpSRktHWXFudnVaQ3dIQQAAAAAAaV9qFnJQRHpIYkVaUkZLR1lxbnZ1WkN3SEEAAAAAAGlfaRZyUER6SGJFWlJGS0dZcW52dVpDd0hB"
}

索引管理

创建和删除索引

创建索引

手动创建索引,在请求中加入所有设置和类型映射

PUT /my_index
{
    "settings": { ... any settings ... },
    "mappings": {
        "type_one": { ... any mappings ... },
        "type_two": { ... any mappings ... },
        ...
    }

config/elasticsearch.yml中添加下面的配置来防止自动创建索引,就是通过POST一个不存在索引的数据造成的

action.auto_create_index: false

删除索引

使用以下的请求来删除索引:

DELETE /my_index

你也可以用下面的方式删除多个索引

DELETE /index_one,index_two
DELETE /index_*

你甚至可以删除所有索引

DELETE /_all

索引设置

更多可以参考

  • number_of_shards

定义一个索引的主分片个数,默认值是 5。这个配置在索引创建后不能修改。

  • number_of_replicas

每个主分片的复制分片个数,默认是 1。这个配置可以随时在活跃的索引上修改。

创建只有一个主分片,没有复制分片的小索引。

PUT /my_temp_index
{
    "settings": {
        "number_of_shards" :   1,
        "number_of_replicas" : 0
    }
}

动态修改复制分片个数:

PUT /my_temp_index/_settings
{
    "number_of_replicas": 1
}

配置分析器

配置已存在的分析器或创建自定义分析器来定制化索引。

standard分析器是用于全文字段的默认分析器,对于大部分西方语系来说是一个不错的选择。它考虑了以下几点:

  • standard 分词器,在词层级上分割输入的文本。
  • standard 标记过滤器,被设计用来整理分词器触发的所有标记(但是目前什么都没做)。
  • lowercase 标记过滤器,将所有标记转换为小写。
  • stop 标记过滤器,删除所有可能会造成搜索歧义的停用词,如 a,the,and,is

创建了一个新的分析器,叫做es_std,并使用预定义的西班牙语停用词

PUT /spanish_docs
{
    "settings": {
        "analysis": {
            "analyzer": {
                "es_std": {
                    "type":      "standard",
                    "stopwords": "_spanish_"
                }
            }
        }
    }
}

es_std分析器不是全局的,它仅仅存在于我们定义的spanish_docs索引中

GET /spanish_docs/_analyze?analyzer=es_std

自定义分析器

我可能并不关心这个,链接

类型和映射

类型在Elasticsearch中表示一组相似的文档。类型由一个名称和一个类似数据库表结构的映射组成,描述了文档中可能包含的每个字段的属性,数据类型(比如 string, integer 或 date),和是否这些字段需要被Lucene索引或储存。

Lucene如何处理文档

Lucene中,一个文档由一组简单的键值对组成,一个字段至少需要有一个值,但是任何字段都可以有多个值。类似的,一个单独的字符串可能在分析过程中被转换成多个值。Lucene 不关心这些值是字符串,数字或日期,所有的值都被当成不透明字节

在Lucene中索引一个文档时,每个字段的值都被加到相关字段的倒排索引中。你也可以选择将原始数据储存来以备今后取回。

类型是怎么实现的

一个索引可能包含多个类型,每个类型有各自的映射和文档,保存在同一个索引中

  • Lucene没有文档类型的概念,每个文档的类型名被储存在一个叫_type的元数据字段上,当搜索type的时候,Es通过_type字段过滤出这些文档。
  • Lucene没有映射的概念,是Es将Json文档映射成Lucene需要的扁平化字段
  • 在加入倒排索引前,需要通过分析器来分析

预防类型陷阱

不同类型的文档可以被加到同一个索引里带来了一些预想不到的困难。

索引中有两种类型,blog_en表示英语版的博客,blog_es表示西班牙语版的博客。两种类型都有title字段,但是其中一种类型使用english分析器,另一种使用spanish分析器

查询就会遇到问题

GET /_search
{
    "query": {
        "match": {
            "title": "The quick brown fox"
        }
    }
}

可以通过给字段取不同的名字来避免这种错误 —— 比如,用title_entitle_es。或者在查询中明确包含各自的类型名。

GET /_search
{
    "query": {
        "multi_match": { <1>
            "query":    "The quick brown fox",
            "fields": [ "blog_en.title", "blog_es.title" ]
        }
    }
}

对于不同类型的字段{ "login": "john_smith" }{ "login": "2014-06-01" },在排序的时候Elasticsearch需要将login字段的值加载到内存中,加载会失败,取决于它遇到的第一个login字段

根对象

映射的最高一层被称为根对象,可能包含以下几项

  • 一个properties节点,列出了文档中可能包含的每个字段的映射
  • 多个元数据字段,每一个都以下划线开头,例如_type, _id_source
  • 设置项,控制如何动态处理新的字段,例如analyzer, dynamic_date_formatsdynamic_templates
  • 其他设置,可以同时应用在根对象和其他object类型的字段上,例如enabled, dynamicinclude_in_all

what,这是啥子?

属性

字段的属性的三个最重要的设置

  • type: 字段的数据类型,例如stringdate
  • index: 字段是否应当被当成全文来搜索(analyzed),或被当成一个准确的值(not_analyzed),还是完全不可被搜索(no)
  • analyzer: 确定在索引和或搜索时全文字段使用的分析器

元数据:_source字段

默认情况下,Elasticsearch用JSON字符串来表示文档主体保存在_source字段中。像其他保存的字段一样,_source字段也会在写入硬盘前压缩

优点是

  • 搜索结果中能得到完整的文档,不需要额外去别的数据源中查询文档
  • 如果缺少_source字段,部分更新请求不会起作用
  • 当你的映射有变化,而且你需要重新索引数据时,你可以直接在Elasticsearch中操作而不需要重新从别的数据源中取回数据
  • 你可以从_source中通过getsearch请求取回部分字段,而不是整个文档
  • 你可以准确的看到每个文档中包含的内容,而不是只能从一堆 ID 中猜测他们的内容

存储_source字段还是要占用硬盘空间的,如果不需要以上优点,可以用下面的映射禁用_source字段

PUT /my_index
{
    "mappings": {
        "my_type": {
            "_source": {
                "enabled":  false
            }
        }
    }
}

在搜索请求中你可以通过限定_source字段来请求指定字段

GET /_search
{
    "query":   { "match_all": {}},
    "_source": [ "title", "created" ]
}

这些字段会从_source中提取出来,而不是返回整个_source字段

元数据:_all字段

_all字段:一个所有其他字段值的特殊字符串字段。query_string在没有指定字段时默认用_all字段查询

_all是一种简单粗暴的搜索方式,更精确的还是要靠精确到字段的

相关性算法考虑的一个最重要的原则是字段的长度:字段越短,就越重要。在较短的title字段中的短语会比较长的content字段中的短语显得更重要。而字段间的这种差异在_all字段中就不会出现

禁用_all字段

PUT /my_index/_mapping/my_type
{
    "my_type": {
        "_all": { "enabled": false }
    }
}

通过include_in_all选项可以控制字段是否要被包含在_all字段中,默认值是true。在一个对象上设置include_in_all可以修改这个对象所有字段的默认行为。

保留_all字段来查询所有特定的全文字段,例如title, overview, summary和tags。相对于完全禁用_all字段,你可以先默认禁用include_in_all 选项,而选定字段上启用include_in_all

PUT /my_index/my_type/_mapping
{
    "my_type": {
        "include_in_all": false,
        "properties": {
            "title": {
                "type":           "string",
                "include_in_all": true
            },
            ...
        }
    }
}

_all字段仅仅是一个经过分析的string字段。它使用默认的分析器来分析它的值,而不管这值本来所在的字段指定的分析器。而且像所有string类型字段一样,你可以配置_all字段使用的分析器:

PUT /my_index/my_type/_mapping
{
    "my_type": {
        "_all": { "analyzer": "whitespace" }
    }
}

元数据:_id字段

文档唯一标识由四个元数据字段组成:

  • _id:文档的字符串 ID
  • _type:文档的类型名
  • _index:文档所在的索引
  • _uid_type_id 连接成的 type#id

默认情况下,_uid是被保存(可取回)和索引(可搜索)的。_type字段被索引但是没有保存,_id_index字段则既没有索引也没有储存,它们并不是真实存在的。

可以像真实字段一样查询_id字段。Elasticsearch使用_uid字段来追溯_id。虽然你可以修改这些字段的indexstore设置,但是基本上不需要这么做。

_id字段有一个你可能用得到的设置path,设置告诉Elasticsearch它需要从文档本身的哪个字段中生成_id

PUT /my_index
{
    "mappings": {
        "my_type": {
            "_id": {
                "path": "doc_id" 
            },
            "properties": {
                "doc_id": {
                    "type":   "string",
                    "index":  "not_analyzed"
                }
            }
        }
    }
}

doc_id字段生成_id

POST /my_index/my_type
{
    "doc_id": "123"
}

_id值由文档主体的doc_id字段生成。

{
    "_index":   "my_index",
    "_type":    "my_type",
    "_id":      "123", <1>
    "_version": 1,
    "created":  true
}

虽然这样很方便,但是注意它对bulk请求有细微的性能影响,处理请求的节点将不能仅靠解析元数据行来决定将请求分配给哪一个分片,而需要解析整个文档主体

动态索引

当Elasticsearch处理一个位置的字段时,它通过动态映射来确定字段的数据类型且自动将该字段加到类型映射中。甚至是否自动添加索引

可以通过dynamic设置来设置

  • true:自动添加字段(默认)
  • false:忽略字段
  • strict:当遇到未知字段时抛出异常

dynamic设置可以用在根对象或任何object对象

PUT /my_index
{
    "mappings": {
        "my_type": {
            "dynamic":      "strict", <1>
            "properties": {
                "title":  { "type": "string"},
                "stash":  {
                    "type":     "object",
                    "dynamic":  true <2>
                }
            }
        }
    }
}

dynamic设置成false完全不会修改_source字段的内容。_source将仍旧保持你索引时的完整JSON文档。然而,没有被添加到映射的未知字段将不可被搜索

自定义动态索引

日期监测

Elasticsearch遇到一个新的字符串字段时,它会检测这个字段是否包含一个可识别的日期,比如2014-01-01。如果它看起来像一个日期,这个字段会被作为date类型添加,否则,它会被作为string类型添加。

当索引第一次见到note字段,文档为{ "note": "2014-01-01" },会将其标记为date字段,但是之后再有类似{ "note": "Logged out" }的文档,再以日期存储就会引发异常

日期检测可以通过在根对象上设置date_detectionfalse来关闭:

PUT /my_index
{
    "mappings": {
        "my_type": {
            "date_detection": false
        }
    }
}

动态模板

dynamic_templates,你可以完全控制新字段的映射,你设置可以通过字段名或数据类型应用一个完全不同的映射。

每个模板都有一个名字用于描述这个模板的用途,一个mapping字段用于指明这个映射怎么使用,和至少一个参数(例如 match)来定义这个模板适用于哪个字段。

模板按照顺序来检测,第一个匹配的模板会被启用。例如,我们给string类型字段定义两个模板:

  • es: 字段名以_es结尾需要使用spanish分析器。
  • en: 所有其他字段使用english分析器。

我们将es模板放在第一位,因为它比匹配所有字符串的en模板更特殊一点

PUT /my_index
{
    "mappings": {
        "my_type": {
            "dynamic_templates": [
                { "es": {
                      "match":              "*_es", 
                      "match_mapping_type": "string",
                      "mapping": {
                          "type":           "string",
                          "analyzer":       "spanish"
                      }
                }},
                { "en": {
                      "match":              "*", 
                      "match_mapping_type": "string",
                      "mapping": {
                          "type":           "string",
                          "analyzer":       "english"
                      }
                }}
            ]
}}}

match_mapping_type允许你限制模板只能使用在特定的类型上,就像由标准动态映射规则检测的一样,(例如strong和long)

match参数只匹配字段名,path_match参数则匹配字段在一个对象中的完整路径,所以address.*.name规则将匹配一个这样的字段:

{
    "address": {
        "city": {
            "name": "New York"
        }
    }
}

unmatch 和 path_unmatch 规则将用于排除未被匹配的字段。

默认映射

通常,一个索引中的所有类型具有共享的字段和设置。用_default_映射来指定公用设置会更加方便,而不是每次创建新的类型时重复操作。_default_映射像新类型的模板。所有在_default_映射 之后 的类型将包含所有的默认设置,除非在自己的类型映射中明确覆盖这些配置。

例如,我们可以使用_default_映射对所有类型禁用 _all 字段,而只在 blog 字段上开启它:

PUT /my_index
{
    "mappings": {
        "_default_": {
            "_all": { "enabled":  false }
        },
        "blog": {
            "_all": { "enabled":  true  }
        }
    }
}

_default_映射也是定义索引级别的动态模板的好地方。

重新索引数据

虽然你可以给索引添加新的类型,或给类型添加新的字段,但是你不能添加新的分析器或修改已有字段。假如你这样做,已被索引的数据会变得不正确而你的搜索也不会正常工作。

修改在已存在的数据最简单的方法是重新索引:创建一个新配置好的索引,然后将所有的文档从旧的索引复制到新的上。

_source字段的一个最大的好处是你已经在 Elasticsearch 中有了完整的文档,你不再需要从数据库中重建你的索引,这样通常会比较慢。

为了更高效的索引旧索引中的文档,使用scan-scoll来批量读取旧索引的文档,然后将通过bulk API来将它们推送给新的索引。

批量重新索引:

你可以在同一时间执行多个重新索引的任务,但是你显然不愿意它们的结果有重叠。所以,可以将重建大索引的任务通过日期或时间戳字段拆分成较小的任务:

GET /old_index/_search?search_type=scan&scroll=1m
{
    "query": {
        "range": {
            "date": {
                "gte":  "2014-01-01",
                "lt":   "2014-02-01"
            }
        }
    },
    "size":  1000
}

假如你继续在旧索引上做修改,你可能想确保新增的文档被加到了新的索引中。这可以通过重新运行重建索引程序来完成,但是记得只要过滤出上次执行后新增的文档就行了。

索引别名

前面提到的重新索引过程中的问题是必须更新你的应用,来使用另一个索引名。索引别名正是用来解决这个问题的!

索引别名就像一个快捷方式或软连接,可以指向一个或多个索引,也可以给任何需要索引名的API使用。别名带给我们极大的灵活性,允许我们做到:

  • 在一个运行的集群上无缝的从一个索引切换到另一个
  • 给多个索引分类(例如,last_three_months
  • 给索引的一个子集创建视图
  • 我们以后会讨论更多别名的使用场景。

现在我们将介绍用它们怎么在零停机时间内从旧的索引切换到新的索引。

这里有两种管理别名的途径:_alias用于单个操作,_aliases用于原子化多个操作。

在这一章中,我们假设你的应用采用一个叫my_index的索引。而事实上,my_index 是一个指向当前真实索引的别名。真实的索引名将包含一个版本号:my_index_v1, my_index_v2 等等。

开始,我们创建一个索引my_index_v1,然后将别名my_index指向它:

PUT /my_index_v1
PUT /my_index_v1/_alias/my_index <2>
  1. 创建索引my_index_v1
  2. 将别名my_index指向my_index_v1

你可以检测这个别名指向哪个索引:

GET /*/_alias/my_index

或哪些别名指向这个索引:

GET /my_index_v1/_alias/*

两者都将返回下列值:

{
    "my_index_v1" : {
        "aliases" : {
            "my_index" : { }
        }
    }
}

然后,我们决定修改索引中一个字段的映射。当然我们不能修改现存的映射,索引我们需要重新索引数据。首先,我们创建有新的映射的索引 my_index_v2。

PUT /my_index_v2
{
    "mappings": {
        "my_type": {
            "properties": {
                "tags": {
                    "type":   "string",
                    "index":  "not_analyzed"
                }
            }
        }
    }
}

然后我们从将数据从my_index_v1迁移到my_index_v2,下面的过程在【重新索引】中描述过了。一旦我们认为数据已经被正确的索引了,我们就将别名指向新的索引。

别名可以指向多个索引,所以我们需要在新索引中添加别名的同时从旧索引中删除它。这个操作需要原子化,所以我们需要用 _aliases 操作:

POST /_aliases
{
    "actions": [
        { "remove": { "index": "my_index_v1", "alias": "my_index" }},
        { "add":    { "index": "my_index_v2", "alias": "my_index" }}
    ]
}

这样,你的应用就从旧索引迁移到了新的,而没有停机时间。

提示:

即使你认为现在的索引设计已经是完美的了,当你的应用在生产环境使用时,还是有可能在今后有一些改变的。

所以请做好准备:在应用中使用别名而不是索引。然后你就可以在任何时候重建索引。别名的开销很小,应当广泛使用。

深入分片

使文本可以被搜索

第一个不得不解决的挑战是如何让文本变得可搜索。在传统的数据库中,一个字段存一个值,但是这对于全文搜索是不足的。想要让文本中的每个单词都可以被搜索,这意味这数据库需要存多个值。

支持一个字段多个值的最佳数据结构是倒排索引。倒排索引包含了出现在所有文档中唯一的值或词的有序列表,以及每个词所属的文档列表。

Term Doc 1 Doc 2 Doc 3 ...
brown X X ...
fox X X X ...
quick X X ...
the X X ...

当讨论倒排索引时,我们说的是把文档加入索引。因为之前,一个倒排索引是用来索引整个非结构化的文本文档。ES中的文档是一个结构化的JSON文档。实际上,每一个JSON文档中被索引的字段都有它自己的倒排索引。

倒排索引存储了比包含了一个特定term的文档列表多地多的信息。它可能存储包含每个term的文档数量,一个term出现在指定文档中的频次,每个文档中term的顺序,每个文档的长度,所有文档的平均长度,等等。这些统计信息让Elasticsearch知道哪些term更重要,哪些文档更重要,也就是相关性。

需要意识到,为了实现倒排索引预期的功能,它必须要知道集合中所有的文档。

在全文检索的早些时候,会为整个文档集合建立一个大索引,并且写入磁盘。只有新的索引准备好了,它就会替代旧的索引,最近的修改才可以被检索。

不可变性

写入磁盘的倒排索引是不可变的,它有如下好处:

不需要锁。如果从来不需要更新一个索引,就不必担心多个程序同时尝试修改。

一旦索引被读入文件系统的缓存(即内存),它就一直在那儿,因为不会改变。只要文件系统缓存有足够的空间,大部分的读会直接访问内存而不是磁盘。这有助于性能提升。 在索引的声明周期内,所有的其他缓存都可用。它们不需要在每次数据变化了都重建,因为数据不会变。

写入单个大的倒排索引,可以压缩数据,较少磁盘IO和需要缓存索引的内存大小。

当然,不可变的索引有它的缺点,首先是它不可变!你不能改变它。如果想要搜索一个新文档,必须重见整个索引。这不仅严重限制了一个索引所能装下的数据,还有一个索引可以被更新的频次。

动态索引

如何在保持不可变好处的同时更新倒排索引。

答案是,使用多个索引。

不是重写整个倒排索引,而是增加额外的索引反映最近的变化。每个倒排索引都可以按顺序查询,从最老的开始,最后把结果聚合。

Elasticsearch底层依赖的Lucene,引入了per-segment search的概念。一个段(segment)是有完整功能的倒排索引,但是现在Lucene中的索引指的是段的集合,再加上提交点(commit point,包括所有段的文件)

Lucene索引是Elasticsearch中的分片,Elasticsearch中的索引是分片的集合。当Elasticsearch搜索索引时,它发送查询请求给该索引下的所有分片,然后过滤这些结果,聚合成全局的结果。新的文档,在被写入磁盘的段之前,首先写入内存区的索引缓存。

per-segment search的工作顺序

  1. 新的文档首先写入内存区的索引缓存
  2. buffer被提交,产生一个新的段,即一个额外的倒排索引,最后写入到磁盘中
  3. 新段提供使用,其内部包含的文档可以被搜索
  4. 内存中的缓存清除,等待新的文档

当一个请求被接受,所有段依次查询。所有段上的Term统计信息被聚合,确保每个term和文档的相关性被正确计算。通过这种方式,新的文档以较小的代价加入索引。

段的删除和更新

段是不可变的,所以文档既不能从旧的段中移除,旧的段也不能更新以反映文档最新的版本。相反,每一个提交点包括一个.del文件,包含了段上已经被删除的文档。

当一个文档被删除,它实际上只是在.del文件中被标记为删除,依然可以匹配查询,但是最终返回之前会被从结果中删除。

文档的更新操作是类似的:当一个文档被更新,旧版本的文档被标记为删除,新版本的文档在新的段中索引。也许该文档的不同版本都会匹配一个查询,但是更老版本会从结果中删除。

在合并段这节,我们会展示删除的文件是如何从文件系统中清除的。

近实时搜索

因为per-segment search机制,索引和搜索一个文档之间是有延迟的。新的文档会在几分钟内可以搜索,但是这依然不够快。

磁盘是瓶颈。提交一个新的段到磁盘需要fsync操作,确保段被物理地写入磁盘,即时电源失效也不会丢失数据。但是fsync是昂贵的,它不能在每个文档被索引的时就触发。

所以需要一种更轻量级的方式使新的文档可以被搜索,这意味这移除fsync。

位于Elasticsearch和磁盘间的是文件系统缓存。如前所说,在内存索引缓存中的文档被写入新的段,但是新的段首先写入文件系统缓存,这代价很低,之后会被同步到磁盘,这个代价很大。但是一旦一个文件被缓存,它也可以被打开和读取,就像其他文件一样。

Lucene允许新段写入打开,好让它们包括的文档可搜索,而不用执行一次全量提交。这是比提交更轻量的过程,可以经常操作,而不会影响性能。

在Elesticsearch中,这种写入打开一个新段的轻量级过程,叫做refresh。默认情况下,每个分片每秒自动刷新一次。这就是为什么说Elasticsearch是近实时的搜索了:文档的改动不会立即被搜索,但是会在一秒内可见。

手动刷新

POST /_refresh
POST /blogs/_refresh

虽然刷新比提交更轻量,但是它依然有消耗。人工刷新在测试写的时有用,但是不要在生产环境中每写一次就执行刷新,这会影响性能。相反,你的应用需要意识到ES近实时搜索的本质,并且容忍它。

不是所有的用户都需要每秒刷新一次。也许你使用ES索引百万日志文件,你更想要优化索引的速度,而不是进实时搜索。你可以通过修改配置项refresh_interval减少刷新的频率

PUT /my_logs
{
  "settings": {
    "refresh_interval": "30s"
  }
}

refresh_interval可以在存在的索引上动态更新。你在创建大索引的时候可以关闭自动刷新,在要使用索引的时候再打开它。

PUT /my_logs/_settings
{ "refresh_interval": -1 } 
PUT /my_logs/_settings
{ "refresh_interval": "1s" }

持久化变更

没用fsync同步文件系统缓存到磁盘,我们不能确保电源失效,甚至正常退出应用后,数据的安全。为了ES的可靠性,需要确保变更持久化到磁盘。

我们说过一次全提交同步段到磁盘,写提交点,这会列出所有的已知的段。在重启,或重新打开索引时,ES使用这次提交点决定哪些段属于当前的分片。

当我们通过每秒的刷新获得近实时的搜索,我们依然需要定时地执行全提交确保能从失败中恢复。但是提交之间的文档怎么办?我们也不想丢失它们。

ES增加了事务日志(translog),来记录每次操作。有了事务日志,过程现在如下

  1. 当一个文档被索引,它被加入到内存缓存,同时加到事务日志
  2. refresh使得缓存数据写入段,然后段被打开,使得新的文档可以搜索,缓存被清除。每秒分片都进行refeash,但是事务日志没有被清除,也没有进行fsync
  3. 随着更多的文档加入到缓存区,写入日志,这个过程会继续
  4. 日志很大了,新的日志会创建,会进行一次全提交,内存缓存区的所有文档会写入到新段中,清除缓存,一个提交点写入硬盘,文件系统缓存通过fsync操作flush到硬盘,事务日志被清除

事务日志记录了没有flush到硬盘的所有操作。当故障重启后,ES会用最近一次提交点从硬盘恢复所有已知的段,并且从日志里恢复所有的操作。

事务日志还用来提供实时的CRUD操作。当你尝试用ID进行CRUD时,它在检索相关段内的文档前会首先检查日志最新的改动。这意味着ES可以实时地获取文档的最新版本。

在ES中,进行一次提交并删除事务日志的操作叫做 flush。分片每30分钟,或事务日志过大会进行一次flush操作。

flush API可用来进行一次手动flush:

POST /blogs/_flush 
POST /_flush?wait_for_ongoing 
  • flush索引blogs
  • flush所有索引,等待操作结束再返回

你很少需要手动flush,通常自动的就够了。

当你要重启或关闭一个索引,flush该索引是很有用的。当ES尝试恢复或者重新打开一个索引时,它必须重放所有事务日志中的操作,所以日志越小,恢复速度越快。

合并段

通过每秒自动刷新创建新的段,用不了多久段的数量就爆炸了。有太多的段是一个问题。每个段消费文件句柄,内存,cpu资源。更重要的是,每次搜索请求都需要依次检查每个段。段越多,查询越慢。

ES通过后台合并段解决这个问题。小段被合并成大段,再合并成更大的段。

这是旧的文档从文件系统删除的时候。旧的段不会再复制到更大的新段中。

这个过程你不必做什么。当你在索引和搜索时ES会自动处理。这个过程如图:两个提交的段和一个未提交的段合并为了一个更大的段所示:

  1. 索引过程中,refresh会创建新的段,并打开它。
  2. 合并过程会在后台选择一些小的段合并成大的段,这个过程不会中断索引和搜索。
  3. 新的段flush到了硬盘,新的提交点写入新的段,排除旧的段,新的段打开供搜索,旧的段被删除。

合并大的段会消耗很多IO和CPU,如果不检查会影响到搜素性能。默认情况下,ES会限制合并过程,这样搜索就可以有足够的资源进行。

optimize API最好描述为强制合并段API。它强制分片合并段以达到指定max_num_segments参数。这是为了减少段的数量(通常为1)达到提高搜索性能的目的。

不要在动态的索引(正在活跃更新)上使用optimize API。后台的合并处理已经做的很好了,优化命令会阻碍它的工作。

在特定的环境下,optimize API是有用的。典型的场景是记录日志,这中情况下日志是按照每天,周,月存入索引。旧的索引一般是只可读的,它们是不可能修改的。

这种情况下,把每个索引的段降至1是有效的。搜索过程就会用到更少的资源,性能更好:

POST /logstash-2014-10/_optimize?max_num_segments=1

把索引中的每个分片都合并成一个段