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

ElasticSearch 映射管理的最佳实践与经验分享

2024-04-038.0k 阅读

ElasticSearch 映射管理基础

ElasticSearch 是一个分布式的搜索和分析引擎,在处理海量数据时,映射(Mapping)起着至关重要的作用。映射定义了文档及其包含的字段如何被存储和索引。它类似于关系型数据库中的表结构定义,但更加灵活和动态。

映射的基本概念

  1. 文档类型(Type):在早期版本的 ElasticSearch 中,文档类型用于对文档进行逻辑分组,一个索引可以包含多个文档类型。但从 ElasticSearch 7.0 开始,已经逐步弃用文档类型,到 8.0 版本完全移除。这使得索引结构更加简洁,避免了一些因类型使用不当导致的问题。
  2. 字段(Field):文档由多个字段组成,每个字段都有自己的数据类型,如字符串、数字、日期等。ElasticSearch 支持丰富的数据类型,正确定义字段类型对于数据的存储、索引和查询性能至关重要。例如,将日期字段正确定义为日期类型,而不是简单地作为字符串存储,这样可以利用 ElasticSearch 提供的日期相关查询功能。

动态映射(Dynamic Mapping)

ElasticSearch 具有动态映射功能,当写入一个新文档时,如果索引中不存在该文档字段的映射定义,ElasticSearch 会自动根据文档内容推断字段的数据类型,并添加相应的映射。这极大地方便了开发人员,无需预先定义所有字段的映射。例如:

PUT my_index/_doc/1
{
  "title": "这是一篇文章",
  "content": "详细内容...",
  "views": 100,
  "published_date": "2023-10-01"
}

在上述例子中,当执行这个 PUT 请求时,如果 my_index 索引不存在,ElasticSearch 会自动创建索引,并为 title(推断为字符串类型)、content(字符串类型)、views(数字类型)和 published_date(日期类型,因为格式符合 ISO 8601 标准)添加动态映射。

然而,动态映射并非总是理想的。在一些场景下,可能会导致映射类型推断错误。比如,如果一个字段有时存储数字,有时存储字符串,动态映射可能会选择一种不合适的类型。为了避免这种情况,可以对索引进行部分预定义映射,并限制动态映射的行为。

静态映射(Static Mapping)

与动态映射相对,静态映射是指在创建索引时,手动定义好所有字段的映射。这种方式适用于对数据结构有明确要求,并且希望严格控制数据类型的场景。例如:

PUT my_index
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "content": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "views": {
        "type": "long"
      },
      "published_date": {
        "type": "date",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

在上述示例中,通过 PUT 请求创建了 my_index 索引,并定义了 titlecontentviewspublished_date 字段的静态映射。titlecontent 字段被定义为 text 类型,并使用 ik_max_word 分词器(适用于中文分词),views 定义为 long 类型,published_date 定义为 date 类型,并指定了日期格式。

映射管理的最佳实践

选择合适的数据类型

  1. 字符串类型的选择:ElasticSearch 中字符串类型分为 textkeywordtext 类型用于全文搜索,会对字符串进行分词处理;而 keyword 类型用于精确匹配,不会分词。例如,对于文章标题、正文等适合全文搜索的字段,应使用 text 类型;对于身份证号、订单号等需要精确匹配的字段,应使用 keyword 类型。
PUT my_index
{
  "mappings": {
    "properties": {
      "article_title": {
        "type": "text",
        "analyzer": "standard"
      },
      "order_id": {
        "type": "keyword"
      }
    }
  }
}
  1. 数字类型的选择:根据数据的范围和精度选择合适的数字类型。对于较小范围的整数,可以使用 shortbyte;对于较大范围的整数,使用 long;对于浮点数,根据精度要求选择 floatdouble。例如,如果存储文章的点赞数,一般使用 long 类型:
PUT my_index
{
  "mappings": {
    "properties": {
      "likes": {
        "type": "long"
      }
    }
  }
}
  1. 日期类型:确保日期字段使用 date 类型,并根据实际数据格式指定正确的 format。除了常见的 yyyy - MM - dd 格式,还支持多种日期格式,如 epoch_millis(时间戳格式)等。
PUT my_index
{
  "mappings": {
    "properties": {
      "create_date": {
        "type": "date",
        "format": "epoch_millis"
      }
    }
  }
}

分词器的优化

  1. 选择合适的分词器:对于中文文本,ik_max_wordik_smart 是常用的分词器。ik_max_word 会将文本尽可能细粒度地拆分,适合全文搜索场景;ik_smart 则是粗粒度分词,适合短文本匹配场景。例如,对于一篇新闻文章的正文,使用 ik_max_word 分词器可以提高搜索的召回率:
PUT my_index
{
  "mappings": {
    "properties": {
      "news_content": {
        "type": "text",
        "analyzer": "ik_max_word"
      }
    }
  }
}
  1. 自定义分词器:在一些特殊场景下,可能需要自定义分词器。可以通过组合字符过滤器、分词器和过滤器来创建满足特定需求的分词器。例如,假设需要对一些包含特定行业术语的文本进行分词,并且要去除一些特殊字符,可以这样定义自定义分词器:
PUT my_index
{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_custom_analyzer": {
          "type": "custom",
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "my_custom_stopwords"
          ]
        }
      },
      "filter": {
        "my_custom_stopwords": {
          "type": "stop",
          "stopwords": [
            "the",
            "and",
            "is"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "industry_text": {
        "type": "text",
        "analyzer": "my_custom_analyzer"
      }
    }
  }
}

