ES1_2使用索引和文档

这篇文档总结ES中的基础数据结构,并介绍如何操作它们。

ES存储的基础概念 - 索引、映射和文档

几个概念的映射关系:
Relational DB -> Databases -> Tables -> Rows -> Columns
Elasticsearch -> Indices -> Types -> Documents -> Fields

将 Type 类比为 Table 并不恰当,因为 ES 中一个索引下的多个类型共用相同的空间。

文档

  • ES是面向文档的,文档是所有可搜索数据的最小单位。
  • 文档会被序列化成JSON格式,保存在ES中;
    JSON对象由字段组成,每个字段都有对应的字段类型。
  • 每个文档都有一个Unique ID。
    这个Unique ID可以自己指定或由ES自动生成。

文档和字段 - Document、Field

一个文档是一个可被索引的基础信息单元,文档以 JSON 格式来表示。
在一个 index/type 里面,可以存储任意多的文档,每个文档都有唯一 id。
每个文档包含多个字段(fields),即 json 数据里的字段。

文档元数据

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

  • _index
    一个 索引 应该是因共同的特性被分组到一起的文档集合。
    索引名字必须小写,不能以下划线开头,不能包含逗号。
  • _type
    Lucene 没有文档类型的概念,而是使用一个元数据字段_type 文档表示的对象类别,数据可能在索引中只是松散的组合在一起,但是通常明确定义一些数据中的子分区是很有用的,不同 types 的文档可能有不同的字段,但最好能够非常相似。
    一个 _type 命名可以是大写或者小写,但是不能以下划线或者句号开头,不应该包含逗号, 并且长度限制为 256 个字符。
    当我们要检索某个类型的文档时, Elasticsearch 通过在 _type 字段上使用过滤器限制只返回这个类型的文档。
  • _id
    文档唯一标识,和 _index 以及 _type 组合就可以唯一确定 Elasticsearch 中的一个文档。
    id 也可以由 Elasticsearch 自动生成。
  • _version
    在 Elasticsearch 中每个文档都有一个版本号。当每次对文档进行修改时(包括删除), _version 的值会递增。这个字段用来确保这些改变在跨多节点时以正确的顺序执行。
    版本号——不管是内部的还是引用外部的——都必须是在(0, 9.2E+18)范围内的一个 long 类型的正数。
  • _source
    即索引数据时发送给 Elasticsearch 的原始 JSON 文档。
  • _score
    相关性打分
  • _all
    整合所有字段内容到该字段,已被废除。

文档属性

文档里有几个最重要的设置:

  • type
    字段的数据类型,例如 string 或 date
  • index
    字段是否应当被当成全文来搜索(analyzed),或被当成一个准确的值(not_analyzed),还是完全不可被搜索( no )
  • analyzer
    确定在索引和搜索时全文字段使用的 analyzer
  • _source
    存储代表文档体的 JSON 字符串,和所有被存储的字段一样, _source 字段在被写入磁盘之前先会被压缩。这个字段有以下作用:
    1. 搜索结果包括了整个可用的文档——不需要额外的从另一个的数据仓库来取文档。
    2. 如果没有 _source 字段,部分 update 请求不会生效。
    3. 当你的映射改变时,你需要重新索引你的数据,有了_source 字段你可以直接从 Elasticsearch 这样做,而不必从另一个(通常是速度更慢的)数据仓库取回你的所有文档。
    4. 当你不需要看到整个文档时,单个字段可以从 _source 字段提取和通过 get 或者 search 请求返回。
      1
      2
      3
      4
      5
      GET /_search
      {
      "query": { "match_all": {}},
      "_source": [ "title", "created" ]
      }
    5. 调试查询语句更加简单,因为你可以直接看到每个文档包括什么,而不是从一列 id 猜测它们的内容。
      也可以调用下面的映射来禁用_source 字段:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      PUT /my_index
      {
      "mappings": {
      "my_type": {
      "_source": {
      "enabled": false
      }
      }
      }
      }

对象和文档

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

索引 - Index

一个索引(index)就像是传统关系数据库中的数据库,它是相关文档存储的地方,实际上是组织数据的逻辑命名空间
在一个索引中,可以定义一种或多种类型

  • 作为名词,一个 索引 类似于传统关系数据库中的一个 数据库,是一个存储关系型文档的地方;

  • 作为动词,索引一个文档 就是存储一个文档到一个 索引 (名词)中以便它可以被检索和查询到,文档已存在时会被覆盖掉。

  • Index体现了逻辑空间的概念,每个索引都有自己的Mapping定义,用于定义包含的文档的字段名和字段类型;

  • Shard体现了物理空间的概念,索引中的数据分散在Shard上。

倒排索引

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

  • 单词词典(Term Dictionary)
    记录所有文档的单词,记录单词到倒排列表的关联关系
    单词词典一般比较大,可以通过B+树或哈希拉链法来实现,以实现高性能的插入和查询
  • 倒排列表(Posting List)记录了单词对应的文档结合,由倒排索引项组成
    倒排索引项由文档ID、词频TF(该单词在文档中出现的次数,用于相关性评分)、位置Position(单词在文档中分词的位置,用于语句搜索)、偏移Offset(记录单词的开始和结束位置,实现高亮显示)

类型 - Type

一个类型是索引的一个逻辑上的分类,代表一类相似的文档,类型由 名称(比如 user 或 blogpost)和 映射 组成。但是在 ES 6.0.0 以后,这个概念会被废弃
类型可以很好的抽象划分相似但不相同的数据,但由于 Lucene 的处理方式,类型的使用有些限制。Lucene 没有文档类型的概念,每个文档的类型名被存储在一个叫 _type 的元数据字段上。 当我们要检索某个类型的文档时, Elasticsearch 通过在 _type 字段上使用过滤器限制只返回这个类型的文档。
每个 Lucene 索引中的所有字段都包含一个单一的、扁平的模式。一个特定字段可以映射成 string 类型也可以是 number 类型,但是不能两者兼具(比如两个类型都有一个 name 字段,但是他们映射到不同的数据类型)。

映射 - Mapping

映射就像数据库中的 schema ,描述了数据在每个字段内如何存储,包括文档可能具有的字段或 属性 、 每个字段的数据类型—比如 string, integer 或 date —以及 Lucene 是如何索引和存储这些字段的。
Lucene 也没有映射的概念,映射是 Elasticsearch 将复杂 JSON 文档 映射 成 Lucene 需要的扁平化数据的方式。

  • 比如下面的索引名叫 data,其中定义了 people 和 transactions 类型:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    {
    "data": {
    "mappings": {
    "people": {
    "properties": {
    "name": {
    "type": "string",
    },
    "address": {
    "type": "string"
    }
    }
    },
    "transactions": {
    "properties": {
    "timestamp": {
    "type": "date",
    "format": "strict_date_optional_time"
    },
    "message": {
    "type": "string"
    }
    }
    }
    }
    }
    }
    会被转换为类似下面的映射保存:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    {
    "data": {
    "mappings": {
    "_type": {
    "type": "string",
    "index": "not_analyzed"
    },
    "name": {
    "type": "string"
    }
    "address": {
    "type": "string"
    }
    "timestamp": {
    "type": "long"
    }
    "message": {
    "type": "string"
    }
    }
    }
    }
    所以虽然创建一个文档后其类型就确定了,但是实际上这个文档所占用的空间是该索引内所有字段的总和
    所以有一条建议:一个索引中的类型应当都是相似的,他们有类似的字段,比如 man 和 woman 共享 name 属性;如果两个类型的字段集互不相同,创建一个 类型的文档后将浪费很多空间,而是应该将他们分到不同的索引中。

动态映射机制

在索引一个新的文档时,es 会自动为每个字段推断类型,这个过程称为动态映射。这意味着如果你通过引号( “123” )索引一个数字,它会被映射为 string 类型,而不是 long 。但是,如果这个域已经映射为 long ,那么 Elasticsearch 会尝试将这个字符串转化为 long ,如果无法转化,则抛出一个异常。

分析和搜索 - Analysis、Search

分析表示全文是如何处理使之可以被搜索的。
Elasticsearch 除了支持在各种字段上的结构化查询,还支持排序、全文检索并分析相关性。

Query DSL

特定的查询语言,查询主要分为评分查询(query)不评分查询(filter),前者在查询完毕后还需要为文档进行评分,主要用于全文搜索,而后者只需要决定是否采用结果,所以速度会快一些。
es 提供的查询 DSL 将语句分为叶子语句(如 match)复合语句(如 bool),通过组合可以表达复杂的语义。

根对象

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

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

精确值和全文

