Elastic Search 的原理
Elastic Search 分布式工作原理
前言
Elastic Search 是分布式的,但是对于我们开发者来说并未过多的参与其中,我们只需启动对应数量的节点,并给它们分配相同的 cluster.name,让它们归属于同一个集群,创建索引的时候只需指定索引主分片数和副分片数即可,其他的都交给了 ES 内部自己去实现。
这和数据库的分布式和同源的 solr 实现分布式都是有区别的,数据库要做集群分布式,比如分库分表需要我们指定路由规则和数据同步策略等,包括读写分离,主从同步等,solr 的分布式也需依赖 zookeeper,但是 Elastic Search 完全屏蔽了这些。
虽然 Elastic Search 天生就是分布式的,并且在设计时屏蔽了分布式的复杂性,但是我们还得知道它内部的原理。
节点交互原理

- ES 和其他中间件一样,比如 mysql,redis 有 master-slave 模式。ES 集群也会选举一个节点做为 master 节点。
- master 节点它的职责是维护全局集群状态,在节点加入或离开集群的时候重新分配分片。
- 所有文档级别的写操作不会与 master 节点通信,master 节点并不需要涉及到文档级别的变更和搜索等操作,ES 分布式不太像 mysql 的 master-slave 模式,mysql 是写在主库,然后再同步数据到从库。而 ES 文档写操作是分片上而不是节点上,先写在主分片,主分片再同步给副分片,因为主分片可以分布在不同的节点上,所以当集群只有一个 master 节点的情况下,即使流量的增加它也不会成为瓶颈,就算它挂了,任何节点都有机会成为主节点。
- 读写可以请求任意节点,节点再通过转发请求到目的节点,比如一个文档的新增,文档通过路由算法分配到某个主分片,然后找到对应的节点,将数据写入到主分片上,然后再同步到副分片上。
写入文档

- 客户端向 node-1 发送新增文档请求。
- 节点通过文档的路由算法确定该文档属于主分片-P0。因为主分片-P0在 node-3,所以请求会转发到 node-3。
- 文档在 node-3 的主分片-P0上新增,新增成功后,将请求转发到 node-1 和 node-2 对应的副分片-R0上。一旦所有的副分片都报告成功,node-3 向 node-1 报告成功,node-1 向客户端报告成功。
读取文档
- 客户端向 node-1 发送读取文档请求。
- 在处理读取请求时,node-1 在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。
Elastic Search文档的路由原理
前言
当新增一个文档的时候,文档会被存储到一个主分片中。 Elastic Search 如何知道一个文档应该存放到哪个分片中呢?当我们创建文档时,它如何决定这个文档应当被存储在分片 1 还是分片 2 中呢?
路由算法
首先这肯定不会是随机的,否则将来要获取文档的时候我们就不知道从何处寻找了。实际上,这个过程是根据下面这个公式决定的:
shard = hash(routing) % number_of_primary_shards
routing 是一个可变值,默认是文档的 _id,也可以设置成一个自定义的值。routing通过 hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards (主分片的数量)后得到余数。这个分布在 0 到 number_of_primary_shards-1 之间的余数,就是我们所寻求的文档所在分片的位置。
这就解释了为什么我们要在创建索引的时候就确定好主分片的数量并且永远不会改变这个数量:因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了。
新增一个文档(指定id)
PUT /nba/_doc/1
{
"name": "哈登",
"team_name": "?火箭",
"position": "得分后卫",
"play_year": "10",
"jerse_no": "13"
}查看文档在哪个分片上
GET /nba/_search_shards?routing=1
返回值如下:
{
"nodes": {
"V1JO7QXLSX-yeVI82WkgtA": {
"name": "node-1",
"ephemeral_id": "_d96PgOSTnKo6nrJVqIYpw",
"transport_address": "192.168.1.101:9300",
"attributes": {
"ml.machine_memory": "8589934592",
"xpack.installed": "true",
"ml.max_open_jobs": "20"
}
},
"z65Hwe_RR_efA4yj3n8sHQ": {
"name": "node-3",
"ephemeral_id": "MOE_Ne7ZRyaKRHFSWJZWpA",
"transport_address": "192.168.1.101:9500",
"attributes": {
"ml.machine_memory": "8589934592",
"ml.max_open_jobs": "20",
"xpack.installed": "true"
}
}
},
"indices": {
"nba": {}
},
"shards": [
[
{
"state": "STARTED",
"primary": true,
"node": "V1JO7QXLSX-yeVI82WkgtA",
"relocating_node": null,
"shard": 2,
"index": "nba",
"allocation_id": {
"id": "leX_k6McShyMoM1eNQJXOA"
}
},
{
"state": "STARTED",
"primary": false,
"node": "z65Hwe_RR_efA4yj3n8sHQ",
"relocating_node": null,
"shard": 2,
"index": "nba",
"allocation_id": {
"id": "6sUSANMuSGKLgcIpBa4yYg"
}
}
]
]
}Elastic Search 的乐观锁
锁的简单分类
- 悲观锁
- 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
- 乐观锁
- 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,比如可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,因为我们 Elastic Search 一般业务场景都是写少读多,所以通过乐观锁可以在控制并发的情况下又能有效的提高系统吞吐量。
版本号乐观锁
Elastic Search 中对文档的 index, GET 和 delete 请求时,都会返回一个 _version,当文档被修改时版本号递增。
所有文档的更新或删除 API,都可以接受 version 参数,这允许你在代码中使用乐观的并发控制,这里要注意的是版本号要大于旧的版本号,并且加上 version_type=external。
获取文档
GET /nba/_doc/1
返回值如下:
{
"_index": "nba",
"_type": "_doc",
"_id": "1",
"_version": 1,
"_seq_no": 4,
"_primary_term": 7,
"found": true,
"_source": {
"name": "哈登",
"team_name": "?火箭",
"position": "得分后卫",
"play_year": "10",
"jerse_no": "13"
}
}通过版本号新增文档(version 要大于旧的 version)
POST /nba/_doc/1?version=2&version_type=external
参数:
{
"name": "哈登",
"team_name": "?火箭",
"position": "得分后卫",
"play_year": "10",
"jerse_no": "13"
}倒排索引的原理
我们打开 NBA 中国官网,搜索 james 得到以下结果

假设文档集合如下图所示

- 我们是怎么通过 james 查找到名字带有 james 的球员呢?
- 如果按照这个图,我们是不是得把这5个文档遍历一遍,把文档带有 james 的球员查找出来?
- 如果按照这种顺序扫描,那每次输入不同的关键字,岂不是要从头到尾遍历一遍?
假设文档集合如下图所示

- 我们把这个5个球员的名字进行分词,每个分词转成小写字母,并且以每个分词分组,统计它所在文档的位置。
- 当有关键字请求过来的时候,将关键字转成小写,查找出关键字匹配到的文档位置,然后全部返回。
完善倒排索引

参数解释
- DocId:单词出现的文档id
- TF:单词在某个文档中出现的次数
- POS:单词在文档中出现的位置
Elastic Search 的分词原理
前言一
我们创建一个文档
PUT test/_doc/1
参数:{
"msg": "乔丹是篮球之神"
}我们通过‘乔丹‘这个关键词来搜索这个文档
POST /test/_search
参数:
{
"query": {
"match": {
"msg": "乔丹"
}
}
}我们发现能匹配文档出来,那整一个过程的原理是怎样的呢?
前言二
我们来试下使用中文分词器
PUT test/_mapping
参数:
{
"properties": {
"msg_chinese": {
"type": "text",
"analyzer": "ik_max_word"
}
}
}
POST test/_doc/1
参数:
{
"msg": "乔丹是篮球之神",
"msg_chinese": "乔丹是篮球之神"
}
POST /test/_search
参数:
{
"query": {
"match": {
"msg_chinese": "乔"
}
}
}
POST /test/_search
参数:
{
"query": {
"match": {
"msg": "乔"
}
}
}为什么同样是输入‘乔‘,为什么 msg 能匹配出文档,而 msg_chinese 不能呢?
写时分词
我们来分析 msg 这个字段是怎样分词的
POST test/_analyze
参数:
{
"field": "msg",
"text": "乔丹是篮球之神"
}
返回值:
乔,丹,是,篮,球,之,神再来分析 msg_chinese 这个字段是怎样分词的
POST test/_analyze
参数:
{
"field": "msg_chinese",
"text": "乔丹是篮球之神"
}
返回值:
乔丹, 是, 篮球, 之神文档写入的时候会根据字段设置的分词器类型进行分词,如果不指定就是默认的 standard 分词器。
写时分词器需要在 mapping 中指定,而且一旦指定就不能再修改,若要修改必须重建索引。
读时分词
由于读时分词器默认与写时分词器默认保持一致,拿上面的例子,你搜索 msg 字段,那么读时分词器为 standard ,搜索 msg_chinese 时分词器则为 ik_max_word。这种默认设定也是非常容易理解的,读写采用一致的分词器,才能尽最大可能保证分词的结果是可以匹配的。
允许读时分词器单独设置
POST test/_search
参数:
{
"query": {
"match": {
"msg_chinese": {
"query": "乔丹",
"analyzer": "standard"
}
}
}
}一般来讲不需要特别指定读时分词器,如果读的时候不单独设置分词器,那么读时分词器的验证方法与写时一致。
深入分析

分析器(analyzer)有三部分组成
- char filter:字符过滤器
- tokenizer:分词器
- token filter:token 过滤器
char filter(字符过滤器)
字符过滤器以字符流的形式接收原始文本,并可以通过添加、删除或更改字符来转换该流。一个分析器可能有0个或多个字符过滤器。
tokenizer (分词器)
一个分词器接收一个字符流,并将其拆分成单个 token (通常是单个单词),并输出一个 token 流。比如使用 whitespace 分词器当遇到空格的时候会将文本拆分成 token。
"eating an apple" >> [eating, an, apple]。一个分析器必须只能有一个分词器
POST _analyze
{
"text": "eating an apple",
"analyzer": "whitespace"
}token filter (token过滤器)
token 过滤器接收 token 流,并且可能会添加、删除或更改 tokens。比如一个 lowercase token filter 可以将所有的 token 转成小写。一个分析器可能有0个或多个 token 过滤器,它们按顺序应用。
standard分析器
- tokenizer
- Stanard tokenizer
- token filters
- Standard Token Filter
- Lower Case Token Filter