在上述示例中,定义了一个名为 my_custom_analyzer 的自定义分词器,它使用了 html_strip 字符过滤器去除 HTML 标签,standard 分词器进行分词,然后通过 lowercase 过滤器将单词转换为小写,并使用自定义的 my_custom_stopwords 过滤器去除一些常见的停用词。

处理嵌套和父子关系

  1. 嵌套类型(Nested Type):当文档中的一个字段包含多个对象,且这些对象之间需要独立查询时,应使用嵌套类型。例如,一个电商产品文档可能包含多个评论,每个评论是一个独立的对象,需要单独进行查询。
PUT product_index
{
  "mappings": {
    "properties": {
      "product_name": {
        "type": "text"
      },
      "reviews": {
        "type": "nested",
        "properties": {
          "reviewer_name": {
            "type": "text"
          },
          "rating": {
            "type": "integer"
          },
          "comment": {
            "type": "text"
          }
        }
      }
    }
  }
}

在上述示例中,reviews 字段被定义为 nested 类型,每个评论对象包含 reviewer_nameratingcomment 字段。这样可以使用嵌套查询来独立查询每个评论,例如:

GET product_index/_search
{
  "query": {
    "nested": {
      "path": "reviews",
      "query": {
        "match": {
          "reviews.comment": "产品很棒"
        }
      }
    }
  }
}
  1. 父子关系(Parent - Child Relationship):虽然从 ElasticSearch 7.0 开始不推荐使用父子关系,但在某些遗留系统或特定场景下仍可能用到。父子关系允许在不同文档之间建立层次关系,例如博客文章和评论可以建立父子关系。 首先,需要在创建索引时定义父子关系:
PUT blog_index
{
  "mappings": {
    "properties": {
      "article": {
        "type": "join",
        "relations": {
          "article": "comment"
        }
      }
    }
  }
}

然后,可以创建文章和评论文档,并建立父子关系:

// 创建文章文档
PUT blog_index/_doc/1?refresh=true
{
  "title": "这是一篇博客文章",
  "article": {
    "name": "article",
    "parent": null
  }
}
// 创建评论文档
PUT blog_index/_doc/2?refresh=true
{
  "comment_text": "这篇文章写得不错",
  "article": {
    "name": "comment",
    "parent": "1"
  }
}

通过这种方式,可以基于父子关系进行查询,例如查询某篇文章的所有评论:

GET blog_index/_search
{
  "query": {
    "has_parent": {
      "parent_type": "article",
      "query": {
        "match": {
          "title": "这是一篇博客文章"
        }
      }
    }
  }
}

映射更新策略

  1. 全量重建索引:当索引结构发生较大变化,如添加新的字段类型、修改字段的核心属性(如数据类型从 text 改为 keyword)时,最稳妥的方法是全量重建索引。首先,创建一个新的索引并定义好正确的映射,然后将旧索引的数据迁移到新索引。可以使用 ElasticSearch 的 reindex API 来实现数据迁移,例如:
POST _reindex
{
  "source": {
    "index": "old_index"
  },
  "dest": {
    "index": "new_index"
  }
}
  1. 部分更新映射:对于一些较小的变化,如添加新的字段或修改字段的非核心属性(如分词器),可以使用 PUT mapping API 进行部分更新。例如,为已有的索引添加一个新字段:
PUT my_index/_mapping
{
  "properties": {
    "new_field": {
      "type": "text",
      "analyzer": "standard"
    }
  }
}

需要注意的是,部分更新映射时,不能修改已存在字段的核心属性,否则会导致数据丢失或查询异常。

映射管理的性能优化

避免过度映射

  1. 精简字段:只定义实际需要的字段,避免添加过多无用字段。每个字段都会占用一定的存储空间和索引资源,过多的字段会导致索引体积增大,查询性能下降。例如,在一个用户信息索引中,如果只需要存储用户名、邮箱和手机号,就不要添加其他无关的字段。
  2. 合并相似字段:如果有多个含义相近的字段,可以考虑合并为一个字段。例如,假设一个商品索引中有 product_name_enproduct_name_cn 两个字段分别存储英文和中文商品名,可以合并为一个 product_name 字段,并通过不同的分词器来处理不同语言。

索引性能调优

  1. 设置合理的分片和副本:分片数决定了索引数据的分布,副本数决定了数据的冗余和高可用性。一般来说,分片数在创建索引时确定,后期很难调整。应根据数据量和硬件资源合理设置分片数。对于较小的数据量,过多的分片会增加管理开销;对于大数据量,过少的分片会影响查询性能。例如,对于一个预计有 100GB 数据的索引,可以根据每片 15 - 30GB 的经验值,设置 4 - 7 个分片。
PUT my_index
{
  "settings": {
    "number_of_shards": 5,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      // 字段映射定义
    }
  }
}
  1. 使用索引模板:索引模板可以定义一组通用的映射和设置,应用到多个索引上。这样可以保证不同索引之间的一致性,同时简化索引创建过程。例如,定义一个通用的日志索引模板:
PUT _template/log_template
{
  "index_patterns": [
    "log_*"
  ],
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy - MM - dd HH:mm:ss"
      },
      "log_level": {
        "type": "keyword"
      },
      "message": {
        "type": "text"
      }
    }
  }
}

此后,创建以 log_ 开头的索引时,会自动应用该模板的设置和映射。

缓存与预热

  1. 字段数据缓存(Field Data Cache):ElasticSearch 使用字段数据缓存来加速聚合和排序操作。对于频繁用于聚合或排序的字段,可以通过设置 eager_global_ordinals 来预热字段数据缓存,提高查询性能。例如,对于一个按类别统计商品数量的场景,类别字段可以设置如下:
PUT product_index
{
  "mappings": {
    "properties": {
      "category": {
        "type": "keyword",
        "eager_global_ordinals": true
      }
    }
  }
}
  1. 过滤器缓存(Filter Cache):过滤器缓存用于缓存过滤器查询的结果,提高重复过滤器查询的性能。默认情况下,ElasticSearch 会自动管理过滤器缓存。可以通过调整 index.cache.filter.size 设置来控制过滤器缓存的大小。例如,在一个经常进行时间范围过滤查询的场景中,可以适当增大过滤器缓存的大小:
PUT my_index/_settings
{
  "index.cache.filter.size": "20%"
}

复杂场景下的映射管理

多语言文本处理

  1. 多语言分词器:对于包含多种语言的文本,可以使用支持多语言的分词器,如 icu_tokenizer。它可以根据文本的语言自动选择合适的分词方式。例如:
PUT multi_lang_index
{
  "settings": {
    "analysis": {
      "analyzer": {
        "multi_lang_analyzer": {
          "type": "custom",
          "tokenizer": "icu_tokenizer"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "multi_lang_text": {
        "type": "text",
        "analyzer": "multi_lang_analyzer"
      }
    }
  }
}
  1. 语言识别与索引:还可以在文档中添加语言标识字段,并根据语言分别进行索引和搜索。例如,在一个多语言新闻索引中,可以这样定义映射:
PUT news_index
{
  "mappings": {
    "properties": {
      "language": {
        "type": "keyword"
      },
      "title_en": {
        "type": "text",
        "analyzer": "english"
      },
      "title_cn": {
        "type": "text",
        "analyzer": "ik_max_word"
      }
    }
  }
}

然后在查询时,根据语言字段选择相应的字段进行搜索。

地理位置数据处理

  1. 地理位置类型:ElasticSearch 支持 geo_pointgeo_shape 两种地理位置类型。geo_point 用于表示点坐标,geo_shape 用于表示复杂的地理形状,如多边形。例如,对于一个存储店铺位置的索引:
PUT store_index
{
  "mappings": {
    "properties": {
      "location": {
        "type": "geo_point"
      }
    }
  }
}

可以通过以下方式插入数据:

PUT store_index/_doc/1
{
  "store_name": "店铺 A",
  "location": {
    "lat": 30.5,
    "lon": 120.3
  }
}
  1. 地理位置查询:支持多种地理位置查询,如距离查询、边界查询等。例如,查询距离某个点 10 公里内的店铺:
GET store_index/_search
{
  "query": {
    "geo_distance": {
      "distance": "10km",
      "location": {
        "lat": 30.6,
        "lon": 120.4
      }
    }
  }
}

高并发写入场景下的映射管理

  1. 批量写入:在高并发写入场景下,使用批量写入 API(bulk API)可以显著提高写入性能。将多个文档的操作合并为一个请求发送到 ElasticSearch。例如:
POST _bulk
{ "index": { "_index": "my_index", "_id": "1" } }
{ "field1": "value1" }
{ "index": { "_index": "my_index", "_id": "2" } }
{ "field1": "value2" }
  1. 索引设置优化:可以适当调整索引的刷新间隔(refresh_interval),在高并发写入时,增大刷新间隔可以减少索引刷新次数,提高写入性能,但会增加数据可见的延迟。例如:
PUT my_index/_settings
{
  "index.refresh_interval": "30s"
}

同时,合理设置 index.translog.durabilityindex.translog.sync_interval 等参数,平衡数据持久性和写入性能。

映射管理中的常见问题与解决方法

映射冲突问题

  1. 字段类型冲突:当尝试更新映射,且新的字段类型与现有数据不兼容时,会发生字段类型冲突。例如,将一个已存储数字的字段从 long 改为 text 类型。解决方法是全量重建索引,按照正确的类型重新创建索引并迁移数据。
  2. 动态映射与静态映射冲突:如果在已有静态映射的索引上,通过动态映射添加了与静态映射冲突的字段,会导致错误。应避免这种情况,确保静态映射覆盖所有需要的字段,或者禁用动态映射。可以在创建索引时设置 dynamic 参数为 false 来禁用动态映射:
PUT my_index
{
  "mappings": {
    "dynamic": false,
    "properties": {
      // 字段映射定义
    }
  }
}

查询性能问题

  1. 分词问题导致查询不准确:如果分词器选择不当或分词配置错误,会导致查询结果不准确。例如,使用了错误的分词器对中文文本进行分词,导致搜索时无法匹配到相关文档。解决方法是根据文本特点选择合适的分词器,并进行测试和优化。
  2. 聚合性能问题:在进行大规模聚合操作时,可能会出现性能瓶颈。可以通过设置合适的字段数据缓存、优化索引结构(如避免过多分片)以及使用 cardinality 聚合的 precision_threshold 参数等方式来提高聚合性能。例如,对于一个统计用户唯一标识数量的聚合操作,可以设置 precision_threshold 来平衡精度和性能:
GET user_index/_search
{
  "aggs": {
    "unique_users": {
      "cardinality": {
        "field": "user_id",
        "precision_threshold": 1000
      }
    }
  }
}

数据一致性问题

  1. 写入一致性:在高并发写入时,可能会出现数据一致性问题。可以通过设置合适的 consistency 参数来保证写入一致性。例如,设置 consistencyquorum,表示只有当大多数分片写入成功时,写入操作才被认为成功:
PUT my_index/_doc/1?consistency=quorum
{
  "field1": "value1"
}
  1. 副本同步问题:副本之间的数据同步可能会出现延迟,导致查询结果不一致。可以通过调整副本同步策略和监控副本状态来解决。可以使用 _cat/replicas API 查看副本状态,确保副本同步正常。

通过遵循上述最佳实践,深入理解 ElasticSearch 映射管理的本质,并合理运用代码示例中的方法,可以有效地管理 ElasticSearch 索引的映射,提高系统的性能、可靠性和可扩展性,满足各种复杂业务场景的需求。