一文彻底搞懂 Elasticsearch:原理、场景、避坑与优化

12 阅读7分钟

一、 ES 是什么?

Elasticsearch 是一个基于 Apache Lucene 构建的开源、分布式、RESTful 风格的搜索和分析引擎。它是一个文档型 NoSQL 数据库,专门为海量数据的全文检索、复杂查询和实时分析而生。常与 Logstash、Kibana 组成著名的 ELK 技术栈

二、 ES 的架构与原理图解(Mermaid 版)

1. 核心原理:倒排索引(快的原因)

传统数据库是“通过 ID 找内容”(正向索引),ES 是“通过内容找 ID”(倒排索引)。

image.png

2. 物理架构:分布式集群与分片(能存海量的原因)

ES 天生分布式,数据被切成多片分布在多台机器上,主分片负责写,副本分片负责读和容灾。 image.png

3. 写入原理:近实时 NRT(为什么有 1 秒延迟)

数据写入后并非直接落盘,而是先在内存中缓冲,每隔 1 秒刷新到系统缓存中开放搜索。

image.png

三、 为什么要用 ES?

  1. 模糊查询降维打击:MySQL 用 LIKE '%词%' 会导致全表扫描锁表,百万级数据就崩溃;ES 基于倒排索引,十亿级数据也能毫秒级响应。

    【代码对比】 MySQL 灾难级写法SELECT * FROM goods WHERE title LIKE '%苹果手机%'; (无法走索引) ES 毫秒级写法

   > GET /goods/_search
   > {
   >   "query": {
   >     "match": { "title": "苹果手机" }
   >   }
   > }
   > 
  1. 支持复杂打分与相关性:搜“苹果”,ES 会通过 BM25 算法算出“卖苹果手机的店”排在“卖苹果水果的店”前面,MySQL 很难做到。
  2. 天生分布式,横向扩展极简:MySQL 分库分表极其痛苦,ES 加机器只需改配置,自动完成数据迁移和负载均衡。
  3. 强大的聚合分析:类似 SQL 的 Group By,但可以处理海量数据并实时出结果。

四、 什么场景下用 ES?

黄金法则:ES 绝不能当核心业务主库(无事务支持),必须是 MySQL 的“异构索引库”或“附属分析库”。

  • 搜索类:电商商品搜索、App 内内容搜索(微信搜聊天记录、知乎搜文章)、企业内部文档检索。
  • 日志与监控类:IT 运维日志分析(ELK)、微服务链路追踪(APM)、安全日志审计。
  • 数据分析类:双十一实时成交额大屏、用户行为漏斗分析、BI 报表。
  • 地理空间类:滴滴找附近的车、美团找附近的店(内置 Geo 数据类型)。

五、 ES 常见问题有哪些?(生产环境的坑)

1. 数据一致性问题(最常见)

  • 现象:MySQL 修改了数据,但 ES 搜索出来的还是旧数据。
  • 原因:同步延迟。无论是通过 Canal 监听 Binlog 还是通过 MQ 异步同步,都会有毫秒到秒级的延迟。
  • 对策:对于强一致性要求的业务(如库存),以 MySQL 为准;对于搜索容忍最终一致性。

2. 深分页问题

  • 现象:查询 from=9990, size=10 时,报错或极其缓慢,甚至把集群拖垮。
  • 原因:ES 的查询逻辑是集中式的。假设有 5 个分片,查第 10000 条数据,每个分片都要查出前 10010 条数据返回给协调节点,协调节点合并 50050 条数据后,丢弃前 10000 条,返回最后 10 条。内存和网络的消耗随页码呈指数级上升。(ES 默认 max_result_window 限制为 10000 条)。

    【报错复现】

    > GET /goods/_search
    > {
    >   "from": 10000,
    >   "size": 10,
    >   "query": { "match_all": {} }
    > }
    > // 返回报错:Result window is too large, from + size must be less than or equal to: [10000]
    

3. “删除”数据后磁盘空间不减少

  • 现象:删除了 ES 里几千万条数据,磁盘空间丝毫没变小。
  • 原因:Lucene 的 Segment 文件是不可变的。删除操作实际上只是在 Segment 里标记了一个“删除位”,并没有真正从物理磁盘抹掉数据。
  • 对策:需要手动触发 Force Merge(强制合并段)操作,或者等 ES 后台自动合并。
    # 强制将索引合并为 1 个段文件,物理删除带删除标记的数据
    POST /my_index/_forcemerge?max_num_segments=1
 

4. OOM(内存溢出)与集群只读

  • 现象:节点掉线,或集群状态变红,无法写入新数据。
  • 原因
    • 堆内存设置过大(超过 31GB,导致 JVM 压缩指针失效)。
    • 复杂的聚合查询吃光了内存。
    • 磁盘空间超过 95%(ES 会触发自我保护,将索引变为只读模式)。
    > PUT /_all/_settings
    > {
    >   "index.blocks.read_only_allow_delete": null
    > }

