首页 > 技术文章 > ES基础入门

fengda 2018-12-05 20:32 原文

ElasticSearch是一个高可用的、开源的、全文检索引擎。它可以存储,搜索和实时快速的分析大量数据。

应用场景:

电商网站中的商品搜索;
结合Logstash收集日志和业务数据,注入到ElasticSearch中,再进行搜索;

安装报错:

  1. $./bin/elasticsearch
  2. Java HotSpot(TM) 64-Bit Server VM warning: INFO: os::commit_memory(0x0000000085330000, 2060255232, 0) failed; error=’Cannot allocate memory’ (errno=12)
  3. #
  4. # There is insufficient memory for the Java Runtime Environment to continue.
  5. # Native memory allocation (mmap) failed to map 2060255232 bytes for committing reserved memory.
  6. # An error report file with more information is saved as:
  7. # /data/elasticsearch-5.2.2/hs_err_pid26945.log

解决方案:调小启动内存

  1. # vi /usr/local/elasticsearch/config/jvm.options
  2. #-Xms2g
  3. #-Xmx2g
  4. -Xms256m
  5. -Xmx64m

上面设置的分配的内存的最大值为256MB和最小值64mb,您可以根据自己的机器情况设置内存大小。

在应用程序中对象很少只是一个简单的键和值的列表。通常,它们拥有更复杂的数据结构,可能包括日期、地理信息、其他对象或者数组等。

也许有一天你想把这些对象存储在数据库中。使用关系型数据库的行和列存储,这相当于是把一个表现力丰富的对象挤压到一个非常大的电子表格中:你必须将这个对象扁平化来适应表结构–通常一个字段>对应一列–而且又不得不在每次查询时重新构造对象。

Elasticsearch 是 面向文档 的,意味着它存储整个对象或 文档。Elasticsearch 不仅存储文档,而且 索引 每个文档的内容使之可以被检索。在 Elasticsearch 中,你 对文档进行索引、检索、排序和过滤–而不是对行列数据。这是一种完全不同的思考数据的方式,也是 Elasticsearch 能支持复杂全文检索的原因。

Elasticsearch: 权威指南

https://www.elastic.co/guide/cn/elasticsearch/guide/current/index.html

一、什么是 ElasticSearch

ElasticSearch是一个基于 Lucene 的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful web 接口。Elasticsearch 是用 Java 开发的,并作为 Apache 许可条款下的开放源码发布,是当前流行的企业级搜索引擎。设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。

1.1 基础概念

索引:含有相同属性的文档集合

类型:索引可以定义一个或多个类型,文档必须属于一个类型

文档:可以被索引的基础数据单位

分片:每个索引都有多个分片,每个分片都是 Lucene 索引

备份:拷贝一份分片就完成分片的备份

形象比喻:

百货大楼里有各式各样的商品,例如书籍、笔、水果等。书籍可以根据内容划分成不同种类,如科技类、教育类、悬疑推理等。悬疑推理类的小说中比较有名气的有《福尔摩斯探案集》、《白夜行》等。

百货大楼 –> ElasticSearch 数据库

书籍 –> 索引

悬疑推理 –> 类型

白夜行 –> 文档

1.2 应用场景

  • 海量数据分析引擎
  • 站内搜索引擎
  • 数据仓库

你也许已经注意到 索引 这个词在 Elasticsearch 语境中包含多重意思, 所以有必要做一点儿说明:

索引(名词):

如前所述,一个 索引 类似于传统关系数据库中的一个 数据库 ,是一个存储关系型文档的地方。 索引 (index) 的复数词为 indices 或 indexes 。

索引(动词):

索引一个文档 就是存储一个文档到一个 索引 (名词)中以便它可以被检索和查询到。这非常类似于 SQL 语句中的 INSERT 关键词,除了文档已存在时新文档会替换旧文档情况之外。

倒排索引:

关系型数据库通过增加一个 索引 比如一个 B树(B-tree)索引 到指定的列上,以便提升数据检索速度。Elasticsearch 和 Lucene 使用了一个叫做 倒排索引 的结构来达到相同的目的。

  • 默认的,一个文档中的每一个属性都是 被索引 的(有一个倒排索引)和可搜索的。一个没有倒排索引的属性是不能被搜索到的

二、安装和配置

本次测试使用一台 ip 为 192.168.2.41 的虚拟机(Centos7),建议使用 7.x 版本,笔者之前使用 6.x 启动服务时报出各种错误

2.1 依赖环境

JDK 和 NodeJS

2.2 下载

登陆 elasticSearch 官网下载文件。

2.3 安装

tar -zxvf elasticsearch-5.6.1.tar.gz -C /usr

cd elasticsearch-5.6.1

2.4 启动

启动之前,请先查看“踩坑提醒”。

1)启动服务:

bin/elasticsearch 或 bin/elasticsearch -d # -d 表示后台启动
  • 1

踩坑提醒 1:

因为 Elasticsearch 可以执行脚本文件,为了安全性,默认不允许通过 root 用户启动服务。我们需要新创建用户名和用户组启动服务

#增加 es 组
groupadd es

#增加 es 用户并附加到 es 组
useradd es -g es -p es

#给目录权限
chown -R es:es elasticsearch-5.6.1

#使用es用户
su es

踩坑提醒 2:

默认情况下,Elasticsearch 只允许本机访问,如果需要远程访问,需要修改其配置文件

vim config/elasticsearch.yml

# 去掉 network.host 前边的注释,将它的值改成0.0.0.0
network.host: 0.0.0.0

踩坑提醒 3:

启动报错:max file descriptors [4096] for elasticsearch process is too low, increase to at least [65536]

命令行键入:

ulimit -n 65536

踩坑提醒 4:

启动报错:max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]

vim /etc/sysctl.conf

vm.max_map_count=262144

保存,并执行 sysctl -p。

在启动过程中,Centos 环境下可能还会报错,具体解决方案请参照文章末尾提供的资料

2)通过浏览器访问 http://192.168.2.41:9200 ,当出现如下内容说明启动成功:

{
  "name" : "OwUwJe-",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "vanzxnpaRumdRKiYic3f5A",
  "version" : {
    "number" : "5.6.1",
    "build_hash" : "667b497",
    "build_date" : "2017-09-14T19:22:05.189Z",
    "build_snapshot" : false,
    "lucene_version" : "6.6.1"
  },
  "tagline" : "You Know, for Search"
}

2.5 安装插件

访问 http://192.168.2.41:9200 查看内容显示效果不友好,因此,我们需要安装一个名为 elasticsearch-head 的插件,让内容显示效果比较舒适。安装之前需要先安装node。

登陆 GitHub 网站,搜索 mobz/elasticsearch-head ,将其下载到本地。

wget https://github.com/mobz/elasticsearch-head/archive/master.zip

unzip master.zip

cd elasticsearch-head-master

npm install

npm run start

 

通过上述命令的操作,我们已经安装好 elasticsearch-head 插件。通过浏览器访问 http://192.168.2.41:9100,如下图:

这里写图片描述

图中我们发现 elasticsearch-head 插件和 Elasticsearch 服务并没有建立连接,所以我们还需要修改 Elasticsearch 的配置文件:

cd elasticsearch-5.6.1

vim config/elasticsearch.yml

# 在文件末尾添加 2 段配置

http.cors.enabled: true
http.cors.allow-origin: "*"

保存文件后,分别起来 2 个程序:

cd elasticsearch-5.6.1

# 后台启动 elasticSearch 服务
bin/elasticsearch -d

cd elasticsearch-head-master

npm run start

通过浏览器访问 http://192.168.2.41:9100,如下图: 

通过插件创建索引 

查看索引基本情况 
这里写图片描述

该插件能直接对 Elasticsearch 的数据进行增删改查,因此存在安全性的问题。建议生产环境下不要使用该插件!

三、简单使用

无论我们写什么样的程序,目的都是一样的:以某种方式组织数据服务我们的目的 。但是数据不仅仅由随机位和字节组成。我们建立数据元素之间的关系以便于表示实体,或者现实世界中存在的 事物 。

一个 对象 是基于特定语言的内存的数据结构。 为了通过网络发送或者存储它,我们需要将它表示成某种标准的格式。 JSON 是一种以人可读的文本表示对象的方法。 它已经变成 NoSQL 世界交换数据的事实标准。当一个对象被序列化成为 JSON,它被称为一个 JSON 文档 。

Elastcisearch 是分布式的 文档 存储。它能存储和检索复杂的数据结构–序列化成为JSON文档–以 实时 的方式。 换句话说,一旦一个文档被存储在 Elasticsearch 中,它就是可以被集群中的任意节点检索到。

在 Elasticsearch 中, 每个字段的所有数据 都是 默认被索引的 。 即每个字段都有为了快速检索设置的专用倒排索引。

文档

通常情况下,我们使用的术语 对象 和 文档 是可以互相替换的。不过,有一个区别: 一个对象仅仅是类似于 hash 、 hashmap 、字典或者关联数组的 JSON 对象,对象中也可以嵌套其他的对象。 对象可能包含了另外一些对象。在 Elasticsearch 中,术语 文档 有着特定的含义。它是指最顶层或者根对象, 这个根对象被序列化成 JSON 并存储到 Elasticsearch 中,指定了唯一 ID。

一个文档不仅仅包含它的数据 ,也包含 元数据 —— 有关 文档的信息。 三个必须的元数据元素如下:

  • _index

    文档在哪存放 。一个 索引 应该是因共同的特性被分组到一起的文档集合。 索引名必须小写,不能以下划线开头,不能包含逗号。

  • _type 
    文档表示的对象类别

  • _id 
    文档唯一标识

Elasticsearch 支持 RESTFUL 风格 API,其 API 基本格式如下:

http://<ip>:<port>/<索引>/<类型>/<文档id>

3.1 创建/删除索引

为了方便测试,我们使用 POSTMAN 工具进行接口的请求。

http://39.106.195.92:9200/megacorp/employee/1

如果我们不希望创建索引的时候覆盖之前存在的索引,怎么搞?

请记住, _index 、 _type 和 _id 的组合可以唯一标识一个文档。所以,确保创建一个新文档的最简单办法是,使用索引请求的 POST 形式让 Elasticsearch 自动生成唯一 _id :

http://39.106.195.92:9200/megacorp/employee

然而,我们已经有了自己的id的话, 然而,如果已经有自己的 _id ,那么我们必须告诉 Elasticsearch ,只有在相同的 _index 、 _type 和 _id 不存在时才接受我们的索引请求。这里有两种方式,他们做的实际是相同的事情。使用哪种,取决于哪种使用起来更方便。

第一种方法使用 op_type 查询 -字符串参数:

http://39.106.195.92:9200/megacorp/employee/1?op_type=create 

http://39.106.195.92:9200/megacorp/employee/1/_create

如果创建新文档的请求成功执行,Elasticsearch 会返回元数据和一个 201 Created 的 HTTP 响应码。

另一方面,如果具有相同的 _index 、 _type 和 _id 的文档已经存在,Elasticsearch 将会返回 409 Conflict 响应码,以及如下的错误信息:

{
    "error": {
        "root_cause": [
            {
                "type": "version_conflict_engine_exception",
                "reason": "[employee][1]: version conflict, document already exists (current version [4])",
                "index_uuid": "lo-6VdTHQWKAYVuMu3djQg",
                "shard": "3",
                "index": "megacorp"
            }
        ],
        "type": "version_conflict_engine_exception",
        "reason": "[employee][1]: version conflict, document already exists (current version [4])",
        "index_uuid": "lo-6VdTHQWKAYVuMu3djQg",
        "shard": "3",
        "index": "megacorp"
    },
    "status": 409
}

 

删除一个索引,需要使用 DELETE 请求。执行:

http://39.106.195.92:9200/megacorp/employee/3

 

返回结果:

{
    "found": true,
    "_index": "megacorp",
    "_type": "employee",
    "_id": "3",
    "_version": 2,     //+1
    "result": "deleted",
    "_shards": {
        "total": 2,
        "successful": 1,
        "failed": 0
    }
}

 

3.2 插入数据

插入指定 ID 的数据,需要使用 PUT 请求。如下图: 
这里写图片描述

megacorp:索引名称;employee索引类型;1代表数据ID,请求体是JSON数据,包含一些信息 
也可以不指定 ID ,Elasticsearch 可以帮我们自动生成 ID 。需要使用 POST 请求。如下图: 
这里写图片描述

3.3 查找数据(检索)

1、GET方式简单搜索

查询指定ID的数据,使用 GET 请求。

查询id是1的雇员信息,执行:

[GET] http://39.106.195.92:9200/megacorp/employee/1

返回结果:

{
    "_index": "megacorp",
    "_type": "employee",
    "_id": "1",
    "_version": 2,
    "found": true,    //表示文档被找到
    "_source": {
        "first_name": "Douglas",
        "last_name": "Fir",
        "age": 35,
        "about": "I like to build cabinets",
        "interests": [
            "forestry"
        ]
    }
}

 

查询文档的一部分(只查询名字)

[GET] http://39.106.195.92:9200/megacorp/employee/1?_source=first_name,last_name

查询所有信息,不指定id,使用_search,执行:

[GET] http://39.106.195.92:9200/megacorp/employee/_search

按条件查询,仍然在请求路径中使用 _search 端点,并将查询本身赋值给参数 q= ,执行:

[GET] http://39.106.195.92:9200/megacorp/employee/_search?q=last_name:Smith

查询文档是否存在,把GET方式换位HEAD方式就可以了,HEAD 请求没有返回体,只返回一个 HTTP 请求报头: 存在返回200,否在返回404。

2、POST方式搜索

GET条件查询的方式虽然方便,但有很大的局限性,ES提供了POST的方式查询语言–查询表达式,它支持构建更加复杂和健壮的查询。 POST方式的搜索url都相同,只是body不同,

执行:

[POST] http://39.106.195.92:9200/megacorp/employee/_search

请求参数:使用 JSON 构造,并使用了一个 match 查询(属于查询类型之一,这里match不能匹配多个字段。匹配多个字段要使用multi_match )

查找 last_name= “Smith”:

{
    "query" : {
        "match" : {
            "last_name" : "Smith"   //只能写一个
        }
    }
}

 

返回结果:

{
    "took": 5,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": 1,
        "max_score": 0.2876821,
        "hits": [
            {
                "_index": "megacorp",
                "_type": "employee",
                "_id": "2",
                "_score": 0.2876821,
                "_source": {
                    "first_name": "Jane",
                    "last_name": "Smith",
                    "age": 32,
                    "about": "I like to collect rock albums",
                    "interests": [
                        "music"
                    ]
                }
            }
        ]
    }
}

 

3、复杂的搜索

使用过滤器filter搜索姓氏为 Smith 的雇员 且年龄大于30,使用的是range过滤器,其中 gt 表示_大于 :

{
    "query" : {
        "bool": {
            "must": {
                "match" : {
                    "last_name" : "smith" 
                }
            },
            "filter": {
                "range" : {
                    "age" : { "gt" : 30 } 
                }
            }
        }
    }
}

 

4、全文搜索

使用match全文搜索,Elasticsearch 默认按照相关性得分排序,即每个文档跟查询的匹配程度

{
    "query" : {
        "match" : {
            "about" : "rock climbing"
        }
    }
}

 

5、短语搜索

使用match_phrase短语搜索(准确搜索)

{
    "query" : {
        "match_phrase" : {
            "about" : "rock climbing"
        }
    }
}

 

6、高亮搜索

{
    "query" : {
        "match_phrase" : {
            "about" : "rock climbing"
        }
    },
    "highlight": {
        "fields" : {
            "about" : {}
        }
    }
}

 

7、分析

Elasticsearch 有一个功能叫聚合(aggregations),允许我们基于数据生成一些精细的分析结果。聚合与 SQL 中的 GROUP BY 类似但更强大。

挖掘出雇员中最受欢迎的兴趣爱好

{
  "aggs": {
    "all_interests": {
      "terms": { "field": "interests" }
    }
  }
}

 

可以看到,两位员工对音乐感兴趣,一位对林地感兴趣,一位对运动感兴趣。这些聚合并非预先统计,而是从匹配当前查询的文档中即时生成的

