Loading

【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,搜索就是查词典取交集/并集,因此全文检索是毫秒级。

ES 集群

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 设计深坑

  • text vs keyword 是核心选择: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。
  • nested vs object:对象数组(如 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. 性能与运维要点

  1. 写入用批量 bulk(每批 5~15MB),避免逐条写;
  2. 段合并耗资源,写入密集期调大 refresh_interval(如 30s);
  3. 索引规划:按时间滚动(logs-2026.09.19)+ ILM 自动滚动/删除;
  4. 避免字段爆炸:mapping 动态字段设 strict 或关闭;
  5. 集群:3 台起(专用 master 候选),磁盘水位 85% 告警、90% 停写;
  6. 需要复杂关联查询时,宽表化建模(写入时打平 join 结果),ES 不擅长关系型 join。

6.1、近实时写入流程(Segment 机制,核心原理)

ES 不是"写一条立刻可搜到",而是近实时(NRT,默认 1 秒)。理解 segment 才懂这个延迟从哪来。

写入链路:

  1. 文档先写 translog(预写日志,落盘,保证宕机不丢);
  2. 写入 in-memory buffer(内存);
  3. 默认每 refresh_interval=1s,buffer 里的文档生成一个 segment(倒排索引的小文件),落到文件系统缓存(未 fsync),此时文档可被搜索 → "近实时";
  4. translog 定期/超过阈值 flush:segment 真正 fsync 到磁盘,translog 清空;
  5. 后台 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 到新索引、旧索引降副本/冻结/删,全自动化。
  • 监控:_cat API、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 扛全文检索与分析。

posted @ 2026-09-19 05:41  Stellan7  阅读(7)  评论(0)    收藏  举报