Appearance
索引详解
要理解Elasticsearch(以下简称ES)的索引(Index),必须先打破“ES索引=数据库表”的表层认知——它本质是基于Lucene的分布式倒排索引集合,核心是解决“快速从海量文本中检索目标信息”的问题。以下从基础概念对齐、倒排索引核心原理、关键数据结构细节三个维度展开第一次讲解。
一、先理清:ES Index 与 Lucene Index 的本质区别
很多人混淆了ES和Lucene的“索引”概念,这是理解原理的关键门槛:
- Lucene Index:物理索引,是Lucene的核心数据结构,对应一个不可变的倒排索引文件集合(称为“段(Segment)”)。Lucene Index的特点是一旦写入无法修改(Immutable),只能通过生成新段、合并旧段来实现更新。
- ES Index:逻辑索引,是ES对外暴露的“数据集合”概念(类似数据库的“表”),但底层由多个Lucene Index组成——ES会将一个逻辑索引分片(Shard) 为N个主分片(Primary Shard)和M个副本分片(Replica Shard),每个分片对应一个独立的Lucene Index。
举个例子:如果创建一个ES索引my_index,设置number_of_shards=3(3个主分片)、number_of_replicas=1(1个副本),那么底层会生成3(主)+3(副)=6个Lucene Index,分布在ES集群的不同节点上。
二、倒排索引:ES索引的“灵魂”
倒排索引(Inverted Index)是ES实现快速全文检索的核心,其设计思路与“正排索引”完全相反:
- 正排索引:以 文档(Document) 为中心,记录每个文档包含的所有词项(Term)(比如“文档1包含词A、词B”)。适合“已知文档找内容”,但无法高效回答“哪些文档包含词A”。
- 倒排索引:以词项(Term) 为中心,记录每个词项出现在哪些文档中(比如“词A出现在文档1、文档3”)。这正是全文检索的核心需求——从“关键词”快速定位“文档”。
1. 倒排索引的组成:两大核心结构
倒排索引由词汇表(Term Dictionary) 和倒排列表(Posting List) 组成,辅以压缩和加速结构(如FST、跳表),形成完整的检索体系。
(1)词汇表(Term Dictionary):所有词项的“字典”
词汇表是所有经过分词、标准化后的词项的有序集合,相当于倒排索引的“目录”。它的作用是:当用户查询某个关键词时,先通过词汇表快速定位到该词项对应的倒排列表。
但问题来了:如果索引中有100万个词项,直接存储成“词项→倒排列表地址”的键值对,会占用大量内存,且查询速度慢(线性查找或二分查找的效率有限)。ES用有限状态转移机(FST, Finite State Transducer) 解决了这个问题。
- FST的原理:FST是一种共享前缀和后缀的有向无环图(DAG),可以将多个词项压缩成一个紧凑的结构。例如,词项“apple”、“app”、“apply”的前缀“app”可以共享,后缀“le”和“ly”则分支存储。
- FST的优势:
- 空间压缩:相比哈希表或有序列表,FST的存储空间可减少50%~90%(尤其是词项存在大量公共前缀时);
- 快速查找:支持精确查询(如“apple”)、前缀查询(如“app*”)、模糊查询(如“appl?”),时间复杂度接近O(k)(k为词项长度);
- 内存友好:FST可以完全加载到内存中,避免磁盘IO,大幅提升查询速度。
(2)倒排列表(Posting List):词项的“文档清单”
倒排列表是某个词项对应的所有文档的元数据集合,是倒排索引的“内容”。每个词项对应一个倒排列表,每个条目(称为Posting)包含以下关键信息:
- 文档ID(Doc ID):唯一标识文档的整数(ES中每个分片内的文档ID是递增的,全局唯一需结合分片ID);
- 词频(TF, Term Frequency):该词项在文档中出现的次数(用于计算相关性评分,如TF-IDF);
- 位置信息(Position):词项在文档中的字符位置(用于短语查询,如“Elasticsearch engine”需要确保两个词项的位置是连续的);
- 偏移量(Offset):词项在文档中的字符起始和结束位置(用于高亮显示,如搜索“Elasticsearch”时,在结果中标记该词的位置)。
举个具体例子: 假设我们有2篇文档:
- 文档1(Doc ID=1):“Elasticsearch is a distributed search engine”
- 文档2(Doc ID=2):“Elasticsearch is built on Lucene”
对“Elasticsearch”这个词项,其倒排列表为:
[
{ doc_id: 1, tf: 1, positions: [0], offsets: [0, 13] },
{ doc_id: 2, tf: 1, positions: [0], offsets: [0, 13] }
]对“search”这个词项,其倒排列表为:
[
{ doc_id: 1, tf: 1, positions: [4], offsets: [25, 30] }
]2. 倒排列表的优化:压缩与加速
倒排列表的大小直接影响检索性能——如果一个词项出现在100万篇文档中,存储原始文档ID会占用大量空间。ES通过差值编码和帧内参考编码(FOR, Frame Of Reference) 解决了这个问题。
(1)差值编码(Delta Encoding)
由于ES分片内的文档ID是单调递增的(新文档的ID比旧文档大),倒排列表中的Doc ID也是有序的。例如,Doc ID序列为[1,3,5,7,9],差值序列为[1,2,2,2,2](第一个值是原始ID,后面每个值是与前一个的差值)。差值通常远小于原始ID,因此可以用更少的位数存储。
(2)FOR编码:进一步压缩差值
FOR编码将差值序列分成固定大小的块(比如128个差值为一块),然后对每个块:
- 找出块内最大的差值,计算其所需的二进制位数(比如最大差值是7,需要3位);
- 用该位数对块内所有差值进行编码(比如7→111,2→010);
- 存储块的“位数头”(比如3位)和编码后的差值。
例如,差值序列[1,2,2,2,2](假设块大小为5):
- 最大差值是2,需要2位;
- 编码后的值为
01,10,10,10,10; - 存储“2位”的头和编码后的数据,总大小远小于原始的5个4字节整数。
通过差值+FOR编码,倒排列表的存储空间可压缩至原始大小的10%~20%,大幅降低磁盘IO和内存占用。
3. 倒排索引的构建流程:从文档到可检索结构
ES索引的构建过程,本质是将用户写入的文档转化为倒排索引的过程,核心步骤如下:
- 文档接收:用户通过
indexAPI写入文档,ES将文档路由到对应的分片(根据_id或自定义路由键); - 字段分析(Analysis):对文档中的text类型字段进行分词和标准化(非text字段如keyword直接作为整词存储):
- 分词(Tokenization):将文本拆分为词项(比如“Elasticsearch is great”→
["elasticsearch", "is", "great"]); - 标准化(Normalization):将词项转化为统一形式(比如小写(“Elasticsearch”→“elasticsearch”)、去停用词(“is”被过滤)、词干提取(“running”→“run”));
- 分词(Tokenization):将文本拆分为词项(比如“Elasticsearch is great”→
- Term映射:将每个标准化后的词项与文档ID、词频、位置、偏移量关联;
- 构建词汇表与倒排列表:将词项插入FST结构(词汇表),并将关联的文档元数据追加到对应的倒排列表中;
- 写入磁盘:ES将内存中的倒排索引暂存于内存缓冲区(In-Memory Buffer),每隔
refresh_interval(默认1秒)将缓冲区内容写入分段(Segment)(即Lucene的不可变索引文件),此时文档变为“可搜索”状态; - 持久化:分段文件会定期被
fsync到磁盘(通过flush_interval控制,默认30分钟),确保数据不会因节点宕机丢失。
三、总结:第一次讲解的核心要点
- ES索引是逻辑概念,底层由多个Lucene Index(分片)组成;
- 倒排索引是ES的核心,由词汇表(FST压缩) 和倒排列表(差值+FOR压缩) 组成;
- 倒排索引的优势是快速全文检索,通过“词项→文档”的映射直接定位目标;
- FST、差值编码、FOR编码是倒排索引高性能的关键——它们在压缩存储空间的同时,保证了查询速度。
接下来,我们深入ES索引的分布式实现、Segment管理细节、Mapping对索引的底层影响,以及生产环境的性能优化策略——这些是理解ES索引“如何工作”和“如何调优”的关键。
四、分布式索引:分片与副本的底层逻辑
ES的核心优势之一是分布式,而索引的分布式能力依赖于分片(Shard) 和副本(Replica) 的设计。这部分要解决的问题是:如何将海量数据分散存储,同时保证高可用和查询性能?
1. 分片的类型与职责
ES索引的每个分片对应一个独立的Lucene Index,分为两类:
- 主分片(Primary Shard):
数据的“原始存储节点”,负责处理所有写入请求(如index、update、delete)。主分片的数量在索引创建时指定(number_of_shards),一旦创建无法修改(除非通过reindex重建索引)。 - 副本分片(Replica Shard):
主分片的只读拷贝,用于两个核心场景:- 高可用:当主分片所在节点故障时,副本会自动升级为新的主分片;
- 负载均衡:分担查询请求(如
search、get),避免主分片成为性能瓶颈。
副本的数量可以动态调整(number_of_replicas),例如创建索引后执行PUT /my_index/_settings {"number_of_replicas": 2},会立即新增副本分片。
2. 分片路由:文档如何找到“家”?
当写入文档时,ES需要确定将文档分配到哪个主分片——这个过程叫路由(Routing),核心算法是:
plaintext
shard_id = hash(routing_key) % number_of_shards- routing_key:默认是文档的
_id(如果未指定_id,ES会自动生成一个UUID); - hash函数:使用MurmurHash3(高效且分布均匀的哈希算法);
- 取模运算:确保结果落在
0 ~ number_of_shards-1的范围内,对应主分片的ID。
示例:假设my_index的number_of_shards=3,文档_id=user123,MurmurHash3计算user123得到哈希值123456,123456 % 3 = 0,因此文档会被路由到主分片0。
自定义路由的价值:如果希望同一类文档(如同一用户的所有订单)路由到同一个分片,可以指定routing参数(如PUT /my_index/_doc/order123?routing=user123)。这样做的好处是:
- 查询时可以指定
routing,只查询目标分片(减少跨分片查询的开销); - 避免分片间数据分布不均(比如某些分片存储大量热点数据)。
3. 分片的分配与高可用
ES集群的主节点(Master Node) 负责管理分片的分配和故障转移:
- 初始分配:创建索引时,主节点会根据节点负载、可用磁盘空间、机架感知(Rack Awareness) 等策略,将主分片分散到不同节点,副本分片则分配到与主分片不同的节点(避免单节点故障导致数据丢失)。
- 故障转移:当主分片所在节点宕机时,主节点会:
- 从该主分片的副本中选择一个“最健康”的副本(如延迟最低、负载最低);
- 将该副本升级为新的主分片;
- 自动创建新的副本分片(保持
number_of_replicas的数量),确保高可用。
五、Segment管理:不可变索引的“生存法则”
Lucene的Segment是不可变(Immutable) 的——一旦写入磁盘,就无法修改。这种设计带来了写入性能的提升(无需加锁、直接追加),但也带来了更新/删除的难题,以及小Segment过多的性能问题。ES通过一套复杂的Segment管理机制解决了这些问题。
1. Segment的生命周期
Segment的一生可以分为生成→存在→合并→删除四个阶段:
(1)生成:从内存到可搜索
当用户写入文档时,文档会先进入内存缓冲区(In-Memory Buffer),同时写入事务日志(Transaction Log, TxLog)(防止节点宕机导致内存数据丢失)。
每隔refresh_interval(默认1秒),ES会执行一次refresh操作:
- 将内存缓冲区的内容写入一个新的Segment(存储在文件系统缓存,未持久化到磁盘);
- 清空内存缓冲区(但TxLog保留,直到
flush); - 该Segment立即变为“可搜索”(无需等待持久化)。
这就是ES“近实时(Near-Real-Time, NRT)”检索的基础——文档写入后1秒内可被搜索到。
(2)存在:不可变的只读Segment
Segment一旦生成,就会被“冻结”:无法修改、删除或追加数据。所有查询操作都会遍历当前所有Segment,合并结果后返回。
例如,查询“Elasticsearch”时,ES会从每个Segment的FST中找到该词项的倒排列表,然后合并所有文档ID,去重(排除被标记删除的文档),再计算相关性评分。
(3)合并:解决小Segment的“性能债务”
随着refresh的频繁执行,会生成大量小Segment(比如每1秒一个,一天就有86400个)。小Segment的问题是:
- 查询时需要遍历更多Segment,合并结果的开销大;
- 每个Segment都需要占用文件句柄和内存,资源浪费严重。
ES通过Merge(合并) 解决这个问题:将多个小Segment合并成一个大Segment,同时清理被标记删除的文档(Tombstone)。
Merge的过程:
- 选择Segment:由Lucene的
MergePolicy决定(默认是TieredMergePolicy,选择大小相近的Segment); - 合并数据:将多个Segment的倒排索引合并成一个新的大Segment;
- 替换旧Segment:新Segment生成后,旧Segment会被标记为“待删除”;
- 清理旧Segment:当所有查询不再引用旧Segment时,ES会删除它们的文件。
Merge的优化:
- 控制Merge的线程数(
index.merge.scheduler.max_thread_count,默认是max(1, min(4, available_processors/2))),避免占用过多IO; - 设置合并后的最大Segment大小(
index.merge.policy.max_merged_segment,默认5GB),防止生成过大的Segment(合并时间过长)。
(4)删除与更新:用“标记”代替修改
由于Segment不可变,删除文档不是真正删除,而是在一个叫删除文件(Deletion File) 的单独文件中,标记该文档的Doc ID为“已删除”(Tombstone)。查询时,ES会跳过被标记的文档。
更新文档则是“删除旧文档+插入新文档”的组合:
- 标记旧文档为删除(Tombstone);
- 将新文档写入新的Segment;
- 查询时,旧文档被跳过,新文档被返回。
只有当Merge发生时,被标记的旧文档才会被真正从磁盘中删除。
六、Mapping:索引的“基因”,决定检索的能力
Mapping是ES中定义文档结构的元数据,相当于数据库的“Schema”。它直接决定了字段如何被索引、存储和查询—— Mapping设计错误,会导致索引无法满足业务需求,甚至性能崩溃 。
1. Mapping的核心组件
Mapping的每个字段定义包含以下关键参数:
- type:字段类型(如
text、keyword、long、date); - analyzer:字段的分析器(用于
text类型,决定如何分词); - index:是否为该字段构建倒排索引(
true/false,默认true); - store:是否单独存储该字段的内容(
true/false,默认false,因为_source已存储原始文档); - doc_values:是否为该字段构建列式存储(用于排序、聚合、范围查询,默认非
text字段开启); - fielddata:是否为
text字段构建内存列式存储(默认false,因为text字段词项多,占用内存大)。
2. 字段类型的选择:决定检索方式
不同的字段类型对应不同的索引策略,选择错误会导致严重问题:
| 字段类型 | 适用场景 | 索引策略 | 示例 |
|---|---|---|---|
text | 全文检索(如文章内容、商品描述) | 分词+标准化(如小写、去停用词) | 搜索“Elasticsearch”时,匹配“elasticsearch”、“ElasticSearch”等 |
keyword | 精确查询/排序/聚合(如用户ID、订单状态、标签) | 整词存储,不分词 | 搜索“user123”时,仅匹配“user123” |
long/integer | 数值运算(如年龄、价格) | 存储为有序结构(BKD树),支持范围查询 | 查询“价格>100” |
date | 日期时间(如订单时间、日志时间) | 解析为毫秒级时间戳,存储为数值类型 | 查询“2025-07-01 至 2025-07-31”的日志 |
object | 嵌套对象(如用户的地址信息) | 扁平化为多字段(如user.address.city) | 搜索“user.address.city:北京” |
3. 分析器:文本如何变成“可检索的词项”?
分析器是text类型字段的核心,负责将文本拆分为词项(Term) 并标准化。一个分析器由三部分组成:
- 字符过滤器(Character Filter):预处理文本(如去除HTML标签、替换特殊字符);
- 分词器(Tokenizer):将文本拆分为词项(如
standard分词器按空格和标点拆分); - 词项过滤器(Token Filter):标准化词项(如小写、去停用词、词干提取)。
示例:用ik_max_word分析器处理中文文本“我爱北京天安门”:
- 字符过滤器:无(假设文本无特殊字符);
- 分词器:拆分为“我”、“爱”、“北京”、“天安门”;
- 词项过滤器:无(
ik分词器已处理标准化); - 最终词项:
["我", "爱", "北京", "天安门"]。
如果用standard分析器处理同样的文本,会拆分为“我”、“爱”、“北”、“京”、“天”、“安”、“门”——显然ik更符合中文检索需求。
4. 动态映射 vs 显式映射:生产环境的选择
- 动态映射(Dynamic Mapping):ES自动推断字段类型(如“123”→
long,“2025-07-17”→date)。优点是“零配置”,适合快速原型;缺点是容易出错(如将“123”推断为long,但后续写入“abc”会报错;将日期字符串推断为text,无法进行范围查询)。 - 显式映射(Explicit Mapping):提前定义所有字段的类型和参数。优点是可控性强,避免自动推断的错误;缺点是需要提前规划。
生产环境建议:禁用动态映射("dynamic": "strict"),所有字段都通过显式映射定义。例如:
json
PUT /my_index
{
"mappings": {
"dynamic": "strict", // 禁用动态映射
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word", // 中文分词器
"fields": {
"keyword": { // 用于排序/聚合的子字段
"type": "keyword",
"ignore_above": 256 // 超过256字符的部分忽略
}
}
},
"price": {
"type": "integer" // 数值类型,支持范围查询
},
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss" // 自定义日期格式
}
}
}
}七、索引性能优化:生产环境的“避坑指南”
ES索引的性能优化需要从分片规划、Segment管理、Mapping设计、缓存利用四个维度入手,以下是高频优化策略:
1. 分片规划:避免“过大/过小”的陷阱
- 分片数量:每个节点的分片数量(主+副)建议控制在30~50个以内(过多会增加主节点的管理开销)。例如,一个节点有8核CPU、32GB内存,建议分片数量不超过40个。
- 分片大小:每个分片的大小建议在10~50GB之间(太小会导致小Segment过多,太大则Merge和恢复时间过长)。例如,预计索引总大小为100GB,
number_of_shards=5(每个分片20GB),number_of_replicas=1(总分片数10)。
2. Segment管理:平衡实时性与性能
- 调整
refresh_interval:如果业务对实时性要求不高(如日志索引),可以将refresh_interval调大(如30s或1m),减少小Segment的生成数量,降低Merge开销。例如:jsonPUT /my_index/_settings { "index.refresh_interval": "30s" } - 强制合并(Force Merge):对于“只读”索引(如历史日志),可以执行
_forcemerge操作,将所有Segment合并成1个大Segment(减少查询时的Segment遍历开销)。例如:json注意:POST /my_index/_forcemerge?max_num_segments=1_forcemerge是不可逆的,仅适用于不再写入的索引。
3. Mapping设计:“能省则省”的原则
- 关闭不需要的索引:对于不需要检索的字段(如日志中的
raw_message),设置index=false,避免构建倒排索引。例如:json"raw_message": { "type": "text", "index": false // 不构建倒排索引,无法检索 } - 用
keyword子字段替代fielddata:如果需要对text字段排序或聚合,建议添加keyword子字段(而不是开启fielddata),因为keyword的列式存储(doc_values)更节省内存。例如:json"title": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } - 关闭
_source(谨慎):如果不需要返回原始文档(如仅做聚合统计),可以关闭_source字段,节省存储空间。但关闭后无法执行update、reindex操作,需谨慎使用:jsonPUT /my_index { "mappings": { "_source": { "enabled": false } } }
4. 缓存利用:减少重复计算
ES有两个核心缓存,合理利用可以大幅提升查询性能:
- 查询缓存(Query Cache):缓存过滤查询的结果(如
term、range、bool过滤),默认开启。可以通过indices.query.cache.size调整缓存大小(如10%,表示占堆内存的10%):jsonPUT /my_index/_settings { "indices.query.cache.size": "10%" } - 字段数据缓存(Field Data Cache):缓存
text字段的列式存储数据(用于排序/聚合),默认关闭。如果必须开启,建议限制缓存大小(如20%):jsonPUT /my_index/_settings { "indices.fielddata.cache.size": "20%" }
八、总结:ES索引的“底层逻辑链”
到这里,我们可以串联起ES索引的完整逻辑:
- 用户写入文档→ES通过路由算法将文档分配到主分片→文档进入内存缓冲区+事务日志;
- refresh操作→内存缓冲区的内容写入小Segment→文档可搜索;
