🔍 Elasticsearch 从零到生产

搜索引擎完全教程 — 从"倒排索引是什么"到"线上怎么不挂"

倒排索引 Mapping映射 分词器Analyzer 搜索DSL 聚合Aggregation 集群架构 Spring Boot集成 生产踩坑

P1. 什么是 Elasticsearch?— 从淘宝搜索理解搜索引擎

你在淘宝搜"碎花连衣裙",几亿个商品里 0.1 秒就出结果了。如果淘宝是逐个扫描每个商品的标题和描述(就像 MySQL 的 LIKE '%碎花%'),那就得等到明天。而且 LIKE 只能匹配字面文字,不懂"碎花"和"小碎花"有关联。

淘宝能这么快,是因为它提前建好了一个"词→商品列表"的字典——"碎花"→[商品1, 商品5, 商品88...],"连衣裙"→[商品2, 商品5, ...]。搜的时候不扫描所有商品,直接查字典再取交集。这个字典,就是倒排索引

Elasticsearch(简称 ES)就是专门做搜索的数据库。它用倒排索引实现:
  • 🚀 毫秒级搜索上亿条数据
  • 🧠 理解分词("碎花连衣裙"→ "碎花" + "连衣裙")
  • 📊 按相关性打分排序(最相关的排最前)
  • 📈 统计分析(每个类目有多少商品?平均价格多少?)

📖 ES vs MySQL 对比

维度MySQLElasticsearch
定位关系型数据库,存数据搜索引擎,找数据
底层结构B+Tree 索引倒排索引(Inverted Index)
全文搜索LIKE '%关键词%' — 慢,不智能分词 + 倒排索引 — 快,智能
数据结构表(固定列)JSON 文档(灵活)
强项事务、JOIN、数据一致性搜索、聚合分析、近实时
弱项全文搜索慢,不适合日志分析不支持事务,不适合做主存储
典型场景订单、账户、库存搜索框、日志分析、监控大屏
数据库DatabaseIndex(索引)
TableType(7.x 后废弃,一个 Index 只有一个 Type)
RowDocument(文档)
ColumnField(字段)
主键Primary Key_id
SQLSELECT * FROM ...GET /index/_search { ... }

🤔 典型使用场景

🔍 搜索引擎
电商商品搜索
文章/新闻搜索
用户搜索(通讯录)
📋 日志分析
ELK 技术栈
应用日志收集分析
排查线上问题
📊 数据可视化
监控大屏
运营数据看板
Kibana 图表
🔔 监控告警
服务器性能监控
业务指标告警
异常检测
⚠️ 你每天用的这些搜索,背后都是 ES:
  • 淘宝商品搜索 → ES
  • 美团餐厅搜索 → ES
  • 微信聊天记录搜索 → ES
  • GitHub 代码搜索 → ES
💡 生产中常见的做法:MySQL 存业务数据(订单、账户),数据变更时同步到 ES。搜索和统计查 ES,事务和关联查 MySQL。
ES 不是 MySQL 的替代品,而是补充。数据同步方式有:同步双写、异步消息队列(MQ)、Canal 监听 binlog。

P2. 倒排索引 — ES 为什么搜索这么快?

📖 正排索引 vs 倒排索引

正排索引(MySQL B+Tree)— 通过文档找关键词

文档ID → 内容
Doc 1: "田园碎花连衣裙 夏"
Doc 2: "碎花半身裙 春秋"
Doc 3: "运动连帽卫衣"
Doc 4: "碎花雪纺连衣裙"
查"碎花"→ 要看每一个文档
有没有"碎花"这个词
❌ 全表扫描,慢!

倒排索引(ES)— 通过关键词找文档

分词 → 倒排表
"田园" → [Doc 1]
"碎花" → [Doc 1, Doc 2, Doc 4]
"连衣裙" → [Doc 1, Doc 4]
"夏" → [Doc 1]
"半身裙" → [Doc 2]
"春秋" → [Doc 2]
"运动" → [Doc 3]
"连帽" → [Doc 3]
"卫衣" → [Doc 3]
"雪纺" → [Doc 4]
查"碎花"→ 直接查倒排表
→ Doc 1, Doc 2, Doc 4
✅ 秒出结果!

🔬 倒排索引的内部结构

💡 图书馆目录卡类比:以前去图书馆查书,不是一排排书架扫过去找,而是先去查目录卡——按主题/作者/书名排序的卡片盒。你翻到"科幻小说"那张卡,背面写着第3排第5层、第7排第2层……然后直接去拿书。

