ES集群文档读写流程及底层存储原理揭秘
ES集群文档读写流程及底层存储原理揭秘
Elasticsearch(ES)是基于Lucene构建的分布式搜索和分析引擎,本文面向资深用户,系统介绍 ES 文档的索引(写入)、更新、查询、删除等流程,并深入剖析其底层存储原理。我们基于截至 2025 年最新版本的 Elasticsearch,结合源码文档、技术博客等资料,用图文并茂的形式展示 ES 集群架构、分片/路由、主备(Primary/Replica)的读写分工,以及 Lucene 的 Segment、倒排索引(Inverted Index)、DocValues、Merge、Commit 等概念。同时给出 Python 客户端或 REST API 的示例代码,帮助读者直观理解各类操作流程,并给出相应的调优建议,如批量写入、刷新间隔、合并策略、缓存配置等。
集群架构总览
Elasticsearch 集群由多个节点(Node)组成,每个节点都可以承载数据、进行查询处理等。节点根据配置可被标记为主节点(Master、负责集群管理)、数据节点(Data、存储数据、执行搜索/聚合)或协调节点(Coordinating,仅做请求路由)。客户端请求可以发送给任意节点,该节点即作为协调节点(Coordinating Node)来协调请求的执行。
每个索引被划分为多个主分片(Primary Shard),以实现水平扩展;主分片可以设置一个或多个副本分片(Replica Shard),用于提高可用性和查询吞吐。分片映射到具体的节点上,不同分片和副本通常分布在不同节点上以避免单点故障。例如,一个索引设置5个主分片、1个副本,将总共生成10个分片拷贝(5主+5副本),它们会在集群中不同节点上分布。这样即使某一节点宕机,其上的主分片或副本仍可通过其他副本保证数据不丢失。
ES 使用路由机制决定文档落在哪个分片:默认情况下,路由键(routing,默认等于文档 _id
)经过哈希后对分片数取模,即 shard = hash(_routing) % number_of_shards
,从而将文档均匀分布到各分片。当接收写/查请求时,协调节点会根据该路由值确定目标主分片所属的节点,然后将请求转发给对应的主分片执行。
【图】下图展示了一个典型 ES 集群架构示意:客户端请求到达协调节点,根据索引和路由信息找到目标主分片,然后由主分片节点执行操作并将结果/更改复制到副本分片。各节点之间通过传输层协议(TCP)通信,主节点负责维护集群元数据(分片布局等)。
图:ES 索引写入流程示意(文档经过协调节点路由到主分片,并被写入 Lucene 引擎,然后复制至副本分片;其中可插入 Ingest 流水线处理步骤)
文档写入流程详解
索引(Index)操作流程: 客户端发起索引请求(PUT/POST),请求首先抵达一个协调节点。协调节点使用路由策略确定目标主分片,然后将请求转发到该主分片所在的数据节点。主分片接收请求后,执行校验并在本地的 Lucene 引擎中对文档进行索引,生成新的倒排索引条目(挂起在内存缓冲区中)。此时,主分片将操作写入其事务日志(Translog)以保证持久性。然后主分片并行将该索引操作复制(replicate)给所有在同步复制集(in-sync copies)中的副本分片。所有必要的副本分片执行本地写入并返回确认后,主分片才向协调节点返回成功响应;随后协调节点再将成功结果返给客户端。整个过程可划分为三个阶段:协调阶段(协调节点选择目标分片)、主分片阶段(主分片验证并执行操作,然后发起复制)和副本阶段(所有副本执行操作后返回结果)。
更新(Update)操作流程: 更新本质上也是对索引的写操作。和索引类似,协调节点根据文档ID路由到对应的主分片。主分片需要先检索待更新文档(若为部分更新,则获取旧文档内容并合并变更),然后执行“先标记旧文档删除,再写入新文档”的流程。具体来说,Lucene 的段是不变的,所以更新文档会在旧文档所在的段上打删除标记(逻辑删除),并将更新后的文档当作一个新文档写入内存缓冲和事务日志。随后复制给副本分片,同样等待所有副本确认后才完成更新。这意味着 Lucene 底层并不会原地改写文档;更新操作等价于删除旧文档并新增新文档的组合。
删除(Delete)操作流程: 删除操作也遵循主备复制模型。协调节点根据文档ID路由到相应主分片。主分片收到删除请求时,不会立即从索引中物理移除文档,而是在当前活跃段的删除位图中将该文档标记为已删除。主分片同样将删除操作写入事务日志,然后将该删除请求转发给所有副本分片。所有副本打删除标记并确认后,主分片返回成功,协调节点将结果通知客户端。需要注意的是,在文档真正从磁盘文件中清除之前,它会继续被标记(直到段合并时才物理删除)。
图:ES 删除数据流程示意(协调节点将删除请求路由到主分片,主分片在段内标记文档删除并写入事务日志,并将删除操作复制给副本分片;完成后返回成功)
查询流程与协调节点角色
查询(Search)请求流程: ES 支持多种查询操作,从简单的按ID取文档,到复杂的全文检索或聚合。客户端将查询请求发送到集群中任意一个节点,该节点即作为协调节点。协调节点解析请求中涉及的索引和路由信息后,会将查询请求并行转发给所有相关分片的一个副本(主分片或副本分片中的一个)。例如,一个索引有5个分片,则协调节点会向5个分片分别选取一个副本节点发送查询。默认情况下,ES 会通过自适应副本选择(Adaptive Replica Selection)机制均衡地选择主/副分片,以利用所有节点资源。
各分片节点收到查询请求后,在其本地的所有 Lucene 段中执行检索操作(包括构建倒排索引查询、逐段搜索并评分)。每个分片会返回符合查询的文档ID列表(以及排序/评分信息、聚合结果等)给协调节点。这个阶段称为“查询阶段”(Query Phase)。随后,协调节点收集各分片返回的结果,并进行合并与排序。例如对于分页查询,将对各分片结果进行全局排序取前N条;聚合时对各分片结果合并计算最终值。
取回阶段(Fetch Phase): 在基本检索完成后,协调节点可能需要获取文档的具体字段内容(对于需要返回文档内容的查询)。此时协调节点会再向每个命中结果所在的分片(通常与第一阶段选定的副本相同)发起“取回”请求,由分片返回文档的 _source
或指定字段。这一步称为Fetch 阶段。一般来说,查询分为前期确定匹配ID并排序的查询阶段和后期获取文档内容的取回阶段。协调节点最终将所有聚合和文档结果封装返回给客户端。
协调节点(Coordinating Node)作用: 无论是写入还是读取,请求进入集群的第一个节点都是协调节点。它负责解析请求目标(索引和分片),并分配给对应的主分片或副本分片执行,最终收集所有分片的响应并汇总结果。在大型集群中,通常会专门部署一些协调节点(只承担路由合并角色,不存储数据),以隔离流量高峰对数据节点的影响。
图:ES 查询数据流程示意(协调节点将查询并行转发到各相关分片,分片执行搜索并返回文档ID列表,协调节点汇总排序后在 fetch 阶段获取文档内容并返回给客户端)
Lucene 底层原理揭秘
在 ES 中,每个分片本质上是一个 Lucene 索引(索引下的一个物理目录)。Lucene 索引由多个不可变的**段(Segment)**组成。每个段都是一个迷你索引,包含它所收录文档的倒排索引、字段数据、存储字段等结构。倒排索引(Inverted Index)是 Lucene 的核心数据结构:它维护了所有不同词项(term)的词典和倒排列表(posting list),列出每个词出现在哪些文档及其位置信息,从而实现高效的全文检索。例如词典中记录词 “apple”,倒排列表中存储所有包含 “apple” 的文档ID及出现位置,检索时只需直接查找词典并获取对应列表。
Lucene 的索引文件是不可变的。一旦一个段写入磁盘后,其内部数据结构(倒排列表、词典等)就不会被修改。删除文档时,Lucene 并不在原段中移除数据,而是在段对应的“删除位图”(deletion bitset)中将该文档标记为已删除。更新文档也是先标记旧文档删除再插入新文档。这些标记会被保存在内存和事务日志中,并最终在下次段合并时才会真正清理已删除文档的空间。
新文档或更新产生的数据首先缓存在内存中。当缓冲区达到阈值或达到刷新时,Lucene 会创建一个新的索引段并将其中的文档内容写到磁盘上。每次刷新(Refresh)操作都会开启一个 Lucene 提交(commit),将当前内存索引切分出一个新的段,以使最新数据对搜索可见。ES 默认每秒自动刷新一次(如果最近收到过搜索请求),但这个行为可以调节或禁用。完成写入的每个段都被附加到索引目录下,索引最终由多个这样的段文件组成。为了避免过多小段影响查询效率,Lucene 会根据合并策略**异步合并(Merge)**旧的多个小段为一个大段。合并时会丢弃已删除文档,仅保留存活数据,从而逐步回收空间。用户也可以在必要时调用 _forcemerge
强制将分段数合并到指定数量,以优化查询性能。
DocValues:对于排序、聚合等场景,Lucene 提供了列式存储方案 DocValues。它在索引阶段为每个字段生成一份“正排”数据,将字段所有文档的值连续存储,方便随机访问。这样在分片内部执行排序或聚合时,只需一次顺序读即可获取多个文档的字段值,大幅提高了性能。所有非文本字段默认开启 DocValues,对于分析型字段通常会关闭,因为它们使用倒排索引即可满足查询需要。
事务日志与持久化:ES 为了保证写入的持久性,引入了 Lucene 之外的事务日志(Translog)。每次索引或删除操作在写入 Lucene 索引后,都会同时记录到分片的 translog 中。只有当操作被 fsync 到磁盘且确认写入 translog 后,ES 才向客户端返回成功(这是默认的 request 模式持久性)。当一个分片发生故障重启时,未提交到最新 Lucene 提交点的已写入 translog 的操作可被恢复。ES 的flush操作会执行一次 Lucene 提交,并启动新的 translog,这样可以截断过大的 translog 以加快恢复。
总之,Lucene 底层的数据落盘过程为:文档先被解析和分析为词项写入内存缓冲,当刷新/提交时形成新的段文件;段文件不可变,删除用位图标记,更新等于删旧插新;多个小段随着时间合并为大段;段级缓存和 DocValues 等机制支持高效查询。
实操代码演示
下面给出 Python Elasticsearch 客户端(elasticsearch
包)示例,演示文档的写入、查询、更新和删除流程。
- 写入(Index)示例:\`\`\`python
from elasticsearch import Elasticsearch
es = Elasticsearch(["http\://localhost:9200"])
定义要写入的文档
doc = {"user": "alice", "age": 30, "message": "Hello Elasticsearch"}
索引文档到 index 为 test\_idx,id 为 1
res = es.index(index="test\_idx", id=1, document=doc)
print("Index response:", res["result"])
这段代码向名为 `test_idx` 的索引插入一个文档。如果索引不存在,ES 会自动创建索引。写入请求会按照上述写入流程执行,主分片写入后复制到副本。
- **查询(Search)示例:**```python
# 简单全文检索,按 user 字段匹配
query = {"query": {"match": {"user": "alice"}}}
res = es.search(index="test_idx", body=query)
print("Search hits:", res["hits"]["total"])
for hit in res["hits"]["hits"]:
print(hit["_source"])
此查询请求被任意节点接受并作为协调节点,然后分发给持有 test_idx
数据的分片执行,最后协调节点将结果合并返回。这里示例将匹配 user 为 "alice" 的文档,并打印命中结果的 _source
内容。
- 更新(Update)示例:\`\`\`python
更新文档 ID=1,将 age 字段加1
update\_body = {"doc": {"age": 31}}
res = es.update(index="test\_idx", id=1, body=update\_body)
print("Update response:", res["result"])
Update API 会首先路由到目标文档所在的主分片,然后执行标记原文档删除、插入新文档的过程。更新操作后,文档的版本号会自动递增。
- **删除(Delete)示例:**```python
# 删除文档 ID=1
res = es.delete(index="test_idx", id=1)
print("Delete response:", res["result"])
Delete 请求同样被路由到主分片,主分片在 Lucene 中打删除标记并写入 translog,然后传播到副本分片。删除操作完成后,从此文档将不再可搜索(直到段合并清理空间)。
性能调优建议
为了提高 ES 写入和查询性能,可参考以下建议并结合业务场景调优:
- 批量写入(Bulk)与并发: 尽量使用 Bulk API 批量发送文档,减少单次请求开销。可以并行使用多个线程或进程向集群发送批量请求,以充分利用集群资源。通过基准测试确定最优的批量大小和并发量,注意过大的批量或并发会带来内存压力或拒绝响应(
429
)。 - 刷新间隔(Refresh Interval): 默认情况下,ES 会每秒刷新索引使写入可搜索,这对写入性能有开销。对于写密集型场景,可暂时增加或禁用刷新间隔(例如
PUT /test_idx/_settings { "index": {"refresh_interval": "30s"} }
),待写入完成后再恢复默认。官方建议无搜索流量时关闭刷新,或将refresh_interval
调高。 - 副本数(Replicas): 索引初期大量写入时可以暂时将
number_of_replicas
设为0,以减少复制开销,写入完成后再恢复副本数。注意在关闭副本时存在单点数据丢失风险,应确保能够重新执行写入。 - 合并优化: 在批量写入结束后,可调用
_forcemerge
API 将索引段合并为较少的段数,提高查询性能。但合并是耗时操作,应在无写入时执行,并谨慎设置目标段数。 - 缓存配置: Lucene 使用操作系统文件缓存以及段级缓存来加速读取。合理配置
indices.queries.cache.size
、禁止查询缓存(对于过滤条件不变时启用)等。也可使用 Warmer 脚本预热缓存(旧版特性,在新版中一般不需要)。 - 硬件资源: 为了让文件系统缓存发挥作用,应预留足够的内存给 OS 缓存。I/O 密集时优先使用 SSD 存储。避免集群节点发生交换(swap),并合理分配 ES 的堆内存(建议不超过系统内存一半)。
- 其他: 使用自动生成 ID 可以避免 ES 在写入时查重,提高写入速度;必要时可配置更大的索引缓冲区(
indices.memory.index_buffer_size
),或开启专用的 Ingest 节点进行预处理;在应用层设计中尽量避免热点写入(即大量写入同一分片/ID),可考虑通过自定义路由分散压力。
总结
本文从集群架构、文档写入/更新/查询/删除流程,以及 Lucene 底层存储结构等角度,对 Elasticsearch 的工作原理进行了系统解读。索引和删除操作都经过协调节点路由到主分片,主分片执行操作并复制给副本;查询操作同样通过协调节点并行下发到各分片,最后合并结果返回。Lucene 层面,ES 利用倒排索引、不可变段以及 DocValues 等技术实现高效搜索,并借助事务日志保证写入安全。理解这些原理有助于更好地诊断系统问题和进行性能调优。希望本文对深入掌握 Elasticsearch 的内部机制有所帮助,并能指导实践中写入性能优化、合并策略调整、缓存利用等操作。
参考资料: 本文内容参考了 Elasticsearch 官方文档及业内技术博客等,包括 ES 数据复制模型、索引/查询流程说明、Lucene 存储原理等。
评论已关闭