精确值是结构化的,如日期或者用户 ID,字符串也可以表示精确值,例如用户名或邮箱地址,对于精确值来讲,Foo 和 foo 是不同的,2014 和 2014-09-15 也是不同的。
查询精确值很容易,结果是二进制的:要么匹配查询,要么不匹配。
全文是指文本数据(通常以人类容易识别的语言书写),例如一个推文的内容或一封邮件的内容。
查询全文数据要微妙的多。我们问的不只是“这个文档匹配查询吗”,而是“该文档匹配查询的程度有多大?”换句话说,该文档与给定查询的相关性如何?es 解决这个需求的办法是为文档建立倒排索引。

常用操作 - 索引(Index)

查询

1
2
3
4
5
6
7
8
9
GET _cat/indices
# 查看indices,用do*来前缀匹配
get /_cat/indices/do*?v&s=index
# 查看状态为黄色的索引
get /_cat/indices?v&health=yellow
# 查看索引doc的相关信息
get doc
# 查看索引的文档总数
get doc/_count

创建

在 Elasticsearch 中,我们的数据是被存储和索引在 分片 中,而一个索引仅仅是逻辑上的命名空间, 这个命名空间由一个或者多个分片组合在一起。

1
2
3
4
5
6
7
8
PUT /megacorp/employee/1
{
"first_name" : "John",
"last_name" : "Smith",
"age" : 25,
"about" : "I love to go rock climbing",
"interests": [ "sports", "music" ]
}

第一行指定了索引名/类型名/特定雇员id,其他所有的创建索引、指定每个属性的数据类型等工作都由后台默认设置完成。
如果你想禁止自动创建索引,你 可以通过在 config/elasticsearch.yml 的每个节点下添加下面的配置:

1
action.auto_create_index: false

删除

1
2
DELETE /index_one,index_two
DELETE /index_*

删除所有索引:

