MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

ElasticSearch索引结构的深度剖析与优化

2024-03-126.2k 阅读

ElasticSearch索引结构概述

倒排索引基础

ElasticSearch 是基于 Lucene 构建的分布式搜索引擎,其核心索引结构是倒排索引。倒排索引与传统的正向索引不同,正向索引是以文档为中心,记录每个文档包含的词汇及位置;而倒排索引则是以词汇为中心,记录每个词汇出现在哪些文档中以及出现的位置等信息。

例如,假设有两个文档: 文档1:"ElasticSearch is a powerful search engine" 文档2:"Lucene is the foundation of ElasticSearch"

正向索引可能会是这样记录:

  • 文档1:["ElasticSearch", "is", "a", "powerful", "search", "engine"]
  • 文档2:["Lucene", "is", "the", "foundation", "of", "ElasticSearch"]

而倒排索引则会是:

  • "ElasticSearch":[文档1, 文档2]
  • "is":[文档1, 文档2]
  • "a":[文档1]
  • "powerful":[文档1]
  • "search":[文档1]
  • "engine":[文档1]
  • "Lucene":[文档2]
  • "the":[文档2]
  • "foundation":[文档2]
  • "of":[文档2]

这种结构使得 ElasticSearch 在处理搜索请求时,能够快速定位包含特定词汇的文档,大大提高了搜索效率。

ElasticSearch倒排索引的实现

在 ElasticSearch 中,倒排索引被组织成多个段(Segment)。每个段都是一个自包含的倒排索引,包含了一部分文档的数据。段一旦创建,就不可变。这使得 ElasticSearch 可以对段进行并行处理,提高搜索性能。

当新文档被索引时,ElasticSearch 并不会直接修改已有的段,而是先将文档写入一个内存中的缓冲区(In - Memory Buffer)。当缓冲区达到一定大小或经过一定时间后,缓冲区中的数据会被刷新(Flush)到磁盘上,形成一个新的段。

例如,我们通过以下代码使用 ElasticSearch 的 Java API 来索引文档,在这个过程中就涉及到了上述提到的索引构建机制:

import org.apache.http.HttpHost;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;

import java.io.IOException;

public class ElasticSearchIndexExample {
    public static void main(String[] args) throws IOException {
        RestHighLevelClient client = new RestHighLevelClient(
                RestClient.builder(
                        new HttpHost("localhost", 9200, "http")));

        IndexRequest request = new IndexRequest("my_index");
        request.id("1");
        request.source("{\"title\":\"Sample Document\",\"content\":\"This is a sample content\"}", XContentType.JSON);

        IndexResponse response = client.index(request, RequestOptions.DEFAULT);

        client.close();
    }
}

在上述代码中,当执行 client.index(request, RequestOptions.DEFAULT) 时,文档首先会被写入内存缓冲区,后续根据 ElasticSearch 的配置,会被刷新到磁盘形成新的段。

ElasticSearch索引结构剖析

段的内部结构

每个段内部由多个文件组成,其中最重要的文件有:

  1. 倒排索引文件(Term Dictionary + Term Index)
    • Term Dictionary:存储所有的词汇及其对应的文档列表等信息。它通常是按照词汇的字典序存储的。例如,对于一个包含词汇 "apple", "banana", "cherry" 的段,Term Dictionary 会按照字母顺序存储这些词汇以及它们对应的文档出现信息。
    • Term Index:为了加速在 Term Dictionary 中的查找,ElasticSearch 引入了 Term Index。Term Index 是一个稀疏索引,它并不会为每个词汇都建立索引项,而是每隔一定数量的词汇建立一个索引项。比如,Term Index 可能每隔 100 个词汇记录一个位置信息,这样在查找某个词汇时,先通过 Term Index 快速定位到词汇所在的大致范围,再在 Term Dictionary 中精确查找,从而提高查找效率。
  2. 文档文件(Document Store):存储文档的原始内容。当通过倒排索引找到相关文档的 ID 后,需要从文档文件中获取文档的完整内容。ElasticSearch 支持多种方式存储文档,如全量存储、只存储部分字段等,这可以通过索引映射(Index Mapping)来配置。

索引合并

由于段是不可变的,随着新文档的不断索引,段的数量会不断增加。过多的段会带来性能问题,因为每个段在搜索时都需要单独处理,增加了 I/O 和 CPU 的开销。为了解决这个问题,ElasticSearch 会定期执行索引合并(Index Merge)操作。

索引合并会将多个小的段合并成一个大的段。在合并过程中,ElasticSearch 会读取多个段的倒排索引数据,重新组织并写入一个新的段中。同时,旧的段会被删除。例如,假设有三个小段 S1、S2、S3,合并后会生成一个新段 S。

合并操作可以通过以下 ElasticSearch API 手动触发(虽然通常情况下是自动执行的):

POST /my_index/_forcemerge?max_num_segments=1

