Skip to content

索引详解

要理解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的优势
    1. 空间压缩:相比哈希表或有序列表,FST的存储空间可减少50%~90%(尤其是词项存在大量公共前缀时);
    2. 快速查找:支持精确查询(如“apple”)、前缀查询(如“app*”)、模糊查询(如“appl?”),时间复杂度接近O(k)(k为词项长度);
    3. 内存友好: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个差值为一块),然后对每个块:

  1. 找出块内最大的差值,计算其所需的二进制位数(比如最大差值是7,需要3位);
  2. 用该位数对块内所有差值进行编码(比如7→111,2→010);
  3. 存储块的“位数头”(比如3位)和编码后的差值。

例如,差值序列[1,2,2,2,2](假设块大小为5):

  • 最大差值是2,需要2位;
  • 编码后的值为01,10,10,10,10
  • 存储“2位”的头和编码后的数据,总大小远小于原始的5个4字节整数。

通过差值+FOR编码,倒排列表的存储空间可压缩至原始大小的10%~20%,大幅降低磁盘IO和内存占用。

3. 倒排索引的构建流程:从文档到可检索结构

ES索引的构建过程,本质是将用户写入的文档转化为倒排索引的过程,核心步骤如下:

  1. 文档接收:用户通过indexAPI写入文档,ES将文档路由到对应的分片(根据_id或自定义路由键);
  2. 字段分析(Analysis):对文档中的text类型字段进行分词和标准化(非text字段如keyword直接作为整词存储):
    • 分词(Tokenization):将文本拆分为词项(比如“Elasticsearch is great”→["elasticsearch", "is", "great"]);
    • 标准化(Normalization):将词项转化为统一形式(比如小写(“Elasticsearch”→“elasticsearch”)、去停用词(“is”被过滤)、词干提取(“running”→“run”));
  3. Term映射:将每个标准化后的词项与文档ID、词频、位置、偏移量关联;
  4. 构建词汇表与倒排列表:将词项插入FST结构(词汇表),并将关联的文档元数据追加到对应的倒排列表中;
  5. 写入磁盘:ES将内存中的倒排索引暂存于内存缓冲区(In-Memory Buffer),每隔refresh_interval(默认1秒)将缓冲区内容写入分段(Segment)(即Lucene的不可变索引文件),此时文档变为“可搜索”状态;
  6. 持久化:分段文件会定期被fsync到磁盘(通过flush_interval控制,默认30分钟),确保数据不会因节点宕机丢失。

三、总结:第一次讲解的核心要点

  1. ES索引是逻辑概念,底层由多个Lucene Index(分片)组成;
  2. 倒排索引是ES的核心,由词汇表(FST压缩)倒排列表(差值+FOR压缩) 组成;
  3. 倒排索引的优势是快速全文检索,通过“词项→文档”的映射直接定位目标;
  4. FST、差值编码、FOR编码是倒排索引高性能的关键——它们在压缩存储空间的同时,保证了查询速度。

接下来,我们深入ES索引的分布式实现Segment管理细节Mapping对索引的底层影响,以及生产环境的性能优化策略——这些是理解ES索引“如何工作”和“如何调优”的关键。

四、分布式索引:分片与副本的底层逻辑

ES的核心优势之一是分布式,而索引的分布式能力依赖于分片(Shard)副本(Replica) 的设计。这部分要解决的问题是:如何将海量数据分散存储,同时保证高可用和查询性能?

1. 分片的类型与职责

ES索引的每个分片对应一个独立的Lucene Index,分为两类:

  • 主分片(Primary Shard)
    数据的“原始存储节点”,负责处理所有写入请求(如indexupdatedelete)。主分片的数量在索引创建时指定(number_of_shards),一旦创建无法修改(除非通过reindex重建索引)。
  • 副本分片(Replica Shard)
    主分片的只读拷贝,用于两个核心场景:
    1. 高可用:当主分片所在节点故障时,副本会自动升级为新的主分片;
    2. 负载均衡:分担查询请求(如searchget),避免主分片成为性能瓶颈。