1
2
DELETE /_all
DELETE /*

如果想要避免意外删除所有数据带来的风险,可以在配置文件 elasticsearch.yml 中加入下面配置来禁止使用_all 和通配符删除索引:

1
action.destructive_requires_name: true

常用操作 - 映射(Mapping)

一些默认的映射

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

自定义映射

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

查看映射

1
2
GET /megacrp/_mapping
GET /megacrp/employee/_mapping

返回属性包括:

  • index 属性控制怎样索引字符串
    • analyzed 分析字符串再索引(全文索引),字符串且只有字符串可以取这个属性
    • not_analyzed 不分析、直接索引精确值
    • no 不索引、不能被搜索到
  • analyzer 对于 analyzed 字符串域,用 analyzer 属性指定在搜索和索引时使用的分析器,默认时 standard

更新映射

  • 可以通过更新一个映射来添加一个新域,并为其设置映射(后来版本取消了 string 类型,改成了text,要注意
  • 不能将一个存在的域从 analyzed 改为 not_analyzed。因为如果一个域的映射已经存在,那么该域的数据可能已经被索引。如果你意图修改这个域的映射,索引的数据可能会出错,不能被正常的搜索。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    PUT /gb
    {
    "mappings": {
    "testmapping" : {
    "properties" : {
    "tweetgjghjggh" : {
    "type" : "text",
    "analyzer": "english"
    },
    "date" : {
    "type" : "date"
    },
    "user_id" : {
    "type" : "long"
    }}}}}

常用操作 - 对象(文档 Document)

操作类型

文档的CRUD:

  • Index
    Index操作——如果ID不存在——则创建新的文档,否则删除现有的再创建新的,版本号会增加
    PUT my_index/_doc/1
  • Create
    Create操作——如果ID已经存在——会失败
    PUT my_index/_create/1
    不指定ID,自动生成
    POST my_index/_doc
  • Read
    GET my_index/_doc/1
  • Update
    文档必须已经存在,更新只会对相应字段做增量修改
    POST my_index/_update/1
  • Delete
    my_index/_doc/1

常见返回

问题 原因
无法连接 网络故障或集群挂了
连接无法关闭 网络故障或节点出错
429 集群过于繁忙
4xx 请求体格式有问题
500 集群内部错误

更新 - PUT

更新现有的对象需要自己指定对象的 id,如果不存在将自动创建一个,文档更新后_version 字段的值也会相应提高。在内部,Elasticsearch 已将旧文档标记为已删除,并增加一个全新的文档。 尽管你不能再对旧版本的文档进行访问,但它并不会立即消失。当继续索引更多的数据,Elasticsearch 会在后台清理这些已删除文档。
检索 和 重建索引 步骤的间隔越小,变更冲突的机会越小。 但是它并不能完全消除冲突的可能性。 还是有可能在 update 设法重新索引之前,来自另一进程的请求修改了文档。为了避免数据丢失, update API 在 检索 步骤时检索得到文档当前的 _version 号,并传递版本号到 重建索引 步骤的 index 请求。 如果另一个进程修改了处于检索和重新索引步骤之间的文档,那么 _version 号将不匹配,更新请求将会失败。为了实现版本号控制只需要在请求参数中加入 version(如上所示)。

1
2
3
4
5
6
PUT /website/blog/123
{
"title": "My first blog entry",
"text": "Just trying this out...",
"date": "2014/01/01"
}

如果已经有自己的 _id 、而又想执行创建,那么我们必须告诉 Elasticsearch ,只有在相同的 _index 、 _type 和 _id 不存在时才接受我们的索引请求——而不是覆盖掉,有两种方式:

1
2
3
4
5
6
# 指定ID的index操作,其实是个upsert操作
PUT /website/blog/123?op_type=create
{ ... }
# 创建一个文档
PUT /website/blog/123/_create
{ ... }

文档是不可变的:他们不能被修改,只能被替换。 update API 必须遵循同样的规则。 从外部来看,我们在一个文档的某个位置进行部分更新。然而在内部, update API 简单使用与之前描述相同的 检索-修改-重建索引 的处理过程。 区别在于这个过程发生在分片内部,这样就避免了多次请求的网络开销。通过减少检索和重建索引步骤之间的时间,我们也减少了其他进程的变更带来冲突的可能性

创建 - POST

不需要指定对象 id,由 Elasticsearch 自动生成,自动生成的 ID 是 URL-safe、 基于 Base64 编码且长度为 20 个字符的 GUID 字符串。 这些 GUID 字符串由可修改的 FlakeID 模式生成,这种模式允许多个节点并行生成唯一 ID ,且互相之间的冲突概率几乎为零。

1
2
3
4
5
6
POST /website/blog/
{
"title": "My second blog entry",
"text": "Still trying this out...",
"date": "2014/01/01"
}

部分更新 - POST

update 请求最简单的一种形式是接收文档的一部分作为 doc 的参数, 它只是与现有的文档进行合并。对象被合并到一起,覆盖现有的字段,增加新的字段。

1
2
3
4
5
6
7
8
# 文档必须已经存在
POST /website/blog/1/_update
{
"doc" : {
"tags" : [ "testing" ],
"views": 0
}
}

使用脚本部分更新文档:脚本可以在 update API 中用来改变 _source 的字段内容, 它在更新脚本中称为 ctx._source ,运行在一个沙盒内,默认使用 Painless 语言作为脚本语言。下面这个脚本在页面不存在时执行新增并初始化 views=1(第一次运行这个请求时, upsert 值作为新文档被索引,初始化 views 字段为 1 ;在后续的运行中,由于文档已经存在, script 更新操作将替代 upsert 进行应用,对 views 计数器进行累加)、页面被浏览 2 次后执行删除,其他情况浏览量+1 并添加一个新标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /website/blog/zVmOW2EBsZ0GEqF92yf6/_update
{
"script" : {
"source" : "if(ctx._source.views == params.count) { ctx.op = 'delete'} ctx._source.views+=1; ctx._source.tags.add(params.new_tag)",
"params" : {
"new_tag" : "search",
"count": 2
}
},
"upsert": {
"views": 1
}
}

重试
正如之前所说,update 操作是检索-修改-重新索引的过程, 检索 和 重建索引 步骤的间隔越小,变更冲突的机会越小。 但是它并不能完全消除冲突的可能性。 还是有可能在 update 设法重新索引之前,来自另一进程的请求修改了文档。为了避免数据丢失, update API 在 检索 步骤时检索得到文档当前的 _version 号,并传递版本号到 重建索引 步骤的 index 请求。 如果另一个进程修改了处于检索和重新索引步骤之间的文档,那么 _version 号将不匹配,更新请求将会失败。
对于部分更新的很多使用场景,文档已经被改变也没有关系。 例如,如果两个进程都对页面访问量计数器进行递增操作,它们发生的先后顺序其实不太重要; 如果冲突发生了,我们唯一需要做的就是尝试再次更新。这可以通过 设置参数 retry_on_conflict 来自动完成, 这个参数规定了失败之前 update 应该重试的次数,它的默认值为 0

1
2
3
4
5
6
7
POST /website/blog/zVmOW2EBsZ0GEqF92yf6/_update?retry_on_conflict=5 
{
"script" : "ctx._source.views+=1",
"upsert": {
"views": 0
}
}

GET(搜索)

在请求的查询串参数中加上 pretty 参数,这将会调用 Elasticsearch 的 pretty-print 功能,该功能 使得 JSON 响应体更加可读,但其中的 _source 字段并不是被当成字符串打印出来,而是格式化成了 JSON 串:

1
2
3
GET /website/blog/123?pretty
GET /website/blog/123/_source
GET /website/blog/123?_source=title,text

将多个请求合并成一个,避免单独处理每个请求花费的网络延时和开销。 如果你需要从 Elasticsearch 检索很多文档,那么使用 multi-get 或者 mget API 来将这些检索请求放在一个请求中,将比逐个文档请求更快地检索到全部文档。
mget API 要求有一个 docs 数组作为参数,每个 元素包含需要检索文档的元数据, 包括 _index 、 _type 和 _id 。如果你想检索一个或者多个特定的字段,那么你可以通过 _source 参数来指定这些字段的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET /_mget
{
"docs" : [
{
"_index" : "website",
"_type" : "blog",
"_id" : "zVmOW2EBsZ0GEqF92yf6"
},
{
"_index" : "website",
"_type" : "blog",
"_id" : 1,
"_source": "views"
}
]
}
GET /website/blog/_mget
{
"ids" : [ "2", "1" ]
}

HEAD(ping)

如果只想检查一个文档是否存在——根本不想关心内容——那么用 HEAD 方法来代替 GET 方法。

1
HEAD /website/blog/124

DELETE(删除)

1
DELETE /website/blog/123

bulk(批量操作)

每一行——包括最后一行——都必须以换行符结尾,格式如下所示:

1
2
3
4
{ action: { metadata }}\n
{ request body }\n
{ action: { metadata }}\n
{ request body }\n

action/metadata 行指定 哪一个文档 做 什么操作 。action 必须是以下选项之一:
create:如果文档不存在,那么就创建它。类似POSTPUT /_create
index:创建一个新文档或者替换一个现有的文档。类似POSTPUT
update:部分更新一个文档。类似POST /_update
delete:删除一个文档。类似DELETE
metadata 应该 指定被索引、创建、更新或者删除的文档的 _index 、 _type 和 _id ,每个请求的 metadata 都会覆盖请求 URL 中带上的默认元数据。
request body 行由文档的 _source 本身组成–文档包含的字段和值。它是 index、create、update 操作所必需的。
为什么不直接用一个 JSON 数组来保存?主要是考虑效率问题,解析为数组需要有更多的 RAM 空间,且 JVM 要花时间进行 gc。而直接使用原始数据只需要多注意每条数据之间的间隔(换行符)。
每个子请求都是独立执行,因此某个子请求的失败不会对其他子请求的成功与否造成影响。 如果其中任何子请求失败,最顶层的 error 标志被设置为 true ,并且在相应的请求报告出错误明细。这也意味着 bulk 请求不是原子的: 不能用它来实现事务控制。每个请求是单独处理的,因此一个请求的成功或失败不会影响其他的请求。

1
2
3
4
5
6
7
8
POST /_bulk
{ "delete": { "_index": "website", "_type": "blog", "_id": "123" }}
{ "create": { "_index": "website", "_type": "blog", "_id": "123" }}
{ "title": "My first blog post" }
{ "index": { "_index": "website", "_type": "blog" }}
{ "title": "My second blog post" }
{ "update": { "_index": "webiite", "_type": "blog", "_id": "123", "_retry_on_conflict" : 3} }
{ "doc" : {"title" : "My updated blog post"} }

批量请求的大小有一个最佳值,大于这个值,性能将不再提升,甚至会下降。 但是最佳值不是一个固定的值,它完全取决于硬件、文档的大小和复杂度、索引和搜索的负载的整体情况
幸运的是,很容易找到这个 最佳点 :通过批量索引典型文档,并不断增加批量大小进行尝试。 当性能开始下降,那么你的批量大小就太大了。一个好的办法是开始时将 1,000 到 5,000 个文档作为一个批次, 如果你的文档非常大,那么就减少批量的文档个数。并且请求的文档也最好不要太大,一个好的批量大小在开始处理后所占用的物理大小约为 5-15 MB。