上述命令会强制将 my_index 索引的段合并为一个段。max_num_segments 参数指定了合并后段的最大数量。

索引的动态更新

虽然段是不可变的,但 ElasticSearch 仍然支持对索引的动态更新,包括文档的新增、修改和删除。

  1. 文档新增:如前文所述,新文档会先写入内存缓冲区,然后刷新到磁盘形成新段。
  2. 文档修改:实际上 ElasticSearch 并没有真正的修改操作,而是标记旧文档为删除,然后索引新的文档版本。这样旧版本的文档在后续的合并操作中会被彻底删除。
  3. 文档删除:ElasticSearch 通过在倒排索引中标记文档为删除来实现。被标记为删除的文档在搜索结果中不会被返回,但在段合并之前仍然占用空间。

例如,通过以下代码使用 Java API 删除文档:

import org.apache.http.HttpHost;
import org.elasticsearch.action.delete.DeleteRequest;
import org.elasticsearch.action.delete.DeleteResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;

import java.io.IOException;

public class ElasticSearchDeleteExample {
    public static void main(String[] args) throws IOException {
        RestHighLevelClient client = new RestHighLevelClient(
                RestClient.builder(
                        new HttpHost("localhost", 9200, "http")));

        DeleteRequest request = new DeleteRequest("my_index", "1");

        DeleteResponse response = client.delete(request, RequestOptions.DEFAULT);

        client.close();
    }
}

在上述代码中,执行 client.delete(request, RequestOptions.DEFAULT) 后,文档 "1" 在 my_index 索引中会被标记为删除。

ElasticSearch索引结构优化

优化索引映射

  1. 字段类型选择:选择合适的字段类型对于索引性能至关重要。例如,如果一个字段只需要存储数字,应选择合适的数值类型(如 integerlongfloatdouble 等),而不是使用通用的 text 类型。text 类型会对文本进行分词处理,增加索引大小和处理时间。
    • 对于不需要分词的文本字段,如产品型号、身份证号码等,应使用 keyword 类型。keyword 类型会将整个文本作为一个词项进行索引,适合精确匹配。
    • 对于日期字段,使用 date 类型,并指定合适的日期格式,这样 ElasticSearch 可以对日期进行高效的范围查询等操作。
  2. 多字段映射:有时候一个字段可能需要以不同的方式进行索引。例如,一个产品名称字段,既需要进行全文搜索(使用 text 类型并分词),又需要进行精确匹配(使用 keyword 类型)。这时可以使用多字段映射:
{
    "mappings": {
        "properties": {
            "product_name": {
                "type": "text",
                "fields": {
                    "keyword": {
                        "type": "keyword"
                    }
                }
            }
        }
    }
}

上述映射中,product_name 字段既可以用于全文搜索,也可以通过 product_name.keyword 进行精确匹配。

控制索引大小

  1. 文档大小:尽量避免索引过大的文档。过大的文档会增加索引和搜索的时间,同时也会占用更多的磁盘空间。如果文档包含大量的二进制数据(如图像、视频等),不建议直接在 ElasticSearch 中索引,可以考虑将这些数据存储在其他存储系统(如对象存储)中,然后在 ElasticSearch 中存储指向这些数据的链接。
  2. 索引字段数量:减少不必要的索引字段。每个字段都会增加索引的大小和处理时间。只索引那些实际需要用于搜索和分析的字段。例如,如果一个日志系统中有一些只用于调试的字段,且不会用于搜索和分析,就可以不将这些字段包含在索引中。

优化索引性能

  1. 批量操作:在索引文档时,使用批量操作可以显著提高性能。通过一次请求索引多个文档,可以减少网络开销和索引操作的次数。例如,使用 Java API 进行批量索引:
import org.apache.http.HttpHost;
import org.elasticsearch.action.bulk.BulkRequest;
import org.elasticsearch.action.bulk.BulkResponse;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;

import java.io.IOException;

public class ElasticSearchBulkIndexExample {
    public static void main(String[] args) throws IOException {
        RestHighLevelClient client = new RestHighLevelClient(
                RestClient.builder(
                        new HttpHost("localhost", 9200, "http")));

        BulkRequest bulkRequest = new BulkRequest();

        bulkRequest.add(new IndexRequest("my_index").id("1").source("{\"title\":\"Document 1\",\"content\":\"Content of document 1\"}", XContentType.JSON));
        bulkRequest.add(new IndexRequest("my_index").id("2").source("{\"title\":\"Document 2\",\"content\":\"Content of document 2\"}", XContentType.JSON));

        BulkResponse bulkResponse = client.bulk(bulkRequest, RequestOptions.DEFAULT);

        client.close();
    }
}
  1. 合理设置刷新间隔:ElasticSearch 的刷新间隔决定了内存缓冲区中的数据多久会被刷新到磁盘形成新段。默认的刷新间隔是 1 秒,这对于一些实时性要求不高的场景可能过于频繁。可以适当增大刷新间隔,如设置为 5 秒或 10 秒,这样可以减少 I/O 操作,提高索引性能。可以通过索引设置来修改刷新间隔:
PUT /my_index/_settings
{
    "index.refresh_interval": "5s"
}
  1. 预热索引:在应用程序启动时,可以对经常使用的索引进行预热。预热操作会提前加载索引数据到内存中,从而减少首次搜索的响应时间。可以通过以下 API 进行索引预热:
POST /my_index/_warmup

处理高并发索引

  1. 增加副本数:通过增加索引的副本数,可以提高系统的读性能和可用性。每个副本都是主索引的一份拷贝,当有读请求时,请求可以分发到不同的副本上,从而减轻主索引的压力。可以通过以下 API 增加副本数:
PUT /my_index/_settings
{
    "index.number_of_replicas": 2
}

上述命令将 my_index 索引的副本数设置为 2。 2. 使用异步操作:在高并发场景下,使用异步操作可以避免阻塞应用程序的主线程。例如,在索引文档时,可以使用异步 API,这样应用程序可以在索引操作执行的同时继续执行其他任务。在 Java 中,可以使用 IndexAsyncRequest 来实现异步索引:

import org.apache.http.HttpHost;
import org.elasticsearch.action.index.IndexAsyncRequest;
import org.elasticsearch.action.index.IndexAsyncResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;

import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

public class ElasticSearchAsyncIndexExample {
    public static void main(String[] args) throws IOException, ExecutionException, InterruptedException {
        RestHighLevelClient client = new RestHighLevelClient(
                RestClient.builder(
                        new HttpHost("localhost", 9200, "http")));

        IndexAsyncRequest request = new IndexAsyncRequest("my_index");
        request.id("1");
        request.source("{\"title\":\"Async Document\",\"content\":\"This is an async indexed document\"}", XContentType.JSON);

        Future<IndexAsyncResponse> future = client.indexAsync(request, RequestOptions.DEFAULT);
        IndexAsyncResponse response = future.get();

        client.close();
    }
}

索引结构优化实践案例

假设我们有一个电商搜索系统,索引包含商品的标题、描述、价格、品牌等信息。

  1. 优化前
    • 所有文本字段都使用 text 类型,包括品牌字段,这导致品牌的精确匹配性能较低。
    • 文档大小没有控制,有些商品描述非常长,甚至包含了一些图片的 Base64 编码数据,导致索引大小过大。
    • 索引操作都是单个文档进行,没有使用批量操作,在高并发写入时性能较差。
  2. 优化后
    • 对于品牌字段,使用多字段映射,增加 keyword 类型的子字段,用于精确匹配。
    • 从文档中移除图片的 Base64 编码数据,将图片存储在对象存储中,在 ElasticSearch 中只存储图片的链接。
    • 在索引商品数据时,使用批量操作,将多个商品数据合并为一个批量请求进行索引。

经过这些优化,电商搜索系统的索引性能得到了显著提升,搜索响应时间缩短,同时索引占用的磁盘空间也大幅减少。

索引监控与调优

  1. 监控指标
    • 索引写入速度:通过监控索引写入速度,可以了解系统在高并发写入时的性能表现。如果写入速度过慢,可能需要调整批量大小、刷新间隔等参数。可以通过 ElasticSearch 的监控 API 获取索引写入速度指标。
    • 段数量:过多的段会影响搜索性能,应监控段的数量变化。如果段数量持续增加且没有进行有效的合并,可能需要调整合并策略。
    • 磁盘使用率:索引数据存储在磁盘上,监控磁盘使用率可以避免磁盘空间不足导致的索引问题。可以使用系统自带的磁盘监控工具结合 ElasticSearch 的索引大小统计信息来进行监控。
  2. 调优流程
    • 收集数据:通过 ElasticSearch 的监控 API、系统监控工具等收集上述监控指标的数据。
    • 分析数据:分析收集到的数据,找出性能瓶颈。例如,如果发现索引写入速度慢且批量大小较小,可以尝试增大批量大小;如果段数量过多,可以考虑调整合并参数。
    • 实施调整:根据分析结果,对 ElasticSearch 的配置参数、索引映射等进行调整。
    • 验证效果:调整后,再次收集监控数据,验证性能是否得到提升。如果没有达到预期效果,重复上述分析和调整过程。

例如,通过 ElasticSearch 的 _cat/indices API 可以查看索引的相关信息,包括段数量、磁盘使用等:

GET _cat/indices?v

通过分析这些信息,可以针对性地进行索引结构的优化。

综上所述,深入理解 ElasticSearch 的索引结构并进行优化,对于构建高性能、可扩展的搜索应用至关重要。通过合理选择索引映射、控制索引大小、优化索引性能、处理高并发索引以及持续监控和调优等措施,可以显著提升 ElasticSearch 的使用效果。