elasticsearch底层数据结构
如上图所示,自顶向下看,
- index:类似 Mysql 中的数据库;
- 每个index又由一个或多个分片组成;
- 每个分片都是一个 Lucene 索引实例,可以将其视作一个独立的搜索引擎,它能够对 Elasticsearch 集群中的数据子集进行索引并处理相关查询;
- 每个分片包含多个segment(段),每一个segment都是一个倒排索引。
索引和分片
一个 Lucene 索引在 Elasticsearch 称作分片。一个 Elasticsearch 索引是分片的集合。 当 Elasticsearch 在索引中搜索的时候,他发送查询到每一个属于索引的分片(Lucene 索引),然后像执行分布式检索提到的那样,合并每个分片的结果到一个全局的结果集。
段(segment)
每一个segment都是一个倒排索引,倒排索引被写入磁盘后是不可改变的:它永远不会修改
不变性有重要的价值:
- 不需要锁。如果你从来不更新索引,你就不需要担心多进程同时修改数据的问题。
- 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。
- 其它缓存(像filter缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
- 写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O 和 需要被缓存到内存的索引的使用量
当然,一个不变的索引也有不好的地方。主要事实是它是不可变的! 你不能修改它。如果你需要让一个新的文档 可被搜索,你需要重建整个索引。这要么对一个索引所能包含的数据量造成了很大的限制,要么对索引可被更新的频率造成了很大的限制。
而且elasticsearch自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦
Elasticsearch通过在后台进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。
段合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。
文档入库流程
在文档写入时,会根据_routing来计算得出文档要写入哪个shard,这里的写入请求只会写主分片,当主分片写入成功后,会同时把写入请求发送给所有的副本分片,当副本分片写入成功后,会传回返回信息给主分片,主分片得到所有副本分片的返回信息后,再返回给客户端。
写入请求到shard后,首先是写Lucene,其实就是创建索引,索引创建好后并不是马上生成segment,这个时候索引数据还在缓存中,这里的缓存是lucene的缓存,并非Elasticsearch缓存。lucene缓存中的数据是不可被查询的。写入内存之后会写TransLog,TransLog可以理解为事务日志,类似数据库中的Binlog。Lucene缓存中的数据默认1秒之后才生成segment文件,即使是生成了segment文件,这个segment是写到页面缓存中的,并不是实时的写到磁盘,只有达到一定时间或者达到一定的量才会强制flush磁盘。
更新文档流程
- 接受update请求后,首先通过id在Segments或TransLog中取出要修改的文档内容,记录版本号为V1。
- 将版本号为V1的全部文档内容与要修改的文档内容(修改部分字段内容)合并,同时更新内存中的VersionMap。自此修改后的新文档已经生成,接下来就是index请求,把修改后的文档索引。
- 加锁
- 再次从versionMap中读取该id的最大版本号V2,如果versionMap中没有,则从Segment或者TransLog中读取。
- 检查版本是否冲突,如果V1==V2,则说明修改的是最高版本号的文档,则进入下一步。如果V1 < V2,说明本次修改并非修改的最高版本号的文档,修改失败。返回第一步重新执行。
- 将版本号递增为V3(V2+1)。然后把文档写入到Lucene中,Lucene会先删除掉原来id的文档,然后把修改后的新文档增加进去。然后更新VersionMap中的版本号为V3。
- 释放锁,更新结束
段是不可改变的,所以既不能从把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。 取而代之的是,每个提交点会包含一个 .del
文件,文件中会列出这些被删除文档的段信息。
当一个文档被 “删除” 时,它实际上只是在 .del
文件中被 标记 删除。一个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。
文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。
段合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。
启动段合并不需要你做任何事。进行索引和搜索时会自动进行。
1、 当索引的时候,刷新(refresh)操作会创建新的段并将段打开以供搜索使用。
2、 合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。
ElasticSearch index mapping字段解析
先给出一个mapping示例,如下:
{
"_source": {
"enabled": true,
"includes": ["*.count", "meta.*"],
"excludes": ["meta.des", "meta.o.*"]
},
"properties": {
"title": {
"type": "string",
"index": "not_analyzed",
"store": true
},
"content": {
"type": "string"
}
}
}
source 字段解析
es在存储数据的时候会把json文档内容存储到"source"字段里,可以理解为source 就是数据库里的一条记录
- enable:bool,是否存储文档内容到source,默认为true
- includes:list,指定存储到source的字段
- excludes:list,排除存储到source的字段
index字段解析
索引选项控制是否对字段值建立索引。 它接受true或false,默认为true。 未索引的字段不可查询。
store字段解析
默认flase
是否将此字段在 source 之外在独立存储一份,这么做的目的主要是针对内容比较多的字段,放到 source 返回的话,因为source 是把所有字段保存为一份文档,命中后读取只需要一次 IO,包含内容特别多的字段会很占带宽影响性能,通常我们也不需要完整的内容返回(可能只关心摘要),这时候就没必要放到 source 里一起返回
搜索聚合方式
场景举例:
假设我们现在有一些关于电影的数据集,每条数据里面会有一个数组类型的字段存储表演该电影的所有演员的名字。如:
{
"actors": [
"Fred Jones",
"Mary Jane",
"Elizabeth Worthing"
]
}
现在我们想要查询出演影片最多的十个演员以及与他们合作最多的演员
深度优先(depth-first)
elasticsearch 里面桶的叫法和 SQL 里面分组的概念是类似的,一个桶就类似 SQL 里面的一个 group,多级嵌套的 aggregation,类似 SQL 里面的多字段分组(group by field1,field2, ……)
深度优先是默认使用的集合模式,先构建完整的树,然后修剪无用节点。其适用于总组数固定或组的数量不多的场景。
针对上面的例子,可以通过下面的搜索语句得到想要的结果:
{
"aggs": {
"actors": {
"terms": {
"field": "actors",
"size": 10
},
"aggs": {
"costars": {
"terms": {
"field": "actors",
"size": 5
}
}
}
}
}
}
当数据量很大的时候,这个看上去简单的查询可以轻而易举地消耗大量内存。因为这种查询方式会在内存中为每个演员建立一个桶,然后内套在第一层的每个节点之下,costars聚合会构建第二层,每个联合出演一个桶,这意味着每部影片会生成 n 2 n^2 n2 个桶(n为演员数)!用真实点的数据,设想平均每部影片有 10 名演员,每部影片就会生成 1 0 2 10^2 102 == 100 个桶。如果总共有 20000 部影片,粗率计算就会生成 2000000 个桶,这样会消耗大量的内存,因此这种场景并不适合使用深度优先模式,此种场景更适合广度优先模式。
广度优先(breadth-first)
广度优先仅仅适用于每个组的聚合数量远远小于当前总组数的情况下,因为广度优先会在内存中缓存裁剪后的仅仅需要缓存的每个组的所有数据,以便于它的子聚合分组查询可以复用上级聚合的数据
示例:
{
"aggs": {
"actors": {
"terms": {
"field": "actors",
"size": 10,
"collect_mode": "breadth_first"
},
"aggs": {
"costars": {
"terms": {
"field": "actors",
"size": 5
}
}
}
}
}
}