副本的数量可以动态调整(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_indexnumber_of_shards=3,文档_id=user123,MurmurHash3计算user123得到哈希值123456123456 % 3 = 0,因此文档会被路由到主分片0

自定义路由的价值:如果希望同一类文档(如同一用户的所有订单)路由到同一个分片,可以指定routing参数(如PUT /my_index/_doc/order123?routing=user123)。这样做的好处是:

  • 查询时可以指定routing,只查询目标分片(减少跨分片查询的开销);
  • 避免分片间数据分布不均(比如某些分片存储大量热点数据)。

3. 分片的分配与高可用

ES集群的主节点(Master Node) 负责管理分片的分配和故障转移:

  • 初始分配:创建索引时,主节点会根据节点负载可用磁盘空间机架感知(Rack Awareness) 等策略,将主分片分散到不同节点,副本分片则分配到与主分片不同的节点(避免单节点故障导致数据丢失)。
  • 故障转移:当主分片所在节点宕机时,主节点会:
    1. 从该主分片的副本中选择一个“最健康”的副本(如延迟最低、负载最低);
    2. 将该副本升级为新的主分片;
    3. 自动创建新的副本分片(保持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的过程

  1. 选择Segment:由Lucene的MergePolicy决定(默认是TieredMergePolicy,选择大小相近的Segment);
  2. 合并数据:将多个Segment的倒排索引合并成一个新的大Segment;
  3. 替换旧Segment:新Segment生成后,旧Segment会被标记为“待删除”;
  4. 清理旧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会跳过被标记的文档。
更新文档则是“删除旧文档+插入新文档”的组合:

  1. 标记旧文档为删除(Tombstone);
  2. 将新文档写入新的Segment;
  3. 查询时,旧文档被跳过,新文档被返回。

只有当Merge发生时,被标记的旧文档才会被真正从磁盘中删除。

六、Mapping:索引的“基因”,决定检索的能力

Mapping是ES中定义文档结构的元数据,相当于数据库的“Schema”。它直接决定了字段如何被索引、存储和查询—— Mapping设计错误,会导致索引无法满足业务需求,甚至性能崩溃

1. Mapping的核心组件

Mapping的每个字段定义包含以下关键参数:

  • type:字段类型(如textkeywordlongdate);
  • 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分析器处理中文文本“我爱北京天安门”:

  1. 字符过滤器:无(假设文本无特殊字符);
  2. 分词器:拆分为“我”、“爱”、“北京”、“天安门”;
  3. 词项过滤器:无(ik分词器已处理标准化);
  4. 最终词项:["我", "爱", "北京", "天安门"]

如果用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调大(如30s1m),减少小Segment的生成数量,降低Merge开销。例如:
    json
    PUT /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字段,节省存储空间。但关闭后无法执行updatereindex操作,需谨慎使用:
    json
    PUT /my_index
    {
      "mappings": {
        "_source": {
          "enabled": false
        }
      }
    }

4. 缓存利用:减少重复计算

ES有两个核心缓存,合理利用可以大幅提升查询性能:

  • 查询缓存(Query Cache):缓存过滤查询的结果(如termrangebool过滤),默认开启。可以通过indices.query.cache.size调整缓存大小(如10%,表示占堆内存的10%):
    json
    PUT /my_index/_settings
    {
      "indices.query.cache.size": "10%"
    }
  • 字段数据缓存(Field Data Cache):缓存text字段的列式存储数据(用于排序/聚合),默认关闭。如果必须开启,建议限制缓存大小(如20%):
    json
    PUT /my_index/_settings
    {
      "indices.fielddata.cache.size": "20%"
    }

八、总结:ES索引的“底层逻辑链”

到这里,我们可以串联起ES索引的完整逻辑:

  1. 用户写入文档→ES通过路由算法将文档分配到主分片→文档进入内存缓冲区+事务日志;
  2. refresh操作→内存缓冲区的内容写入小Segment→文档可搜索;