{
   ...
   "hits": { ... },
   "aggregations": {
      "all_interests": {
         "buckets": [
            {
               "key":       "music",
               "doc_count": 2
            },
            {
               "key":       "forestry",
               "doc_count": 1
            },
            {
               "key":       "sports",
               "doc_count": 1
            }
         ]
      }
   }
}

 

8、mget批量查询

[POST] http://39.106.195.92:9200/_mget

{
   "docs" : [
      {
         "_index" : "megacorp",
         "_type" :  "employee",
         "_id" :    2
      },
      {
         "_index" : "website",    //没有索引的话,会报错搜索不到该索引的内容
         "_type" :  "employee",
         "_id" :    1,
         "_source": "age"
      }
   ]
}

 

3.3 修改数据

更新整个文档:在 Elasticsearch 中文档是 不可改变 的,不能修改它们。 相反,如果想要更新现有的文档,需要 重建索引 或者进行替换, 我们可以使用相同的 index API 进行实现 。

[PUT] http://39.106.195.92:9200/megacorp/employee/1

{
    "_index": "megacorp",
    "_type": "employee",
    "_id": "1",
    "_version": 3,
    "result": "updated",
    "_shards": {
        "total": 2,
        "successful": 1,
        "failed": 0
    },
    "created": false    //这里变成了false,表示是更新
}

部分更新,也可以使用 POST 请求,且 URL 需要添加 _update,它只是与现有的文档进行合并

执行:

[POST] http://39.106.195.92:9200/megacorp/employee/1/_update

请求参数(修改年龄为88,也可以增加参数):

{
    "doc": {
        "age": "88"
    }
}

 

 

返回结果:

{
    "_index": "megacorp",
    "_type": "employee",
    "_id": "1",
    "_version": 4,
    "result": "updated",
    "_shards": {
        "total": 2,
        "successful": 1,
        "failed": 0
    }
}

 

使用脚本部分更新文档

脚本可以在 update API中用来改变 _source 的字段内容, 它在更新脚本中称为 ctx._source 。 例如,我们可以使用脚本来增加博客文章中 views 的数量:

[POST] http://39.106.195.92:9200/megacorp/employee/1/_update

{
   "script" : "ctx._source.age+=1"
}

 

在数据库领域中,有两种方法通常被用来确保并发更新时变更不会丢失:

  • 悲观并发控制 
    这种方法被关系型数据库广泛使用,它假定有变更冲突可能发生,因此阻塞访问资源以防止冲突。 一个典型的例子是读取一行数据之前先将其锁住,确保只有放置锁的线程能够对这行数据进行修改。
  • 乐观并发控制 
    Elasticsearch 中使用的这种方法假定冲突是不可能发生的,并且不会阻塞正在尝试的操作。 然而,如果源数据在读写当中被修改,更新将会失败。应用程序接下来将决定该如何解决冲突。 例如,可以重试更新、使用新的数据、或者将相关情况报告给用户。

    lasticsearch 是分布式的。当文档创建、更新或删除时, 新版本的文档必须复制到集群中的其他节点。Elasticsearch 也是异步和并发的,这意味着这些复制请求被并行发送,并且到达目的地时也许 顺序是乱的 。 Elasticsearch 需要一种方法确保文档的旧版本不会覆盖新的版本。

    当我们之前讨论 index , GET 和 delete 请求时,我们指出每个文档都有一个 _version (版本)号,当文档被修改时版本号递增。 Elasticsearch 使用这个 _version 号来确保变更以正确顺序得到执行。如果旧版本的文档在新版本之后到达,它可以被简单的忽略。

    我们可以利用 _version 号来确保 应用中相互冲突的变更不会导致数据丢失。我们通过指定想要修改文档的 version 号来达到这个目的。 如果该版本不是当前版本号,我们的请求将会失败。

代价较小的批量操作

与 mget 可以使我们一次取回多个文档同样的方式, bulk API 允许在单个步骤中进行多次 create 、 index 、 update 或 delete 请求。 如果你需要索引一个数据流比如日志事件,它可以排队和索引数百或数千批次。

bulk 与其他的请求体格式稍有不同,如下所示:

{ action: { metadata }}\n
{ request body        }\n
{ action: { metadata }}\n
{ request body        }\n
...

 

这种格式类似一个有效的单行 JSON 文档 流 ,它通过换行符(\n)连接到一起。注意两个要点:

  • 每行一定要以换行符(\n)结尾, 包括最后一行 。这些换行符被用作一个标记,可以有效分隔行。
  • 这些行不能包含未转义的换行符,因为他们将会对解析造成干扰。这意味着这个 JSON 不 能使用 pretty 参数打印。

3.4 删除数据

修改数据,需要使用 DELETE 请求。

执行:

[DELETE] http://192.168.2.41:9200/fruit/apple/1

返回结果:

{
    "found": true,
    "_index": "fruit",
    "_type": "apple",
    "_id": "1",
    "_version": 8,
    "result": "deleted",
    "_shards": {
        "total": 2,
        "successful": 1,
        "failed": 0
    }
}

 

3.5 集群内原理

ElasticSearch 的主旨是随时可用和按需扩容

一个运行中的 Elasticsearch 实例称为一个 节点,而集群是由一个或者多个拥有相同 cluster.name 配置的节点组成, 它们共同承担数据和负载的压力。当有节点加入集群中或者从集群中移除节点时,集群将会重新平均分布所有的数据。

当一个节点被选举成为 主 节点时, 它将负责管理集群范围内的所有变更,例如增加、删除索引,或者增加、删除节点等。 而主节点并不需要涉及到文档级别的变更和搜索等操作,所以当集群只拥有一个主节点的情况下,即使流量的增加它也不会成为瓶颈。 任何节点都可以成为主节点。我们的示例集群就只有一个节点,所以它同时也成为了主节点。

作为用户,我们可以将请求发送到 集群中的任何节点 ,包括主节点。 每个节点都知道任意文档所处的位置,并且能够将我们的请求直接转发到存储我们所需文档的节点。 无论我们将请求发送到哪个节点,它都能负责从各个包含我们所需文档的节点收集回数据,并将最终结果返回給客户端。 Elasticsearch 对这一切的管理都是透明的。

3.5.1 集群健康

Elasticsearch 的集群监控信息中包含了许多的统计数据,其中最为重要的一项就是 集群健康 , 它在 status 字段中展示为 green 、 yellow 或者 red 。

3.5.2 添加索引

我们往 Elasticsearch 添加数据时需要用到 索引 —— 保存相关数据的地方。 索引实际上是指向一个或者多个物理 分片 的 逻辑命名空间 。

一个 分片 是一个底层的 工作单元 ,它仅保存了 全部数据中的一部分。 一个分片就是一个 Lucene 的实例,以及它本身就是一个完整的搜索引擎。 我们的文档被存储和索引到分片内,但是应用程序是直接与索引而不是与分片进行交互。

Elasticsearch 是利用分片将数据分发到集群内各处的。分片是数据的容器,文档保存在分片内,分片又被分配到集群内的各个节点里。 当你的集群规模扩大或者缩小时, Elasticsearch 会自动的在各节点中迁移分片,使得数据仍然均匀分布在集群里。

一个分片可以是 主 分片或者 副本 分片。 索引内任意一个文档都归属于一个主分片,所以主分片的数目决定着索引能够保存的最大数据量。

四、 分布式文档存储

路由一个文档到一个分片中

当索引一个文档的时候,文档会被存储到一个主分片中。 Elasticsearch 如何知道一个文档应该存放到哪个分片中呢?当我们创建文档时,它如何决定这个文档应当被存储在分片 1 还是分片 2 中呢?

首先这肯定不会是随机的,否则将来要获取文档的时候我们就不知道从何处寻找了。实际上,这个过程是根据下面这个公式决定的:

shard = hash(routing) % number_of_primary_shards

routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。 routing 通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到 余数 。这个分布在 0 到 number_of_primary_shards-1 之间的余数,就是我们所寻求的文档所在分片的位置。

这就解释了为什么我们要在创建索引的时候就确定好主分片的数量 并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。

所有的文档 API( get 、 index 、 delete 、 bulk 、 update 以及 mget )都接受一个叫做 routing 的路由参数 ,通过这个参数我们可以自定义文档到分片的映射。一个自定义的路由参数可以用来确保所有相关的文档——例如所有属于同一个用户的文档——都被存储到同一个分片中。

五、 搜索

搜索(search) 可以做到:

  • 在类似于 gender 或者 age 这样的字段 上使用结构化查询,join_date 这样的字段上使用排序,就像SQL的结构化查询一样。
  • 全文检索,找出所有匹配关键字的文档并按照相关性(relevance) 排序后返回结果。
  • 以上二者兼而有之。

很多搜索都是开箱即用的,为了充分挖掘 Elasticsearch 的潜力,你需要理解以下三个概念:

  • 映射(Mapping)

    描述数据在每个字段内如何存储

  • 分析(Analysis)

    全文是如何处理使之可以被搜索的

  • 领域特定查询语言(Query DSL)

    Elasticsearch 中强大灵活的查询语言

空搜索

GET /_search      //简单地返回集群中所有索引下的所有文档

然而,经常的情况下,你 想在一个或多个特殊的索引并且在一个或者多个特殊的类型中进行搜索。我们可以通过在URL中指定特殊的索引和类型达到这种效果,如下所示:

  • /_search 
    在所有的索引中搜索所有的类型

  • /gb/_search 
    在 gb 索引中搜索所有的类型

  • /gb,us/_search 
    在 gb 和 us 索引中搜索所有的文档

  • /g*,u*/_search 
    在任何以 g 或者 u 开头的索引中搜索所有的类型

  • /gb/user/_search 
    在 gb 索引中搜索 user 类型

  • /gb,us/user,tweet/_search 
    在 gb 和 us 索引中搜索 user 和 tweet 类型

  • /_all/user,tweet/_search 
    在所有的索引中搜索 user 和 tweet 类型

当在单一的索引下进行搜索的时候,Elasticsearch 转发请求到索引的每个分片中,可以是主分片也可以是副本分片,然后从每个分片中收集结果。多索引搜索恰好也是用相同的方式工作的–只是会涉及到更多的分片。

分页

在之前的 空搜索 中说明了集群中有 14 个文档匹配了(empty)query 。 但是在 hits 数组中只有 10 个文档。如何才能看到其他的文档?

和 SQL 使用 LIMIT 关键字返回单个 page 结果的方法相同,Elasticsearch 接受 from 和 size 参数:

  • size 
    显示应该返回的结果数量,默认是 10
  • from 
    显示应该跳过的初始结果数量,默认是 0

如果每页展示 5 条结果,可以用下面方式请求得到 1 到 3 页的结果:

GET /_search?size=5
GET /_search?size=5&from=5
GET /_search?size=5&from=10

考虑到分页过深以及一次请求太多结果的情况,结果集在返回之前先进行排序。 但请记住一个请求经常跨越多个分片,每个分片都产生自己的排序结果,这些结果需要进行集中排序以保证整体顺序是正确的。

轻量 搜索

有两种形式的 搜索 API:一种是 “轻量的” 查询字符串 版本,要求在查询字符串中传递所有的 参数,另一种是更完整的 请求体 版本,要求使用 JSON 格式和更丰富的查询表达式作为搜索语言。

查询字符串搜索非常适用于通过命令行做即席查询。例如,查询在 tweet 类型中 tweet 字段包含 elasticsearch 单词的所有文档:

GET /_all/tweet/_search?q=tweet:elasticsearch

 

下一个查询在 name 字段中包含 john 并且在 tweet 字段中包含 mary 的文档。实际的查询就是这样

+name:john +tweet:mary

 

但是查询字符串参数所需要的 百分比编码 (译者注:URL编码)实际上更加难懂:

GET /_search?q=%2Bname%3Ajohn+%2Btweet%3Amary

 

  • 前缀表示必须与查询条件匹配。类似地, - 前缀表示一定不与查询条件匹配。没有 + 或者 - 的所有其他条件都是可选的——匹配的越多,文档就越相关。

    _all字段

这个简单搜索返回包含 mary 的所有文档:

GET /_search?q=mary

 

之前的例子中,我们在 tweet 和 name 字段中搜索内容。然而,这个查询的结果在三个地方提到了 mary :

  • 有一个用户叫做 Mary
  • 6条微博发自 Mary
  • 一条微博直接 @mary

Elasticsearch 是如何在三个不同的字段中查找到结果的呢?

当索引一个文档的时候,Elasticsearch 取出所有字段的值拼接成一个大的字符串,作为 _all 字段进行索引。例如,当索引这个文档时:

{
    "tweet":    "However did I manage before Elasticsearch?",
    "date":     "2014-09-14",
    "name":     "Mary Jones",
    "user_id":  1
}

 

这就好似增加了一个名叫 _all 的额外字段:

"However did I manage before Elasticsearch? 2014-09-14 Mary Jones 1"

 

除非设置特定字段,否则查询字符串就使用 _all 字段进行搜索。

在刚开始开发一个应用时,_all 字段是一个很实用的特性。之后,你会发现如果搜索时用指定字段来代替 _all 字段,将会更好控制搜索结果。当 _all 字段不再有用的时候,可以将它置为失效,正如在 元数据: _all 字段 中所解释的。

更复杂的查询

下面的查询针对tweents类型,并使用以下的条件:

  • name 字段中包含 mary 或者 john
  • date 值大于 2014-09-10
  • _all 字段包含 aggregations 或者 geo

    +name:(mary john) +date:>2014-09-10 +(aggregations geo)

查询字符串在做了适当的编码后,可读性很差:

?q=%2Bname%3A(mary+john)+%2Bdate%3A%3E2014-09-10+%2B(aggregations+geo)

 

从之前的例子中可以看出,这种 轻量 的查询字符串搜索效果还是挺让人惊喜的。 它的查询语法在相关参考文档中有详细解释,以便简洁的表达很复杂的查询。对于通过命令做一次性查询,或者是在开发阶段,都非常方便。

但同时也可以看到,这种精简让调试更加晦涩和困难。而且很脆弱,一些查询字符串中很小的语法错误,像 - , : , / 或者 ” 不匹配等,将会返回错误而不是搜索结果。

最后,查询字符串搜索允许任何用户在索引的任意字段上执行可能较慢且重量级的查询,这可能会暴露隐私信息,甚至将集群拖垮。

因为这些原因,不推荐直接向用户暴露查询字符串搜索功能,除非对于集群和数据来说非常信任他们。

相反,我们经常在生产环境中更多地使用功能全面的 request body 查询API,除了能完成以上所有功能,还有一些附加功能。但在到达那个阶段之前,首先需要了解数据在 Elasticsearch 中是如何被索引的。

六、映射和分析

精确值 VS 全文

Elasticsearch 中的数据可以概括的分为两类:精确值和全文。

精确值 如它们听起来那样精确。例如日期或者用户 ID,但字符串也可以表示精确值,例如用户名或邮箱地址。对于精确值来讲,Foo 和 foo 是不同的,2014 和 2014-09-15 也是不同的。

另一方面,全文 是指文本数据(通常以人类容易识别的语言书写),例如一个推文的内容或一封邮件的内容。

全文通常是指非结构化的数据,但这里有一个误解:自然语言是高度结构化的。问题在于自然语言的规则是复杂的,导致计算机难以正确解析。例如,考虑这条语句:

May is fun but June bores me.

它指的是月份还是人?

精确值很容易查询。结果是二进制的:要么匹配查询,要么不匹配。这种查询很容易用 SQL 表示:

WHERE name    = "John Smith"
  AND user_id = 2
  AND date    > "2014-09-15"

查询全文数据要微妙的多。我们问的不只是“这个文档匹配查询吗”,而是“该文档匹配查询的程度有多大?”换句话说,该文档与给定查询的相关性如何?

我们很少对全文类型的域做精确匹配。相反,我们希望在文本类型的域中搜索。不仅如此,我们还希望搜索能够理解我们的 意图 :

  • 搜索 UK ,会返回包含 United Kindom 的文档。
  • 搜索 jump ,会匹配 jumped , jumps , jumping ,甚至是 leap 。
  • 搜索 johnny walker 会匹配 Johnnie Walker , johnnie depp 应该匹配 Johnny Depp 。
  • fox news hunting 应该返回福克斯新闻( Foxs News )中关于狩猎的故事,同时, fox hunting news 应该返回关于猎狐的故事。

