Elasticsearch 完全教学指南:从入门到精通

本文将带你从零开始系统学习 Elasticsearch,涵盖核心概念、使用场景、基础操作、查询语法、聚合分析、底层原理、性能调优等内容。无论你是初学者还是有一定经验的开发者,都能从中获益。

文章目录

    • [一、认识 Elasticsearch](#一、认识 Elasticsearch)
      • [1.1 什么是 Elasticsearch?](#1.1 什么是 Elasticsearch?)
      • [1.2 核心概念速览](#1.2 核心概念速览)
      • [1.3 ES 在技术生态中的位置](#1.3 ES 在技术生态中的位置)
    • 二、使用场景
      • [2.1 全文搜索](#2.1 全文搜索)
      • [2.2 日志分析](#2.2 日志分析)
      • [2.3 指标聚合与可视化](#2.3 指标聚合与可视化)
      • [2.4 地理信息搜索](#2.4 地理信息搜索)
      • [2.5 自动补全与推荐](#2.5 自动补全与推荐)
    • 三、环境搭建与基础操作
    • [四、查询语法(Query DSL)](#四、查询语法(Query DSL))
      • [4.1 查询与过滤的区别](#4.1 查询与过滤的区别)
      • [4.2 全文搜索查询](#4.2 全文搜索查询)
      • [4.3 精确查询](#4.3 精确查询)
      • [4.4 复合查询(Bool Query)](#4.4 复合查询(Bool Query))
      • [4.5 高亮显示](#4.5 高亮显示)
      • [4.6 排序与分页](#4.6 排序与分页)
    • 五、聚合分析(Aggregations)
      • [5.1 桶聚合(Bucket Aggregation)](#5.1 桶聚合(Bucket Aggregation))
      • [5.2 指标聚合(Metric Aggregation)](#5.2 指标聚合(Metric Aggregation))
      • [5.3 嵌套聚合](#5.3 嵌套聚合)
      • [5.4 Pipeline 聚合](#5.4 Pipeline 聚合)
    • 六、关联关系与数据建模
      • [6.1 方案一:宽表冗余(Denormalization)](#6.1 方案一:宽表冗余(Denormalization))
      • [6.2 方案二:嵌套对象(Nested)](#6.2 方案二:嵌套对象(Nested))
        • [为什么需要 Nested?------Object 类型的陷阱](#为什么需要 Nested?——Object 类型的陷阱)
        • [Nested 的解决方案](#Nested 的解决方案)
        • [Nested 的底层原理](#Nested 的底层原理)
        • [Nested 的代价与限制](#Nested 的代价与限制)
      • [6.3 方案三:Join 父子文档(Parent-Child)](#6.3 方案三:Join 父子文档(Parent-Child))
      • [6.4 方案四:应用层关联(Application-Side Join)](#6.4 方案四:应用层关联(Application-Side Join))
        • [Terms Lookup(ES 内置的简化方案)](#Terms Lookup(ES 内置的简化方案))
      • [6.5 四种方案对比与选型指南](#6.5 四种方案对比与选型指南)
    • 七、底层原理
      • [7.1 倒排索引(Inverted Index)](#7.1 倒排索引(Inverted Index))
      • [7.2 Segment(段)的生命周期](#7.2 Segment(段)的生命周期)
      • [7.3 分布式架构](#7.3 分布式架构)
      • [7.4 BM25 评分算法](#7.4 BM25 评分算法)
      • [7.5 深度分页问题与解决方案](#7.5 深度分页问题与解决方案)
    • 八、性能调优
      • [8.1 写入优化](#8.1 写入优化)
      • [8.2 搜索优化](#8.2 搜索优化)
      • [8.3 分片策略](#8.3 分片策略)
      • [8.4 JVM 与内存调优](#8.4 JVM 与内存调优)
      • [8.5 熔断器(Circuit Breaker)](#8.5 熔断器(Circuit Breaker))
      • [8.6 索引生命周期管理(ILM)](#8.6 索引生命周期管理(ILM))
    • [九、Java 客户端集成](#九、Java 客户端集成)
      • [9.1 客户端选型](#9.1 客户端选型)
      • [9.2 Elasticsearch Java API Client 示例](#9.2 Elasticsearch Java API Client 示例)
      • [9.3 Spring Data Elasticsearch 集成](#9.3 Spring Data Elasticsearch 集成)
    • 十、生产环境最佳实践
      • [10.1 集群规划](#10.1 集群规划)
      • [10.2 索引设计原则](#10.2 索引设计原则)
      • [10.3 监控告警](#10.3 监控告警)
      • [10.4 安全与备份](#10.4 安全与备份)
    • 十一、常见问题与避坑指南
      • [11.1 为什么 term 查询搜不到数据?](#11.1 为什么 term 查询搜不到数据?)
      • [11.2 为什么聚合结果不准确?](#11.2 为什么聚合结果不准确?)
      • [11.3 为什么刚写入的数据搜不到?](#11.3 为什么刚写入的数据搜不到?)
      • [11.4 为什么集群状态是 Yellow?](#11.4 为什么集群状态是 Yellow?)
      • [11.5 Mapping 字段爆炸(Mapping Explosion)](#11.5 Mapping 字段爆炸(Mapping Explosion))
    • 十二、学习路线建议
    • 总结

一、认识 Elasticsearch

1.1 什么是 Elasticsearch?

Elasticsearch(以下简称 ES)是一个基于 Apache Lucene 构建的分布式、RESTful 风格的搜索和分析引擎。它能够以近实时(Near Real-Time, NRT)的速度存储、搜索和分析海量数据。

简单来说,ES 就像一个超级强大的"搜索数据库"------你把数据扔给它,它会帮你建立高效的索引结构,让你能在毫秒级别内从海量数据中找到你想要的内容。

1.2 核心概念速览

概念 类比关系型数据库 说明
Index(索引) Database 数据的逻辑命名空间
Document(文档) Row 一条具体的数据记录,JSON 格式
Field(字段) Column 文档中的一个属性
Mapping(映射) Schema 定义字段类型和索引方式
Shard(分片) Partition 索引的水平拆分单元
Replica(副本) Replica 分片的冗余拷贝,提供高可用

注意 :ES 7.x 之后已经移除了 Type 的概念(类似于关系型数据库中的 Table),一个 Index 下只有一个默认 Type _doc。

1.3 ES 在技术生态中的位置

ES 通常作为 ELK Stack(Elastic Stack)的核心组件使用:

  • Elasticsearch:数据存储与搜索引擎
  • Logstash:数据采集与转换管道
  • Kibana:数据可视化与管理界面
  • Beats:轻量级数据采集器(Filebeat、Metricbeat 等)

数据流通常是:数据源 → Beats/Logstash → Elasticsearch → Kibana


二、使用场景

2.1 全文搜索

这是 ES 最经典的使用场景。电商平台的商品搜索、新闻网站的文章搜索、知识库检索等都离不开 ES。它支持中文分词、同义词、拼音搜索、拼写纠错等高级搜索特性。

2.2 日志分析

结合 ELK Stack,ES 是目前最主流的日志分析方案。海量的应用日志、访问日志、系统日志都可以写入 ES,然后通过 Kibana 进行实时监控和分析。

2.3 指标聚合与可视化

ES 强大的聚合(Aggregation)能力使其可以用于实时的数据分析场景,例如计算 PV/UV、统计销售趋势、分析用户行为等。

2.4 地理信息搜索

ES 原生支持 geo_point 和 geo_shape 类型,可以实现"附近的人"、"范围内的门店"等地理位置搜索功能。

2.5 自动补全与推荐

通过 completion 类型和 suggest API,ES 可以高效实现搜索框的自动补全(如"输入'苹'自动提示'苹果手机'")。


三、环境搭建与基础操作

3.1 安装与启动

Docker 快速启动(推荐学习使用):

bash 复制代码
# 启动单节点 ES
docker run -d --name es \
  -p 9200:9200 -p 9300:9300 \
  -e "discovery.type=single-node" \
  -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
  elasticsearch:8.12.0

# 启动 Kibana
docker run -d --name kibana \
  -p 5601:5601 \
  --link es:elasticsearch \
  kibana:8.12.0

验证安装:

bash 复制代码
curl http://localhost:9200

3.2 索引管理

创建索引:

json 复制代码
PUT /my_blog
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "title": { "type": "text", "analyzer": "ik_max_word" },
      "content": { "type": "text", "analyzer": "ik_smart" },
      "author": { "type": "keyword" },
      "publish_date": { "type": "date" },
      "views": { "type": "integer" },
      "tags": { "type": "keyword" }
    }
  }
}

查看索引信息:

json 复制代码
GET /my_blog
GET /my_blog/_mapping
GET /my_blog/_settings

删除索引:

json 复制代码
DELETE /my_blog

3.3 索引别名(Index Alias)

什么是索引别名?

索引别名是指向一个或多个真实索引的"虚拟名称"。你可以把它理解为 Linux 中的软链接(symlink) 或 Java 中的接口------应用程序面向别名编程,底层真实索引可以随时替换,应用无感知。

复制代码
应用层代码: GET /my_app_logs/_search   ← 始终使用别名
                    ↓
别名映射:   my_app_logs → logs-2024.07.15(当前活跃索引)
基本操作

创建别名:

json 复制代码
// 方式一:创建索引时指定
PUT /logs-2024-07-15
{
  "aliases": {
    "logs_current": {}
  }
}

// 方式二:对已有索引添加别名
POST /_aliases
{
  "actions": [
    { "add": { "index": "logs-2024-07-15", "alias": "logs_current" } }
  ]
}

查看别名:

json 复制代码
GET /_alias/logs_current
GET /logs-2024-07-15/_alias

删除别名:

json 复制代码
POST /_aliases
{
  "actions": [
    { "remove": { "index": "logs-2024-07-15", "alias": "logs_current" } }
  ]
}

原子切换(最核心的能力):

json 复制代码
POST /_aliases
{
  "actions": [
    { "remove": { "index": "logs-2024-07-14", "alias": "logs_current" } },
    { "add": { "index": "logs-2024-07-15", "alias": "logs_current" } }
  ]
}

这两个操作在一个原子请求中完成------不存在中间状态,应用不会读到空数据或报错。这是索引别名最强大的特性。

带过滤器的别名

别名可以附带一个 filter,相当于创建了一个"视图"(类似于数据库 View),查询时自动追加过滤条件:

json 复制代码
POST /_aliases
{
  "actions": [
    {
      "add": {
        "index": "orders",
        "alias": "orders_beijing",
        "filter": { "term": { "city": "beijing" } }
      }
    }
  ]
}

// 查询 orders_beijing 等价于查询 orders + 自动加上 city=beijing 过滤
GET /orders_beijing/_search
{
  "query": { "match": { "product": "手机" } }
}
写索引别名(Write Index)

当一个别名关联多个索引时,必须指定哪个是写索引,否则写入会报错:

json 复制代码
POST /_aliases
{
  "actions": [
    { "add": { "index": "logs-2024-07-14", "alias": "logs", "is_write_index": false } },
    { "add": { "index": "logs-2024-07-15", "alias": "logs", "is_write_index": true } }
  ]
}

// 写入走 logs-2024-07-15,搜索会同时搜两个索引
POST /logs/_doc
{ "message": "new log entry" }
底层原理

索引别名的实现非常轻量------它是集群元数据(Cluster State)的一部分,存储在 Master 节点维护的集群状态中。别名不会复制数据,不会创建额外的物理结构,只是一层逻辑映射。

当请求指定别名时,协调节点在路由阶段根据 Cluster State 中的别名映射,将请求展开为对应的真实索引列表,后续处理与直接指定索引名完全一致。所以别名本身没有性能开销。

集群状态的更新是原子的(由 Master 节点广播到所有节点),这保证了 _aliases API 中的多个 action 要么全部生效、要么全部不生效。

核心使用场景

场景一:零停机重建索引(Reindex)

这是别名最经典的用途。当你需要修改 Mapping(比如把一个 text 字段改为 keyword)时,由于 ES 的 Mapping 不支持修改已有字段类型,唯一的方式是创建新索引并迁移数据:

json 复制代码
// 1. 创建新索引(新的 mapping)
PUT /products_v2
{ "mappings": { "properties": { "sku": { "type": "keyword" } } } }

// 2. 迁移数据
POST /_reindex
{
  "source": { "index": "products_v1" },
  "dest": { "index": "products_v2" }
}

// 3. 原子切换别名(应用代码始终访问 "products" 这个别名)
POST /_aliases
{
  "actions": [
    { "remove": { "index": "products_v1", "alias": "products" } },
    { "add": { "index": "products_v2", "alias": "products" } }
  ]
}

// 4. 确认无误后删除旧索引
DELETE /products_v1

整个过程中,应用代码不需要修改任何一行------它始终访问 products 这个别名。

场景二:配合 ILM 实现日志索引滚动

在 Index Lifecycle Management 中,rollover 操作正是依赖别名实现的。当日志索引达到大小或时间阈值时,ES 自动创建新索引并将写别名切换过去:

json 复制代码
// 创建带 rollover 的索引模板
PUT /_index_template/log_template
{
  "index_patterns": ["logs-*"],
  "template": {
    "settings": {
      "index.lifecycle.name": "log_policy",
      "index.lifecycle.rollover_alias": "logs"
    }
  }
}

// 创建初始索引并关联写别名
PUT /logs-000001
{
  "aliases": {
    "logs": { "is_write_index": true }
  }
}

// ILM 策略中的 rollover 会自动执行类似操作:
// 创建 logs-000002,将 is_write_index 切换到新索引
// 搜索 "logs" 别名仍然能查到所有历史索引的数据

场景三:多租户数据隔离

利用带 filter 的别名,可以在同一个物理索引上实现逻辑隔离:

json 复制代码
// 为每个租户创建过滤别名
POST /_aliases
{
  "actions": [
    { "add": { "index": "all_data", "alias": "tenant_A_data", "filter": { "term": { "tenant_id": "A" } } } },
    { "add": { "index": "all_data", "alias": "tenant_B_data", "filter": { "term": { "tenant_id": "B" } } } }
  ]
}

每个租户只能看到属于自己的数据,应用层不需要额外拼接过滤条件。

场景四:蓝绿部署 / A/B 测试

可以让不同的别名指向不同版本的索引,快速切换流量:

json 复制代码
// 正式流量走 v1
POST /_aliases
{ "actions": [{ "add": { "index": "search_v1", "alias": "search_prod" } }] }

// 测试流量走 v2
POST /_aliases
{ "actions": [{ "add": { "index": "search_v2", "alias": "search_canary" } }] }

// 验证通过后,一键切换正式流量到 v2
POST /_aliases
{
  "actions": [
    { "remove": { "index": "search_v1", "alias": "search_prod" } },
    { "add": { "index": "search_v2", "alias": "search_prod" } }
  ]
}
最佳实践
  • 应用代码永远不要直接使用真实索引名,始终通过别名访问。这为后续任何索引变更提供了灵活性。
  • 命名规范建议:真实索引带版本后缀(如 products_v1、logs-000001),别名使用业务语义名称(如 products、logs)。
  • 一个别名可以指向多个索引(用于搜索时跨多个索引),但写入时必须指定唯一的 is_write_index。
  • 别名操作是集群级别的,频繁创建/删除大量别名会增加 Cluster State 的大小,对超大规模集群(数万个索引)需要注意。

3.4 Mapping 字段类型详解

ES 中常用的字段类型:

类型 用途 示例
text 全文搜索,会分词 文章标题、内容
keyword 精确匹配,不分词 状态码、标签、ID
integer/long 数值类型 计数、价格
float/double 浮点数 评分、经纬度
date 日期类型 创建时间
boolean 布尔值 是否上架
nested 嵌套对象(独立索引) 评论列表
geo_point 地理坐标 经纬度
completion 自动补全 搜索建议

关键区别------text vs keyword:

json 复制代码
// text 类型:会分词,适合全文搜索
"title": { "type": "text", "analyzer": "ik_max_word" }

// keyword 类型:不分词,适合精确匹配、排序、聚合
"status": { "type": "keyword" }

// 两者兼备:既能全文搜索,也能精确匹配
"title": {
  "type": "text",
  "analyzer": "ik_max_word",
  "fields": {
    "raw": { "type": "keyword" }
  }
}

3.5 文档 CRUD

创建文档:

json 复制代码
// 指定 ID
PUT /my_blog/_doc/1
{
  "title": "Elasticsearch 入门教程",
  "content": "本文将介绍 Elasticsearch 的基本概念和使用方法...",
  "author": "张三",
  "publish_date": "2024-01-15",
  "views": 1024,
  "tags": ["elasticsearch", "搜索引擎", "教程"]
}

// 自动生成 ID
POST /my_blog/_doc
{
  "title": "ES 性能调优实战",
  "content": "在生产环境中,性能调优是不可避免的...",
  "author": "李四",
  "publish_date": "2024-02-20",
  "views": 2048,
  "tags": ["elasticsearch", "性能优化"]
}

查询文档:

json 复制代码
GET /my_blog/_doc/1

更新文档:

json 复制代码
// 部分更新(推荐)
POST /my_blog/_update/1
{
  "doc": {
    "views": 2048
  }
}

// 脚本更新
POST /my_blog/_update/1
{
  "script": {
    "source": "ctx._source.views += params.count",
    "params": { "count": 100 }
  }
}

删除文档:

json 复制代码
DELETE /my_blog/_doc/1

3.6 Bulk API 详解

Bulk API 是 ES 中最重要的写入接口,生产环境中几乎所有数据写入都通过它完成。它允许在单个 HTTP 请求中执行多个索引/更新/删除操作,相比逐条写入,性能提升可达 10-100 倍。

你可以把它类比为 JDBC 中的 addBatch() + executeBatch()------将多条操作打包提交,减少网络往返开销。

请求格式

Bulk API 使用一种特殊的 NDJSON(Newline Delimited JSON) 格式,每一行都是独立的 JSON 对象,行与行之间用换行符 \n 分隔:

复制代码
POST /_bulk
{action_line}\n
{optional_source_line}\n
{action_line}\n
{optional_source_line}\n
...

关键格式要求:

  • 每行必须是完整的 JSON 对象,不能跨行(不能格式化成多行 JSON)
  • 最后一行必须以换行符结尾,否则会报解析错误
  • Content-Type 必须是 application/x-ndjson(大多数客户端会自动处理)

为什么不用 JSON 数组?因为 NDJSON 格式允许 ES 逐行解析、边读边处理,不需要等待整个请求体加载到内存中,对大批量数据非常友好。

四种操作类型

Bulk API 支持四种操作,每种操作的格式略有不同:

1. index --- 创建或覆盖文档

如果文档 ID 已存在则覆盖(全量替换),不存在则创建:

json 复制代码
POST /_bulk
{"index": {"_index": "products", "_id": "1"}}
{"name": "iPhone 15", "price": 7999, "brand": "Apple"}
{"index": {"_index": "products", "_id": "2"}}
{"name": "Pixel 8", "price": 4999, "brand": "Google"}

如果不指定 _id,ES 会自动生成:

json 复制代码
{"index": {"_index": "products"}}
{"name": "小米14", "price": 3999, "brand": "Xiaomi"}

2. create --- 仅创建(已存在则失败)

与 index 类似,但如果文档 ID 已存在,该条操作会返回 409 Conflict 错误(其他操作不受影响):

json 复制代码
POST /_bulk
{"create": {"_index": "products", "_id": "1"}}
{"name": "iPhone 15", "price": 7999, "brand": "Apple"}

适用场景:确保不会意外覆盖已有数据,比如幂等消费消息队列中的事件时使用。

3. update --- 部分更新

update 操作需要一个额外的 doc 或 script 字段:

json 复制代码
POST /_bulk
{"update": {"_index": "products", "_id": "1"}}
{"doc": {"price": 6999, "on_sale": true}}
{"update": {"_index": "products", "_id": "2"}}
{"doc": {"price": 4499}, "doc_as_upsert": true}
{"update": {"_index": "products", "_id": "3"}}
{"script": {"source": "ctx._source.price -= params.discount", "params": {"discount": 500}}, "upsert": {"name": "默认商品", "price": 1000}}
  • doc:部分字段更新,只修改指定的字段
  • doc_as_upsert: true:如果文档不存在,则将 doc 内容作为新文档插入
  • script + upsert:文档存在时执行脚本,不存在时插入 upsert 中的内容

4. delete --- 删除文档

delete 操作只有 action 行,没有 source 行(这是唯一一个单行操作):

json 复制代码
POST /_bulk
{"delete": {"_index": "products", "_id": "1"}}
{"delete": {"_index": "products", "_id": "2"}}
混合操作示例

四种操作可以在同一个 Bulk 请求中混合使用:

json 复制代码
POST /_bulk
{"index": {"_index": "my_blog", "_id": "10"}}
{"title": "文章10", "author": "王五", "views": 500}
{"create": {"_index": "my_blog", "_id": "11"}}
{"title": "文章11", "author": "赵六", "views": 300}
{"update": {"_index": "my_blog", "_id": "10"}}
{"doc": {"views": 600}}
{"delete": {"_index": "my_blog", "_id": "11"}}

如果所有操作都针对同一个索引,可以在 URL 中指定索引名,简化 action 行:

json 复制代码
POST /my_blog/_bulk
{"index": {"_id": "10"}}
{"title": "文章10", "author": "王五", "views": 500}
{"delete": {"_id": "11"}}
响应结构解读

Bulk 响应是一个包含每条操作结果的数组,部分操作失败不影响其他操作的执行------这一点与数据库事务不同,Bulk 不是原子操作:

json 复制代码
{
  "took": 30,
  "errors": true,    // 只要有一条失败就为 true
  "items": [
    {
      "index": {
        "_index": "products",
        "_id": "1",
        "_version": 1,
        "result": "created",
        "status": 201
      }
    },
    {
      "create": {
        "_index": "products",
        "_id": "1",
        "status": 409,
        "error": {
          "type": "version_conflict_engine_exception",
          "reason": "[1]: version conflict, document already exists"
        }
      }
    }
  ]
}

处理响应的关键逻辑:

  1. 先检查顶层 errors 字段------如果为 false,所有操作都成功,无需逐条检查
  2. 如果 errors: true,遍历 items 数组,找出 status >= 400 的条目进行重试或记录
性能调优参数

最佳 Bulk 大小:

没有绝对最优值,需要根据文档大小和集群性能实测。经验参考:

文档大小 推荐每批条数 推荐每批体积
小文档(< 1KB) 5000-10000 条 5-10 MB
中等文档(1-10KB) 1000-5000 条 5-15 MB
大文档(> 10KB) 100-1000 条 5-15 MB

核心原则:按体积控制(5-15MB 每批),而不是按条数。单次请求体积过大(如超过 100MB)可能导致节点内存压力过高。

并发写入:

在 Bulk 大小确定后,可以通过多线程/多进程并发发送 Bulk 请求来提升吞吐:

复制代码
总吞吐 = 单次 Bulk 大小 × 并发数 / 单次 Bulk 耗时

通常 2-4 个并发线程即可打满 ES 集群的写入能力。可以逐步增加并发,观察 CPU 和写入延迟,找到最佳平衡点。

配合 refresh_interval 调整:

大批量导入时,将 refresh_interval 调大可以显著提升写入速度:

json 复制代码
// 导入前:关闭自动 refresh
PUT /products/_settings
{ "index.refresh_interval": "-1" }

// 执行大量 Bulk 写入...

// 导入后:恢复 refresh 并手动刷新一次
PUT /products/_settings
{ "index.refresh_interval": "1s" }
POST /products/_refresh
错误处理与重试策略

由于 Bulk 不是原子操作,生产环境中需要设计完善的错误处理机制:

可重试的错误(通常是临时性问题):

  • 429 Too Many Requests:ES 写入队列满了,需要退避重试
  • 503 Service Unavailable:节点暂时不可用
  • EsRejectedExecutionException:线程池满

不可重试的错误(数据本身有问题):

  • 400 Bad Request:文档格式错误、字段类型不匹配
  • 409 Conflict:版本冲突(create 操作遇到已存在文档)
  • 404 Not Found:update/delete 的文档不存在

推荐的重试模式(指数退避):

java 复制代码
// Java 伪代码
int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++) {
    BulkResponse response = client.bulk(bulkRequest);
    if (!response.hasFailures()) break;

    // 收集可重试的失败项,构建新的 bulkRequest
    BulkRequest retryRequest = new BulkRequest();
    for (BulkItemResponse item : response.getItems()) {
        if (item.isFailed() && isRetryable(item.status())) {
            retryRequest.add(originalRequests.get(item.getItemId()));
        }
    }
    if (retryRequest.numberOfActions() == 0) break;

    // 指数退避等待
    Thread.sleep((long) Math.pow(2, attempt) * 1000);
    bulkRequest = retryRequest;
}
底层原理:Bulk 为什么快?

理解 Bulk 为什么比逐条写入快,需要知道它在底层节省了什么:

  1. 减少网络往返:10000 条文档只需 1 次 HTTP 请求(而非 10000 次),省掉了 TCP 握手、连接建立、请求解析等开销

  2. 批量路由:协调节点将 Bulk 请求中的操作按目标分片分组,同一分片的操作打包在一次内部 RPC 中发送给数据节点,减少节点间通信次数

  3. Translog 批量刷盘 :多个操作可以合并为一次 fsync,减少磁盘 I/O(当 index.translog.durability 为 request 时,每次 Bulk 请求结束时统一 fsync)

  4. Lucene 批量 addDocument:底层 Lucene 的 IndexWriter 对批量添加文档有内部优化,可以共享 Analyzer 资源、减少 flush 频率

    单条写入:Client → [HTTP] → Coordinating → [RPC] → Data Node → [fsync] → 完成
    × 10000 次

    Bulk 写入:Client → [HTTP] → Coordinating → 按分片分组 → [RPC×分片数] → Data Node → [1次fsync] → 完成
    × 1 次

使用注意事项
  • Bulk 不是事务:部分失败不会回滚已成功的操作。需要自己处理部分失败的情况。
  • 避免单次 Bulk 过大 :超过 http.max_content_length(默认 100MB)的请求会被直接拒绝。即使不超限,过大的请求也会造成节点内存压力。
  • 注意操作顺序:Bulk 中的操作按顺序执行。如果先 index 再 delete 同一个文档,最终结果是文档被删除。
  • 监控 Bulk 拒绝率 :关注 thread_pool.write.rejected 指标,如果持续增长说明写入压力过大。
json 复制代码
// 查看写入线程池状态
GET /_cat/thread_pool/write?v&h=node_name,active,queue,rejected

四、查询语法(Query DSL)

Query DSL 是 ES 最强大的部分,也是日常使用中最核心的能力。

4.1 查询与过滤的区别

维度 Query 上下文 Filter 上下文
是否计算评分 是(_score) 否
是否缓存 否 是
适用场景 全文搜索 精确过滤

设计原则:需要相关性排序的用 query,只需要"是/否"判断的用 filter。

4.2 全文搜索查询

match 查询(最常用的全文搜索):

json 复制代码
GET /my_blog/_search
{
  "query": {
    "match": {
      "title": "Elasticsearch 入门"
    }
  }
}

match 查询会将搜索词进行分词,然后用分词后的 term 去倒排索引中匹配。默认是 OR 逻辑(匹配任意一个词即可),可以改为 AND:

json 复制代码
{
  "query": {
    "match": {
      "title": {
        "query": "Elasticsearch 入门",
        "operator": "and"
      }
    }
  }
}

match_phrase 查询(短语搜索):

json 复制代码
{
  "query": {
    "match_phrase": {
      "content": {
        "query": "性能调优",
        "slop": 1
      }
    }
  }
}

要求搜索词按顺序出现在文档中,slop 参数允许词之间有一定间隔。

multi_match 查询(多字段搜索):

json 复制代码
{
  "query": {
    "multi_match": {
      "query": "Elasticsearch 教程",
      "fields": ["title^3", "content"],
      "type": "best_fields"
    }
  }
}

title^3 表示 title 字段的权重是 content 的 3 倍。type 可选值包括 best_fields(取最高分字段)、most_fields(各字段评分求和)、cross_fields(跨字段分析)。

4.3 精确查询

term 查询(精确匹配):

json 复制代码
{
  "query": {
    "term": {
      "author": { "value": "张三" }
    }
  }
}

重要提醒:term 查询用于 keyword 类型字段。如果对 text 类型字段使用 term 查询,由于 text 字段被分词后存储,很可能匹配不到预期结果。

terms 查询(多值精确匹配):

json 复制代码
{
  "query": {
    "terms": {
      "tags": ["elasticsearch", "教程"]
    }
  }
}

range 查询(范围查询):

json 复制代码
{
  "query": {
    "range": {
      "publish_date": {
        "gte": "2024-01-01",
        "lt": "2024-06-01"
      }
    }
  }
}

操作符说明:gt(大于)、gte(大于等于)、lt(小于)、lte(小于等于)。

exists 查询(字段是否存在):

json 复制代码
{
  "query": {
    "exists": { "field": "tags" }
  }
}

4.4 复合查询(Bool Query)

Bool Query 是实际工作中使用频率最高的查询方式,它通过组合多个子查询实现复杂的搜索逻辑:

json 复制代码
GET /my_blog/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "content": "性能优化" } }
      ],
      "should": [
        { "term": { "tags": "elasticsearch" } },
        { "term": { "tags": "性能" } }
      ],
      "must_not": [
        { "term": { "author": "测试用户" } }
      ],
      "filter": [
        { "range": { "publish_date": { "gte": "2024-01-01" } } },
        { "range": { "views": { "gte": 100 } } }
      ],
      "minimum_should_match": 1
    }
  }
}

各子句含义:

  • must:必须匹配,参与评分
  • should :可选匹配,匹配则加分(minimum_should_match 控制最少匹配数)
  • must_not:必须不匹配,不参与评分
  • filter:必须匹配,不参与评分,结果可被缓存

4.5 高亮显示

json 复制代码
GET /my_blog/_search
{
  "query": {
    "match": { "content": "Elasticsearch" }
  },
  "highlight": {
    "pre_tags": ["<em>"],
    "post_tags": ["</em>"],
    "fields": {
      "content": {
        "fragment_size": 150,
        "number_of_fragments": 3
      }
    }
  }
}

4.6 排序与分页

json 复制代码
GET /my_blog/_search
{
  "query": { "match_all": {} },
  "sort": [
    { "publish_date": "desc" },
    { "_score": "desc" }
  ],
  "from": 0,
  "size": 10
}

深度分页问题 :from + size 的方式在深度分页时性能急剧下降(例如 from=10000, size=10),因为 ES 需要在每个分片上取出 top 10010 条数据,再在协调节点合并排序。解决方案见后文"性能调优"部分。


五、聚合分析(Aggregations)

ES 的聚合功能类似于 SQL 中的 GROUP BY,但远比它强大。

5.1 桶聚合(Bucket Aggregation)

桶聚合负责将文档分组,相当于 SQL 的 GROUP BY。

Terms 聚合(按字段值分组):

json 复制代码
GET /my_blog/_search
{
  "size": 0,
  "aggs": {
    "by_author": {
      "terms": {
        "field": "author",
        "size": 10,
        "order": { "_count": "desc" }
      }
    }
  }
}

Date Histogram 聚合(按时间分桶):

json 复制代码
{
  "size": 0,
  "aggs": {
    "monthly_posts": {
      "date_histogram": {
        "field": "publish_date",
        "calendar_interval": "month",
        "format": "yyyy-MM"
      }
    }
  }
}

Range 聚合(自定义范围分桶):

json 复制代码
{
  "size": 0,
  "aggs": {
    "views_range": {
      "range": {
        "field": "views",
        "ranges": [
          { "key": "冷门", "to": 100 },
          { "key": "普通", "from": 100, "to": 1000 },
          { "key": "热门", "from": 1000 }
        ]
      }
    }
  }
}

5.2 指标聚合(Metric Aggregation)

指标聚合对桶内的文档进行数值计算。

json 复制代码
{
  "size": 0,
  "aggs": {
    "by_author": {
      "terms": { "field": "author", "size": 10 },
      "aggs": {
        "avg_views": { "avg": { "field": "views" } },
        "max_views": { "max": { "field": "views" } },
        "total_views": { "sum": { "field": "views" } },
        "views_stats": { "stats": { "field": "views" } }
      }
    }
  }
}

stats 聚合会一次性返回 count、min、max、avg、sum 五个指标。

5.3 嵌套聚合

聚合可以任意嵌套,实现多维分析:

json 复制代码
{
  "size": 0,
  "aggs": {
    "by_tag": {
      "terms": { "field": "tags", "size": 5 },
      "aggs": {
        "by_month": {
          "date_histogram": {
            "field": "publish_date",
            "calendar_interval": "month"
          },
          "aggs": {
            "avg_views": { "avg": { "field": "views" } }
          }
        }
      }
    }
  }
}

5.4 Pipeline 聚合

Pipeline 聚合对其他聚合的结果进行二次计算:

json 复制代码
{
  "size": 0,
  "aggs": {
    "monthly": {
      "date_histogram": {
        "field": "publish_date",
        "calendar_interval": "month"
      },
      "aggs": {
        "monthly_views": { "sum": { "field": "views" } }
      }
    },
    "max_monthly_views": {
      "max_bucket": {
        "buckets_path": "monthly>monthly_views"
      }
    },
    "avg_monthly_views": {
      "avg_bucket": {
        "buckets_path": "monthly>monthly_views"
      }
    }
  }
}

六、关联关系与数据建模

ES 没有 JOIN,这是从关系型数据库转过来的开发者最不适应的一点。在 MySQL 中,订单表和商品表通过外键关联,一个 SQL JOIN 就能取到所有需要的数据。但 ES 的分布式本质决定了它无法高效执行跨分片的 JOIN 操作------你的订单数据可能在 Shard 0,关联的商品数据却在 Shard 3,JOIN 意味着大量跨节点网络通信。

因此,ES 中处理关联关系需要换一种思维方式。以下按从简单到复杂、从推荐到慎用的顺序,介绍四种处理关联关系的方案。

6.1 方案一:宽表冗余(Denormalization)

核心思想:在写入时就把关联数据"铺平"到一个文档中,用空间换时间。

这是 ES 中最推荐、最常用的方式,也是与关系型数据库思维差异最大的地方。

示例:订单搜索场景

MySQL 中你可能有三张表:orders、products、users。在 ES 中,直接合成一个宽文档:

json 复制代码
PUT /orders/_doc/1001
{
  "order_id": "1001",
  "order_time": "2024-07-15T10:30:00",
  "status": "paid",
  "total_amount": 15998,
  
  "user_name": "张三",
  "user_level": "VIP",
  "user_phone": "138****1234",
  
  "product_name": "iPhone 15 Pro",
  "product_brand": "Apple",
  "product_category": "手机",
  
  "shop_name": "Apple 官方旗舰店",
  "shop_city": "上海"
}

优点:查询极快(单次查询就能拿到所有信息)、逻辑简单、天然支持任意字段组合搜索。

缺点:数据冗余大、关联方数据更新时需要批量刷新所有引用文档。

适用场景:

  • 关联数据变化频率低(如商品名称、分类、用户等级)
  • 搜索性能要求高
  • 可以接受数据更新的延迟(最终一致性)

更新策略 :当商品名称修改时,使用 update_by_query 批量更新所有引用该商品的订单文档:

json 复制代码
POST /orders/_update_by_query
{
  "query": {
    "term": { "product_id": "P001" }
  },
  "script": {
    "source": "ctx._source.product_name = params.new_name",
    "params": { "new_name": "iPhone 15 Pro Max" }
  }
}

6.2 方案二:嵌套对象(Nested)

核心思想 :当一个文档中包含对象数组,且需要对数组中每个对象独立匹配时使用。

为什么需要 Nested?------Object 类型的陷阱

先看一个反面例子。假设一个博客文章有多条评论:

json 复制代码
PUT /blogs/_doc/1
{
  "title": "ES 入门",
  "comments": [
    { "user": "张三", "content": "写得好", "stars": 5 },
    { "user": "李四", "content": "看不懂", "stars": 2 }
  ]
}

如果 comments 字段使用默认的 object 类型,ES 在内部会把它"扁平化"存储为:

json 复制代码
{
  "comments.user": ["张三", "李四"],
  "comments.content": ["写得好", "看不懂"],
  "comments.stars": [5, 2]
}

这时如果搜索"张三发表的评分为 2 的评论",会错误命中这篇文档------因为 ES 认为 comments.user 包含"张三"且 comments.stars 包含 2,但它丢失了对象内部字段之间的关联关系。

Nested 的解决方案

将 comments 定义为 nested 类型:

json 复制代码
PUT /blogs
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "comments": {
        "type": "nested",
        "properties": {
          "user": { "type": "keyword" },
          "content": { "type": "text" },
          "stars": { "type": "integer" }
        }
      }
    }
  }
}

查询时使用 nested query:

json 复制代码
GET /blogs/_search
{
  "query": {
    "nested": {
      "path": "comments",
      "query": {
        "bool": {
          "must": [
            { "term": { "comments.user": "张三" } },
            { "term": { "comments.stars": 5 } }
          ]
        }
      }
    }
  }
}

这次只会匹配"张三评分为 5"的情况,不会与李四的评分混淆。

Nested 聚合:

json 复制代码
GET /blogs/_search
{
  "size": 0,
  "aggs": {
    "comments_agg": {
      "nested": { "path": "comments" },
      "aggs": {
        "avg_stars": { "avg": { "field": "comments.stars" } },
        "by_user": {
          "terms": { "field": "comments.user" }
        }
      }
    }
  }
}
Nested 的底层原理

Nested 对象在 Lucene 层面被存储为独立的隐藏文档(internal document)。一个包含 3 条评论的博客文档,在 Lucene 中实际上是 4 个文档(1 个父文档 + 3 个 nested 文档)。它们被保证存储在同一个 Segment 的相邻位置,使用 Block Join 算法进行高效关联。

复制代码
Lucene 内部:
  [nested_doc: {user:"张三", stars:5}]   ← nested doc
  [nested_doc: {user:"李四", stars:2}]   ← nested doc
  [parent_doc: {title:"ES 入门"}]        ← parent doc (root)
Nested 的代价与限制
  • 写入放大:每个 nested 对象都是独立文档,更新父文档需要重新索引所有 nested 文档
  • 数量限制 :默认单个文档最多 10000 个 nested 对象(index.mapping.nested_objects.limit)
  • 字段限制 :默认一个索引最多 50 个 nested 类型字段(index.mapping.nested_fields.limit)
  • 查询语法特殊 :必须用 nested query/agg 包裹,不能直接访问 nested 字段

适用场景:数组元素数量较少(几十到几百)、需要对象内字段组合查询、父子数据一起读写。典型例子:商品的 SKU 列表、文章的评论列表(评论数量可控时)。

6.3 方案三:Join 父子文档(Parent-Child)

核心思想 :在同一个索引中建立父子关系,父文档和子文档是独立的文档,通过 join 字段关联。

这类似于关系型数据库中的外键关系,但实现在同一个索引(同一个分片)内。

定义 Join 关系
json 复制代码
PUT /qa_forum
{
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "body": { "type": "text" },
      "author": { "type": "keyword" },
      "created_at": { "type": "date" },
      "qa_relation": {
        "type": "join",
        "relations": {
          "question": "answer"
        }
      }
    }
  }
}
写入父文档(问题)
json 复制代码
PUT /qa_forum/_doc/q1
{
  "title": "ES 如何处理关联关系?",
  "body": "在 ES 中没有 JOIN,应该怎么处理一对多关系?",
  "author": "张三",
  "created_at": "2024-07-15",
  "qa_relation": {
    "name": "question"
  }
}
写入子文档(回答)

子文档必须与父文档路由到同一个分片 (通过 routing 参数指定父文档 ID):

json 复制代码
PUT /qa_forum/_doc/a1?routing=q1
{
  "body": "推荐使用宽表冗余或 nested,join 类型性能较差。",
  "author": "李四",
  "created_at": "2024-07-16",
  "qa_relation": {
    "name": "answer",
    "parent": "q1"
  }
}

PUT /qa_forum/_doc/a2?routing=q1
{
  "body": "如果子文档更新频繁,可以考虑 join 类型。",
  "author": "王五",
  "created_at": "2024-07-16",
  "qa_relation": {
    "name": "answer",
    "parent": "q1"
  }
}
查询父子文档

has_child --- 通过子文档条件查找父文档:

"找到有高质量回答的问题":

json 复制代码
GET /qa_forum/_search
{
  "query": {
    "has_child": {
      "type": "answer",
      "query": {
        "match": { "body": "宽表冗余" }
      },
      "score_mode": "max"
    }
  }
}

score_mode 选项:none(不参与评分)、avg、max、sum、min。

has_parent --- 通过父文档条件查找子文档:

"找到张三提问下的所有回答":

json 复制代码
GET /qa_forum/_search
{
  "query": {
    "has_parent": {
      "parent_type": "question",
      "query": {
        "term": { "author": "张三" }
      }
    }
  }
}

parent_id --- 查找指定父文档的所有子文档:

json 复制代码
GET /qa_forum/_search
{
  "query": {
    "parent_id": {
      "type": "answer",
      "id": "q1"
    }
  }
}
Join 的底层原理与代价

Join 要求父子文档必须在同一个分片上 (通过 routing 保证),ES 在内存中维护了一个 _parent 字段到子文档的映射表(类似于 Global Ordinals)。

性能代价:

  • has_child/has_parent 查询需要在分片内做 Block Join,性能远低于普通查询
  • 每个分片需要在内存中维护父子关系映射,占用 JVM 堆内存
  • 子文档更新不需要重新索引父文档(这是相比 nested 的优势)
  • 多层父子关系(祖孙三代)性能呈指数级下降

适用场景:

  • 子文档数量非常大(如一个问题有上万条回答)
  • 子文档更新频繁,但父文档很少变化
  • 父子文档需要独立搜索
  • 不适合嵌入宽表(数据膨胀太严重)

6.4 方案四:应用层关联(Application-Side Join)

核心思想:ES 只存各自的文档,关联逻辑在应用代码中通过多次查询实现。

这本质上就是"把 JOIN 从数据库层面搬到了应用层"------先查一次得到 ID 列表,再用这些 ID 去查另一个索引。

java 复制代码
// 步骤1:搜索符合条件的用户
SearchResponse<User> users = client.search(s -> s
    .index("users")
    .query(q -> q.term(t -> t.field("level").value("VIP")))
    .size(100),
    User.class
);

// 步骤2:提取用户ID列表
List<String> userIds = users.hits().hits().stream()
    .map(hit -> hit.id())
    .collect(Collectors.toList());

// 步骤3:用 terms 查询关联的订单
SearchResponse<Order> orders = client.search(s -> s
    .index("orders")
    .query(q -> q.terms(t -> t
        .field("user_id")
        .terms(tv -> tv.value(
            userIds.stream().map(FieldValue::of).collect(Collectors.toList())
        ))
    ))
    .size(1000),
    Order.class
);

优点:数据模型最清晰、没有冗余、各索引独立维护。

缺点:多次网络请求增加延迟、无法利用 ES 做全局排序和分页、应用代码复杂度高。

适用场景:

  • 关联关系复杂且动态变化
  • 数据量不大,延迟要求不苛刻
  • 某些场景下只是"偶尔需要关联",不值得为此做数据冗余
Terms Lookup(ES 内置的简化方案)

ES 提供了 terms 查询的 lookup 功能,可以在一次请求内自动完成"先查一个索引拿 ID,再用 ID 过滤另一个索引"的过程:

json 复制代码
GET /orders/_search
{
  "query": {
    "terms": {
      "user_id": {
        "index": "vip_users",
        "id": "vip_list_doc",
        "path": "user_ids"
      }
    }
  }
}

这相当于把一组 ID 存在某个文档中,搜索时自动取出来作为 terms 过滤条件。适合关联 ID 集合比较固定的场景(如"黑名单用户列表"、"推荐商品列表"等)。

6.5 四种方案对比与选型指南

维度 宽表冗余 Nested Join 父子文档 应用层关联
查询性能 最快 快 较慢 取决于查询次数
写入复杂度 高(需铺平) 中等 中等 低
更新代价 高(批量刷新) 高(重建整个文档) 低(独立更新子文档) 低
数据一致性 最终一致 强一致 强一致 取决于实现
子元素数量 不限 少量(几十~几百) 大量(几千~几万) 不限
关联层级 单层 单层 支持多层(不推荐) 任意
适用场景 搜索为主、更新少 对象数组需组合查询 子文档频繁更新 关系复杂、偶尔关联
选型决策树
复制代码
需要处理关联关系?
├── 关联数据变化频率如何?
│   ├── 很少变化 → 宽表冗余(首选)
│   └── 频繁变化 ↓
├── 子元素数量多少?
│   ├── 少量(< 200)且整体读写 → Nested
│   ├── 大量(> 1000)且独立更新 → Join 父子文档
│   └── 数量不定、关系复杂 → 应用层关联
└── 是否可以接受多次查询延迟?
    ├── 不能接受 → 宽表冗余
    └── 可以接受 → 应用层关联
实战建议

在实际项目中,这四种方案往往不是互斥的,而是混合使用的:

  • 核心搜索字段(商品名、分类、品牌)→ 宽表冗余,保证搜索性能
  • 商品 SKU 属性(颜色/尺码组合)→ Nested,保证属性组合查询的准确性
  • 商品评价(数量可能很大)→ 独立索引 + 应用层关联,或 Join 父子文档
  • 用户画像标签 → 宽表冗余写入订单文档中

最核心的思维转变 :在 ES 中,不是"如何设计表关系",而是"如何为搜索场景组织数据"。先想清楚要支持什么样的查询,再决定数据如何存储------这是查询驱动的数据建模,与关系型数据库的"先规范化再查询"恰好相反。


七、底层原理

了解 ES 的底层原理,才能真正理解它的行为,做出正确的架构和调优决策。

7.1 倒排索引(Inverted Index)

倒排索引是 ES 实现高效全文搜索的核心数据结构。传统数据库的 B+ 树索引是"给定文档 ID,找到文档内容";而倒排索引是反过来的------给定关键词,找到包含它的所有文档。

倒排索引的构成:

复制代码
Term Dictionary(词典)      Posting List(倒排列表)
─────────────────────        ──────────────────────
elasticsearch     →     [doc1, doc3, doc7, doc12]
性能              →     [doc2, doc5, doc7]
调优              →     [doc2, doc7, doc9]
分布式            →     [doc1, doc3, doc4]

倒排索引由三层结构组成:

  1. Term Index(词项索引):使用 FST(Finite State Transducer)实现的前缀树结构,常驻内存,用于快速定位 Term 在 Term Dictionary 中的位置。FST 的优势在于极高的压缩率------它既是一个有向无环图,又能共享前缀和后缀。

  2. Term Dictionary(词项字典):存储所有的 Term,按字典序排列,存储在磁盘上。通过 Term Index 可以快速二分定位到对应的 block。

  3. Posting List(倒排列表):记录每个 Term 对应的文档 ID 列表,以及词频(TF)、位置(Position)等信息。使用多种压缩算法(如 FOR - Frame Of Reference、Roaring Bitmaps)来减少存储空间。

搜索过程 :查询词 → 分词 → Term Index(内存) → Term Dictionary(磁盘) → Posting List → 文档ID集合 → 取交集/并集 → 取出文档

7.2 Segment(段)的生命周期

ES 中的每个分片(Shard)本质上就是一个 Lucene 索引,而 Lucene 索引由多个 Segment 组成。理解 Segment 的生命周期是理解 ES 写入和搜索行为的关键。

写入流程:

复制代码
客户端写入 → Index Buffer(内存)
                ↓  每 1 秒 refresh
            新 Segment(文件系统缓存)→ 此时可被搜索到
                ↓  每 30 分钟或 translog 达 512MB 时 flush
            Segment 持久化到磁盘 + translog 清空

关键概念:

  • Refresh:将 Index Buffer 中的数据写入一个新的 Segment,该 Segment 进入文件系统缓存(OS Page Cache),此时文档变得可搜索。默认每 1 秒执行一次,这就是 ES "近实时"的由来。

  • Translog(事务日志):类似于 MySQL 的 redo log。每次写入操作都会先写入 translog(fsync 到磁盘),保证即使宕机也不会丢失数据。

  • Flush:将文件系统缓存中的 Segment 真正持久化到磁盘,并清空 translog。

  • Merge(段合并):随着时间推移,小 Segment 越来越多,ES 后台会自动将多个小 Segment 合并为大 Segment。合并过程中还会真正删除标记为"已删除"的文档(ES 的删除是软删除,只是标记)。

    [seg1][seg2][seg3][seg4][seg5] ← 多个小段
    ↓ merge
    [seg_merged_1][seg5] ← 合并后的大段

7.3 分布式架构

集群角色:

  • Master Node:管理集群状态(元数据、分片分配、节点加入/离开)
  • Data Node:存储数据和倒排索引,执行 CRUD 和搜索
  • Coordinating Node:接收客户端请求,转发到相关数据节点,合并结果返回
  • Ingest Node:预处理管道(如 grok 解析日志)

文档路由公式:

复制代码
shard_id = hash(routing) % number_of_primary_shards

默认 routing 值为文档的 _id。这也是为什么主分片数一旦确定就不能修改的原因------修改后路由公式会导致已有数据无法正确定位。

分布式搜索(Query Then Fetch):

搜索分为两个阶段:

  1. Query 阶段:协调节点将请求广播到所有相关分片,每个分片在本地执行搜索并返回排序后的文档 ID 和排序值(轻量级数据)。

  2. Fetch 阶段:协调节点将所有分片返回的结果进行全局排序,取出最终需要的 Top N 条文档 ID,然后再去对应分片拉取完整的文档内容。

    Client → Coordinating Node
    ↓ Query 阶段(广播)
    [Shard0] [Shard1] [Shard2] ← 各自返回 top N 的 docId + score
    ↓ 合并排序,取最终 top N
    ↓ Fetch 阶段(定点获取)
    [Shard0] [Shard2] ← 只取需要的文档全文
    ↓
    Client ← 最终结果

7.4 BM25 评分算法

ES 从 5.0 版本开始使用 BM25 作为默认相关性评分算法(取代了经典的 TF-IDF)。

BM25 公式:

复制代码
score(D, Q) = Σ IDF(qi) × [ f(qi, D) × (k1 + 1) ] / [ f(qi, D) + k1 × (1 - b + b × |D| / avgdl) ]

核心思想(直觉理解):

  • IDF(逆文档频率):一个词越罕见(出现在越少的文档中),它的区分度越高,权重越大。"的"这种常见词 IDF 很低,"Elasticsearch"这种特定词 IDF 较高。

  • TF(词频) :一个词在文档中出现次数越多,文档越可能与该词相关。但 BM25 对 TF 有饱和处理------出现 10 次和出现 100 次的差距不会像 TF-IDF 那样是 10 倍,而是逐渐趋于平缓。

  • 文档长度归一化 :较短的文档中出现搜索词,比在长文档中出现同一词更有意义。参数 b(默认 0.75)控制长度归一化的程度。

  • 参数 k1(默认 1.2):控制 TF 的饱和速度。k1 越大,TF 影响越大。

7.5 深度分页问题与解决方案

问题根源 :from + size 方式下,如果请求 from=10000, size=10,每个分片需要返回 top 10010 条结果给协调节点,如果有 5 个分片就是 50050 条,然后协调节点排序后只取 10 条,大量资源被浪费。

解决方案一:Scroll API(已不推荐用于实时搜索)

json 复制代码
// 创建 scroll 上下文
POST /my_blog/_search?scroll=5m
{
  "size": 100,
  "query": { "match_all": {} }
}

// 后续翻页
POST /_search/scroll
{
  "scroll": "5m",
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2..."
}

Scroll 会创建一个数据快照,适合一次性导出大量数据,但不适合实时搜索场景(因为快照不会反映后续的数据变更)。

解决方案二:search_after(推荐)

json 复制代码
// 第一页
GET /my_blog/_search
{
  "size": 10,
  "query": { "match_all": {} },
  "sort": [
    { "publish_date": "desc" },
    { "_id": "asc" }
  ]
}

// 下一页:使用上一页最后一条的排序值
GET /my_blog/_search
{
  "size": 10,
  "query": { "match_all": {} },
  "sort": [
    { "publish_date": "desc" },
    { "_id": "asc" }
  ],
  "search_after": ["2024-01-15", "abc123"]
}

search_after 通过上一页最后一条记录的排序值来定位下一页的起始位置,避免了深度分页的性能问题。但它只能"向前翻",不能跳页。

解决方案三:Point In Time(PIT)+ search_after

ES 7.10+ 引入了 PIT,它结合 search_after 可以在一个一致的数据视图上进行分页,非常适合需要遍历大量数据的场景:

json 复制代码
// 1. 打开 PIT
POST /articles/_pit?keep_alive=5m

// 2. 使用 PIT + search_after 分页
POST /_search
{
  "size": 100,
  "pit": {
    "id": "上一步返回的pit_id",
    "keep_alive": "5m"
  },
  "sort": [
    { "publish_date": "desc" },
    { "_shard_doc": "asc" }
  ],
  "search_after": ["2024-01-15", 12345]
}

三种方案对比:

方案 适用场景 优点 缺点
from+size 前几页浏览 简单,支持跳页 深度分页性能差
search_after 无限滚动 性能稳定 不能跳页
PIT + search_after 数据导出/遍历 数据一致性好 占用资源

八、性能调优

8.1 写入优化

批量写入是王道:

生产环境中应始终使用 Bulk API,单条写入的性能远不如批量写入。推荐的 Bulk 大小为 5-15MB 或 1000-5000 条文档。

json 复制代码
POST /_bulk
{"index": {"_index": "logs", "_id": "1"}}
{"message": "log entry 1", "timestamp": "2024-01-01T00:00:00"}
{"index": {"_index": "logs", "_id": "2"}}
{"message": "log entry 2", "timestamp": "2024-01-01T00:00:01"}

调整 Refresh Interval:

对于写入密集型场景(如日志导入),可以将 refresh_interval 从默认的 1s 增大甚至临时关闭:

json 复制代码
PUT /logs/_settings
{
  "index.refresh_interval": "30s"
}

大批量导入数据时,可以临时设置为 -1(禁用自动 refresh),导入完成后再恢复。

Translog 策略调整:

默认每次写入操作都会 fsync translog 到磁盘,如果可以接受少量数据丢失风险(如日志场景),可以改为异步刷盘:

json 复制代码
PUT /logs/_settings
{
  "index.translog.durability": "async",
  "index.translog.sync_interval": "5s"
}

其他写入优化技巧:

  • 批量导入时临时关闭副本:"number_of_replicas": 0,导入后再恢复
  • 禁用不需要的字段索引:"index": false
  • 使用自动生成 ID(比手动指定 ID 快,因为省去了查重步骤)
  • 合理设置 index.number_of_shards(后文详述)

8.2 搜索优化

善用 Filter 缓存:

Filter 查询的结果会被缓存为 Bitset,重复查询时直接走缓存。应将不需要评分的条件都放在 filter 子句中:

json 复制代码
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "搜索引擎" } }
      ],
      "filter": [
        { "term": { "status": "published" } },
        { "range": { "date": { "gte": "2024-01-01" } } }
      ]
    }
  }
}

避免使用通配符前缀搜索:

json 复制代码
// 避免!性能极差,无法利用倒排索引
{ "wildcard": { "title": "*search*" } }

// 如果确实需要前缀搜索,prefix 比 wildcard 好
{ "prefix": { "title": "elast" } }

使用 routing 减少搜索范围:

如果业务天然按某个维度分区(如租户 ID),可以在写入和搜索时指定 routing:

json 复制代码
// 写入时指定 routing
PUT /orders/_doc/1?routing=tenant_A
{ "order_id": "001", "tenant": "tenant_A" }

// 搜索时指定 routing,只搜对应分片
GET /orders/_search?routing=tenant_A
{ "query": { "match_all": {} } }

Profile API 诊断慢查询:

json 复制代码
GET /my_blog/_search
{
  "profile": true,
  "query": {
    "match": { "content": "Elasticsearch 性能" }
  }
}

返回结果中会详细显示每个阶段(rewrite、create_weight、build_scorer、next_doc 等)的耗时。

8.3 分片策略

分片设计是 ES 集群最重要的架构决策之一,一旦设定主分片数就不能修改。

分片数量的经验法则:

  • 单个分片大小建议在 10-50GB 之间
  • 每个数据节点上的分片数不超过:节点堆内存(GB) × 20
  • 一个索引的分片数 = 预计数据总量 / 单分片目标大小

过多分片的危害:

  • 每个分片本质上是一个 Lucene 索引,需要消耗文件句柄、内存、CPU
  • 搜索时需要在所有分片上执行,分片越多开销越大
  • 集群状态(Cluster State)膨胀,Master 节点压力增大

过少分片的危害:

  • 无法水平扩展(数据节点数不能超过分片数)
  • 单分片过大导致恢复时间长、merge 压力大

推荐做法:

日志类索引按时间滚动(如 logs-2024.01.15),配合 Index Lifecycle Management(ILM)自动管理。业务索引预估好数据量后设置合理的分片数。

8.4 JVM 与内存调优

堆内存设置原则:

  • 堆内存不超过物理内存的 50%(另外 50% 留给文件系统缓存,ES 重度依赖 OS Page Cache)
  • 堆内存不超过 32GB(超过后 JVM 无法使用指针压缩,反而可能更慢)
  • 最佳实践:-Xms 和 -Xmx 设置为相同值,避免堆大小动态调整
bash 复制代码
# jvm.options
-Xms16g
-Xmx16g

为什么文件系统缓存如此重要?

ES 的倒排索引和存储字段以文件形式存在磁盘上,操作系统会将热点数据缓存到 Page Cache 中。如果堆内存占用过多,留给 Page Cache 的空间就少了,搜索性能会急剧下降。理想情况下,索引数据应该能完全放入 Page Cache。

GC 调优:

ES 默认使用 G1GC(8.x 版本),关注以下 GC 指标:

json 复制代码
GET /_nodes/stats/jvm
  • Young GC 频率高但时间短是正常的
  • Old GC 频繁则说明堆内存不足或存在内存泄漏
  • Stop-The-World 暂停超过 1 秒需要告警

8.5 熔断器(Circuit Breaker)

ES 内置多种熔断器来防止 OOM:

json 复制代码
GET /_nodes/stats/breaker
熔断器 默认阈值 保护对象
Parent 95% JVM heap 所有请求总和
Field Data 40% JVM heap 字段数据缓存(text 字段排序/聚合)
Request 60% JVM heap 单次搜索请求
In-flight 100% JVM heap HTTP 传输层

当触发熔断时,ES 返回 429 Too Many Requests 错误。解决方式:

  1. 增大堆内存(不超过 32GB)
  2. 减少聚合字段的基数(Cardinality)
  3. 避免对 text 字段做排序/聚合(应使用 keyword 子字段)
  4. 减少单次查询返回的数据量

8.6 索引生命周期管理(ILM)

对于日志、指标等时间序列数据,ES 提供了 Index Lifecycle Management(ILM)来自动管理索引的生命周期:

Hot-Warm-Cold-Delete 架构:

复制代码
Hot(热节点)     → Warm(温节点)    → Cold(冷节点)    → Delete
高性能 SSD         普通 SSD/HDD        大容量 HDD          自动删除
最近 7 天数据      7-30 天数据         30-90 天数据        90 天以上

配置 ILM 策略:

json 复制代码
PUT /_ilm/policy/log_policy
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_primary_shard_size": "50gb",
            "max_age": "7d"
          },
          "set_priority": { "priority": 100 }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "shrink": { "number_of_shards": 1 },
          "forcemerge": { "max_num_segments": 1 },
          "set_priority": { "priority": 50 }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "set_priority": { "priority": 0 },
          "allocate": {
            "require": { "data": "cold" }
          }
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

应用策略到索引模板:

json 复制代码
PUT /_index_template/log_template
{
  "index_patterns": ["logs-*"],
  "template": {
    "settings": {
      "number_of_shards": 3,
      "number_of_replicas": 1,
      "index.lifecycle.name": "log_policy",
      "index.lifecycle.rollover_alias": "logs"
    }
  }
}

九、Java 客户端集成

9.1 客户端选型

ES 8.x 版本中客户端选型的变化:

客户端 状态 适用版本
RestHighLevelClient 已废弃 7.x 及之前
Elasticsearch Java API Client 推荐 7.15+ / 8.x
Spring Data Elasticsearch 推荐(Spring 项目) Spring Boot 3+
Low Level REST Client 维护中 所有版本

9.2 Elasticsearch Java API Client 示例

Maven 依赖:

xml 复制代码
<dependency>
    <groupId>co.elastic.clients</groupId>
    <artifactId>elasticsearch-java</artifactId>
    <version>8.12.0</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.16.0</version>
</dependency>

创建客户端:

java 复制代码
RestClient restClient = RestClient.builder(
    new HttpHost("localhost", 9200)
).build();

ElasticsearchTransport transport = new RestClientTransport(
    restClient, new JacksonJsonpMapper()
);

ElasticsearchClient client = new ElasticsearchClient(transport);

索引文档:

java 复制代码
Product product = new Product("iPhone 15", "Apple 最新手机", 7999.0);

IndexResponse response = client.index(i -> i
    .index("products")
    .id("1")
    .document(product)
);

搜索:

java 复制代码
SearchResponse<Product> response = client.search(s -> s
    .index("products")
    .query(q -> q
        .bool(b -> b
            .must(m -> m.match(t -> t.field("name").query("iPhone")))
            .filter(f -> f.range(r -> r.field("price").gte(JsonData.of(5000))))
        )
    )
    .size(10),
    Product.class
);

for (Hit<Product> hit : response.hits().hits()) {
    Product p = hit.source();
    System.out.println(p.getName() + " - score: " + hit.score());
}

批量写入:

java 复制代码
BulkRequest.Builder br = new BulkRequest.Builder();

for (Product product : products) {
    br.operations(op -> op
        .index(idx -> idx
            .index("products")
            .id(product.getId())
            .document(product)
        )
    );
}

BulkResponse result = client.bulk(br.build());

9.3 Spring Data Elasticsearch 集成

实体类定义:

java 复制代码
@Document(indexName = "articles")
public class Article {
    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String title;

    @Field(type = FieldType.Text, analyzer = "ik_smart")
    private String content;

    @Field(type = FieldType.Keyword)
    private String author;

    @Field(type = FieldType.Date, format = DateFormat.date)
    private LocalDate publishDate;

    @Field(type = FieldType.Integer)
    private Integer views;
}

Repository 接口:

java 复制代码
public interface ArticleRepository extends ElasticsearchRepository<Article, String> {
    List<Article> findByAuthor(String author);
    List<Article> findByTitleContaining(String keyword);
    List<Article> findByPublishDateBetween(LocalDate start, LocalDate end);
}

十、生产环境最佳实践

10.1 集群规划

  • Master 节点:至少 3 个专用 Master 节点(避免脑裂),不存储数据,不处理搜索请求
  • Data 节点:根据数据量和性能要求规划,热数据节点用 SSD
  • Coordinating 节点:高负载场景下可设置专用协调节点,分担查询合并压力

10.2 索引设计原则

  • 明确区分 text 和 keyword 类型,不要依赖动态 mapping
  • 禁用不需要搜索的字段的索引:"index": false
  • 对不需要返回原文的字段禁用 _source(谨慎使用)
  • 合理使用 copy_to 将多个字段合并搜索
  • 使用 Index Template 确保所有索引结构一致

10.3 监控告警

关键监控指标:

指标 健康阈值 说明
Cluster Health green yellow 表示副本未分配,red 表示主分片丢失
JVM Heap 使用率 < 75% 长期 > 85% 需要扩容
GC 时间占比 < 5% Old GC 频繁需关注
Search Latency(P99) < 500ms 根据业务 SLA 调整
Indexing Latency < 200ms 写入延迟过高需排查
磁盘使用率 < 85% 达到 85% ES 会触发 watermark 限制

常用诊断 API:

json 复制代码
// 集群健康状态
GET /_cluster/health

// 节点级别统计
GET /_nodes/stats

// 索引级别统计
GET /my_index/_stats

// 热点线程(排查 CPU 问题)
GET /_nodes/hot_threads

// 慢日志配置
PUT /my_index/_settings
{
  "index.search.slowlog.threshold.query.warn": "10s",
  "index.search.slowlog.threshold.query.info": "5s",
  "index.indexing.slowlog.threshold.index.warn": "10s"
}

10.4 安全与备份

  • 生产环境必须开启 Security(ES 8.x 默认开启)
  • 配置 TLS/SSL 加密节点间通信
  • 使用 Snapshot API 定期备份到对象存储(S3/HDFS/NAS)
json 复制代码
// 注册备份仓库
PUT /_snapshot/my_backup
{
  "type": "fs",
  "settings": {
    "location": "/mount/backups/es"
  }
}

// 创建快照
PUT /_snapshot/my_backup/snapshot_2024_01_15?wait_for_completion=true
{
  "indices": "my_blog,products",
  "include_global_state": false
}

十一、常见问题与避坑指南

11.1 为什么 term 查询搜不到数据?

最常见的新手问题。原因是对 text 字段使用了 term 查询。text 字段会被分词器处理后存储(如 "Hello World" 会被拆成 "hello" 和 "world"),而 term 查询不会对查询词做分词,用 "Hello World" 去查自然找不到。

解决方案:用 match 查询搜 text 字段,或者给字段加一个 keyword 子字段用于精确匹配。

11.2 为什么聚合结果不准确?

Terms 聚合默认只返回近似值(每个分片独立计算 Top N 后合并),可能丢失长尾数据。可以通过增大 shard_size 参数来提升准确性:

json 复制代码
{
  "aggs": {
    "by_tag": {
      "terms": {
        "field": "tags",
        "size": 10,
        "shard_size": 30
      }
    }
  }
}

11.3 为什么刚写入的数据搜不到?

因为 ES 的近实时特性------数据需要经过 refresh(默认 1 秒)才能被搜索到。如果需要写入后立即可见,可以手动 refresh:

json 复制代码
POST /my_index/_refresh

或者在写入时指定 refresh=true(不推荐在高吞吐场景使用):

json 复制代码
PUT /my_index/_doc/1?refresh=true
{ "title": "test" }

11.4 为什么集群状态是 Yellow?

Yellow 表示所有主分片正常,但部分副本分片未分配。最常见的原因是单节点集群------副本无法分配到和主分片相同的节点上。开发环境可以将副本数设为 0:

json 复制代码
PUT /my_index/_settings
{ "number_of_replicas": 0 }

11.5 Mapping 字段爆炸(Mapping Explosion)

如果索引的文档结构不固定(如直接把用户输入的 JSON 存入 ES),可能导致字段数量暴增,严重影响集群性能。

预防措施:

json 复制代码
PUT /my_index/_settings
{
  "index.mapping.total_fields.limit": 1000
}

更好的做法是关闭动态映射或设为 strict:

json 复制代码
PUT /my_index
{
  "mappings": {
    "dynamic": "strict",
    "properties": { ... }
  }
}

十二、学习路线建议

  1. 入门阶段:理解核心概念 → Docker 搭环境 → 用 Kibana Dev Tools 做 CRUD → 熟练 Query DSL
  2. 进阶阶段:深入 Mapping 设计 → 聚合分析 → 理解 Analyzer 和分词 → Java 客户端集成
  3. 高级阶段:倒排索引原理 → Segment 生命周期 → 分布式原理 → 性能调优 → 集群规划
  4. 实战阶段:ELK 日志平台搭建 → 商品搜索系统 → 实时数据分析

总结

Elasticsearch 是一个集搜索、分析、存储于一体的强大引擎。它的核心优势在于:基于倒排索引的高效全文搜索能力、分布式架构带来的水平扩展能力、以及丰富的聚合分析能力。

理解 ES,关键在于理解它和传统关系型数据库的根本差异------它不是一个事务型数据库,而是一个为"搜索"和"分析"场景优化的系统。写入有延迟(近实时)、删除是软删除、结果可能近似、不支持 JOIN......这些都是它为了搜索性能而做出的 trade-off。

掌握 ES 的最好方式是动手实践。搭一个本地环境,导入一些数据,尝试各种查询和聚合,观察 _explain 的评分细节,用 profile 分析查询性能------这些比只看文档有用得多。

祝你学习愉快!

相关推荐
计算机毕业编程指导师43 分钟前
【大数据毕设选题推荐】基于Spark的WTA职业网球赛事演变与竞技格局分析系统源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习 深度学习
大数据·python·计算机·spark·毕业设计·课程设计·wta网球
写后端的胖头鱼2 小时前
【高频面试题】FullText 全文索引(MySQL)
数据库·mysql·索引·全文索引
躺柒2 小时前
读数据架构知识体系指南14数据编织
大数据·数据仓库·数据分析·数据湖·数据编织
专业程序开发源2 小时前
SSM校园拍摄交流服务平台36936-计算机课程设计、毕业设计
java·spring boot·后端·python·elasticsearch·php·课程设计
路远的数据库笔记2 小时前
SQL Server迁移国产数据库怎么做?九套口岸库零停机割接实战
数据库·经验分享·sqlserver·dba
樱花落木兰2 小时前
分布式登录实战:Session 会话共享改造,Redis 存储用户登录状态
java·javascript·数据库·redis·分布式·缓存
kaiyou20262 小时前
用户研究岗秋招,统计分析能力怎么学习和证明?
数据库·python·学习
计算机毕业编程指导师3 小时前
大数据毕设怎么做?基于Hadoop的多源社交媒体心理健康情感分析与可视化系统从零到一指导 源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习 深度学习
大数据·hadoop·计算机·数据分析·毕业设计·课程设计·媒体
mjhcsp3 小时前
DeepSeek V4.1 Flash 批量处理效能实测
java·前端·数据库
计算机毕业设计杰瑞3 小时前
【最新原创大数据】基于大数据的酒店数据分析与可视化,附源码_高质量项目_可视化_数据分析_毕设选题推荐_SPark_Hadoop_毕设指导
大数据·信息可视化·数据挖掘·课程设计