本文将带你从零开始系统学习 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 自动补全与推荐)
- 三、环境搭建与基础操作
-
- [3.1 安装与启动](#3.1 安装与启动)
- [3.2 索引管理](#3.2 索引管理)
- [3.3 索引别名(Index Alias)](#3.3 索引别名(Index Alias))
- [3.4 Mapping 字段类型详解](#3.4 Mapping 字段类型详解)
- [3.5 文档 CRUD](#3.5 文档 CRUD)
- [3.6 Bulk API 详解](#3.6 Bulk API 详解)
- [四、查询语法(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"
}
}
}
]
}
处理响应的关键逻辑:
- 先检查顶层
errors字段------如果为false,所有操作都成功,无需逐条检查 - 如果
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 为什么比逐条写入快,需要知道它在底层节省了什么:
-
减少网络往返:10000 条文档只需 1 次 HTTP 请求(而非 10000 次),省掉了 TCP 握手、连接建立、请求解析等开销
-
批量路由:协调节点将 Bulk 请求中的操作按目标分片分组,同一分片的操作打包在一次内部 RPC 中发送给数据节点,减少节点间通信次数
-
Translog 批量刷盘 :多个操作可以合并为一次 fsync,减少磁盘 I/O(当
index.translog.durability为request时,每次 Bulk 请求结束时统一 fsync) -
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) - 查询语法特殊 :必须用
nestedquery/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]
倒排索引由三层结构组成:
-
Term Index(词项索引):使用 FST(Finite State Transducer)实现的前缀树结构,常驻内存,用于快速定位 Term 在 Term Dictionary 中的位置。FST 的优势在于极高的压缩率------它既是一个有向无环图,又能共享前缀和后缀。
-
Term Dictionary(词项字典):存储所有的 Term,按字典序排列,存储在磁盘上。通过 Term Index 可以快速二分定位到对应的 block。
-
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):
搜索分为两个阶段:
-
Query 阶段:协调节点将请求广播到所有相关分片,每个分片在本地执行搜索并返回排序后的文档 ID 和排序值(轻量级数据)。
-
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 错误。解决方式:
- 增大堆内存(不超过 32GB)
- 减少聚合字段的基数(Cardinality)
- 避免对 text 字段做排序/聚合(应使用 keyword 子字段)
- 减少单次查询返回的数据量
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": { ... }
}
}
十二、学习路线建议
- 入门阶段:理解核心概念 → Docker 搭环境 → 用 Kibana Dev Tools 做 CRUD → 熟练 Query DSL
- 进阶阶段:深入 Mapping 设计 → 聚合分析 → 理解 Analyzer 和分词 → Java 客户端集成
- 高级阶段:倒排索引原理 → Segment 生命周期 → 分布式原理 → 性能调优 → 集群规划
- 实战阶段:ELK 日志平台搭建 → 商品搜索系统 → 实时数据分析
总结
Elasticsearch 是一个集搜索、分析、存储于一体的强大引擎。它的核心优势在于:基于倒排索引的高效全文搜索能力、分布式架构带来的水平扩展能力、以及丰富的聚合分析能力。
理解 ES,关键在于理解它和传统关系型数据库的根本差异------它不是一个事务型数据库,而是一个为"搜索"和"分析"场景优化的系统。写入有延迟(近实时)、删除是软删除、结果可能近似、不支持 JOIN......这些都是它为了搜索性能而做出的 trade-off。
掌握 ES 的最好方式是动手实践。搭一个本地环境,导入一些数据,尝试各种查询和聚合,观察 _explain 的评分细节,用 profile 分析查询性能------这些比只看文档有用得多。
祝你学习愉快!