目录卡 = Term Dictionary(按字母排序的词项字典)
卡片背面写的书架号 = Postings List(倒排列表,哪些文档包含这个词)
这本书里"科幻"出现了几次 = TF(出现越多越相关)
出现在第几页 = Position(用于短语搜索——两个词必须相邻)

一个 Term(词项)的倒排记录

Term: "碎花"
Term Dictionary(词项字典):
  "碎花" → 指向 Postings List 的指针

Postings List(倒排列表):
  ┌──────────┬────────┬──────────────┬───────────────┐
  │ Doc_ID │ TF(次数)│ Position(位置) │ Offset(偏移) │
  ├──────────┼────────┼──────────────┼───────────────┤
  │ Doc 1 │ 1 │ 1 │ [2,4] │
  │ Doc 2 │ 1 │ 0 │ [0,2] │
  │ Doc 4 │ 1 │ 0 │ [0,2] │
  └──────────┴────────┴──────────────┴───────────────┘

TF(Term Frequency):"碎花"在文档中出现的次数,越多越相关
Position:"碎花"在文档中的第几个词,用于短语查询
Offset:字符偏移量,用于高亮显示搜索关键词

⚡ 为什么倒排索引这么快?

用户搜索 "碎花连衣裙" 分词:碎花 + 连衣裙 查倒排表 碎花→[1,2,4] 连衣裙→[1,4] 取交集 [1,4] 返回 Doc 1, Doc 4

不需要扫描所有文档!直接查"碎花"的倒排表和"连衣裙"的倒排表,取交集就是结果。
这就是为什么 ES 能在 1 亿条数据中毫秒级返回结果。

P3. 核心概念 — Index / Shard / Replica

📦 Index(索引)= MongoDB 的"数据库"

一个 Index 就是一个逻辑命名空间,包含一堆 JSON 文档。比如:products 索引存所有商品,orders 索引存所有订单。

🧩 Shard(分片)= Index 被切成的小块

一个 Index 被分成多个 Shard

Index: products(5个主分片)
Shard 0
Doc A,B,C
Shard 1
Doc D,E,F
Shard 2
Doc G,H,I
Shard 3
Doc J,K,L
Shard 4
Doc M,N,O
为什么分片?一个 Index 数据量太大,一台机器存不下。
分成 5 个 Shard → 可以分布到 5 台机器上,并行处理。

⚠️ 分片数一旦创建就不能改!(只能通过 reindex 重建)

🔄 Replica(副本)= Shard 的备份

每个 Shard 都有副本(高可用)

Shard 0(主)
Shard 0(副本)
Shard 1(主)
Shard 1(副本)
Shard 2(主)
Shard 2(副本)
主分片(Primary Shard):负责读写的"正本"
副本分片(Replica Shard):主分片的复制,主挂了副本顶上,也可以分担读请求

常见配置:5个主分片 + 1个副本 = 总共 10 个分片(5主 + 5副本)

🏠 Node(节点)— 集群中的一台服务器

节点类型作用类比
Master Node管理集群状态、创建/删除索引、分配分片图书馆馆长(管目录的完整性)
Data Node存储数据、执行 CRUD 和搜索书架(真正存书的地方)
Coordinating Node接收请求、分发到各分片、汇总结果前台咨询台(帮你查,自己不存书)
Ingest Node数据预处理(Pipeline)图书编目室(新书上架前的整理、贴标签)

📐 路由 — 一条文档存到哪个分片?

// 路由公式: shard_num = hash(_routing) % num_primary_shards // 默认 _routing 就是文档的 _id // 比如 _id = "product_123",有5个主分片 // hash("product_123") % 5 = 2 → 存到 Shard 2 // 查询时也是同样的公式,知道去哪个分片找
⚠️ 这就是为什么分片数创建后不能改!如果从5个变成6个,之前 hash % 5 的文档路由就全错了,找不到数据了。

P4. Mapping 与分词器 — 告诉 ES 怎么理解你的数据

📖 什么是 Mapping?

Mapping 就是 ES 的"表结构定义"。它告诉 ES:这个字段是什么类型、要不要分词、用什么分词器。

重要:ES 可以自动推断类型(Dynamic Mapping),但生产中强烈建议手动定义 Mapping,避免自动推断出错。

// 创建索引时定义 Mapping PUT /products { "mappings": { "properties": { "name": { "type": "text", // 全文搜索类型(会分词) "analyzer": "ik_max_word" // 使用 IK 中文分词器 }, "category": { "type": "keyword" // 精确匹配类型(不分词) }, "price": { "type": "double" }, "inStock": { "type": "boolean" }, "createdAt": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" }, "description": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" // 搜索时用更粗粒度的分词 }, "tags": { "type": "keyword" // 数组也用 keyword } } } }

📋 字段类型速查

类型说明是否分词典型场景
text全文搜索✅ 分词商品名称、文章标题、描述
keyword精确匹配❌ 不分词分类、标签、状态、排序字段
integer/long整数价格、数量、ID
float/double浮点数评分、金额
boolean布尔是否上架、是否删除
date日期创建时间、更新时间
object嵌套JSON按字段内嵌对象
nested嵌套数组(独立查询)按字段订单商品列表(需要独立匹配)
geo_point经纬度门店位置、附近搜索
completion自动补全特殊搜索建议、下拉提示
⚠️ text vs keyword 是面试必考、生产必踩的坑!
  • text:会分词。"田园碎花连衣裙 夏" → ["田园","碎花","连衣裙","夏"]。搜索"碎花"能找到。但不能用来排序和聚合
  • keyword:不分词。"田园碎花连衣裙 夏"就是一整个字符串。搜索必须完全匹配。可以排序和聚合
  • 多字段类型(Multi-field):同一个字段同时建 text 和 keyword:
// 一个字段同时支持全文搜索和精确匹配 "name": { "type": "text", "analyzer": "ik_max_word", "fields": { "keyword": { // 子字段叫 name.keyword "type": "keyword" } } } // 使用: // 全文搜索 → 用 name 字段 // 精确匹配/排序/聚合 → 用 name.keyword 字段

🔧 分词器(Analyzer)— ES 怎么"理解"中文

分词三步骤

原始文本 1. Character Filter(字符过滤) 2. Tokenizer(分词) 3. Token Filter(词项过滤) Token 流
示例:"芝士草莓淘宝搜索,大杯 半糖"
→ 字符过滤:去掉特殊符号
→ 分词(IK分词器):["芝士","草莓","淘宝搜索","大杯","半糖"]
→ 词项过滤:转小写、去停用词
→ 最终:["芝士","草莓","淘宝搜索","大杯","半糖"]

🇨🇳 中文分词器 IK Analyzer

ES 自带的标准分词器对中文只能逐字拆分,不能理解词意。生产中必须装 IK 分词器。

// 安装 IK 分词器(ES 版本要匹配) $ ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.12.0/elasticsearch-analysis-ik-8.12.0.zip // 测试分词效果 GET /_analyze { "analyzer": "standard", // 标准分词器 "text": "芝士草莓淘宝搜索" } // 结果:["芝","士","草","莓","奶","茶"] ← 每个字一个词,没用! GET /_analyze { "analyzer": "ik_max_word", // IK 最细粒度分词 "text": "芝士草莓淘宝搜索" } // 结果:["芝士","草莓","淘宝搜索"] ← 正确的中文分词! GET /_analyze { "analyzer": "ik_smart", // IK 智能分词(更粗粒度) "text": "芝士草莓淘宝搜索" } // 结果:["芝士","草莓淘宝搜索"] ← 粒度更粗
💡 生产建议:索引时用 ik_max_word(最细粒度,搜索时更容易命中),搜索时用 ik_smart(粗粒度,减少无关结果)。

📝 自定义词典(IK 扩展词库)

// IK 分词器默认词典没有你的行业术语 // 比如"算法精解"会被拆成 ["算","法","精","解"] // 你需要配置扩展词典 // config/IKAnalyzer.cfg.xml <properties> <entry key="ext_dict">custom.dic</entry> // 扩展词典文件 </properties> // custom.dic(每行一个词) 算法精解 芝士奶盖 经典美式 多肉葡萄 // 配置后重启 ES,"算法精解"就不会被拆开了

P5. CRUD 操作 — RESTful 风格的增删改查

➕ Create — 索引文档

// 指定 ID 创建(幂等,ID存在则覆盖) PUT /products/_doc/1 { "name": "芝士草莓淘宝搜索", "category": "淘宝搜索", "price": 22.0, "inStock": true, "tags": ["热卖", "新品"], "createdAt": "2025-01-12 14:30:00" } // 返回: { "_index": "products", "_id": "1", "result": "created", ← 第一次是 created "_version": 1 } // 自动生成 ID(用 POST,不指定ID) POST /products/_doc { "name": "淘宝搜索", "price": 15.0 } // "_id": "auto-generated-uuid" // 批量创建(Bulk API — 生产常用!) POST /_bulk {"index":{"_index":"products","_id":"2"}} {"name":"淘宝搜索","category":"淘宝搜索","price":15} {"index":{"_index":"products","_id":"3"}} {"name":"算法精解","category":"图书","price":28} {"index":{"_index":"products","_id":"4"}} {"name":"美式咖啡","category":"咖啡","price":18}
💡 Bulk API:一次请求批量操作,比逐条 insert 快 10~100 倍。生产环境数据同步都用 Bulk。

🔍 Read — 查询文档

// 按 ID 查询 GET /products/_doc/1 { "_index": "products", "_id": "1", "_version": 1, "found": true, "_source": { ← 原始文档存在 _source 里 "name": "芝士草莓淘宝搜索", "category": "淘宝搜索", "price": 22.0 } } // 查询所有文档 GET /products/_search // 只返回特定字段 GET /products/_search { "_source": ["name", "price"], "query": { "match_all": {} } }

📝 Update — 更新文档

// 部分更新(只更新指定字段) POST /products/_update/1 { "doc": { "price": 25.0, "inStock": false } } // 脚本更新(复杂逻辑) POST /products/_update/1 { "script": { "source": "ctx._source.price += params.increment", "params": { "increment": 3 } } } // 乐观并发控制(用版本号) POST /products/_update/1?if_primary_term=1&if_seq_no=5 { "doc": { "price": 28.0 } } // 如果文档在此期间被改过(seq_no变了),更新会失败

❌ Delete — 删除文档

// 按 ID 删除 DELETE /products/_doc/4 // 按条件删除(删除所有 category 为 "咖啡" 的) POST /products/_delete_by_query { "query": { "term": { "category": "咖啡" } } } // 删除整个索引(危险!数据全没了) DELETE /products

P6. 搜索 DSL — 各种查询姿势

🔍 1. 全文搜索(match)— 最常用!

// 基本全文搜索 GET /products/_search { "query": { "match": { "name": "草莓淘宝搜索" // 会分词为 "草莓" + "淘宝搜索" } } } // 结果:包含"草莓"或"淘宝搜索"的文档都会返回 // 按相关性打分排序(两个词都匹配的分数更高) // AND 模式(两个词都要匹配) GET /products/_search { "query": { "match": { "name": { "query": "草莓淘宝搜索", "operator": "and" // 默认是 or } } } } // 多字段搜索(搜 name 或 description) GET /products/_search { "query": { "multi_match": { "query": "草莓", "fields": ["name", "description"] } } }

🎯 2. 精确匹配(term)

// 精确匹配(不分词,必须完全一样) GET /products/_search { "query": { "term": { "category": "淘宝搜索" // category 是 keyword 类型 } } } // 多值匹配(类似 SQL IN) GET /products/_search { "query": { "terms": { "category": ["淘宝搜索", "图书"] } } } // 范围查询 GET /products/_search { "query": { "range": { "price": { "gte": 15, // >= 15 "lte": 25 // <= 25 } } } }
⚠️ 常见坑:对 text 字段用 term 查询
term 不分词,如果字段是 text 类型,存储时已经分词了。
比如文档 {"name": "芝士草莓淘宝搜索"},name 是 text 类型,存储时分词为 ["芝士","草莓","淘宝搜索"]。
你用 term: {name: "芝士草莓淘宝搜索"} 去查 → 查不到!因为倒排索引里根本没有"芝士草莓淘宝搜索"这个完整的词。

解决:全文搜索用 match,精确匹配用 keyword 类型的字段。

🔀 3. 组合查询(bool)— 生产最常用!

💡 生活类比:你在淘宝搜"连衣裙"时,可以勾选"包邮"(filter)、"价格100-200"(range)、按销量排序(sort)。这些筛选条件组合起来,就是 bool 查询——must 是搜索关键词,filter 是筛选条件,should 是加分项(比如"热卖"标签优先),must_not 是排除项。
// 搜索:name 包含"草莓",价格 15~30,类别是"淘宝搜索"或"图书" GET /products/_search { "query": { "bool": { "must": [ // 必须满足(影响打分) { "match": { "name": "草莓" } } ], "filter": [ // 过滤(不影响打分,更快) { "range": { "price": { "gte": 15, "lte": 30 } } }, { "terms": { "category": ["淘宝搜索", "图书"] } }, { "term": { "inStock": true } } ], "must_not": [ // 必须不满足 { "term": { "status": "discontinued" } } ], "should": [ // 满足加分,不满足也没关系 { "term": { "tags": "热卖" } } ] } } }
💡 must vs filter 的区别:
  • must:必须满足,且影响相关性得分(_score)。适合搜索关键词。
  • filter:必须满足,但不影响得分。适合范围、分类、状态等过滤条件。更快!因为不需要计算得分,且可以被缓存。