为了促进这类在全文域中的查询,Elasticsearch 首先 分析 文档,之后根据结果创建 倒排索引 。

倒排索引

Elasticsearch 使用一种称为 倒排索引 的结构,它适用于快速的全文搜索。一个倒排索引由文档中所有不重复词的列表构成,对于其中每个词,有一个包含它的文档列表。

例如,假设我们有两个文档,每个文档的 content 域包含如下内容:

  1. The quick brown fox jumped over the lazy dog
  2. Quick brown foxes leap over lazy dogs in summer

为了创建倒排索引,我们首先将每个文档的 content 域拆分成单独的词(我们称它为 词条 或 tokens ),创建一个包含所有不重复词条的排序列表,然后列出每个词条出现在哪个文档。结果如下所示:

Term      Doc_1  Doc_2
-------------------------
Quick   |       |  X
The     |   X   |
brown   |   X   |  X
dog     |   X   |
dogs    |       |  X
fox     |   X   |
foxes   |       |  X
in      |       |  X
jumped  |   X   |
lazy    |   X   |  X
leap    |       |  X
over    |   X   |  X
quick   |   X   |
summer  |       |  X
the     |   X   |
------------------------

 

现在,如果我们想搜索 quick brown ,我们只需要查找包含每个词条的文档:

Term      Doc_1  Doc_2
-------------------------
brown   |   X   |  X
quick   |   X   |
------------------------
Total   |   2   |  1

 

两个文档都匹配,但是第一个文档比第二个匹配度更高。如果我们使用仅计算匹配词条数量的简单 相似性算法 ,那么,我们可以说,对于我们查询的相关性来讲,第一个文档比第二个文档更佳。

但是,我们目前的倒排索引有一些问题:

  • Quick 和 quick 以独立的词条出现,然而用户可能认为它们是相同的词。
  • fox 和 foxes 非常相似, 就像 dog 和 dogs ;他们有相同的词根。
  • jumped 和 leap, 尽管没有相同的词根,但他们的意思很相近。他们是同义词。

使用前面的索引搜索 +Quick +fox 不会得到任何匹配文档。(记住,+ 前缀表明这个词必须存在。)只有同时出现 Quick 和 fox 的文档才满足这个查询条件,但是第一个文档包含 quick fox ,第二个文档包含 Quick foxes 。

我们的用户可以合理的期望两个文档与查询匹配。我们可以做的更好。

如果我们将词条规范为标准模式,那么我们可以找到与用户搜索的词条不完全一致,但具有足够相关性的文档。例如:

  • Quick 可以小写化为 quick 。
  • foxes 可以 词干提取 –变为词根的格式– 为 fox 。类似的, dogs 可以为提取为 dog 。
  • jumped 和 leap 是同义词,可以索引为相同的单词 jump 。

现在索引看上去像这样:

Term      Doc_1  Doc_2
-------------------------
brown   |   X   |  X
dog     |   X   |  X
fox     |   X   |  X
in      |       |  X
jump    |   X   |  X
lazy    |   X   |  X
over    |   X   |  X
quick   |   X   |  X
summer  |       |  X
the     |   X   |  X
------------------------

 

这还远远不够。我们搜索 +Quick +fox 仍然 会失败,因为在我们的索引中,已经没有 Quick 了。但是,如果我们对搜索的字符串使用与 content 域相同的标准化规则,会变成查询 +quick +fox ,这样两个文档都会匹配!

这非常重要。你只能搜索在索引中出现的词条,所以索引文本和查询字符串必须标准化为相同的格式。

分词和标准化的过程称为 分析 。

分析与分析器

分析 包含下面的过程:

  • 首先,将一块文本分成适合于倒排索引的独立的 词条 ,
  • 之后,将这些词条统一化为标准格式以提高它们的“可搜索性”,或者 recall

分析器执行上面的工作。 分析器 实际上是将三个功能封装到了一个包里:

  • 字符过滤器 
    首先,字符串按顺序通过每个 字符过滤器 。他们的任务是在分词前整理字符串。一个字符过滤器可以用来去掉HTML,或者将 & 转化成 and

  • 分词器 
    其次,字符串被 分词器 分为单个的词条。一个简单的分词器遇到空格和标点的时候,可能会将文本拆分成词条。

  • Token 过滤器 
    最后,词条按顺序通过每个 token 过滤器 。这个过程可能会改变词条(例如,小写化 Quick ),删除词条(例如, 像 a, and, the 等无用词),或者增加词条(例如,像 jump 和 leap 这种同义词)。

Elasticsearch提供了开箱即用的字符过滤器、分词器和token 过滤器。 这些可以组合起来形成自定义的分析器以用于不同的目的。

内置分析器

但是, Elasticsearch还附带了可以直接使用的预包装的分析器。 接下来我们会列出最重要的分析器。为了证明它们的差异,我们看看每个分析器会从下面的字符串得到哪些词条:

"Set the shape to semi-transparent by calling set_trans(5)"

- 标准分析器 
标准分析器是Elasticsearch默认使用的分析器。它是分析 
各种语言文本最常用的选择。它根据 Unicode 联盟 定义的 单词边界 划分文本。删除绝大部分标点。最后,将词条小写。它会产生 set, the, shape, to, semi, transparent, by, calling, set_trans, 5 
- 简单分析器 
简单分析器在任何不是字母的地方分隔文本,将词条小写。它会产生 set, the, shape, to, semi, transparent, by, calling, set, trans 
- 空格分析器 
空格分析器在空格的地方划分文本。它会产生 Set, the, shape, to, semi-transparent, by, calling, set_trans(5) 
- 语言分析器 
特定语言分析器可用于 很多语言。它们可以考虑指定语言的特点。例如, 英语 分析器附带了一组英语无用词(常用单词,例如 and 或者 the ,它们对相关性没有多少影响),它们会被删除。 由于理解英语语法的规则,这个分词器可以提取英语单词的 词干 。 英语 分词器会产生下面的词条:set, shape, semi, transpar, call, set_tran, 5注意看 transparent、 calling 和 set_trans 已经变为词根格式。

什么时候使用分析器

当我们 索引 一个文档,它的全文域被分析成词条以用来创建倒排索引。 但是,当我们在全文域 搜索 的时候,我们需要将查询字符串通过 相同的分析过程 ,以保证我们搜索的词条格式与索引中的词条格式一致。

全文查询,理解每个域是如何定义的,因此它们可以做 正确的事:

  • 当你查询一个 全文 域时, 会对查询字符串应用相同的分析器,以产生正确的搜索词条列表。
  • 当你查询一个 精确值 域时,不会分析查询字符串, 而是搜索你指定的精确值。

现在你可以理解之前的查询为什么返回那样的结果:

  • date 域包含一个精确值:单独的词条 2014-09-15。
  • _all 域是一个全文域,所以分词进程将日期转化为三个词条: 2014, 09, 和 15。

当我们在 _all 域查询 2014,它匹配所有的12条推文,因为它们都含有 2014 :

GET /_search?q=2014              # 12 results

当我们在 _all 域查询 2014-09-15,它首先分析查询字符串,产生匹配 2014, 09, 或 15 中 任意 词条的查询。这也会匹配所有12条推文,因为它们都含有 2014 :

GET /_search?q=2014-09-15        # 12 results !

当我们在 date 域查询 2014-09-15,它寻找 精确 日期,只找到一个推文:

GET /_search?q=date:2014-09-15   # 1  result

当我们在 date 域查询 2014,它找不到任何文档,因为没有文档含有这个精确日志:

GET /_search?q=date:2014         # 0  results !

测试分析器

有些时候很难理解分词的过程和实际被存储到索引中的词条,特别是你刚接触 Elasticsearch。为了理解发生了什么,你可以使用 analyze API 来看文本是如何被分析的。在消息体里,指定分析器和要分析的文本:

GET /_analyze
{
  "analyzer": "standard",
  "text": "Text to analyze"
}

结果中每个元素代表一个单独的词条:

{
   "tokens": [
      {
         "token":        "text",
         "start_offset": 0,
         "end_offset":   4,
         "type":         "<ALPHANUM>",
         "position":     1
      },
      {
         "token":        "to",
         "start_offset": 5,
         "end_offset":   7,
         "type":         "<ALPHANUM>",
         "position":     2
      },
      {
         "token":        "analyze",
         "start_offset": 8,
         "end_offset":   15,
         "type":         "<ALPHANUM>",
         "position":     3
      }
   ]
}

token 是实际存储到索引中的词条。 position 指明词条在原始文本中出现的位置。 start_offset 和 end_offset 指明字符在原始字符串中的位置。

每个分析器的 type 值都不一样,可以忽略它们。它们在Elasticsearch中的唯一作用在于keep_types token 过滤器。

analyze API 是一个有用的工具,它有助于我们理解Elasticsearch索引内部发生了什么,随着深入,我们会进一步讨论它。

指定分析器

当Elasticsearch在你的文档中检测到一个新的字符串域 ,它会自动设置其为一个全文 字符串 域,使用 标准 分析器对它进行分析。

你不希望总是这样。可能你想使用一个不同的分析器,适用于你的数据使用的语言。有时候你想要一个字符串域就是一个字符串域–不使用分析,直接索引你传入的精确值,例如用户ID或者一个内部的状态域或标签。

要做到这一点,我们必须手动指定这些域的映射。

映射

为了能够将时间域视为时间,数字域视为数字,字符串域视为全文或精确值字符串, Elasticsearch 需要知道每个域中数据的类型。这个信息包含在映射中。

索引中每个文档都有 类型 。每种类型都有它自己的 映射 ,或者 模式定义 。映射定义了类型中的域,每个域的数据类型,以及Elasticsearch如何处理这些域。映射也用于配置与类型有关的元数据。

核心简单域类型

Elasticsearch 支持 如下简单域类型:

  • 字符串: string
  • 整数 : byte, short, integer, long
  • 浮点数: float, double
  • 布尔型: boolean
  • 日期: date

当你索引一个包含新域的文档–之前未曾出现– Elasticsearch 会使用 动态映射 ,通过JSON中基本数据类型,尝试猜测域类型,使用如下规则:

JSON type 域 type 
布尔型: true 或者 false boolean 
整数: 123 long 
浮点数: 123.45 double 
字符串,有效日期: 2014-09-15 date 
字符串: foo bar string

这意味着如果你通过引号( “123” )索引一个数字,它会被映射为 string 类型,而不是 long 。但是,如果这个域已经映射为 long ,那么 Elasticsearch 会尝试将这个字符串转化为 long ,如果无法转化,则抛出一个异常。

查看映射

通过 /_mapping ,我们可以查看 Elasticsearch 在一个或多个索引中的一个或多个类型的映射 。比如取得索引 gb 中类型 tweet 的映射:

GET /gb/_mapping/tweet

Elasticsearch 根据我们索引的文档,为域(称为 属性 )动态生成的映射。

{
   "gb": {
      "mappings": {
         "tweet": {
            "properties": {
               "date": {
                  "type": "date",
                  "format": "strict_date_optional_time||epoch_millis"
               },
               "name": {
                  "type": "string"
               },
               "tweet": {
                  "type": "string"
               },
               "user_id": {
                  "type": "long"
               }
            }
         }
      }
   }
}

错误的映射,例如 将 age 域映射为 string 类型,而不是 integer ,会导致查询出现令人困惑的结果。

自定义域映射

尽管在很多情况下基本域数据类型 已经够用,但你经常需要为单独域自定义映射 ,特别是字符串域。自定义映射允许你执行下面的操作:

  • 全文字符串域和精确值字符串域的区别
  • 使用特定语言分析器
  • 优化域以适应部分匹配
  • 指定自定义数据格式
  • 还有更多

域最重要的属性是 type 。对于不是 string 的域,你一般只需要设置 type :

{
    "number_of_clicks": {
        "type": "integer"
    }
}

默认, string 类型域会被认为包含全文。就是说,它们的值在索引前,会通过 一个分析器,针对于这个域的查询在搜索前也会经过一个分析器。

string 域映射的两个最重要 属性是 index 和 analyzer 。

index

index 属性控制怎样索引字符串。它可以是下面三个值:

  • analyzed 
    首先分析字符串,然后索引它。换句话说,以全文索引这个域。
  • not_analyzed 
    索引这个域,所以它能够被搜索,但索引的是精确值。不会对它进行分析。
  • no 
    不索引这个域。这个域不会被搜索到。

string 域 index 属性默认是 analyzed 。如果我们想映射这个字段为一个精确值,我们需要设置它为 not_analyzed :

{
    "tag": {
        "type":     "string",
        "index":    "not_analyzed"
    }
}

其他简单类型(例如 long , double , date 等)也接受 index 参数,但有意义的值只有 no 和 not_analyzed , 因为它们永远不会被分析。

analyzer

对于 analyzed 字符串域,用 analyzer 属性指定在搜索和索引时使用的分析器。默认, Elasticsearch 使用 standard 分析器, 但你可以指定一个内置的分析器替代它,例如 whitespace 、 simple 和 english:

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

更新映射

当你首次 创建一个索引的时候,可以指定类型的映射。你也可以使用 /_mapping 为新类型(或者为存在的类型更新映射)增加映射。

尽管你可以 增加_ 一个存在的映射,你不能 _修改 存在的域映射。如果一个域的映射已经存在,那么该域的数据可能已经被索引。如果你意图修改这个域的映射,索引的数据可能会出错,不能被正常的搜索。

我们可以更新一个映射来添加一个新域,但不能将一个存在的域从 analyzed 改为 not_analyzed 。

为了描述指定映射的两种方式,我们先删除 gd 索引:

DELETE /gb

然后创建一个新索引,指定 tweet 域使用 english 分析器:

PUT /gb 
{
  "mappings": {
    "tweet" : {
      "properties" : {
        "tweet" : {
          "type" :    "string",
          "analyzer": "english"
        },
        "date" : {
          "type" :   "date"
        },
        "name" : {
          "type" :   "string"
        },
        "user_id" : {
          "type" :   "long"
        }
      }
    }
  }
}

通过消息体中指定的 mappings 创建了索引。

稍后,我们决定在 tweet 映射增加一个新的名为 tag 的 not_analyzed 的文本域,使用 _mapping :

PUT /gb/_mapping/tweet
{
  "properties" : {
    "tag" : {
      "type" :    "string",
      "index":    "not_analyzed"
    }
  }
}

注意,我们不需要再次列出所有已存在的域,因为无论如何我们都无法改变它们。新域已经被合并到存在的映射中。

测试映射

你可以使用 analyze API 测试字符串域的映射。比较下面两个请求的输出:

GET /gb/_analyze
{
  "field": "tweet",
  "text": "Black-cats" 
}

GET /gb/_analyze
{
  "field": "tag",
  "text": "Black-cats" 
}

消息体里面传输我们想要分析的文本。

tweet 域产生两个词条 black 和 cat , tag 域产生单独的词条 Black-cats 。换句话说,我们的映射正常工作。

复杂核心域类型

除了我们提到的简单标量数据类型, JSON 还有 null 值,数组,和对象,这些 Elasticsearch 都是支持的。

多值域

很有可能,我们希望 tag 域 包含多个标签。我们可以以数组的形式索引标签:

{ "tag": [ "search", "nosql" ]}

对于数组,没有特殊的映射需求。任何域都可以包含0、1或者多个值,就像全文域分析得到多个词条。

这暗示 数组中所有的值必须是相同数据类型的 。你不能将日期和字符串混在一起。如果你通过索引数组来创建新的域,Elasticsearch 会用数组中第一个值的数据类型作为这个域的 类型 。

当你从 Elasticsearch 得到一个文档,每个数组的顺序和你当初索引文档时一样。你得到的 _source 域,包含与你索引的一模一样的 JSON 文档。

但是,数组是以多值域 索引的—可以搜索,但是无序的。 在搜索的时候,你不能指定 “第一个” 或者 “最后一个”。 更确切的说,把数组想象成 装在袋子里的值 。

空域