六、 ES 搜索性能如何优化?(实战指南)

1. 硬件与 OS 层面优化

  • 内存分配黄金法则:ES 的 JVM 堆内存最多分配 31GB(利用零基压缩指针,节省内存)。且堆内存不要超过物理内存的 50%,剩下的 50% 必须留给操作系统做 Lucene 的文件系统缓存,这是快的关键。
  • 磁盘:绝对不要用 NFS 等网络存储,必须用本地 SSD

2. 索引设计层面优化(建库时决定生死)

  • 拒绝动态映射:生产环境必须关闭 dynamic: true,手动定义 Mapping。避免 ES 自动推断字段类型导致性能浪费。
  • 精准控制字段属性
    • 不需要搜索、排序、聚合的字段,设置 "index": false
    • 对于需要精确匹配(如状态码、手机号、ID)的字段,类型设为 keyword绝不要用 texttext 会走分词器,浪费 CPU 且无法精确匹配)。

      【Mapping 设计黄金模板】

   > PUT /goods
   > {
   >   "mappings": {
   >     "dynamic": "false",  // 1. 拒绝动态映射
   >     "properties": {
   >       "title": { 
   >         "type": "text", 
   >         "analyzer": "ik_max_word", // 中文分词器
   >         "fields": {
   >           "keyword": { "type": "keyword", "ignore_above": 256 } // 支持精确匹配的混合字段
   >         }
   >       },
   >       "status": { 
   >         "type": "keyword"  // 2. 绝对精确匹配坚决用 keyword
   >       },
   >       "price": { 
   >         "type": "double"
   >       },
   >       "description": { 
   >         "type": "text", 
   >         "index": false    // 3. 仅展示不搜索的字段,关闭索引节省内存
   >       }
   >     }
   >   }
   > }
  • 路由优化:如果查询总是带着某个特定条件(如 tenant_id),设置自定义路由。查询时直接去对应分片查,避免扫全部分片。
  • 分片数量控制:分片不是越多越好。单个分片大小建议保持在 10GB - 50GB 之间。分片过多会导致集群元数据庞大、恢复极慢。

3. 查询语句层面优化(代码端优化)

  • 用 Filter 替代 Query
    • query(如 match)会计算相关性打分,耗费 CPU。
    • filter(如 termrange)只判断 Yes/No,不参与打分,且结果会被 ES 自动缓存。对于“状态=1 且 价格>100”这种绝对条件,必须放在 filter 里。

      【Query 与 Filter 结合的正确姿势】

   > GET /goods/_search
   > {
   >   "query": {
   >     "bool": {
   >       "must": [
   >         { "match": { "title": "手机" } }  // 参与打分,决定排序相关性
   >       ],
   >       "filter": [                         // 不打分,结果直接进缓存
   >         { "term": { "status": "1" } },    // 精确匹配
   >         { "range": { "price": { "gte": 1000, "lte": 5000 } } } // 范围匹配
   >       ]
   >     }
   >   }
   > }
  • 避免通配符开头的模糊查询*abc 会导致全词典扫描,性能极差。尽量使用 ES 的 ngram 分词器在建索引时处理好前缀匹配。
  • 解决深分页的三大法宝
    1. search_after(强烈推荐):类似 MySQL 的游标翻页。每次查询带上上一页最后一条数据的排序值,性能极高且无深度限制。

      【search_after 实战代码】

      > // 第一页查询
      > GET /goods/_search
      > {
      >   "size": 10,
      >   "query": { "match": { "title": "手机" } },
      >   "sort": [
      >     { "price": "asc" },     // 排序字段1
      >     { "_id": "asc" }        // 排序字段2(必须加唯一字段防并发相同值)
      >   ]
      > }
      > // 假设第一页返回的最后一条数据 price 是 2999, _id 是 "abc123"
      > // 第二页查询(将上条数据的 sort 值原封不动放入 search_after)
      > GET /goods/_search
      > {
      >   "size": 10,
      >   "query": { "match": { "title": "手机" } },
      >   "sort": [
      >     { "price": "asc" },
      >     { "_id": "asc" }
      >   ],
      >   "search_after": [2999.00, "abc123"] 
      > }
      
    2. scroll:适用于海量数据的全量导出/批处理(维护上下文快照),绝对不要用于前端翻页
    3. 业务折中:类似百度/谷歌,前端只允许翻到第 100 页,拒绝提供无限下拉翻页功能。
  • 避免返回大字段:使用 _source_includes 只返回列表需要的字段,拒绝 SELECT *,大幅减少网络传输开销。

    【拒绝 SELECT * 的写法】

    > GET /goods/_search
    > {
    >   "_source": ["id", "title", "price", "main_image"], // 仅返回这4个字段
    >   "query": { "match_all": {} }
    > }