【WebWiki】12 · ElasticSearch
05 ElasticSearch
WebWiki · 搜索篇
1. 概念对照
| MySQL | ElasticSearch | 说明 |
|---|---|---|
| Database | Index | 索引 = 同类文档的集合 |
| Table | (mapping) | 7.x 后一个索引一种 mapping |
| Row | Document(JSON) | 文档 |
| Column | Field | 字段 |
| 主键 | _id | 文档唯一标识 |
| — | Shard / Replica | 分片与副本 |
倒排索引:正排是"文档 → 词",倒排是"词 → 文档列表"。写入时先分词(analyzer),为每个词建立 posting list,搜索就是查词典取交集/并集,因此全文检索是毫秒级。

1.1、倒排索引(Inverted Index)原理深入
这是 ES 全文检索快的根基。理解它,才知道"为什么 LIKE '%xx%' 在 MySQL 慢、在 ES 快"。
正排索引(Forward):doc1 → ["无线","耳机","降噪"],用于"已知文档找词"。
倒排索引(Inverted):把关系反过来,按"词"组织:
词(Term) → 倒排列表(Posting List)
无线 → [doc1, doc3, doc7]
耳机 → [doc1, doc2]
降噪 → [doc1, doc9]
- Term Dictionary(词典):所有词的有序集合,用 FST(有限状态转换器)压缩存储,查找词接近 O(1)。
- Posting List(倒排列表):每个词对应"包含它的文档 id 列表",还记录词频(tf)、位置(position,用于短语/高亮)、偏移等。列表用 FOR / Roaring Bitmap 等压缩,节省内存且支持位运算求交/并。
- 查询过程:
无线 耳机→ 在词典查到两个 term → 取各自 posting list → 位运算求交集[doc1]→ 返回 doc1。即使上亿文档,交集几步算完,毫秒级。
与 MySQL 对比:MySQL
LIKE '%耳机%'要全表逐行扫字符串;ES 直接"词→文档"定位,根本不扫文档内容。
1.2、分词(Analysis)原理
写入和查询都要经过 analyzer,且两边用相同分析器才能保证命中。
一个 analyzer = Character Filters(字符过滤,如去 HTML)→ Tokenizer(切词,如按空格/ik 词典)→ Token Filters(词元过滤,如转小写、去停用词、同义词)。
- 写入时分词:文档字段
text类型按 analyzer 切成 term,建倒排索引。 - 查询时分词:查询串
match也按(同)analyzer 切 term,再去查倒排。 - 关键词匹配
term:不分词,原样精确匹配倒排里的 term(所以term查中文整句通常查不到,因为索引里是切好的词)。
// 查看分词结果(调试必用):写入 analyzer 与查询 analyzer 是否一致
GET /product/_analyze
{ "field": "name", "text": "无线蓝牙耳机" }
// 返回:["无线","蓝牙","耳机"](ik_max_word 细粒度)
GET /_analyze
{ "analyzer": "ik_smart", "text": "无线蓝牙耳机" }
// 返回:["无线蓝牙耳机"] 或 ["无线","蓝牙耳机"](ik_smart 粗粒度)
常见坑:字段用
ik_max_word建索引,查询却用默认standard(按字切),导致"蓝","牙"单字匹配、召回不准。建索引与搜索建议:索引时用ik_max_word(更全),搜索时用ik_smart(更准),可在 mapping 用search_analyzer分别指定。
2. Mapping 设计
PUT /product
{
"settings": { "number_of_shards": 3, "number_of_replicas": 1 },
"mappings": {
"properties": {
"name": { "type": "text", "analyzer": "ik_max_word",
"fields": { "kw": { "type": "keyword" } } },
"brand": { "type": "keyword" },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"stock": { "type": "integer" },
"tags": { "type": "keyword" },
"soldCount":{ "type": "long" },
"createdAt":{ "type": "date" },
"desc": { "type": "text", "index": false }
}
}
}
| 类型 | 选择要点 |
|---|---|
| text | 要分词检索的字段;聚合/排序需加 .keyword 子字段 |
| keyword | 精确匹配、聚合、排序(状态、品牌、标签) |
| 数值 | 整数用 byte/integer/long;价格用 scaled_float |
| date | 支持 epoch 毫秒与格式化字符串 |
中文分词用 ik_max_word(细粒度,建索引用)+ ik_smart(粗粒度,搜索用)。
2.1、Mapping 设计深坑
textvskeyword是核心选择:text分词,能match全文搜,但不能直接聚合/排序(因为被切成词了)。要对name既搜又聚合/排序,加fields: { kw: keyword }子字段,聚合用name.kw。keyword有ignore_above:默认超 256 字符的 keyword 不索引(防超长),长字符串聚合要调大或改 text。scaled_float存金额:price=19.99, scaling_factor=100→ 实际存整数1999,省空间且避免浮点误差,查询自动换算回 19.99。index: false(如desc):存但不索引,省空间、不可搜,适合只展示/只聚合的字段。dynamic策略:true自动加字段(易字段爆炸)、false忽略新字段、strict遇新字段报错。生产建议strict或关动态 + 白名单,防止脏数据撑爆 mapping。nestedvsobject:对象数组(如comments: [{user, content}])用默认object会"扁平化"丢失内部关联(user 与 content 配对错乱);要保持"一对整体"用nested类型(查询用nested查询,开销略大)。_source:默认存原始 JSON,支持只返回部分字段(_source: ["name","price"]);若完全不需要回原文档可关(一般不关,利于 reindex/更新)。
// 演示 nested:商品的多条评价要保持 user-content 配对
PUT /product2
{
"mappings": {
"properties": {
"comments": {
"type": "nested",
"properties": { "user": {"type":"keyword"}, "content": {"type":"text"} }
}
}
}
}
// 查询:找"用户 A 评价过'好'的文档"
GET /product2/_search
{ "query": { "nested": { "path": "comments",
"query": { "bool": { "must": [
{ "term": { "comments.user": "A" } },
{ "match": { "comments.content": "好" } } ] } } } } }
3. 常用 DSL
// 多条件搜索:名称模糊 + 品牌精确 + 价格区间,按销量排序,分页
GET /product/_search
{
"query": {
"bool": {
"must": [{ "match": { "name": "无线耳机" } }],
"filter": [
{ "term": { "brand": "sony" } },
{ "range": { "price": { "gte": 100, "lte": 1000 } } }
]
}
},
"sort": [{ "soldCount": "desc" }],
"from": 0, "size": 20,
"highlight": { "fields": { "name": {} } }
}
must(算分)/filter(不算分、有缓存)——能用 filter 就用 filter;- 深分页禁用
from + size(默认 max 10000),翻页深用 search_after 或滚动导出用 scroll / PIT;
3.1、查询上下文 vs 过滤上下文(算分与否)
bool里must/should在查询上下文(query context):会计算相关性评分_score,判断"多相关"。filter/must_not在过滤上下文(filter context):只判断"是否匹配"(yes/no),不算分、结果可被缓存,更快。
- 相关性
_score:ES 用 BM25(TF-IDF 改进)算分——词频越高、逆文档频率越高(越稀有越重要)、字段长度越短,分越高。must里的match会算这个分。 - 实战:凡是不需要"相关性排序"的条件(状态、品牌、价格区间、时间范围、是否存在)都放进
filter,既提速(缓存)又不被无关算分干扰。 should语义:bool中若没有must,should是"或"(至少命中一个);有must时should只是"加分项"(命中更高分),可用minimum_should_match控制最少命中数。
// 典型:搜索相关性 + 过滤条件分离
GET /product/_search
{
"query": {
"bool": {
"must": [{ "match": { "name": "耳机" } }], // 算分:相关性
"should": [{ "match": { "desc": "降噪" } }], // 加分项
"filter": [ // 不过滤算分、可缓存
{ "term": { "brand": "sony" } },
{ "range": { "price": { "gte": 100, "lte": 1000 } } },
{ "range": { "createdAt": { "gte": "now-90d" } } }
]
}
}
}
聚合不能用
query之外:聚合是基于"查询命中的文档集"再分组,所以想"先过滤再聚合"就把条件放query/filter,聚合结果只来自这些文档。
// 聚合:各品牌平均价与数量
GET /product/_search
{ "size": 0,
"aggs": {
"by_brand": {
"terms": { "field": "brand", "size": 10 },
"aggs": { "avg_price": { "avg": { "field": "price" } } }
}
}
}
3.2、高亮、Source 过滤、批量
// 只返回需要的字段,减少网络/解析
GET /product/_search
{ "_source": ["name","price","brand"], "query": { "match": { "name": "耳机" } },
"highlight": { "fields": { "name": { "pre_tags":["<em>"], "post_tags":["</em>"] } } } }
// _msearch 批量:一次请求多个查询,省 RTT
GET /product/_msearch
{"index":"product"}
{"query":{"match":{"name":"耳机"}},"size":5}
{"index":"product"}
{"query":{"term":{"brand":"sony"}},"size":5}
4. 与 MySQL 同步
| 方案 | 原理 | 适用 |
|---|---|---|
| 同步双写 | 业务代码写完 MySQL 写 ES | 简单但耦合、一致性差,不推荐 |
| MQ 异步 | 写库后发消息,消费者更新 ES | 常用,解耦,允许秒级延迟 |
| Canal/Flink CDC | 订阅 binlog 感知变更 | 推荐:零侵入,最终一致 |
| Logstash / DataX | 定时批量拉取 | 全量/周期同步、初始化 |
首次上线先全量(DataX/Logstash),之后靠 binlog 增量维持。
同步方案深入
- Canal 原理:伪装成 MySQL 从库,订阅 binlog(ROW 格式),解析出
insert/update/delete事件,发到 MQ 或直接调 ES 客户端更新。业务代码零改动、近实时、可靠。- 双写一致性:同步双写若"写 MySQL 成功、写 ES 失败"会不一致,需补偿(重试 + 对账任务扫差异)。Canal 把"写 ES"挪到异步链路,主流程只管 MySQL,失败重试不影响主业务。
- 全量初始化:新索引建好 mapping → DataX/Logstash/ETL 把 MySQL 历史数据批量灌入 → 切读新索引(或用别名 alias 热切换)→ 开启增量。
- 别名(alias)妙用:业务读
product_alias,实际指向product_v1;重建索引后原子切到product_v2(_aliases原子操作),实现零停机重建/扩容。
// 原子切换别名(零停机重建索引)
POST /_aliases
{ "actions": [
{ "remove": { "index": "product_v1", "alias": "product" } },
{ "add": { "index": "product_v2", "alias": "product" } }
] }
5. Java 客户端
// elasticsearch-java 8.x
SearchResponse<Product> resp = esClient.search(s -> s
.index("product")
.query(q -> q.bool(b -> b
.must(m -> m.match(t -> t.field("name").query("无线耳机")))
.filter(f -> f.term(t -> t.field("brand").value("sony")))
.filter(f -> f.range(r -> r.field("price").gte(JsonData.of(100)))))
.from(0).size(20)
.sort(so -> so.field(f -> f.field("soldCount").order(SortOrder.Desc))),
Product.class);
// Spring Data Elasticsearch:方法名派生 + 注解映射
@Document(indexName = "product")
public record ProductDoc(@Id Long id, String name, String brand, Double price) {}
interface ProductRepo extends ElasticsearchRepository<ProductDoc, Long> {
Page<ProductDoc> findByNameAndBrand(String name, String brand, Pageable page);
}
客户端选型:8.x 官方推
elasticsearch-java(强类型、DSL builder);Spring 生态用spring-data-elasticsearch(Repository 风格,开发快)。不要用已废弃的 TransportClient(9300 端口),改用 RestClient/High Level(也逐步收敛到新 java client)。批量写入用_bulk/BulkRequest(见 6.1)。
6. 性能与运维要点
- 写入用批量 bulk(每批 5~15MB),避免逐条写;
- 段合并耗资源,写入密集期调大
refresh_interval(如 30s); - 索引规划:按时间滚动(
logs-2026.09.19)+ ILM 自动滚动/删除; - 避免字段爆炸:mapping 动态字段设
strict或关闭; - 集群:3 台起(专用 master 候选),磁盘水位 85% 告警、90% 停写;
- 需要复杂关联查询时,宽表化建模(写入时打平 join 结果),ES 不擅长关系型 join。
6.1、近实时写入流程(Segment 机制,核心原理)
ES 不是"写一条立刻可搜到",而是近实时(NRT,默认 1 秒)。理解 segment 才懂这个延迟从哪来。
写入链路:
- 文档先写 translog(预写日志,落盘,保证宕机不丢);
- 写入 in-memory buffer(内存);
- 默认每
refresh_interval=1s,buffer 里的文档生成一个 segment(倒排索引的小文件),落到文件系统缓存(未 fsync),此时文档可被搜索 → "近实时"; - translog 定期/超过阈值 flush:segment 真正 fsync 到磁盘,translog 清空;
- 后台 merge:多个小 segment 合并成大 segment(Lucene 后台任务),合并后旧 segment 标记删除,空间回收。
写请求 → translog(磁盘) + memory buffer
│ refresh(每1s)
▼
in-memory buffer ──► 新 segment(文件系统缓存, 可搜) + 旧 segment
│ flush(默认30s/512mb)
▼
segment fsync 落盘, translog 清空
│ 后台 merge
▼
多个小 segment 合并成大 segment(删除旧文件)
关键调优
- 想要更快可见:调小
refresh_interval(如 200ms),代价是更多小 segment、CPU/IO 升。- 想要更高写入吞吐:导入期把
refresh_interval设为-1(关闭自动 refresh),导完再改回1s,避免边写边建段;批量用_bulk(每批 5~15MB)。- segment 太多:查询要合并多个 segment 结果,慢;靠 merge +
forcemerge(只读索引max_num_segments=1,但不要对热索引用,会巨耗资源)。- translog 保持久:
index.translog.durability=request每条 fsync(最安全),async异步(快但可能丢一点)。
6.2、分片(Shard)与副本(Replica)
分片是 ES 水平扩展与并行计算的基本单位;副本提供高可用与读扩展。
- 主分片(primary shard):文档的唯一归属,数量在建索引时定、之后不可改(除非 reindex)。
number_of_shards决定数据怎么切、最多能扩到多少节点并行。 - 副本分片(replica):主分片的拷贝,数量可随时改。作用:① 主挂了副本顶上(高可用);② 副本可分担读(
replica越多读吞吐越高,但写放大)。 - 路由:
routing = hash(_routing) % 主分片数,默认_routing=_id,决定文档落哪个分片。GET/UPDATE/DELETE带相同 routing 可直达分片,省广播。 - 写入流程:文档 → 算 routing → 落主分片 → 并行复制到所有副本 → 多数副本成功才返回(由
wait_for_active_shards控制)。 - 容量规划经验:
- 单分片大小建议 10~50GB(日志类可到 50GB,搜索类 20~40GB);分片太多(成千)master 压力大、段多查询慢;分片太大恢复/迁移慢。
- 总数据量 / 单分片目标大小 ≈ 主分片数。如 300GB、目标 30GB → 10 个主分片。
- 副本数 = 读 QPS 需求 / 单分片读能力;生产至少
replicas=1。
# 看分片分布与健康
GET _cat/shards/product?v
GET _cluster/health
# 扩副本(在线)
PUT /product/_settings { "number_of_replicas": 2 }
# 主分片数不可在线改;要改需 reindex 到新索引
坑:分片数设太大(如 1000)是常见错误,导致集群状态庞大、恢复慢;设太小(如 1)则无法水平扩展。建索引前按"未来 1~2 年数据量"规划。
6.3、聚合分析(Aggregation)深入
聚合是 ES 的"分析引擎",在查询命中文档上做分组/统计,类似 SQL 的
GROUP BY+ 聚合函数,但更强(嵌套、管道聚合)。
- 桶聚合(Bucket):按字段/区间分组——
terms(按词)、date_histogram(按时间桶)、range/histogram(数值区间)。 - 指标聚合(Metric):算数值——
avg/sum/max/min/cardinality(去重计数,基于 HyperLogLog,近似值)/percentiles(分位数,如 P99 延迟)。 - 管道聚合(Pipeline):基于其他聚合结果再算——
derivative(导数)、cumulative_sum(累计和)、bucket_sort(对桶排序/截断)。 - 深分页 + 聚合注意:聚合默认只扫
size命中文档?错——聚合是在所有命中文档上算(与size无关),大结果集聚合要设shard_size控精度/内存。
// 示例:按品牌分组,算均价/最高价,再取销售额 Top3 品牌
GET /product/_search
{
"size": 0,
"aggs": {
"by_brand": {
"terms": { "field": "brand", "size": 20, "order": { "total_sales": "desc" } },
"aggs": {
"avg_price": { "avg": { "field": "price" } },
"total_sales": { "sum": { "field": "soldCount" } },
"price_percentiles": { "percentiles": { "field": "price", "percents": [50, 90, 99] } }
}
}
}
}
// 子聚合嵌套:每个品牌下按月份统计(date_histogram 嵌套)
聚合性能坑:
terms聚合在高基数字段(如 user_id)上需每个分片返回size个桶再合并,可能不准/耗内存;用execution_hint、shard_size、或composite聚合(支持深翻页的聚合)兜底。
6.4、深度分页问题(重点)
ES 的
from + size默认最多查10000(index.max_result_window),且翻得越深越慢——这是分布式分页的天然代价。
为什么深分页慢? from=100000, size=20:协调节点要向每个分片要"前 100020 条",各分片返回后协调节点合并排序取第 100001~100020。分片数 × 100020 条都要拉到协调节点内存排序,翻深 = 内存与 CPU 爆炸。
三种解法:
方案 A:from + size(浅分页,不深的场景)
GET /product/_search { "from": 0, "size": 20, "query": {...} }
仅适合 from+size < 10000、前端"上一页/下一页"少量翻。深了报错 Result window is too large。
方案 B:search_after(深分页推荐,实时翻页)
用"上一页最后一条的排序值"作为游标,不偏移、不分片全量拉取,性能恒定。
// 第一页
GET /product/_search
{ "size": 20, "sort": [ {"soldCount":"desc"}, {"_id":"asc"} ], "query": {...} }
// 记下最后一条的 sort 值,如 [ 999, "doc_xyz" ]
// 下一页:用 search_after 接上(必须同 sort 含唯一字段 _id 防并列)
GET /product/_search
{ "size": 20, "sort": [ {"soldCount":"desc"}, {"_id":"asc"} ],
"search_after": [ 999, "doc_xyz" ], "query": {...} }
- 优点:无深度限制、性能好;缺点:不支持随机跳页(只能往后翻),需业务接受"无限滚动/下一页"。
sort必须包含唯一字段(如_id或业务唯一键),否则并列行会漏/重。
方案 C:scroll / PIT(导出、离线遍历)
scroll 一次性生成快照游标,适合"全量导出/重建索引",不适合实时用户翻页(占用资源、快照期间数据不变)。7.10+ 推荐 PIT(Point In Time)+ search_after 替代 scroll。
// 1) 开 PIT(保留一致性视图 1 分钟)
POST /product/_pit?keep_alive=1m
// 返回 id: "pit_xxx"
// 2) 用 PIT + search_after 翻页(不指定 index,用 pit)
GET /_search
{ "size": 1000, "pit": { "id": "pit_xxx", "keep_alive": "1m" },
"sort": [ {"_id":"asc"} ], "query": {...} }
// 下一页把返回的 pit_id 与 sort 值传入 search_after,循环直到无结果
// 老式 scroll(了解即可)
POST /product/_search?scroll=1m { "size":1000, "query":{...} }
// 后续:GET /_search/scroll { "scroll":"1m", "scroll_id":"xxx" }
选型总结
场景 方案 前几页、可随机跳页 from + size(限 1 万内) 实时深翻页(无限滚动) search_after(+ 可选 PIT 保一致) 全量导出/重建索引 PIT + search_after / scroll
6.5、集群运维与故障
- 集群颜色:
green(主副全分配)、yellow(主全在、副本未分配,单点风险)、red(有主分片丢失,部分数据不可写/不可搜)。yellow常见是副本数 > 可用节点数,加节点或降副本即可。 - 脑裂:7.x 后靠
cluster.initial_master_nodes+ 多数派(quorum)防脑裂;discovery.zen.minimum_master_nodes在 7.x 已废弃,别再配。 - 磁盘水位:
watermark.low=85%不再分配新分片、high=90%迁走、flood=95%只读(index.blocks.read_only_allow_delete)。磁盘满导致red时,清空间后需手动DELETE触发或cluster.routing.allocation.disk.threshold_enabled调整。 - ILM(索引生命周期管理):热→温→冷→删自动滚动。日志类按天建
logs-2026.09.19,rollover 到新索引、旧索引降副本/冻结/删,全自动化。 - 监控:
_catAPI、GET _nodes/stats、GET _cluster/health、Kibana 仪表盘;关注堆内存(ES 吃堆,JVM 堆 ≤ 50% 物理、≤ 31GB 避免压缩指针失效)。
7. 小结
| 主题 | 要点 |
|---|---|
| 倒排索引 | 词→文档列表,词典 FST + posting list 位运算求交,毫秒级 |
| 分词 | 写入/查询同 analyzer;ik_max_word 建、ik_smart 搜 |
| Mapping | text 搜、keyword 聚排;nested 保对象关联;scaled_float 金额 |
| 查询/过滤 | must 算分、filter 不算分可缓存;should 加分 |
| 写入 | translog + buffer → refresh 成 segment 可搜(NRT 1s)→ flush → merge |
| 分片副本 | 主分片数定后不可改;单分片 10~50GB;副本保高可用+读扩展 |
| 聚合 | 桶/指标/管道;高基数用 composite |
| 深分页 | 浅用 from+size、深用 search_after、导出用 PIT/scroll |
| 同步 | Canal/CDC 增量 + 全量初始化 + 别名零停机切换 |
ES 与 MySQL(09 篇)、Redis(10/11 篇)构成"持久化关系库 + 缓存 + 搜索"的铁三角:MySQL 是源头真相,Redis 抗高并发读,ES 扛全文检索与分析。

浙公网安备 33010602011771号