当然,数组可以为空。 这相当于存在零值。 事实上,在 Lucene 中是不能存储 null 值的,所以我们认为存在 null 值的域为空域。

下面三种域被认为是空的,它们将不会被索引:

"null_value":               null,
"empty_array":              [],
"array_with_null_value":    [ null ]

多层级对象

我们讨论的最后一个 JSON 原生数据类是 对象 – 在其他语言中称为哈希,哈希 map,字典或者关联数组。

内部对象 经常用于 嵌入一个实体或对象到其它对象中。例如,与其在 tweet 文档中包含 user_name 和 user_id 域,我们也可以这样写:

{
    "tweet":            "Elasticsearch is very flexible",
    "user": {
        "id":           "@johnsmith",
        "gender":       "male",
        "age":          26,
        "name": {
            "full":     "John Smith",
            "first":    "John",
            "last":     "Smith"
        }
    }
}

内部对象的映射

Elasticsearch 会动态 监测新的对象域并映射它们为 对象 ,在 properties 属性下列出内部域:

{
  "gb": {
    "tweet": { 
      "properties": {
        "tweet":            { "type": "string" },
        "user": { 
          "type":             "object",
          "properties": {
            "id":           { "type": "string" },
            "gender":       { "type": "string" },
            "age":          { "type": "long"   },
            "name":   { 
              "type":         "object",
              "properties": {
                "full":     { "type": "string" },
                "first":    { "type": "string" },
                "last":     { "type": "string" }
              }
            }
          }
        }
      }
    }
  }
}

user 和 name 域的映射结构与 tweet 类型的相同。事实上, type 映射只是一种特殊的 对象 映射,我们称之为 根对象 。除了它有一些文档元数据的特殊顶级域,例如 _source 和 _all 域,它和其他对象一样。

内部对象是如何索引的

Lucene 不理解内部对象。 Lucene 文档是由一组键值对列表组成的。为了能让 Elasticsearch 有效地索引内部类,它把我们的文档转化成这样:

{
    "tweet":            [elasticsearch, flexible, very],
    "user.id":          [@johnsmith],
    "user.gender":      [male],
    "user.age":         [26],
    "user.name.full":   [john, smith],
    "user.name.first":  [john],
    "user.name.last":   [smith]
}

内部域 可以通过名称引用(例如, first )。为了区分同名的两个域,我们可以使用全 路径 (例如, user.name.first ) 或 type 名加路径( tweet.user.name.first )。

在前面简单扁平的文档中,没有 user 和 user.name 域。Lucene 索引只有标量和简单值,没有复杂数据结构。

内部对象数组

最后,考虑包含 内部对象的数组是如何被索引的。 假设我们有个 followers 数组:

{
    "followers": [
        { "age": 35, "name": "Mary White"},
        { "age": 26, "name": "Alex Jones"},
        { "age": 19, "name": "Lisa Smith"}
    ]
}

这个文档会像我们之前描述的那样被扁平化处理,结果如下所示:

{
    "followers.age":    [19, 26, 35],
    "followers.name":   [alex, jones, lisa, smith, mary, white]
}

{age: 35} 和 {name: Mary White} 之间的相关性已经丢失了,因为每个多值域只是一包无序的值,而不是有序数组。这足以让我们问,“有一个26岁的追随者?”

但是我们不能得到一个准确的答案:“是否有一个26岁 名字叫 Alex Jones 的追随者?”

相关内部对象被称为 nested 对象,可以回答上面的查询。

七、请求体查询

1. 空查询

让我们以 最简单的 search API 的形式开启我们的旅程,空查询将返回所有索引库(indices)中的所有文档:

GET /_search
{} 

只用一个查询字符串,你就可以在一个、多个或者 _all 索引库(indices)和一个、多个或者所有types中查询:

GET /index_2014*/type1,type2/_search
{}

同时你可以使用 from 和 size 参数来分页:

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

2. 查询表达式

查询表达式(Query DSL)是一种非常灵活又富有表现力的 查询语言。 Elasticsearch 使用它可以以简单的 JSON 接口来展现 Lucene 功能的绝大部分。在你的应用中,你应该用它来编写你的查询语句。它可以使你的查询语句更灵活、更精确、易读和易调试。

要使用这种查询表达式,只需将查询语句传递给 query 参数:

GET /_search
{
    "query": YOUR_QUERY_HERE
}

空查询(empty search) —{}— 在功能上等价于使用 match_all 查询, 正如其名字一样,匹配所有文档:

GET /_search
{
    "query": {
        "match_all": {}
    }
}

查询语句的结构

一个查询语句 的典型结构:

{
    QUERY_NAME: {
        ARGUMENT: VALUE,
        ARGUMENT: VALUE,...
    }
}

如果是针对某个字段,那么它的结构如下:

{
    QUERY_NAME: {
        FIELD_NAME: {
            ARGUMENT: VALUE,
            ARGUMENT: VALUE,...
        }
    }
}

举个例子,你可以使用 match 查询语句 来查询 tweet 字段中包含 elasticsearch 的 tweet:

{
    "match": {
        "tweet": "elasticsearch"
    }
}

完整的查询请求如下:

GET /_search
{
    "query": {
        "match": {
            "tweet": "elasticsearch"
        }
    }
}

合并查询语句

查询语句(Query clauses) 就像一些简单的组合块 ,这些组合块可以彼此之间合并组成更复杂的查询。这些语句可以是如下形式:

  • 叶子语句(Leaf clauses) (就像 match 语句) 被用于将查询字符串和一个字段(或者多个字段)对比。
  • 复合(Compound) 语句 主要用于 合并其它查询语句。 比如,一个 bool 语句 允许在你需要的时候组合其它语句,无论是 must 匹配、 must_not 匹配还是 should 匹配,同时它可以包含不评分的过滤器(filters):


    “bool”: { 
    “must”: { “match”: { “tweet”: “elasticsearch” }}, 
    “must_not”: { “match”: { “name”: “mary” }}, 
    “should”: { “match”: { “tweet”: “full text” }}, 
    “filter”: { “range”: { “age” : { “gt” : 30 }} } 

    }

一条复合语句可以合并 任何 其它查询语句,包括复合语句,了解这一点是很重要的。这就意味着,复合语句之间可以互相嵌套,可以表达非常复杂的逻辑。

例如,以下查询是为了找出信件正文包含 business opportunity 的星标邮件,或者在收件箱正文包含 business opportunity 的非垃圾邮件:

{
    "bool": {
        "must": { "match":   { "email": "business opportunity" }},
        "should": [
            { "match":       { "starred": true }},
            { "bool": {
                "must":      { "match": { "folder": "inbox" }},
                "must_not":  { "match": { "spam": true }}
            }}
        ],
        "minimum_should_match": 1
    }
}

到目前为止,你不必太在意这个例子的细节,我们会在后面详细解释。最重要的是你要理解到,一条复合语句可以将多条语句 — 叶子语句和其它复合语句 — 合并成一个单一的查询语句。

3. 查询与过滤

Elasticsearch 使用的查询语言(DSL) 拥有一套查询组件,这些组件可以以无限组合的方式进行搭配。这套组件可以在以下两种情况下使用:过滤情况(filtering context)和查询情况(query context)。

当使用于 过滤情况 时,查询被设置成一个“不评分”或者“过滤”查询。即,这个查询只是简单的问一个问题:“这篇文档是否匹配?”。回答也是非常的简单,yes 或者 no ,二者必居其一。

  • created 时间是否在 2013 与 2014 这个区间?
  • status 字段是否包含 published 这个单词?
  • lat_lon 字段表示的位置是否在指定点的 10km 范围内?

当使用于 查询情况 时,查询就变成了一个“评分”的查询。和不评分的查询类似,也要去判断这个文档是否匹配,同时它还需要判断这个文档匹配的有 多好(匹配程度如何)。 此查询的典型用法是用于查找以下文档:

  • 查找与 full text search 这个词语最佳匹配的文档
  • 包含 run 这个词,也能匹配 runs 、 running 、 jog 或者 sprint
  • 包含 quick 、 brown 和 fox 这几个词 — 词之间离的越近,文档相关性越高
  • 标有 lucene 、 search 或者 java 标签 — 标签越多,相关性越高

一个评分查询计算每一个文档与此查询的 相关程度,同时将这个相关程度分配给表示相关性的字段 _score,并且按照相关性对匹配到的文档进行排序。这种相关性的概念是非常适合全文搜索的情况,因为全文搜索几乎没有完全 “正确” 的答案。

自 Elasticsearch 问世以来,查询与过滤(queries and filters)就独自成为 Elasticsearch 的组件。但从 Elasticsearch 2.0 开始,过滤(filters)已经从技术上被排除了,同时所有的查询(queries)拥有变成不评分查询的能力。

然而,为了明确和简单,我们用 “filter” 这个词表示不评分、只过滤情况下的查询。你可以把 “filter” 、 “filtering query” 和 “non-scoring query” 这几个词视为相同的。

相似的,如果单独地不加任何修饰词地使用 “query” 这个词,我们指的是 “scoring query” 。

性能差异

过滤查询(Filtering queries)只是简单的检查包含或者排除,这就使得计算起来非常快。考虑到至少有一个过滤查询(filtering query)的结果是 “稀少的”(很少匹配的文档),并且经常使用不评分查询(non-scoring queries),结果会被缓存到内存中以便快速读取,所以有各种各样的手段来优化查询结果。

相反,评分查询(scoring queries)不仅仅要找出 匹配的文档,还要计算每个匹配文档的相关性,计算相关性使得它们比不评分查询费力的多。同时,查询结果并不缓存。

多亏倒排索引(inverted index),一个简单的评分查询在匹配少量文档时可能与一个涵盖百万文档的filter表现的一样好,甚至会更好。但是在一般情况下,一个filter 会比一个评分的query性能更优异,并且每次都表现的很稳定。

过滤(filtering)的目标是减少那些需要通过评分查询(scoring queries)进行检查的文档。

如何选择查询与过滤

通常的规则是,使用 查询(query)语句来进行 全文 搜索或者其它任何需要影响 相关性得分 的搜索。除此以外的情况都使用过滤(filters)。

4. 最重要的查询

虽然 Elasticsearch 自带了很多的查询,但经常用到的也就那么几个。

match_all 查询

match_all 查询简单的 匹配所有文档。在没有指定查询方式时,它是默认的查询:

{ "match_all": {}}

它经常与 filter 结合使用–例如,检索收件箱里的所有邮件。所有邮件被认为具有相同的相关性,所以都将获得分值为 1 的中性 _score。

match 查询

无论你在任何字段上进行的是全文搜索还是精确查询,match 查询是你可用的标准查询。

如果你在一个全文字段上使用 match 查询,在执行查询前,它将用正确的分析器去分析查询字符串:

{ "match": { "tweet": "About Search" }}

如果在一个精确值的字段上使用它, 例如数字、日期、布尔或者一个 not_analyzed 字符串字段,那么它将会精确匹配给定的值:

{ "match": { "age":    26           }}
{ "match": { "date":   "2014-09-01" }}
{ "match": { "public": true         }}
{ "match": { "tag":    "full_text"  }}

对于精确值的查询,你可能需要使用 filter 语句来取代 query,因为 filter 将会被缓存。接下来,我们将看到一些关于 filter 的例子。

不像我们在 轻量 搜索 章节介绍的字符串查询(query-string search), match 查询不使用类似 +user_id:2 +tweet:search 的查询语法。它只是去查找给定的单词。这就意味着将查询字段暴露给你的用户是安全的;你需要控制那些允许被查询字段,不易于抛出语法异常。

multi_match 查询

multi_match 查询可以在多个字段上执行相同的 match 查询:

{
    "multi_match": {
        "query":    "full text search",
        "fields":   [ "title", "body" ]
    }
}

range 查询

range 查询找出那些落在指定区间内的数字或者时间:

{
    "range": {
        "age": {
            "gte":  20,
            "lt":   30
        }
    }
}

被允许的操作符如下:

  • gt 
    大于
  • gte 
    大于等于
  • lt 
    小于
  • lte 
    小于等于

term 查询

term 查询被用于精确值 匹配,这些精确值可能是数字、时间、布尔或者那些 not_analyzed 的字符串:

{ "term": { "age":    26           }}
{ "term": { "date":   "2014-09-01" }}
{ "term": { "public": true         }}
{ "term": { "tag":    "full_text"  }}

term 查询对于输入的文本不 分析 ,所以它将给定的值进行精确查询。

terms 查询

terms 查询和 term 查询一样,但它允许你指定多值进行匹配。如果这个字段包含了指定值中的任何一个值,那么这个文档满足条件:

{ "terms": { "tag": [ "search", "full_text", "nosql" ] }}

和 term 查询一样,terms 查询对于输入的文本不分析。它查询那些精确匹配的值(包括在大小写、重音、空格等方面的差异)。

exists 查询和 missing 查询

exists 查询和 missing 查询被用于查找那些指定字段中有值 (exists) 或无值 (missing) 的文档。这与SQL中的 IS_NULL (missing) 和 NOT IS_NULL (exists) 在本质上具有共性:

{
    "exists":   {
        "field":    "title"
    }
}

这些查询经常用于某个字段有值的情况和某个字段缺值的情况。

  1. 组合多查询

现实的查询需求从来都没有那么简单;它们需要在多个字段上查询多种多样的文本,并且根据一系列的标准来过滤。为了构建类似的高级查询,你需要一种能够将多查询组合成单一查询的查询方法。

你可以用 bool 查询来实现你的需求。这种查询将多查询组合在一起,成为用户自己想要的布尔查询。它接收以下参数:

  • must 
    文档 必须 匹配这些条件才能被包含进来。
  • must_not 
    文档 必须不 匹配这些条件才能被包含进来。
  • should 
    如果满足这些语句中的任意语句,将增加 _score ,否则,无任何影响。它们主要用于修正每个文档的相关性得分。
  • filter 
    必须 匹配,但它以不评分、过滤模式来进行。这些语句对评分没有贡献,只是根据过滤标准来排除或包含文档。

由于这是我们看到的第一个包含多个查询的查询,所以有必要讨论一下相关性得分是如何组合的。每一个子查询都独自地计算文档的相关性得分。一旦他们的得分被计算出来, bool 查询就将这些得分进行合并并且返回一个代表整个布尔操作的得分。

下面的查询用于查找 title 字段匹配 how to make millions 并且不被标识为 spam 的文档。那些被标识为 starred 或在2014之后的文档,将比另外那些文档拥有更高的排名。如果 两者 都满足,那么它排名将更高:

{
    "bool": {
        "must":     { "match": { "title": "how to make millions" }},
        "must_not": { "match": { "tag":   "spam" }},
        "should": [
            { "match": { "tag": "starred" }},
            { "range": { "date": { "gte": "2014-01-01" }}}
        ]
    }
}

如果没有 must 语句,那么至少需要能够匹配其中的一条 should 语句。但,如果存在至少一条 must 语句,则对 should 语句的匹配没有要求。

增加带过滤器(filtering)的查询

如果我们不想因为文档的时间而影响得分,可以用 filter 语句来重写前面的例子:

{
    "bool": {
        "must":     { "match": { "title": "how to make millions" }},
        "must_not": { "match": { "tag":   "spam" }},
        "should": [
            { "match": { "tag": "starred" }}
        ],
        "filter": {
          "range": { "date": { "gte": "2014-01-01" }} 
        }
    }
}

range 查询已经从 should 语句中移到 filter 语句

通过将 range 查询移到 filter 语句中,我们将它转成不评分的查询,将不再影响文档的相关性排名。由于它现在是一个不评分的查询,可以使用各种对 filter 查询有效的优化手段来提升性能。

所有查询都可以借鉴这种方式。将查询移到 bool 查询的 filter 语句中,这样它就自动的转成一个不评分的 filter 了。

如果你需要通过多个不同的标准来过滤你的文档,bool 查询本身也可以被用做不评分的查询。简单地将它放置到 filter 语句中并在内部构建布尔逻辑:

{
    "bool": {
        "must":     { "match": { "title": "how to make millions" }},
        "must_not": { "match": { "tag":   "spam" }},
        "should": [
            { "match": { "tag": "starred" }}
        ],
        "filter": {
          "bool": { 
              "must": [
                  { "range": { "date": { "gte": "2014-01-01" }}},
                  { "range": { "price": { "lte": 29.99 }}}
              ],
              "must_not": [
                  { "term": { "category": "ebooks" }}
              ]
          }
        }
    }
}

将 bool 查询包裹在 filter 语句中,我们可以在过滤标准中增加布尔逻辑

通过混合布尔查询,我们可以在我们的查询请求中灵活地编写 scoring 和 filtering 查询逻辑。

constant_score 查询

尽管没有 bool 查询使用这么频繁,constant_score 查询也是你工具箱里有用的查询工具。它将一个不变的常量评分应用于所有匹配的文档。它被经常用于你只需要执行一个 filter 而没有其它查询(例如,评分查询)的情况下。

可以使用它来取代只有 filter 语句的 bool 查询。在性能上是完全相同的,但对于提高查询简洁性和清晰度有很大帮助。

{
    "constant_score":   {
        "filter": {
            "term": { "category": "ebooks" } 
        }
    }
}

term 查询被放置在 constant_score 中,转成不评分的 filter。这种方式可以用来取代只有 filter 语句的 bool 查询。

6. 验证查询

查询可以变得非常的复杂,尤其 和不同的分析器与不同的字段映射结合时,理解起来就有点困难了。不过 validate-query API 可以用来验证查询是否合法。

GET /gb/tweet/_validate/query
{
   "query": {
      "tweet" : {
         "match" : "really powerful"
      }
   }
}

以上 validate 请求的应答告诉我们这个查询是不合法的:

{
  "valid" :         false,
  "_shards" : {
    "total" :       1,
    "successful" :  1,
    "failed" :      0
  }
}

理解错误信息

为了找出 查询不合法的原因,可以将 explain 参数 加到查询字符串中:

GET /gb/tweet/_validate/query?explain 
{
   "query": {
      "tweet" : {
         "match" : "really powerful"
      }
   }
}

explain 参数可以提供更多关于查询不合法的信息。

很明显,我们将查询类型(match)与字段名称 (tweet)搞混了:

{
  "valid" :     false,
  "_shards" :   { ... },
  "explanations" : [ {
    "index" :   "gb",
    "valid" :   false,
    "error" :   "org.elasticsearch.index.query.QueryParsingException:
                 [gb] No query registered for [tweet]"
  } ]
}

理解查询语句

对于合法查询,使用 explain 参数将返回可读的描述,这对准确理解 Elasticsearch 是如何解析你的 query 是非常有用的:

GET /_validate/query?explain
{
   "query": {
      "match" : {
         "tweet" : "really powerful"
      }
   }
}

我们查询的每一个 index 都会返回对应的 explanation ,因为每一个 index 都有自己的映射和分析器:

{
  "valid" :         true,
  "_shards" :       { ... },
  "explanations" : [ {
    "index" :       "us",
    "valid" :       true,
    "explanation" : "tweet:really tweet:powerful"
  }, {
    "index" :       "gb",
    "valid" :       true,
    "explanation" : "tweet:realli tweet:power"
  } ]
}

从 explanation 中可以看出,匹配 really powerful 的 match 查询被重写为两个针对 tweet 字段的 single-term 查询,一个single-term查询对应查询字符串分出来的一个term。

当然,对于索引 us ,这两个 term 分别是 really 和 powerful ,而对于索引 gb ,term 则分别是 realli 和 power 。之所以出现这个情况,是由于我们将索引 gb 中 tweet 字段的分析器修改为 english 分析器。

八、排序与相关性

默认情况下,返回的结果是按照 相关性 进行排序的——最相关的文档排在最前。 在本章的后面部分,我们会解释 相关性 意味着什么以及它是如何计算的, 不过让我们首先看看 sort 参数以及如何使用它。

  1. 排序

为了按照相关性来排序,需要将相关性表示为一个数值。在 Elasticsearch 中, 相关性得分 由一个浮点数进行表示,并在搜索结果中通过 _score 参数返回, 默认排序是 _score 降序。

有时,相关性评分对你来说并没有意义。例如,下面的查询返回所有 user_id 字段包含 1 的结果:

GET /_search
{
    "query" : {
        "bool" : {
            "filter" : {
                "term" : {
                    "user_id" : 1
                }
            }
        }
    }
}

这里没有一个有意义的分数:因为我们使用的是 filter (过滤),这表明我们只希望获取匹配 user_id: 1 的文档,并没有试图确定这些文档的相关性。 实际上文档将按照随机顺序返回,并且每个文档都会评为零分。

如果评分为零对你造成了困扰,你可以使用 constant_score 查询进行替代:

GET /_search
{
    "query" : {
        "constant_score" : {
            "filter" : {
                "term" : {
                    "user_id" : 1
                }
            }
        }
    }
}

这将让所有文档应用一个恒定分数(默认为 1 )。它将执行与前述查询相同的查询,并且所有的文档将像之前一样随机返回,这些文档只是有了一个分数而不是零分。

按照字段的值排序

在这个案例中,通过时间来对 tweets 进行排序是有意义的,最新的 tweets 排在最前。 我们可以使用 sort 参数进行实现:

GET /_search
{
    "query" : {
        "bool" : {
            "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 字段的值表示为自 epoch (January 1, 1970 00:00:00 UTC)以来的毫秒数,通过 sort 字段的值进行返回。

首先我们在每个结果中有一个新的名为 sort 的元素,它包含了我们用于排序的值。 在这个案例中,我们按照 date 进行排序,在内部被索引为 自 epoch 以来的毫秒数 。 long 类型数 1411516800000 等价于日期字符串 2014-09-24 00:00:00 UTC 。

其次 _score 和 max_score 字段都是 null 。 计算 _score 的花销巨大,通常仅用于排序; 我们并不根据相关性排序,所以记录 _score 是没有意义的。如果无论如何你都要计算 _score , 你可以将 track_scores 参数设置为 true 。

一个简便方法是, 你可以 指定一个字段用来排序:

    "sort": "number_of_children"

字段将会默认升序排序 ,而按照 _score 的值进行降序排序。

多级排序

假定我们想要结合使用 date 和 _score 进行查询,并且匹配的结果首先按照日期排序,然后按照相关性排序:

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

排序条件的顺序是很重要的。结果首先按第一个条件排序,仅当结果集的第一个 sort 值完全相同时才会按照第二个条件进行排序,以此类推。

多级排序并不一定包含 _score 。你可以根据一些不同的字段进行排序, 如地理距离或是脚本计算的特定值。

Query-string 搜索 也支持自定义排序,可以在查询字符串中使用 sort 参数:

GET /_search?sort=date:desc&sort=_score&q=search

多值字段的排序

一种情形是字段有多个值的排序, 需要记住这些值并没有固有的顺序;一个多值的字段仅仅是多个值的包装,这时应该选择哪个进行排序呢?

对于数字或日期,你可以将多值字段减为单值,这可以通过使用 min 、 max 、 avg 或是 sum 排序模式 。 例如你可以按照每个 date 字段中的最早日期进行排序,通过以下方法:

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

2. 字符串排序与多字段

被解析的字符串字段也是多值字段, 但是很少会按照你想要的方式进行排序。如果你想分析一个字符串,如 fine old art , 这包含 3 项。我们很可能想要按第一项的字母排序,然后按第二项的字母排序,诸如此类,但是 Elasticsearch 在排序过程中没有这样的信息。

你可以使用 min 和 max 排序模式(默认是 min ),但是这会导致排序以 art 或是 old ,任何一个都不是所希望的。

为了以字符串字段进行排序,这个字段应仅包含一项: 整个 not_analyzed 字符串。 但是我们仍需要 analyzed 字段,这样才能以全文进行查询

一个简单的方法是用两种方式对同一个字符串进行索引,这将在文档中包括两个字段: analyzed 用于搜索, not_analyzed 用于排序

但是保存相同的字符串两次在 _source 字段是浪费空间的。 我们真正想要做的是传递一个 单字段 但是却用两种方式索引它。所有的 _core_field 类型 (strings, numbers, Booleans, dates) 接收一个 fields 参数

该参数允许你转化一个简单的映射如:

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

为一个多字段映射如:

"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 字段排序会消耗大量的内存。

3. 什么是相关性?

我们曾经讲过,默认情况下,返回结果是按相关性倒序排列的。 但是什么是相关性? 相关性如何计算?

每个文档都有相关性评分,用一个正浮点数字段 _score 来表示 。 _score 的评分越高,相关性越高。

查询语句会为每个文档生成一个 _score 字段。评分的计算方式取决于查询类型 不同的查询语句用于不同的目的: fuzzy 查询会计算与关键词的拼写相似程度,terms 查询会计算 找到的内容与关键词组成部分匹配的百分比,但是通常我们说的 relevance 是我们用来计算全文本字段的值相对于全文本检索词相似程度的算法。

Elasticsearch 的相似度算法 被定义为检索词频率/反向文档频率, TF/IDF ,包括以下内容:

  • 检索词频率 
    检索词在该字段出现的频率?出现频率越高,相关性也越高。 字段中出现过 5 次要比只出现过 1 次的相关性高。
  • 反向文档频率 
    每个检索词在索引中出现的频率?频率越高,相关性越低。检索词出现在多数文档中会比出现在少数文档中的权重更低。
  • 字段长度准则 
    字段的长度是多少?长度越长,相关性越低。 检索词出现在一个短的 title 要比同样的词出现在一个长的 content 字段权重更大。

单个查询可以联合使用 TF/IDF 和其他方式,比如短语查询中检索词的距离或模糊查询里的检索词相似度。

相关性并不只是全文本检索的专利。也适用于 yes|no 的子句,匹配的子句越多,相关性评分越高。

如果多条查询子句被合并为一条复合查询语句 ,比如 bool 查询,则每个查询子句计算得出的评分会被合并到总的相关性评分中。

理解评分标准

当调试一条复杂的查询语句时, 想要理解 _score 究竟是如何计算是比较困难的。Elasticsearch 在 每个查询语句中都有一个 explain 参数,将 explain 设为 true 就可以得到更详细的信息。

GET /_search?explain 
{
   "query"   : { "match" : { "tweet" : "honeymoon" }}
}

explain 参数可以让返回结果添加一个 _score 评分的得来依据。

增加一个 explain 参数会为每个匹配到的文档产生一大堆额外内容,但是花时间去理解它是很有意义的。 如果现在看不明白也没关系 — 等你需要的时候再来回顾这一节就行。下面我们来一点点的了解这块知识点。

首先,我们看一下普通查询返回的元数据:

{
    "_index" :      "us",
    "_type" :       "tweet",
    "_id" :         "12",
    "_score" :      0.076713204,
    "_source" :     { ... trimmed ... },

这里加入了该文档来自于哪个节点哪个分片上的信息,这对我们是比较有帮助的,因为词频率和 文档频率是在每个分片中计算出来的,而不是每个索引中:

    "_shard" :      1,
    "_node" :       "mzIVYCsqSWCG_M_ZffSs9Q",

然后它提供了 _explanation 。每个 入口都包含一个 description 、 value 、 details 字段,它分别告诉你计算的类型、计算结果和任何我们需要的计算细节。

"_explanation": { 
   "description": "weight(tweet:honeymoon in 0)
                  [PerFieldSimilarity], result of:",
   "value":       0.076713204,
   "details": [
      {
         "description": "fieldWeight in 0, product of:",
         "value":       0.076713204,
         "details": [
            {  
               "description": "tf(freq=1.0), with freq of:",
               "value":       1,
               "details": [
                  {
                     "description": "termFreq=1.0",
                     "value":       1
                  }
               ]
            },
            { 
               "description": "idf(docFreq=1, maxDocs=1)",
               "value":       0.30685282
            },
            { 
               "description": "fieldNorm(doc=0)",
               "value":        0.25,
            }
         ]
      }
   ]
}


    honeymoon 相关性评分计算的总结
    检索词频率               
    反向文档频率              
    字段长度准则         

输出 explain 结果代价是十分昂贵的,它只能用作调试工具 。千万不要用于生产环境。

第一部分是关于计算的总结。告诉了我们 honeymoon 在 tweet 字段中的检索词频率/反向文档频率或 TF/IDF, (这里的文档 0 是一个内部的 ID,跟我们没有关系,可以忽略。)

然后它提供了权重是如何计算的细节:

检索词频率:

检索词 `honeymoon` 在这个文档的 `tweet` 字段中的出现次数。

反向文档频率:

检索词 `honeymoon` 在索引上所有文档的 `tweet` 字段中出现的次数。

字段长度准则:

在这个文档中, `tweet` 字段内容的长度 -- 内容越长,值越小。

复杂的查询语句解释也非常复杂,但是包含的内容与上面例子大致相同。 通过这段信息我们可以了解搜索结果是如何产生的。

JSON 形式的 explain 描述是难以阅读的, 但是转成 YAML 会好很多,只需要在参数中加上 format=yaml 。

理解文档是如何被匹配到的

当 explain 选项加到某一文档上时, explain api 会帮助你理解为何这个文档会被匹配,更重要的是,一个文档为何没有被匹配。

请求路径为 /index/type/id/_explain ,如下所示:

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

不只是我们之前看到的充分解释 ,我们现在有了一个 description 元素,它将告诉我们:

"failure to match filter: cache(user_id:[2 TO 2])"

也就是说我们的 user_id 过滤子句使该文档不能匹配到。

  1. Doc Values 介绍

本章的最后一个话题是关于 Elasticsearch 内部的一些运行情况。在这里我们先不介绍新的知识点,所以我们应该意识到,Doc Values 是我们需要反复提到的一个重要话题。

当你对一个字段进行排序时,Elasticsearch 需要访问每个匹配到的文档得到相关的值。倒排索引的检索性能是非常快的,但是在字段值排序时却不是理想的结构。

  • 在搜索的时候,我们能通过搜索关键词快速得到结果集。
  • 当排序的时候,我们需要倒排索引里面某个字段值的集合。换句话说,我们需要 转置 倒排索引。

转置 结构在其他系统中经常被称作 列存储 。实质上,它将所有单字段的值存储在单数据列中,这使得对其进行操作是十分高效的,例如排序。

在 Elasticsearch 中,Doc Values 就是一种列式存储结构,默认情况下每个字段的 Doc Values 都是激活的,Doc Values 是在索引时创建的,当字段索引时,Elasticsearch 为了能够快速检索,会把字段的值加入倒排索引中,同时它也会存储该字段的 Doc Values。

Elasticsearch 中的 Doc Values 常被应用到以下场景:

  • 对一个字段进行排序
  • 对一个字段进行聚合
  • 某些过滤,比如地理位置过滤
  • 某些与字段相关的脚本计算

因为文档值被序列化到磁盘,我们可以依靠操作系统的帮助来快速访问。当 working set 远小于节点的可用内存,系统会自动将所有的文档值保存在内存中,使得其读写十分高速; 当其远大于可用内存,操作系统会自动把 Doc Values 加载到系统的页缓存中,从而避免了 jvm 堆内存溢出异常。

我们稍后会深入讨论 Doc Values。现在所有你需要知道的是排序发生在索引时建立的平行数据结构中。

九、执行分布式检索

在继续之前,我们将绕道讨论一下在分布式环境中搜索是怎么执行的。 这比我们在 分布式文档存储 章节讨论的基本的 增-删-改-查 (CRUD)请求要复杂一些。

内容提示

你可以根据兴趣阅读本章内容。你并不需要为了使用 Elasticsearch 而理解和记住所有的细节。

这章的阅读目的只为初步了解下工作原理,以便将来需要时可以及时找到这些知识, 但是不要被细节所困扰。

一个 CRUD 操作只对单个文档进行处理,文档的唯一性由 _index, _type, 和 routing values (通常默认是该文档的 _id )的组合来确定。 这表示我们确切的知道集群中哪个分片含有此文档。

搜索需要一种更加复杂的执行模型因为我们不知道查询会命中哪些文档: 这些文档有可能在集群的任何分片上。 一个搜索请求必须询问我们关注的索引(index or indices)的所有分片的某个副本来确定它们是否含有任何匹配的文档。

但是找到所有的匹配文档仅仅完成事情的一半。 在 search 接口返回一个 page 结果之前,多分片中的结果必须组合成单个排序列表。 为此,搜索被执行成一个两阶段过程,我们称之为 query then fetch 。

  1. 查询阶段

在初始 查询阶段 时, 查询会广播到索引中每一个分片拷贝(主分片或者副本分片)。 每个分片在本地执行搜索并构建一个匹配文档的 优先队列。

优先队列

一个 优先队列 仅仅是一个存有 top-n 匹配文档的有序列表。优先队列的大小取决于分页参数 from 和 size 。例如,如下搜索请求将需要足够大的优先队列来放入100条文档。

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

这个查询阶段的过程如图 图 14 “查询过程分布式搜索” 所示。

图 14. 查询过程分布式搜索

查询阶段包含以下三个步骤:

  1. 客户端发送一个 search 请求到 Node 3 , Node 3 会创建一个大小为 from + size 的空优先队列。
  2. Node 3 将查询请求转发到索引的每个主分片或副本分片中。每个分片在本地执行查询并添加结果到大小为 from + size 的本地有序优先队列中。
  3. 每个分片返回各自优先队列中所有文档的 ID 和排序值给协调节点,也就是 Node 3 ,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。

当一个搜索请求被发送到某个节点时,这个节点就变成了协调节点。 这个节点的任务是广播查询请求到所有相关分片并将它们的响应整合成全局排序后的结果集合,这个结果集合会返回给客户端。

第一步是广播请求到索引中每一个节点的分片拷贝。就像 document GET requests 所描述的, 查询请求可以被某个主分片或某个副本分片处理, 这就是为什么更多的副本(当结合更多的硬件)能够增加搜索吞吐率。 协调节点将在之后的请求中轮询所有的分片拷贝来分摊负载。

每个分片在本地执行查询请求并且创建一个长度为 from + size 的优先队列—也就是说,每个分片创建的结果集足够大,均可以满足全局的搜索请求。 分片返回一个轻量级的结果列表到协调节点,它仅包含文档 ID 集合以及任何排序需要用到的值,例如 _score 。

协调节点将这些分片级的结果合并到自己的有序优先队列里,它代表了全局排序结果集合。至此查询过程结束。

一个索引可以由一个或几个主分片组成, 所以一个针对单个索引的搜索请求需要能够把来自多个分片的结果组合起来。 针对 multiple 或者 all 索引的搜索工作方式也是完全一致的–仅仅是包含了更多的分片而已。

  1. 取回阶段

查询阶段标识哪些文档满足 搜索请求,但是我们仍然需要取回这些文档。这是取回阶段的任务, 正如 图 15 “分布式搜索的取回阶段” 所展示的。

图 15. 分布式搜索的取回阶段

分布式阶段由以下步骤构成:

  1. 协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。
  2. 每个分片加载并 丰富 文档,如果有需要的话,接着返回文档给协调节点。
  3. 一旦所有的文档都被取回了,协调节点返回结果给客户端。

协调节点首先决定哪些文档 确实 需要被取回。例如,如果我们的查询指定了 { “from”: 90, “size”: 10 } ,最初的90个结果会被丢弃,只有从第91个开始的10个结果需要被取回。这些文档可能来自和最初搜索请求有关的一个、多个甚至全部分片。

协调节点给持有相关文档的每个分片创建一个 multi-get request ,并发送请求给同样处理查询阶段的分片副本。

分片加载文档体– _source 字段–如果有需要,用元数据和 search snippet highlighting 丰富结果文档。 一旦协调节点接收到所有的结果文档,它就组装这些结果为单个响应返回给客户端。

深分页(Deep Pagination)

先查后取的过程支持用 from 和 size 参数分页,但是这是 有限制的 。 要记住需要传递信息给协调节点的每个分片必须先创建一个 from + size 长度的队列,协调节点需要根据 number_of_shards * (from + size) 排序文档,来找到被包含在 size 里的文档。

取决于你的文档的大小,分片的数量和你使用的硬件,给 10,000 到 50,000 的结果文档深分页( 1,000 到 5,000 页)是完全可行的。但是使用足够大的 from 值,排序过程可能会变得非常沉重,使用大量的CPU、内存和带宽。因为这个原因,我们强烈建议你不要使用深分页。

实际上, “深分页” 很少符合人的行为。当2到3页过去以后,人会停止翻页,并且改变搜索标准。会不知疲倦地一页一页的获取网页直到你的服务崩溃的罪魁祸首一般是机器人或者web spider。

如果你 确实 需要从你的集群取回大量的文档,你可以通过用 scroll 查询禁用排序使这个取回行为更有效率,我们会在 later in this chapter 进行讨论。

  1. 搜索选项

有几个 查询参数可以影响搜索过程。

偏好

偏好这个参数 preference 允许 用来控制由哪些分片或节点来处理搜索请求。 它接受像 _primary, _primary_first, _local, _only_node:xyz, _prefer_node:xyz, 和 _shards:2,3 这样的值, 这些值在 search preference 文档页面被详细解释。

但是最有用的值是某些随机字符串,它可以避免 bouncing results 问题。

Bouncing Results

想象一下有两个文档有同样值的时间戳字段,搜索结果用 timestamp 字段来排序。 由于搜索请求是在所有有效的分片副本间轮询的,那就有可能发生主分片处理请求时,这两个文档是一种顺序, 而副本分片处理请求时又是另一种顺序。

这就是所谓的 bouncing results 问题: 每次用户刷新页面,搜索结果表现是不同的顺序。 让同一个用户始终使用同一个分片,这样可以避免这种问题, 可以设置 preference 参数为一个特定的任意值比如用户会话ID来解决。

超时问题

通常分片处理完它所有的数据后再把结果返回给协同节点,协同节点把收到的所有结果合并为最终结果。

这意味着花费的时间是最慢分片的处理时间加结果合并的时间。如果有一个节点有问题,就会导致所有的响应缓慢。

参数 timeout 告诉 分片允许处理数据的最大时间。如果没有足够的时间处理所有数据,这个分片的结果可以是部分的,甚至是空数据。

搜索的返回结果会用属性 timed_out 标明分片是否返回的是部分结果:

    ...
    "timed_out":     true,  
    ...

这个搜索请求超时了。

超时仍然是一个最有效的操作,知道这一点很重要; 很可能查询会超过设定的超时时间。这种行为有两个原因:

  1. 超时检查是基于每文档做的。 但是某些查询类型有大量的工作在文档评估之前需要完成。 这种 “setup” 阶段并不考虑超时设置,所以太长的建立时间会导致超过超时时间的整体延迟。
  2. 因为时间检查是基于每个文档的,一次长时间查询在单个文档上执行并且在下个文档被评估之前不会超时。 这也意味着差的脚本(比如带无限循环的脚本)将会永远执行下去。

路由

在 路由一个文档到一个分片中 中, 我们解释过如何定制参数 routing ,它能够在索引时提供来确保相关的文档,比如属于某个用户的文档被存储在某个分片上。 在搜索的时候,不用搜索索引的所有分片,而是通过指定几个 routing 值来限定只搜索几个相关的分片:

GET /_search?routing=user_1,user2

这个技术在设计大规模搜索系统时就会派上用场,我们在 扩容设计 中详细讨论它。

搜索类型

缺省的搜索类型是 query_then_fetch 。 在某些情况下,你可能想明确设置 search_type 为 dfs_query_then_fetch 来改善相关性精确度:

GET /_search?search_type=dfs_query_then_fetch

搜索类型 dfs_query_then_fetch 有预查询阶段,这个阶段可以从所有相关分片获取词频来计算全局词频。 我们在 被破坏的相关度! 会再讨论它。

  1. 游标查询 Scroll

scroll 查询 可以用来对 Elasticsearch 有效地执行大批量的文档查询,而又不用付出深度分页那种代价。

游标查询允许我们 先做查询初始化,然后再批量地拉取结果。 这有点儿像传统数据库中的 cursor 。

游标查询会取某个时间点的快照数据。 查询初始化之后索引上的任何变化会被它忽略。 它通过保存旧的数据文件来实现这个特性,结果就像保留初始化时的索引 视图 一样。

深度分页的代价根源是结果集全局排序,如果去掉全局排序的特性的话查询结果的成本就会很低。 游标查询用字段 _doc 来排序。 这个指令让 Elasticsearch 仅仅从还有结果的分片返回下一批结果。

启用游标查询可以通过在查询的时候设置参数 scroll 的值为我们期望的游标查询的过期时间。 游标查询的过期时间会在每次做查询的时候刷新,所以这个时间只需要足够处理当前批的结果就可以了,而不是处理查询结果的所有文档的所需时间。 这个过期时间的参数很重要,因为保持这个游标查询窗口需要消耗资源,所以我们期望如果不再需要维护这种资源就该早点儿释放掉。 设置这个超时能够让 Elasticsearch 在稍后空闲的时候自动释放这部分资源。

GET /old_index/_search?scroll=1m 
{
    "query": { "match_all": {}},
    "sort" : ["_doc"], 
    "size":  1000
}


    保持游标查询窗口一分钟。       
    关键字 _doc 是最有效的排序顺序。

这个查询的返回结果包括一个字段 _scroll_id, 它是一个base64编码的长字符串 (((“scroll_id”))) 。 现在我们能传递字段 _scroll_id 到 _search/scroll 查询接口获取下一批结果:

GET /_search/scroll
{
    "scroll": "1m", 
    "scroll_id" : "cXVlcnlUaGVuRmV0Y2g7NTsxMDk5NDpkUmpiR2FjOFNhNnlCM1ZDMWpWYnRROzEwOTk1OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MTA5OTM6ZFJqYkdhYzhTYTZ5QjNWQzFqVmJ0UTsxMTE5MDpBVUtwN2lxc1FLZV8yRGVjWlI2QUVBOzEwOTk2OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MDs="
}


    注意再次设置游标查询过期时间为一分钟。

这个游标查询返回的下一批结果。 尽管我们指定字段 size 的值为1000,我们有可能取到超过这个值数量的文档。 当查询的时候, 字段 size 作用于单个分片,所以每个批次实际返回的文档数量最大为 size * number_of_primary_shards 。

注意游标查询每次返回一个新字段 _scroll_id。每次我们做下一次游标查询, 我们必须把前一次查询返回的字段 _scroll_id 传递进去。 当没有更多的结果返回的时候,我们就处理完所有匹配的文档了。

提示:某些官方的 Elasticsearch 客户端比如 Python 客户端 和 Perl 客户端 提供了这个功能易用的封装。

十、索引管理

我们已经看到 Elasticsearch 让开发一个新的应用变得简单,不需要任何预先计划或设置。 不过,要不了多久你就会开始想要优化索引和搜索过程,以便更好地适合您的特定用例。 这些定制几乎围绕着索引和类型的方方面面,在本章,我们将介绍管理索引和类型映射的 API 以及一些最重要的设置。

  1. 创建一个索引

到目前为止, 我们已经通过索引一篇文档创建了一个新的索引 。这个索引采用的是默认的配置,新的字段通过动态映射的方式被添加到类型映射。现在我们需要对这个建立索引的过程做更多的控制:我们想要确保这个索引有数量适中的主分片,并且在我们索引任何数据 之前 ,分析器和映射已经被建立好。

为了达到这个目的,我们需要手动创建索引,在请求体里面传入设置或类型映射,如下所示:

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

如果你想禁止自动创建索引,你 可以通过在 config/elasticsearch.yml 的每个节点下添加下面的配置:

action.auto_create_index: false

我们会在之后讨论你怎么用 索引模板 来预配置开启自动创建索引。这在索引日志数据的时候尤其有用:你将日志数据索引在一个以日期结尾命名的索引上,子夜时分,一个预配置的新索引将会自动进行创建。

  1. 删除一个索引

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

DELETE /my_index

你也可以这样删除多个索引:

DELETE /index_one,index_two
DELETE /index_*

你甚至可以这样删除 全部 索引:

DELETE /_all
DELETE /*

对一些人来说,能够用单个命令来删除所有数据可能会导致可怕的后果。如果你想要避免意外的大量删除, 你可以在你的 elasticsearch.yml 做如下配置:

action.destructive_requires_name: true

这个设置使删除只限于特定名称指向的数据, 而不允许通过指定 _all 或通配符来删除指定索引库。你同样可以通过 Cluster State API 动态的更新这个设置。

  1. 索引设置

你可以通过修改配置来自定义索引行为,详细配置参照 索引模块

Elasticsearch 提供了优化好的默认配置。 除非你理解这些配置的作用并且知道为什么要去修改,否则不要随意修改。

下面是两个最重要的设置:

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

  • number_of_replicas 
    每个主分片的副本数,默认值是 1 。对于活动的索引库,这个配置可以随时修改。

例如,我们可以创建只有 一个主分片,没有副本的小索引:

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

然后,我们可以用 update-index-settings API 动态修改副本数:

PUT /my_temp_index/_settings
{
    "number_of_replicas": 1
}
  1. 配置分析器

第三个重要的索引设置是 analysis 部分, 用来配置已存在的分析器或针对你的索引创建新的自定义分析器。

在 分析与分析器 ,我们介绍了一些内置的 分析器,用于将全文字符串转换为适合搜索的倒排索引。

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

  • standard 分词器,通过单词边界分割输入的文本。
  • standard 语汇单元过滤器,目的是整理分词器触发的语汇单元(但是目前什么都没做)。
  • lowercase 语汇单元过滤器,转换所有的语汇单元为小写。
  • stop 语汇单元过滤器,删除停用词–对搜索相关性影响不大的常用词,如 a , the , and , is 。

默认情况下,停用词过滤器是被禁用的。如需启用它,你可以通过创建一个基于 standard 分析器的自定义分析器并设置 stopwords 参数。 可以给分析器提供一个停用词列表,或者告知使用一个基于特定语言的预定义停用词列表。

在下面的例子中,我们创建了一个新的分析器,叫做 es_std , 并使用预定义的 西班牙语停用词列表:

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

es_std 分析器不是全局的–它仅仅存在于我们定义的 spanish_docs 索引中。 为了使用 analyze API来对它进行测试,我们必须使用特定的索引名:

GET /spanish_docs/_analyze?analyzer=es_std
El veloz zorro marrón

简化的结果显示西班牙语停用词 El 已被正确的移除:

{
  "tokens" : [
    { "token" :    "veloz",   "position" : 2 },
    { "token" :    "zorro",   "position" : 3 },
    { "token" :    "marrón",  "position" : 4 }
  ]
}
  1. 自定义分析器

虽然Elasticsearch带有一些现成的分析器,然而在分析器上Elasticsearch真正的强大之处在于,你可以通过在一个适合你的特定数据的设置之中组合字符过滤器、分词器、词汇单元过滤器来创建自定义的分析器。

在 分析与分析器 我们说过,一个 分析器 就是在一个包里面组合了三种函数的一个包装器, 三种函数按照顺序被执行:

  • 字符过滤器

    字符过滤器 用来 整理 一个尚未被分词的字符串。例如,如果我们的文本是HTML格式的,它会包含像

    或者

    这样的HTML标签,这些标签是我们不想索引的。我们可以使用 html清除 字符过滤器 来移除掉所有的HTML标签,并且像把 Á 转换为相对应的Unicode字符 Á 这样,转换HTML实体。一个分析器可能有0个或者多个字符过滤器。

     

  • 分词器

    一个分析器 必须 有一个唯一的分词器。 分词器把字符串分解成单个词条或者词汇单元。 标准 分析器里使用的 标准 分词器 把一个字符串根据单词边界分解成单个词条,并且移除掉大部分的标点符号,然而还有其他不同行为的分词器存在。例如, 关键词 分词器 完整地输出 接收到的同样的字符串,并不做任何分词。 空格 分词器 只根据空格分割文本 。 正则 分词器 根据匹配正则表达式来分割文本 。

  • 词单元过滤器

    经过分词,作为结果的 词单元流 会按照指定的顺序通过指定的词单元过滤器 。词单元过滤器可以修改、添加或者移除词单元。我们已经提到过 lowercase 和 stop 词过滤器 ,但是在 Elasticsearch 里面还有很多可供选择的词单元过滤器。 词干过滤器 把单词 遏制 为 词干。 ascii_folding 过滤器移除变音符,把一个像 “très” 这样的词转换为 “tres” 。 ngram 和 edge_ngram 词单元过滤器 可以产生 适合用于部分匹配或者自动补全的词单元。

在 深入搜索,我们讨论了在哪里使用,以及怎样使用分词器和过滤器。但是首先,我们需要解释一下怎样创建自定义的分析器。

创建一个自定义分析器

和我们之前配置 es_std 分析器一样,我们可以在 analysis 下的相应位置设置字符过滤器、分词器和词单元过滤器:

PUT /my_index
{
    "settings": {
        "analysis": {
            "char_filter": { ... custom character filters ... },
            "tokenizer":   { ...    custom tokenizers     ... },
            "filter":      { ...   custom token filters   ... },
            "analyzer":    { ...    custom analyzers      ... }
        }
    }
}

作为示范,让我们一起来创建一个自定义分析器吧,这个分析器可以做到下面的这些事:

  1. 使用 html清除 字符过滤器移除HTML部分。
  2. 使用一个自定义的 映射 字符过滤器把 & 替换为 ” and ” :

    “char_filter”: { 
    “&_to_and”: { 
    “type”: “mapping”, 
    “mappings”: [ “&=> and “] 

    }

  3. 使用 标准 分词器分词。

  4. 小写词条,使用 小写 词过滤器处理。
  5. 使用自定义 停止 词过滤器移除自定义的停止词列表中包含的词:

    “filter”: { 
    “my_stopwords”: { 
    “type”: “stop”, 
    “stopwords”: [ “the”, “a” ] 

    }

我们的分析器定义用我们之前已经设置好的自定义过滤器组合了已经定义好的分词器和过滤器:

"analyzer": {
    "my_analyzer": {
        "type":           "custom",
        "char_filter":  [ "html_strip", "&_to_and" ],
        "tokenizer":      "standard",
        "filter":       [ "lowercase", "my_stopwords" ]
    }
}

汇总起来,完整的 创建索引 请求 看起来应该像这样:

PUT /my_index
{
    "settings": {
        "analysis": {
            "char_filter": {
                "&_to_and": {
                    "type":       "mapping",
                    "mappings": [ "&=> and "]
            }},
            "filter": {
                "my_stopwords": {
                    "type":       "stop",
                    "stopwords": [ "the", "a" ]
            }},
            "analyzer": {
                "my_analyzer": {
                    "type":         "custom",
                    "char_filter":  [ "html_strip", "&_to_and" ],
                    "tokenizer":    "standard",
                    "filter":       [ "lowercase", "my_stopwords" ]
            }}
}}}

索引被创建以后,使用 analyze API 来 测试这个新的分析器:

GET /my_index/_analyze?analyzer=my_analyzer
The quick & brown fox

下面的缩略结果展示出我们的分析器正在正确地运行:

{
  "tokens" : [
      { "token" :   "quick",    "position" : 2 },
      { "token" :   "and",      "position" : 3 },
      { "token" :   "brown",    "position" : 4 },
      { "token" :   "fox",      "position" : 5 }
    ]
}

这个分析器现在是没有多大用处的,除非我们告诉 Elasticsearch在哪里用上它。我们可以像下面这样把这个分析器应用在一个 string 字段上:

PUT /my_index/_mapping/my_type
{
    "properties": {
        "title": {
            "type":      "string",
            "analyzer":  "my_analyzer"
        }
    }
}
  1. 类型和映射

类型 在 Elasticsearch 中表示一类相似的文档。 类型由 名称 —比如 user 或 blogpost —和 映射 组成。

映射, 就像数据库中的 schema ,描述了文档可能具有的字段或 属性 、 每个字段的数据类型—比如 string, integer 或 date —以及Lucene是如何索引和存储这些字段的。

类型可以很好的抽象划分相似但不相同的数据。但由于 Lucene 的处理方式,类型的使用有些限制。

Lucene 如何处理文档

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

当我们在 Lucene 中索引一个文档时,每个字段的值都被添加到相关字段的倒排索引中。你也可以将未处理的原始数据 存储 起来,以便这些原始数据在之后也可以被检索到。

类型是如何实现的

Elasticsearch 类型是 以 Lucene 处理文档的这个方式为基础来实现的。一个索引可以有多个类型,这些类型的文档可以存储在相同的索引中。

Lucene 没有文档类型的概念,每个文档的类型名被存储在一个叫 _type 的元数据字段上。 当我们要检索某个类型的文档时, Elasticsearch 通过在 _type 字段上使用过滤器限制只返回这个类型的文档。

Lucene 也没有映射的概念。 映射是 Elasticsearch 将复杂 JSON 文档 映射 成 Lucene 需要的扁平化数据的方式。

例如,在 user 类型中, name 字段的映射可以声明这个字段是 string 类型,并且它的值被索引到名叫 name 的倒排索引之前,需要通过 whitespace 分词器分析:

"name": {
    "type":     "string",
    "analyzer": "whitespace"
}

避免类型陷阱

这导致了一个有趣的思想实验: 如果有两个不同的类型,每个类型都有同名的字段,但映射不同(例如:一个是字符串一个是数字),将会出现什么情况?

简单回答是,Elasticsearch 不会允许你定义这个映射。当你配置这个映射时,将会出现异常。

详细回答是,每个 Lucene 索引中的所有字段都包含一个单一的、扁平的模式。一个特定字段可以映射成 string 类型也可以是 number 类型,但是不能两者兼具。因为类型是 Elasticsearch 添加的 优于 Lucene 的额外机制(以元数据 _type 字段的形式),在 Elasticsearch 中的所有类型最终都共享相同的映射。

以 data 索引中两种类型的映射为例:

{
   "data": {
      "mappings": {
         "people": {
            "properties": {
               "name": {
                  "type": "string",
               },
               "address": {
                  "type": "string"
               }
            }
         },
         "transactions": {
            "properties": {
               "timestamp": {
                  "type": "date",
                  "format": "strict_date_optional_time"
               },
               "message": {
                  "type": "string"
               }
            }
         }
      }
   }
}

每个类型定义两个字段 (分别是 “name”/”address” 和 “timestamp”/”message” )。它们看起来是相互独立的,但在后台 Lucene 将创建一个映射,如:

{
   "data": {
      "mappings": {
        "_type": {
          "type": "string",
          "index": "not_analyzed"
        },
        "name": {
          "type": "string"
        }
        "address": {
          "type": "string"
        }
        "timestamp": {
          "type": "long"
        }
        "message": {
          "type": "string"
        }
      }
   }
}

注: 这不是真实有效的映射语法,只是用于演示

对于整个索引,映射在本质上被 扁平化 成一个单一的、全局的模式。这就是为什么两个类型不能定义冲突的字段:当映射被扁平化时,Lucene 不知道如何去处理。

类型结论

那么,这个讨论的结论是什么?技术上讲,多个类型可以在相同的索引中存在,只要它们的字段不冲突(要么因为字段是互为独占模式,要么因为它们共享相同的字段)。

重要的一点是: 类型可以很好的区分同一个集合中的不同细分。在不同的细分中数据的整体模式是相同的(或相似的)。

类型不适合 完全不同类型的数据 。如果两个类型的字段集是互不相同的,这就意味着索引中将有一半的数据是空的(字段将是 稀疏的 ),最终将导致性能问题。在这种情况下,最好是使用两个单独的索引。

总结:

  • 正确: 将 kitchen 和 lawn-care 类型放在 products 索引中, 因为这两种类型基本上是相同的模式
  • 错误: 将 products 和 logs 类型放在 data 索引中, 因为这两种类型互不相同。应该将它们放在不同的索引中。

    1. 根对象

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

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

属性

我们已经在 核心简单域类型 和 复杂核心域类型 章节中介绍过文档字段和属性的三个 最重要的设置:

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

我们将在本书的后续部分讨论其他字段类型,例如 ip 、 geo_point 和 geo_shape 。

元数据: _source 字段

默认地,Elasticsearch 在 _source 字段存储代表文档体的JSON字符串。和所有被存储的字段一样, _source 字段在被写入磁盘之前先会被压缩。

这个字段的存储几乎总是我们想要的,因为它意味着下面的这些:

  • 搜索结果包括了整个可用的文档——不需要额外的从另一个的数据仓库来取文档。
  • 如果没有 _source 字段,部分 update 请求不会生效。
  • 当你的映射改变时,你需要重新索引你的数据,有了_source字段你可以直接从Elasticsearch这样做,而不必从另一个(通常是速度更慢的)数据仓库取回你的所有文档。
  • 当你不需要看到整个文档时,单个字段可以从 _source 字段提取和通过 get 或者 search 请求返回。
  • 调试查询语句更加简单,因为你可以直接看到每个文档包括什么,而不是从一列id猜测它们的内容。

然而,存储 _source 字段的确要使用磁盘空间。如果上面的原因对你来说没有一个是重要的,你可以用下面的映射禁用 _source 字段:

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

在一个搜索请求里,你可以通过在请求体中指定 _source 参数,来达到只获取特定的字段的效果:

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

这些字段的值会从 _source 字段被提取和返回,而不是返回整个 _source 。

Stored Fields 被存储字段

为了之后的检索,除了索引一个字段的值,你 还可以选择 存储 原始字段值。有 Lucene 使用背景的用户使用被存储字段来选择他们想要在搜索结果里面返回的字段。事实上, _source 字段就是一个被存储的字段。

在Elasticsearch中,对文档的个别字段设置存储的做法通常不是最优的。整个文档已经被存储为 _source 字段。使用 _source 参数提取你需要的字段总是更好的。

元数据: _all 字段

在 轻量 搜索 中,我们介绍了 _all 字段:一个把其它字段值 当作一个大字符串来索引的特殊字段。 query_string 查询子句(搜索 ?q=john )在没有指定字段时默认使用 _all 字段。

_all 字段在新应用的探索阶段,当你还不清楚文档的最终结构时是比较有用的。你可以使用这个字段来做任何查询,并且有很大可能找到需要的文档:

GET /_search
{
    "match": {
        "_all": "john smith marketing"
    }
}

随着应用的发展,搜索需求变得更加明确,你会发现自己越来越少使用 _all 字段。 _all 字段是搜索的应急之策。通过查询指定字段,你的查询更加灵活、强大,你也可以对相关性最高的搜索结果进行更细粒度的控制。

relevance algorithm 考虑的一个最重要的原则是字段的长度:字段越短越重要。 在较短的 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 选项,仅在你选择的字段上启用:

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 字符串
  • _type 
    文档的类型名
  • _index 
    文档所在的索引
  • _uid 
    _type 和 _id 连接在一起构造成 type#id

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

尽管如此,你仍然可以像真实字段一样查询 _id 字段。Elasticsearch 使用 _uid 字段来派生出 _id 。 虽然你可以修改这些字段的 index 和 store 设置,但是基本上不需要这么做。

  1. 动态映射

当 Elasticsearch 遇到文档中以前 未遇到的字段,它用 dynamic mapping 来确定字段的数据类型并自动把新的字段添加到类型映射。

有时这是想要的行为有时又不希望这样。通常没有人知道以后会有什么新字段加到文档,但是又希望这些字段被自动的索引。也许你只想忽略它们。如果Elasticsearch是作为重要的数据存储,可能就会期望遇到新字段就会抛出异常,这样能及时发现问题。

幸运的是可以用 dynamic 配置来控制这种行为 ,可接受的选项如下:

  • true 
    动态添加新的字段–缺省
  • false 
    忽略新的字段
  • strict 
    如果遇到新字段抛出异常

配置参数 dynamic 可以用在根 object 或任何 object 类型的字段上。你可以将 dynamic 的默认值设置为 strict , 而只在指定的内部对象中开启它, 例如:

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


    如果遇到新字段,对象 my_type 就会抛出异常。 
    而内部对象 stash 遇到新字段就会动态创建新字段。

使用上述动态映射, 你可以给 stash 对象添加新的可检索的字段:

PUT /my_index/my_type/1
{
    "title":   "This doc adds a new field",
    "stash": { "new_field": "Success!" }
}

但是对根节点对象 my_type 进行同样的操作会失败:

PUT /my_index/my_type/1
{
    "title":     "This throws a StrictDynamicMappingException",
    "new_field": "Fail!"
}

把 dynamic 设置为 false 一点儿也不会改变 _source 的字段内容。 _source 仍然包含被索引的整个JSON文档。只是新的字段不会被加到映射中也不可搜索。

  1. 自定义动态映射

如果你想在运行时增加新的字段,你可能会启用动态映射。 然而,有时候,动态映射 规则 可能不太智能。幸运的是,我们可以通过设置去自定义这些规则,以便更好的适用于你的数据。

日期检测

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

有些时候这个行为可能导致一些问题。想象下,你有如下这样的一个文档:

{ "note": "2014-01-01" }

假设这是第一次识别 note 字段,它会被添加为 date 字段。但是如果下一个文档像这样:

{ "note": "Logged out" }

这显然不是一个日期,但为时已晚。这个字段已经是一个日期类型,这个 不合法的日期 将会造成一个异常。

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

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

使用这个映射,字符串将始终作为 string 类型。如果你需要一个 date 字段,你必须手动添加。

Elasticsearch 判断字符串为日期的规则可以通过 dynamic_date_formats setting 来设置。

动态模板

使用 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"
                      }
                }}
            ]
}}}


    匹配字段名以 _es 结尾的字段。
    匹配其他所有字符串类型字段。   

match_mapping_type 允许你应用模板到特定类型的字段上,就像有标准动态映射规则检测的一样, (例如 string 或 long)。

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

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

unmatch 和 path_unmatch将被用于未被匹配的字段。

  1. 缺省映射

通常,一个索引中的所有类型共享相同的字段和设置。 default 映射更加方便地指定通用设置,而不是每次创建新类型时都要重复设置。 default 映射是新类型的模板。在设置 default 映射之后创建的所有类型都将应用这些缺省的设置,除非类型在自己的映射中明确覆盖这些设置。

例如,我们可以使用 default 映射为所有的类型禁用 _all 字段, 而只在 blog 类型启用:

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

default 映射也是一个指定索引 dynamic templates 的好方法。

  1. 重新索引你的数据

尽管可以增加新的类型到索引中,或者增加新的字段到类型中,但是不能添加新的分析器或者对现有的字段做改动。 如果你那么做的话,结果就是那些已经被索引的数据就不正确, 搜索也不能正常工作。

对现有数据的这类改变最简单的办法就是重新索引:用新的设置创建新的索引并把文档从旧的索引复制到新的索引。

字段 _source 的一个优点是在Elasticsearch中已经有整个文档。你不必从源数据中重建索引,而且那样通常比较慢。

为了有效的重新索引所有在旧的索引中的文档,用 scroll 从旧的索引检索批量文档 , 然后用 bulk API 把文档推送到新的索引中。

从Elasticsearch v2.3.0开始, Reindex API 被引入。它能够对文档重建索引而不需要任何插件或外部工具。

批量重新索引

同时并行运行多个重建索引任务,但是你显然不希望结果有重叠。正确的做法是按日期或者时间 这样的字段作为过滤条件把大的重建索引分成小的任务:

GET /old_index/_search?scroll=1m
{
    "query": {
        "range": {
            "date": {
                "gte":  "2014-01-01",
                "lt":   "2014-02-01"
            }
        }
    },
    "sort": ["_doc"],
    "size":  1000
}

如果旧的索引持续会有变化,你希望新的索引中也包括那些新加的文档。那就可以对新加的文档做重新索引, 但还是要用日期类字段过滤来匹配那些新加的文档。

  1. 索引别名和零停机

在前面提到的,重建索引的问题是必须更新应用中的索引名称。 索引别名就是用来解决这个问题的!

索引 别名 就像一个快捷方式或软连接,可以指向一个或多个索引,也可以给任何一个需要索引名的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 


    创建索引 my_index_v1 。            
    设置别名 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" }}
    ]
}

你的应用已经在零停机的情况下从旧索引迁移到新索引了。

即使你认为现在的索引设计已经很完美了,在生产环境中,还是有可能需要做一些修改的。

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

十一、分片内部原理

在 集群内的原理, 我们介绍了 分片, 并将它 描述成最小的 工作单元。但是究竟什么 是 一个分片,它是如何工作的? 在这个章节,我们回答以下问题:

  • 为什么搜索是 近 实时的?
  • 为什么文档的 CRUD (创建-读取-更新-删除) 操作是 实时 的?
  • Elasticsearch 是怎样保证更新被持久化在断电时也不丢失数据?
  • 为什么删除文档不会立刻释放空间?
  • refresh, flush, 和 optimize API 都做了什么, 你什么情况下应该是用他们?

最简单的理解一个分片如何工作的方式是上一堂历史课。 我们将要审视提供一个带近实时搜索和分析的 分布式持久化数据存储需要解决的问题。

内容警告

本章展示的这些信息仅供您兴趣阅读。为了使用 Elasticsearch 您并不需要理解和记忆所有的细节。 读这个章节是为了了解工作机制,并且为了将来您需要这些信息时,知道这些信息在哪里。但是不要被这些细节所累。

  1. 使文本可被搜索

必须解决的第一个挑战是如何 使文本可被搜索。 传统的数据库每个字段存储单个值,但这对全文检索并不够。文本字段中的每个单词需要被搜索,对数据库意味着需要单个字段有索引多值(这里指单词)的能力。

最好的支持 一个字段多个值 需求的数据结构是我们在 倒排索引 章节中介绍过的 倒排索引 。 倒排索引包含一个有序列表,列表包含所有文档出现过的不重复个体,或称为 词项 ,对于每一个词项,包含了它所有曾出现过文档的列表。

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

当讨论倒排索引时,我们会谈到 文档 标引,因为历史原因,倒排索引被用来对整个非结构化文本文档进行标引。 Elasticsearch 中的 文档 是有字段和值的结构化 JSON 文档。事实上,在 JSON 文档中, 每个被索引的字段都有自己的倒排索引。

这个倒排索引相比特定词项出现过的文档列表,会包含更多其它信息。它会保存每一个词项出现过的文档总数, 在对应的文档中一个具体词项出现的总次数,词项在文档中的顺序,每个文档的长度,所有文档的平均长度,等等。这些统计信息允许 Elasticsearch 决定哪些词比其它词更重要,哪些文档比其它文档更重要,这些内容在 什么是相关性? 中有描述。

为了能够实现预期功能,倒排索引需要知道集合中的 所有 文档,这是需要认识到的关键问题。

早期的全文检索会为整个文档集合建立一个很大的倒排索引并将其写入到磁盘。 一旦新的索引就绪,旧的就会被其替换,这样最近的变化便可以被检索到。

不变性

倒排索引被写入磁盘后是 不可改变 的:它永远不会修改。 不变性有重要的价值:

  • 不需要锁。如果你从来不更新索引,你就不需要担心多进程同时修改数据的问题。
  • 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。
  • 其它缓存(像filter缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
  • 写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O 和 需要被缓存到内存的索引的使用量。

当然,一个不变的索引也有不好的地方。主要事实是它是不可变的! 你不能修改它。如果你需要让一个新的文档 可被搜索,你需要重建整个索引。这要么对一个索引所能包含的数据量造成了很大的限制,要么对索引可被更新的频率造成了很大的限制。

  1. 动态更新索引

下一个需要被解决的问题是怎样在保留不变性的前提下实现倒排索引的更新? 答案是: 用更多的索引。

通过增加新的补充索引来反映新近的修改,而不是直接重写整个倒排索引。每一个倒排索引都会被轮流查询到–从最早的开始–查询完后再对结果进行合并。

Elasticsearch 基于 Lucene, 这个 java 库引入了 按段搜索 的概念。 每一 段 本身都是一个倒排索引, 但 索引 在 Lucene 中除表示所有 段 的集合外, 还增加了 提交点 的概念 — 一个列出了所有已知段的文件,就像在 图 16 “一个 Lucene 索引包含一个提交点和三个段” 中描绘的那样。 如 图 17 “一个在内存缓存中包含新文档的 Lucene 索引” 所示,新的文档首先被添加到内存索引缓存中,然后写入到一个基于磁盘的段,如 图 18 “在一次提交后,一个新的段被添加到提交点而且缓存被清空。” 所示。

图 16. 一个 Lucene 索引包含一个提交点和三个段

索引与分片的比较

被混淆的概念是,一个 Lucene 索引 我们在 Elasticsearch 称作 分片 。 一个 Elasticsearch 索引 是分片的集合。 当 Elasticsearch 在索引中搜索的时候, 他发送查询到每一个属于索引的分片(Lucene 索引),然后像 执行分布式检索 提到的那样,合并每个分片的结果到一个全局的结果集。

逐段搜索会以如下流程进行工作:

  1. 新文档被收集到内存索引缓存, 见 图 17 “一个在内存缓存中包含新文档的 Lucene 索引” 。
  2. 不时地, 缓存被 提交 :

    • 一个新的段–一个追加的倒排索引–被写入磁盘。
    • 一个新的包含新段名字的 提交点 被写入磁盘。
    • 磁盘进行 同步 — 所有在文件系统缓存中等待的写入都刷新到磁盘,以确保它们被写入物理文件。
  3. 新的段被开启,让它包含的文档可见以被搜索。

  4. 内存缓存被清空,等待接收新的文档。

图 17. 一个在内存缓存中包含新文档的 Lucene 索引

图 18. 在一次提交后,一个新的段被添加到提交点而且缓存被清空。

当一个查询被触发,所有已知的段按顺序被查询。词项统计会对所有段的结果进行聚合,以保证每个词和每个文档的关联都被准确计算。 这种方式可以用相对较低的成本将新文档添加到索引。

删除和更新

段是不可改变的,所以既不能从把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。 取而代之的是,每个提交点会包含一个 .del 文件,文件中会列出这些被删除文档的段信息。

当一个文档被 “删除” 时,它实际上只是在 .del 文件中被 标记 删除。一个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。

文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。

在 段合并 , 我们展示了一个被删除的文档是怎样被文件系统移除的。

  1. 近实时搜索

随着按段(per-segment)搜索的发展, 一个新的文档从索引到可被搜索的延迟显著降低了。新文档在几分钟之内即可被检索,但这样还是不够快。

磁盘在这里成为了瓶颈。 提交(Commiting)一个新的段到磁盘需要一个 fsync 来确保段被物理性地写入磁盘,这样在断电的时候就不会丢失数据。 但是 fsync 操作代价很大; 如果每次索引一个文档都去执行一次的话会造成很大的性能问题。

我们需要的是一个更轻量的方式来使一个文档可被搜索,这意味着 fsync 要从整个过程中被移除。

在Elasticsearch和磁盘之间是文件系统缓存。 像之前描述的一样, 在内存索引缓冲区( 图 19 “在内存缓冲区中包含了新文档的 Lucene 索引” )中的文档会被写入到一个新的段中( 图 20 “缓冲区的内容已经被写入一个可被搜索的段中,但还没有进行提交” )。 但是这里新段会被先写入到文件系统缓存–这一步代价会比较低,稍后再被刷新到磁盘–这一步代价比较高。不过只要文件已经在缓存中, 就可以像其它文件一样被打开和读取了。

图 19. 在内存缓冲区中包含了新文档的 Lucene 索引

Lucene 允许新段被写入和打开–使其包含的文档在未进行一次完整提交时便对搜索可见。 这种方式比进行一次提交代价要小得多,并且在不影响性能的前提下可以被频繁地执行。

图 20. 缓冲区的内容已经被写入一个可被搜索的段中,但还没有进行提交

refresh API

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

这些行为可能会对新用户造成困惑: 他们索引了一个文档然后尝试搜索它,但却没有搜到。这个问题的解决办法是用 refresh API 执行一次手动刷新:

POST /_refresh 
POST /blogs/_refresh 


    刷新(Refresh)所有的索引。     
    只刷新(Refresh) blogs 索引。

尽管刷新是比提交轻量很多的操作,它还是会有性能开销。 当写测试的时候, 手动刷新很有用,但是不要在生产环境下每次索引一个文档都去手动刷新。 相反,你的应用需要意识到 Elasticsearch 的近实时的性质,并接受它的不足。

并不是所有的情况都需要每秒刷新。可能你正在使用 Elasticsearch 索引大量的日志文件, 你可能想优化索引速度而不是近实时搜索, 可以通过设置 refresh_interval , 降低每个索引的刷新频率:

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


    每30秒刷新 my_logs 索引。

refresh_interval 可以在既存索引上进行动态更新。 在生产环境中,当你正在建立一个大的新索引时,可以先关闭自动刷新,待开始使用该索引时,再把它们调回来:

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

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


    关闭自动刷新。
    每秒自动刷新。

refresh_interval 需要一个 持续时间 值, 例如 1s (1 秒) 或 2m (2 分钟)。 一个绝对值 1 表示的是 1毫秒 –无疑会使你的集群陷入瘫痪。

  1. 持久化变更

如果没有用 fsync 把数据从文件系统缓存刷(flush)到硬盘,我们不能保证数据在断电甚至是程序正常退出之后依然存在。为了保证 Elasticsearch 的可靠性,需要确保数据变化被持久化到磁盘。

在 动态更新索引,我们说一次完整的提交会将段刷到磁盘,并写入一个包含所有段列表的提交点。Elasticsearch 在启动或重新打开一个索引的过程中使用这个提交点来判断哪些段隶属于当前分片。

即使通过每秒刷新(refresh)实现了近实时搜索,我们仍然需要经常进行完整提交来确保能从失败中恢复。但在两次提交之间发生变化的文档怎么办?我们也不希望丢失掉这些数据。

Elasticsearch 增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了日志记录。通过 translog ,整个流程看起来是下面这样:

  1. 一个文档被索引之后,就会被添加到内存缓冲区,并且 追加到了 translog ,正如 图 21 “新的文档被添加到内存缓冲区并且被追加到了事务日志” 描述的一样。

    图 21. 新的文档被添加到内存缓冲区并且被追加到了事务日志

  2. 刷新(refresh)使分片处于 图 22 “刷新(refresh)完成后, 缓存被清空但是事务日志不会” 描述的状态,分片每秒被刷新(refresh)一次:

    • 这些在内存缓冲区的文档被写入到一个新的段中,且没有进行 fsync 操作。
    • 这个段被打开,使其可被搜索。
    • 内存缓冲区被清空。

    图 22. 刷新(refresh)完成后, 缓存被清空但是事务日志不会

  3. 这个进程继续工作,更多的文档被添加到内存缓冲区和追加到事务日志(见 图 23 “事务日志不断积累文档” )。

    图 23. 事务日志不断积累文档

  4. 每隔一段时间–例如 translog 变得越来越大–索引被刷新(flush);一个新的 translog 被创建,并且一个全量提交被执行(见 图 24 “在刷新(flush)之后,段被全量提交,并且事务日志被清空” ):

    • 所有在内存缓冲区的文档都被写入一个新的段。
    • 缓冲区被清空。
    • 一个提交点被写入硬盘。
    • 文件系统缓存通过 fsync 被刷新(flush)。
    • 老的 translog 被删除。

translog 提供所有还没有被刷到磁盘的操作的一个持久化纪录。当 Elasticsearch 启动的时候, 它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。

translog 也被用来提供实时 CRUD 。当你试着通过ID查询、更新、删除一个文档,它会在尝试从相应的段中检索之前, 首先检查 translog 任何最近的变更。这意味着它总是能够实时地获取到文档的最新版本。

图 24. 在刷新(flush)之后,段被全量提交,并且事务日志被清空

flush API

这个执行一个提交并且截断 translog 的行为在 Elasticsearch 被称作一次 flush 。 分片每30分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新。请查看 translog 文档 来设置,它可以用来 控制这些阈值:

flush API 可以 被用来执行一个手工的刷新(flush):

POST /blogs/_flush 

POST /_flush?wait_for_ongoing 


    刷新(flush) blogs 索引。            
    刷新(flush)所有的索引并且并且等待所有刷新在返回前完成。

你很少需要自己手动执行一个的 flush 操作;通常情况下,自动刷新就足够了。

这就是说,在重启节点或关闭索引之前执行 flush 有益于你的索引。当 Elasticsearch 尝试恢复或重新打开一个索引, 它需要重放 translog 中所有的操作,所以如果日志越短,恢复越快。

Translog 有多安全?

translog 的目的是保证操作不会丢失。这引出了这个问题: Translog 有多安全 ?

在文件被 fsync 到磁盘前,被写入的文件在重启之后就会丢失。默认 translog 是每 5 秒被 fsync 刷新到硬盘, 或者在每次写请求完成之后执行(e.g. index, delete, update, bulk)。这个过程在主分片和复制分片都会发生。最终, 基本上,这意味着在整个请求被 fsync 到主分片和复制分片的translog之前,你的客户端不会得到一个 200 OK 响应。

在每次请求后都执行一个 fsync 会带来一些性能损失,尽管实践表明这种损失相对较小(特别是bulk导入,它在一次请求中平摊了大量文档的开销)。

但是对于一些大容量的偶尔丢失几秒数据问题也并不严重的集群,使用异步的 fsync 还是比较有益的。比如,写入的数据被缓存到内存中,再每5秒执行一次 fsync 。

这个行为可以通过设置 durability 参数为 async 来启用:

PUT /my_index/_settings
{
    "index.translog.durability": "async",
    "index.translog.sync_interval": "5s"
}

这个选项可以针对索引单独设置,并且可以动态进行修改。如果你决定使用异步 translog 的话,你需要 保证 在发生crash时,丢失掉 sync_interval 时间段的数据也无所谓。请在决定前知晓这个特性。

如果你不确定这个行为的后果,最好是使用默认的参数( “index.translog.durability”: “request” )来避免数据丢失。

  1. 段合并

由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。 每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段;所以段越多,搜索也就越慢。

Elasticsearch通过在后台进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。

段合并的时候会将那些旧的已删除文档 从文件系统中清除。 被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。

启动段合并不需要你做任何事。进行索引和搜索时会自动进行。这个流程像在 图 25 “两个提交了的段和一个未提交的段正在被合并到一个更大的段” 中提到的一样工作:

1、 当索引的时候,刷新(refresh)操作会创建新的段并将段打开以供搜索使用。

2、 合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。

图 25. 两个提交了的段和一个未提交的段正在被合并到一个更大的段

3、 图 26 “一旦合并结束,老的段被删除” 说明合并完成时的活动:

  • 新的段被刷新(flush)到了磁盘。 ** 写入一个包含新段且排除旧的和较小的段的新提交点。
  • 新的段被打开用来搜索。
  • 老的段被删除。

图 26. 一旦合并结束,老的段被删除

合并大的段需要消耗大量的I/O和CPU资源,如果任其发展会影响搜索性能。Elasticsearch在默认情况下会对合并流程进行资源限制,所以搜索仍然 有足够的资源很好地执行。

查看 段和合并 来为你的实例获取关于合并调整的建议。

optimize API

optimize API大可看做是 强制合并 API 。它会将一个分片强制合并到 max_num_segments 参数指定大小的段数目。 这样做的意图是减少段的数量(通常减少到一个),来提升搜索性能。

optimize API 不应该 被用在一个动态索引————一个正在被活跃更新的索引。后台合并流程已经可以很好地完成工作。 optimizing 会阻碍这个进程。不要干扰它!

在特定情况下,使用 optimize API 颇有益处。例如在日志这种用例下,每天、每周、每月的日志被存储在一个索引中。 老的索引实质上是只读的;它们也并不太可能会发生变化。

在这种情况下,使用optimize优化老的索引,将每一个分片合并为一个单独的段就很有用了;这样既可以节省资源,也可以使搜索更加快速:

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


    合并索引中的每个分片为一个单独的段

请注意,使用 optimize API 触发段合并的操作一点也不会受到任何资源上的限制。这可能会消耗掉你节点上全部的I/O资源, 使其没有余裕来处理搜索请求,从而有可能使集群失去响应。 如果你想要对索引执行 optimize,你需要先使用分片分配(查看 迁移旧索引)把索引移到一个安全的节点,再执行。

推荐阅读