生产建议:能放 filter 的条件都放 filter,只有搜索关键词放 must。

✨ 4. 高亮显示

GET /products/_search { "query": { "match": { "name": "草莓" } }, "highlight": { "fields": { "name": { "pre_tags": ["<em class='hl'>"], "post_tags": ["</em>"] } } } } // 返回结果中会有 highlight 字段: "highlight": { "name": ["芝士草莓淘宝搜索"] }

📄 5. 分页

// from + size 分页(传统方式) GET /products/_search { "query": { "match_all": {} }, "from": 0, // 起始位置(偏移量) "size": 10 // 每页条数 } // 第2页:from=10, size=10 // 第3页:from=20, size=10 // ⚠️ 深分页问题! // from=9990, size=10 → ES 要从每个分片取 10000 条,然后合并排序取最后 10 条 // 默认 max_result_window = 10000,超过就报错
⚠️ 深分页问题与解决方案
问题:from + size 分页,越往后越慢。from=100000 时,ES 要在内存中对所有分片的数据做合并排序。

💡 生活类比:你在淘宝翻到第 100 页,淘宝服务器要把前 10000 个商品都取出来再扔掉 9900 个,给你看最后 100 个——这就是深分页为什么慢。

解决方案:
  1. search_after:基于上一页最后一条的排序值来翻页。适合"下一页"场景,不支持跳页。
  2. scroll API:创建快照,适合全量导出。但有资源开销,用完要清空。
  3. 限制最大页数:产品层面限制,比如"最多看100页"(搜索引擎都是这么做的)。

P7. 聚合分析(Aggregation)— ES 的"GROUP BY"

📊 1. 桶聚合(Bucket Aggregation)— 分组

// 按类别分组,统计每个类别有多少商品 GET /products/_search { "size": 0, // 不返回文档,只要聚合结果 "aggs": { "by_category": { "terms": { "field": "category", // 按 category 字段分组 "size": 10 // 返回前10组 } } } } { "aggregations": { "by_category": { "buckets": [ { "key": "淘宝搜索", "doc_count": 45 }, { "key": "图书", "doc_count": 28 }, { "key": "咖啡", "doc_count": 15 } ] } } }

📊 2. 指标聚合(Metric Aggregation)— 计算

// 计算价格的平均值、最大值、最小值 GET /products/_search { "size": 0, "aggs": { "avg_price": { "avg": { "field": "price" } }, "max_price": { "max": { "field": "price" } }, "min_price": { "min": { "field": "price" } }, "price_stats": { "stats": { "field": "price" } } // 一次返回 count/min/max/avg/sum } } "price_stats": { "count": 88, "min": 8.0, "max": 38.0, "avg": 18.5, "sum": 1628.0 }

📊 3. 嵌套聚合 — 先分组再计算

// 按类别分组,每组计算平均价格 GET /products/_search { "size": 0, "aggs": { "by_category": { "terms": { "field": "category" }, "aggs": { // 子聚合 "avg_price": { "avg": { "field": "price" } } } } } } "by_category": { "buckets": [ { "key": "淘宝搜索", "doc_count": 45, "avg_price": { "value": 18.2 } }, { "key": "图书", "doc_count": 28, "avg_price": { "value": 24.5 } }, { "key": "咖啡", "doc_count": 15, "avg_price": { "value": 22.0 } } ] }

📊 4. 日期直方图 — 按时间统计

// 按天统计每天的订单量 GET /orders/_search { "size": 0, "aggs": { "daily_orders": { "date_histogram": { "field": "createdAt", "calendar_interval": "day", // 按天分组 "format": "yyyy-MM-dd" }, "aggs": { "total_amount": { "sum": { "field": "totalAmount" } } } } } } "daily_orders": { "buckets": [ { "key_as_string": "2025-01-10", "doc_count": 45, "total_amount": { "value": 2340.0 } }, { "key_as_string": "2025-01-11", "doc_count": 52, "total_amount": { "value": 2860.0 } }, { "key_as_string": "2025-01-12", "doc_count": 38, "total_amount": { "value": 1920.0 } } ] }

P8. 集群架构 — 多节点怎么配合?

🔗 一个典型的3节点 ES 集群

🖥️ Node 1(Master + Data)
角色: master, data
Shard 0 (主) 📗
Shard 1 (副本) 🔵
Shard 2 (主) 📗
🖥️ Node 2(Data)
角色: data
Shard 0 (副本) 🔵
Shard 1 (主) 📗
Shard 2 (副本) 🔵
🖥️ Node 3(Master + Data)
角色: master, data
Shard 0 (主) 📗
Shard 1 (主) 📗
Shard 2 (主) 📗
规则:主分片和它的副本不能在同一个节点上(否则同一台挂了主和副本都没了)
选举:Master 节点挂了,其他 Master-eligible 节点自动选举新 Master

📐 写入流程

客户端写入请求 协调节点接收 路由到主分片 写入主分片 同步到副本 返回成功
// 写入一致性级别(在 settings 中配置) "index.write.wait_for_active_shards": "all" // 所有分片(主+副本)都确认才返回 "index.write.wait_for_active_shards": "1" // 默认,只要主分片确认 "index.write.wait_for_active_shards": "quorum" // 大多数分片确认

🔍 搜索流程

客户端搜索请求 协调节点 广播到所有分片 每个分片返回Top N 协调节点合并排序 返回最终结果

为什么搜索也快?因为查询被并行发送到所有分片,每个分片只处理一部分数据。最后协调节点合并结果。
但:如果分片数太多,协调节点合并的开销也很大。

📐 分片数怎么选?

数据量推荐主分片数推荐副本数说明
< 10GB11小数据量不需要分片
10GB ~ 100GB3~51中等规模
100GB ~ 1TB5~101~2大中型项目
> 1TB10~201~2大型项目,注意分片不要太多
⚠️ 分片不是越多越好!
  • 每个分片有内存和文件句柄开销。1000个分片 = 大量资源浪费
  • 官方建议:每个分片 10~50GB 为佳
  • 搜索时分片越多,合并结果越慢
  • 可以用 _cat/shards API 查看各分片大小

P9. Spring Boot 集成 — 在 Java 项目中用 ES

📦 1. 引入依赖

<!-- Spring Boot 3.x + ES 8.x --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency>

⚙️ 2. 配置连接

# application.yml spring: elasticsearch: uris: http://localhost:9200 # 多节点集群 # uris: http://es-node1:9200,http://es-node2:9200,http://es-node3:9200 username: elastic password: ${ES_PASSWORD} connection-timeout: 5s socket-timeout: 30s

📝 3. 定义实体(@Document)

