ES 数据索引流程
ES
的数据从接收、存储到能够支持检索是一个相对比较复杂的过程,这一过程中的每一步都是为了性能、分布式支持、并行、高可用和可拓展等特点作出的设计。
整体流程
- 通过接口接收数据
- 数据路由寻址
- 数据索引
- 让数据支持检索
后面我们来详细了解下整个流程。
通过接口接收数据
ES
会通过 POST/PUT _doc
、POST _bulk
等接受数据,区别只是前者一次性只会发送/接收一条数据,而后者会一次性处理多条数据。在 bulk
的处理中,ES
会自行对数据列表进行遍历,并按单条数据的方式继续后续的操作。
较大的集群,可能会有独立的协调节点(coordinate node
)和/或数据预处理节点(ingest node
),但是任何节点接受了数据之后都会遵循一样的流程——对数据进行分析、转发等。
数据处理及数据流
数据收到之后可能会根据配置(index template
、default_pipeline
等)而会被转发给以下三个目标之一:
- 指定索引
- 当没有别的配置时,数据会直接写入指定索引进行后续的
mapping
等操作 - 如果写入目标是索引别名(
alias
),则会通过别名来寻找一个目标索引进行写入 - 如果写入的别名保护了多个索引,则会往写索引(
"is_write_index": true"
)中写入,如果没有写别名则会报错
- 当没有别的配置时,数据会直接写入指定索引进行后续的
- 数据处理管道(
ingest pipeline
)- 在接口中或者索引模板中可以指定数据处理管道,对写入数据进行预处理
- 当发现数据需要经过数据处理管道时,数据会被转发到带有数据处理功能的节点(
node.roles = ["ingest"]
) - 数据在经过了预处理之后再转发给指定的索引(某些
pipeline
会改变请求中的索引名称,进而影响到数据寻址)
- 数据流(
data stream
)- 数据流更像是一个由
ES
自己维护的,根据生命周期策略(ilm
)进行滚动的一系列索引 - 对数据读/写方来说,他们制定的索引都是一个索引别名,具体的路由部分会由
ES
进行托管 - 这种索引模式比较新,具体的信息可以参考官方文档
- 数据流更像是一个由
路由
数据在接收之后 ES
会根据数据的一系列属性,如索引名称(别名)、主键(_id
)或 router key
等给数据进行寻址,找到合适的放置数据的索引、分片、节点之后会把数据转发过去进行后续的操作。收到数据到数据转发之前可能会经历数据预处理(ingest pipeline
),因为数据管道可能会对数据本身(包括索引名称)进行修改。如果数据中指定的索引不存在,配置中又没有禁止自动创建索引("action.auto_create_index": true
),ES
会尝试先创建指定索引再做数据的后续处理。
为了保证数据写入速度,建议使用原生的(默认由 ES
自己计算出来的)_id
来存储,这样可能不太会造成数据倾斜。在真实业务使用时,可能会有定制化的路由策略,需要指定自己的路由键(如自定 _id
、指定routing=fieldA
等),这时就要注意, ES
在寻址时会需要对指定的字段进行计算,所以要合理的设计路由键,避免不必要的数据运算,同时要将数据尽量打散,避免数据倾斜。
索引
当数据寻址结束被发给目标主分片(primary shard
)之后,就会进入索引流程,大致分为以下几个步骤:
- 数据校验并解析数据处理请求类型
- 从
Segment
或者TransLog
里通过_id
找到完整的文档,如果找不到则跳过 - 将新接受的数据和从系统中找到的数据进行
merge
,给数据版本设为v1
- 解析整条数据,添加一些系统字段(如
_id
之类的) - 根据当前数据状态更新
mapping
- 如果存在
dynamic mapping
则自动解析 - 如果存在
dynamic template
则按模板中的设置进行创建 - 如果存在其他配置,如
"dynamic mapping": false
之类的再按配置对冲突忽略或抛出异常
- 如果存在
- 从
SequcenceNumberService
获取SequenceID
和Version
。SequenceID
用于初始化LocalCheckPoint
Version
是根据当前数据版本(v1
)做v1 + 1
操作防止并发写入带来的数据不一致
- 对
_id
加锁准备写入Lucene
。- 这时会判断当前已存在的数据里是否和上一步中的
v2
冲突(版本大于等于v2
)。如果冲突则返回 2. 或者报错 - 调用
lucene
的新加(addDoucument
)或者更新(updateDocument
)接口写入数据,删除操作也是调用更新接口 - 为了保证数据删除的原子性,
ES
会在删除之前对refresh
操作加锁,等数据更新(添加)操作结束之后再释放
- 这时会判断当前已存在的数据里是否和上一步中的
- 写入
TransLog
- 写完了
segment
之后,数据会以K-V
(_id - Doc
)的形式存在TransLog
里面 - 这样如果存在类似
getById
的需求就可以直接从TransLog
里获取完整的数据了
- 写完了
- 重建
bulkRequest
- 把里面的请求转化为只包含
index
和delete
的请求,用来转发给所有的副本分片
- 把里面的请求转化为只包含
flush translog
- 一般默认情况下,
TransLog
会在数据写完之后进行落盘 - 如果对可靠性要求不高对写入速度要求比较高可以通过将
TransLog
落盘调整为异步,增大落盘间隔和单次落盘数据阈值等方式进行调整"index.translog.durability": "request"
=>"async"
"index.translog.sync_interval": 5s
=>"30s"
"index.translog.flush_threshold_size": "512mb"
=>"2G"
- 把 9. 构建的
bulkRequest
转发给副本 - 主分片会把
bulkRequest
转发给所有的副本,并等待所有副本的结果返回 - 如果某个副本执行失败,主分片会给
master
节点发请求,把这个副本标记为失败并移除 - 在
bulkRequest
发送的同时,主分片也会把SequenceID
、primaryTerm
、GlobalCheckPoint
等信息一起发给副本 - 等待副本的返回
- 当所有副本都返回成功之后,主分片才会返回一个插入成功的
ack
,同时更新主分片的LocalCheckPoint
支持搜索
数据写入分片(副本)之后也不能立刻支持搜索,而是需要先进行 refresh
操作之后才行。refresh
操作主要是用于将内存缓冲区中的数据进行段合并(segment merge
),然后将合并后的结果写入磁盘里 (TransLog
),对于某些通过 "index.store.preload": ["fieldA", "fieldB"]
设置所指定的字段是写入系统缓存,写入成功之后才能支持检索。
这种设计主要是为了保证数据的可靠性,因为写在内存缓冲区的数据可能会因为节点重启等原因丢失,而写入了磁盘之后再进行重启等就不太容易造成数据丢失。
ES
自己也会在后台持续的进行段合并,因为数据的检索是顺序的检索段(segment
),更少数量的段在搜索中需要进行的上下文切换越少。
FAQ
- Q:对于数据插入来说,
PUT
和POST
有什么区别吗?- A:根据原厂工程师的说法,他们设计这俩接口是想遵守
restful
语义的- 即
PUT
代表了添加数据,ES
会尝试自动设置一个_id
- 而
POST
代表了更新数据 - 但是这俩接口后续的处理逻辑是一样的,如果没有设置
_id
,在POST
请求中ES
也会尝试设置一个;如果指定了_id
,ES
会直接用而不会重新计算_id
- 即
- A:根据原厂工程师的说法,他们设计这俩接口是想遵守
- Q:当集群状态为不正常(
Red
)的时候,数据的读写受影响吗?- A:集群状态红了代表至少有一个主分片无法成功放置(如果是副本无法放置则为
yellow
)- 那么在这个分片正常分配之前,针对它的读写都会有影响
- 同时集群会积极的尝试对这个(些)分片进行修复,如给副本升级、重建数据等,这些操作也会挤占集群/节点的内存和
CPU
资源 - 所以结论是,当集群状态不正常时,可能对数据的读写有影响,但是根据访问量的大小以及数据所属的索引/分片的不同这个影响也会有所不同
- A:集群状态红了代表至少有一个主分片无法成功放置(如果是副本无法放置则为
- Q:
7.11
里新加的runtime_fields
呢?- A:
runtime_fields
相当于是对于每条数据通过script
计算一个值出来进行后续的计算 - 在数据写入的时候这部分计算并不会执行,而是在数据请求的时候进行计算
- 所以这部分的召回会被认为是重的(
expenisive
),如果设置了"search.allow_expensive_queries": false
,针对runtime_fields
的请求就会被ES
拒绝- 所有的
expensive queries
参考官方文档
- 所有的
- A: