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_pointgeo_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_v1logs-000001),别名使用业务语义名称(如 productslogs)。
  • 一个别名可以指向多个索引(用于搜索时跨多个索引),但写入时必须指定唯一的 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 操作需要一个额外的 docscript 字段:

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.durabilityrequest 时,每次 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(不参与评分)、avgmaxsummin

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 索引设计原则

  • 明确区分 textkeyword 类型,不要依赖动态 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 分析查询性能------这些比只看文档有用得多。

祝你学习愉快!

相关推荐
睡不醒男孩0308231 小时前
行业解决方案二:CLup打造企业级数据库私有云(DBaaS)平台解决方案
数据库·云计算·clup
猴哥聊项目管理1 小时前
2026年信创项目管理:如何用甘特图提升进度管控
大数据·数据库·项目管理·企业数字化转型·甘特图·敏捷开发·项目进度管理软件
白狐_7981 小时前
从空白模板到文旅风 PPT:用 Claude Code + Kimi API 优化 AI 生成演示文稿
大数据·人工智能
JAMSAN09301 小时前
视线即交互:眼动追踪AR眼镜的“感知革命”与市场蓝图
大数据·人工智能·ar·交互
冷色调的咖啡师1 小时前
1.大数据架构技术 上——搭建分布式Hadoop集群
大数据·linux·hadoop·分布式·hdfs·架构·yarn
j7~1 小时前
MySQL C语言连接库和MYSQL连接池原理与简易数据网站数据流动是如何进行的
c语言·数据库·mysql·连接池·mysqlc语言连接库
cg.family1 小时前
Hadoop vs Kubernetes 对比记忆
大数据·hadoop·kubernetes
暗夜猎手-大魔王2 小时前
转载--Hermes Agent 10 | 7 层安全防线:从用户授权到输入净化
java·数据库·安全
weelinking10 小时前
【产品】12_接入数据库——让数据永久保存
jvm·数据库·python·react.js·数据挖掘·前端框架·产品经理