import org.springframework.data.annotation.Id; import org.springframework.data.elasticsearch.annotations.*; import java.time.LocalDateTime; import java.util.List; @Document(indexName = "products") @Setting(shards = 3, replicas = 1) public class Product { @Id private String id; @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart") private String name; @Field(type = FieldType.Keyword) private String category; @Field(type = FieldType.Double) private Double price; @Field(type = FieldType.Boolean) private Boolean inStock; @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second) private LocalDateTime createdAt; @Field(type = FieldType.Keyword) private List<String> tags; // getter/setter... }

🔍 4. 使用 ElasticsearchOperations(推荐)

import org.springframework.data.elasticsearch.client.elc.NativeQuery; import org.springframework.data.elasticsearch.core.ElasticsearchOperations; import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.data.elasticsearch.core.SearchHits; import org.springframework.stereotype.Service; import co.elastic.clients.elasticsearch._types.query_dsl.*; import java.util.List; import java.util.stream.Collectors; @Service public class ProductSearchService { private final ElasticsearchOperations elasticsearchOperations; public ProductSearchService(ElasticsearchOperations ops) { this.elasticsearchOperations = ops; } // ===== 全文搜索 ===== public List<Product> searchByName(String keyword) { NativeQuery query = NativeQuery.builder() .withQuery(q -> q .match(m -> m .field("name") .query(keyword) ) ) .build(); SearchHits<Product> hits = elasticsearchOperations.search(query, Product.class); return hits.getSearchHits().stream() .map(SearchHit::getContent) .collect(Collectors.toList()); } // ===== 组合查询(bool) ===== public List<Product> searchProducts(String keyword, Double minPrice, Double maxPrice, String category) { NativeQuery query = NativeQuery.builder() .withQuery(q -> q .bool(b -> { // must: 全文搜索 if (keyword != null && !keyword.isEmpty()) { b.must(m -> m.match(mq -> mq.field("name").query(keyword))); } // filter: 不影响打分的条件 if (minPrice != null || maxPrice != null) { b.filter(f -> f.range(r -> { r.field("price"); if (minPrice != null) r.gte(minPrice.toString()); if (maxPrice != null) r.lte(maxPrice.toString()); return r; })); } if (category != null) { b.filter(f -> f.term(t -> t.field("category").value(category))); } return b; }) ) .withHighlightQuery(new HighlightQuery( new Highlight(List.of(new HighlightField("name"))), Product.class )) .build(); SearchHits<Product> hits = elasticsearchOperations.search(query, Product.class); return hits.getSearchHits().stream() .map(SearchHit::getContent) .collect(Collectors.toList()); } // ===== 聚合查询 ===== public List<CategoryStats> getCategoryStats() { NativeQuery query = NativeQuery.builder() .withQuery(q -> q.matchAll(m -> m)) .withAggregation("by_category", Aggregation.of(a -> a .terms(t -> t.field("category").size(20)) .aggregations("avg_price", sa -> sa .avg(av -> av.field("price")) ) )) .withMaxResults(0) // 只要聚合,不要文档 .build(); SearchHits<Product> hits = elasticsearchOperations.search(query, Product.class); // 解析聚合结果... return parseCategoryAggregation(hits); } }

📂 5. 使用 ElasticsearchRepository

public interface ProductRepository extends ElasticsearchRepository<Product, String> { // 方法名自动生成查询 List<Product> findByCategory(String category); List<Product> findByCategoryAndPriceBetween(String category, Double min, Double max); List<Product> findByNameOrDescription(String name, String desc); // 自定义查询(@Query) @Query("{\"bool\": {\"must\": [{\"match\": {\"name\": \"?0\"}}], \"filter\": [{\"range\": {\"price\": {\"gte\": \"?1\", \"lte\": \"?2\"}}}]}}") List<Product> searchByNameAndPriceRange(String keyword, Double minPrice, Double maxPrice); }

🔄 6. MySQL 数据同步到 ES(生产核心问题)

三种同步方案

方案1:同步双写
MySQL写入成功后
立即写入ES

✅ 简单
❌ 代码侵入
❌ ES写入失败咋办?
方案2:异步MQ(推荐)
MySQL写入成功
→ 发MQ消息
→ 消费者写ES

✅ 解耦、可靠
✅ 失败可重试
❌ 有延迟(秒级)
方案3:Canal监听binlog
Canal伪装成MySQL从库
→ 监听binlog变更
→ 自动同步到ES

✅ 零代码侵入
✅ 实时性好
❌ 部署复杂
// 方案2 示例:MQ 异步同步 @Service public class ProductSyncService { private final ProductRepository productRepository; // MySQL private final ElasticsearchOperations esOps; // ES private final RabbitTemplate rabbitTemplate; // 写入 MySQL 后发 MQ 消息 @Transactional public Product createProduct(ProductDTO dto) { Product product = productRepository.save(dto.toEntity()); // 发送同步消息 rabbitTemplate.convertAndSend("es.sync.exchange", "product.sync", product.getId()); return product; } // 消费者:收到消息后同步到 ES @RabbitListener(queues = "es.sync.queue") public void syncToES(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(); esOps.save(product); // 自动同步到 ES } }

P10. 生产环境踩坑指南

坑1:Mapping 爆炸 — 字段无限增长

⚠️ 现象
一个日志索引,每条日志有不同的字段名。运行一个月后索引有 10 万个字段,集群内存爆炸。
java.lang.IllegalArgumentException: Limit of total fields [1000] has been exceeded
✅ 解决:
  1. 设置 "index.mapping.total_fields.limit": 1000 限制最大字段数
  2. 使用 "dynamic": "strict" 禁止自动添加新字段(推荐生产环境配置)
  3. 使用 "dynamic": "false" 忽略未知字段(不报错但不索引)

坑2:深分页 OOM — from + size 过大

⚠️ 现象
调用 from=50000, size=10 报错:
Result window is too large, from + size must be less than or equal to: [10000]
✅ 解决:
  1. 用 search_after 替代深分页
  2. 如果一定要改大限制:PUT /index/_settings {"index.max_result_window": 50000}(不推荐)
// search_after 示例 GET /products/_search { "query": { "match_all": {} }, "size": 10, "sort": [ { "price": "asc" }, { "_id": "asc" } // _id 作为 tiebreaker 确保唯一 ], "search_after": [15.0, "product_123"] // 上一页最后一条的 sort 值 }

坑3:Fielddata OOM — 对 text 字段排序/聚合

⚠️ 现象
对 text 类型的字段做排序或聚合:
GET /products/_search { "sort": [{ "name": "asc" }] }
报错:Fielddata is disabled on text fields by default. Set fielddata=true on [name]

即使你设置了 fielddata=true,也可能导致内存爆炸!
✅ 解决:对 text 字段排序/聚合,用 name.keyword 子字段(Multi-field)!
// 排序用 name.keyword GET /products/_search { "sort": [{ "name.keyword": "asc" }] } // 聚合用 name.keyword GET /products/_search { "aggs": { "by_name": { "terms": { "field": "name.keyword" } } } }

坑4:Mapping 改不了 — 加了新字段类型冲突

⚠️ 现象
一开始 name 被自动推断为 text(第一条数据是字符串)。后来想改成 keyword,报错:
mapper [name] cannot be changed from type [text] to [keyword]

ES 的 Mapping 中已有字段的类型不能修改!只能新建索引。
✅ 解决:
  1. 生产环境一定要预定义 Mapping,不要依赖自动推断
  2. 如果已经错了,创建新索引 → 用 reindex 迁移数据 → 用 alias 切换
// 1. 创建新索引(正确的 Mapping) PUT /products_v2 { "mappings": { ... 正确的 mapping ... } } // 2. 迁移数据 POST /_reindex { "source": { "index": "products" }, "dest": { "index": "products_v2" } } // 3. 用别名切换(零停机) POST /_aliases { "actions": [ { "remove": { "index": "products", "alias": "product_search" } }, { "add": { "index": "products_v2", "alias": "product_search" } } ] }

坑5:索引太大不删除 — 磁盘爆了

⚠️ 现象
日志索引每天一个(logs-2025-01-01, logs-2025-01-02...),三个月后发现磁盘满了。
✅ 解决:Index Lifecycle Management(ILM)— 自动管理索引生命周期
// 创建 ILM 策略 PUT /_ilm/policy/logs_policy { "policy": { "phases": { "hot": { // 热数据阶段 "min_age": "0ms", "actions": { "rollover": { "max_age": "1d", // 每天滚动 "max_primary_shard_size": "50gb" } } }, "warm": { // 温数据阶段 "min_age": "7d", "actions": { "shrink": { "number_of_shards": 1 }, // 缩减分片 "forcemerge": { "max_num_segments": 1 } } }, "delete": { // 删除阶段 "min_age": "30d", // 30天后自动删除 "actions": { "delete": {} } } } } }

坑6:脑裂(Split Brain)— 两个 Master

⚠️ 现象
网络抖动导致节点之间通信断开。Node1 和 Node2 都认为对方挂了,各自选举自己为 Master。集群变成两个"脑",数据不一致。
✅ 解决:
  1. ES 7.x+ 默认使用基于法定人数的选举(quorum),不再需要手动配置 minimum_master_nodes
  2. 确保集群有奇数个 Master-eligible 节点(3个或5个)
  3. 不要让两个独立的 Master 选举同时发生

📋 生产 Checklist

项目检查内容推荐配置
Mapping手动定义了吗?dynamic=strict?生产必须预定义,禁止自动推断
分词器中文项目装了IK吗?IK + 自定义词典
分片数合理吗?不要太多也不要太少每个分片 10~50GB
副本至少1个副本replicas: 1(生产至少1)
刷新间隔默认1秒,写多可以调大写密集场景设 5~30 秒
内存JVM 堆内存 ≤ 32GB分配物理内存的 50%,不超过 32GB
磁盘水位线设了吗?cluster.routing.allocation.disk.watermark: 85%
ILM日志索引有自动清理策略吗?hot-warm-delete 三阶段
安全开启了认证和TLS吗?xpack.security.enabled: true
监控有监控吗?Kibana + Metricbeat 或 Prometheus
数据同步MySQL → ES 的同步方案确定了吗?MQ 异步 或 Canal
熔断有查询保护吗?限制 max_result_window、超时时间
Elasticsearch 从零到生产 · 全栈资料库 · 持续更新