Develop

Elasticsearch 全文搜索实战:从索引设计到查询优化的完整指南

✎ -- 字 🕐 -- 分钟
字号

Elasticsearch 全文搜索实战:从索引设计到查询优化的完整指南

为什么需要 Elasticsearch?

当你的应用从几百条数据增长到百万级,传统数据库的 LIKE 查询就开始力不从心——慢、不支持相关性排序、分词能力弱。Elasticsearch(简称 ES)基于 Apache Lucene 构建,专为全文搜索而生:毫秒级响应、天然分词、相关性评分、分布式扩展。从电商商品搜索到日志分析,从文档检索到地理位置查询,ES 已经是现代架构中不可或缺的搜索基础设施。

这篇文章会带你从原理到实战,覆盖倒排索引机制、索引设计、DSL 查询语法、聚合分析、性能优化和常见陷阱,帮你真正把 ES 用到生产级别。

一、核心原理:倒排索引与分词

1.1 倒排索引是什么

传统数据库用"正排索引"——从文档 ID 找内容。搜索引擎反过来,建立"倒排索引"——从词项(Term)找文档 ID。

假设有三篇文档:

Doc1: "Elasticsearch is a distributed search engine"
Doc2: "Lucene powers Elasticsearch full-text search"
Doc3: "Distributed systems handle large datasets"

分词后建立的倒排索引:

TermDoc IDs
elasticsearch[1, 2]
distributed[1, 3]
search[1, 2]
lucene[2]
systems[3]

当你搜索 "Elasticsearch search" 时,ES 在倒排索引中找到交集 {1, 2},再按 TF-IDF / BM25 评分排序,这就是全文搜索的核心机制。

1.2 分词器(Analyzer)的工作流程

一个 Analyzer 由三部分组成:

Character Filters → Tokenizer → Token Filters

ES 内置了多种 Analyzer:

Analyzer说明适用场景
standard默认,基于 Unicode 分词, lowercase + stop通用英文
simple按非字母字符分割,lowercase简单场景
whitespace仅按空格分割,不转小写精确匹配
keyword不分词,整字段作为单个 termID、标签等
ik_max_word中文最细粒度分词中文全文搜索
ik_smart中文粗粒度分词中文搜索(推荐)

中文搜索必须安装 IK 分词插件,否则默认 standard 会把每个汉字当单独 token:

# 安装 IK 分词插件
cd /usr/share/elasticsearch
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.12.0/elasticsearch-analysis-ik-8.12.0.zip

# 重启 ES
systemctl restart elasticsearch

二、索引设计:Mapping 与最佳实践

2.1 显式 Mapping vs 动态 Mapping

ES 默认会根据首次写入的数据自动推断字段类型(Dynamic Mapping),但这在生产环境中是危险的——一旦类型确定就无法修改(只能重建索引)。所以永远显式定义 Mapping

# 创建索引并定义 Mapping
PUT /products
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "analysis": {
      "analyzer": {
        "ik_smart_pinyin": {
          "type": "custom",
          "tokenizer": "ik_smart",
          "filter": ["pinyin_filter"]
        }
      },
      "filter": {
        "pinyin_filter": {
          "type": "pinyin",
          "keep_first_letter": true,
          "keep_full_pinyin": true
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_smart_pinyin",
        "search_analyzer": "ik_smart",
        "fields": {
          "keyword": { "type": "keyword" }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "price": {
        "type": "double"
      },
      "category": {
        "type": "keyword"
      },
      "tags": {
        "type": "keyword"
      },
      "created_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss"
      },
      "status": {
        "type": "integer"
      }
    }
  }
}

2.2 Mapping 设计要点

text vs keyword 的双字段策略:标题、名称类字段既需要全文搜索(text),也需要精确排序/聚合(keyword),使用 fields 子字段一举两得。

关闭不需要的功能

"log_message": {
  "type": "text",
  "index": true,
  "norms": false,          # 不需要评分时关闭,节省空间
  "index_options": "freqs" # 只存储词频,不存储位置信息
}

分片数规划:单个分片建议不超过 50GB,分片数 = 数据量 / 50GB 向上取整。过多分片浪费资源,过少则无法并行。副本数生产环境至少 1。

2.3 索引模板与滚动索引

日志类场景数据量大、持续写入,推荐用 Index Template + Rollover:

# 创建索引模板
PUT _index_template/logs_template
{
  "index_patterns": ["logs-*"],
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 1,
      "refresh_interval": "30s"
    },
    "mappings": {
      "properties": {
        "timestamp": { "type": "date" },
        "level": { "type": "keyword" },
        "message": { "type": "text", "analyzer": "standard" },
        "service": { "type": "keyword" }
      }
    }
  }
}

# 创建初始滚动索引
PUT logs-000001
{
  "aliases": {
    "logs-write": {}
  }
}

# Rollover:当文档超过 1000 万或索引超过 7 天时自动滚动
POST logs-write/_rollover
{
  "conditions": {
    "max_docs": 10000000,
    "max_age": "7d"
  }
}

三、DSL 查询语法详解

3.1 查询与过滤的区别

这是 ES 最重要的概念之一:

特征Query(查询)Filter(过滤)
是否评分✅ 计算相关性评分❌ 不评分,只有是/否
是否缓存❌ 不缓存✅ 结果可缓存
性能较慢
适用场景全文搜索、相关性排序精确匹配、范围、状态
# 典型组合:filter 过滤 + query 搜索
GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "category": "手机" } },
        { "range": { "price": { "gte": 1000, "lte": 5000 } } },
        { "term": { "status": 1 } }
      ],
      "must": [
        { "match": { "title": "华为旗舰" } }
      ],
      "should": [
        { "match": { "description": "5G" } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "price": "asc" }
  ],
  "from": 0,
  "size": 20
}

3.2 常用查询类型一览

# match:全文搜索(分词后匹配)
{ "match": { "title": "华为手机" } }
# 等价于 title 包含 "华为" OR "手机"

# match_phrase:短语搜索(必须相邻出现)
{ "match_phrase": { "title": "华为手机" } }
# 必须是 "华为手机" 连在一起

# multi_match:多字段搜索
{ "multi_match": { "query": "旗舰", "fields": ["title^3", "description"] } }
# title 权重 3 倍,description 权重 1 倍

# term:精确匹配(不分词)
{ "term": { "category": "手机" } }
# 注意:term 对 text 字段可能匹配不到(因为 text 已分词)

# terms:多值精确匹配
{ "terms": { "tags": ["5G", "旗舰", "国产"] } }

# wildcard:通配符匹配(慎用,性能差)
{ "wildcard": { "title": "华*" } }

# range:范围查询
{ "range": { "price": { "gte": 1000, "lte": 5000 } } }

3.3 高亮显示

搜索结果需要展示匹配片段时,加上 highlight:

GET /products/_search
{
  "query": { "match": { "title": "华为旗舰" } },
  "highlight": {
    "pre_tags": ["<em class='hl'>"],
    "post_tags": ["</em>"],
    "fields": {
      "title": { "fragment_size": 100, "number_of_fragments": 3 },
      "description": {}
    }
  }
}

四、聚合分析:从统计到洞察

4.1 基础聚合

# 按分类统计商品数量和平均价格
GET /products/_search
{
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": { "field": "category", "size": 20 },
      "aggs": {
        "avg_price": { "avg": { "field": "price" } },
        "max_price": { "max": { "field": "price" } }
      }
    }
  }
}

4.2 价格区间分布

GET /products/_search
{
  "size": 0,
  "aggs": {
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "key": "低价", "to": 1000 },
          { "key": "中价", "from": 1000, "to": 3000 },
          { "key": "高价", "from": 3000, "to": 8000 },
          { "key": "旗舰", "from": 8000 }
        ]
      }
    }
  }
}

4.3 日期直方图 + 滚动趋势

# 按天统计订单量
GET /orders/_search
{
  "size": 0,
  "aggs": {
    "daily_orders": {
      "date_histogram": {
        "field": "created_at",
        "calendar_interval": "day",
        "format": "yyyy-MM-dd",
        "min_doc_count": 0
      }
    }
  }
}

五、实战场景:电商搜索服务搭建

5.1 Python 批量写入商品数据

from elasticsearch import Elasticsearch, helpers

es = Elasticsearch(["http://localhost:9200"])

# 批量写入(Bulk API,比逐条写入快 10-50 倍)
products = [
    {"title": "华为 Mate 60 Pro 5G旗舰手机", "price": 6999, "category": "手机", "tags": ["5G", "旗舰", "国产"], "status": 1},
    {"title": "Apple iPhone 15 Pro Max", "price": 9999, "category": "手机", "tags": ["旗舰", "iOS"], "status": 1},
    {"title": "小米14 Ultra 影像旗舰", "price": 5999, "category": "手机", "tags": ["5G", "影像", "国产"], "status": 1},
    {"title": "华为 MatePad Pro 平板电脑", "price": 3299, "category": "平板", "tags": ["国产", "办公"], "status": 1},
    {"title": "MacBook Pro 14 M3芯片", "price": 14999, "category": "笔记本", "tags": ["旗舰", "Apple"], "status": 1},
]

actions = [
    {
        "_index": "products",
        "_source": p,
    }
    for p in products
]

# 执行批量写入
result = helpers.bulk(es, actions, refresh=True)
print(f"写入 {result[0]} 条文档成功")

5.2 Node.js 搜索 API 服务

const { Client } = require('@elastic/elasticsearch');

const client = new Client({ node: 'http://localhost:9200' });

async function searchProducts(keyword, filters = {}, page = 1, size = 20) {
  const must = [];
  const filterClauses = [];

  // 全文搜索
  if (keyword) {
    must.push({
      multi_match: {
        query: keyword,
        fields: ['title^3', 'description^1'],
        type: 'best_fields',
        fuzziness: 'AUTO'  // 允许拼写容错
      }
    });
  }

  // 分类过滤
  if (filters.category) {
    filterClauses.push({ term: { category: filters.category } });
  }

  // 价格范围
  if (filters.priceMin || filters.priceMax) {
    const range = {};
    if (filters.priceMin) range.gte = filters.priceMin;
    if (filters.priceMax) range.lte = filters.priceMax;
    filterClauses.push({ range: { price: range } });
  }

  // 状态过滤
  filterClauses.push({ term: { status: 1 } });

  const body = {
    query: {
      bool: {
        must: must.length ? must : [{ match_all: {} }],
        filter: filterClauses
      }
    },
    sort: [{ _score: 'desc' }, { price: 'asc' }],
    from: (page - 1) * size,
    size: size,
    highlight: {
      fields: {
        title: { fragment_size: 80 },
        description: {}
      }
    }
  };

  const result = await client.search({ index: 'products', body });
  return {
    total: result.hits.total.value,
    items: result.hits.hits.map(h => ({
      ...h._source,
      score: h._score,
      highlight: h.highlight
    }))
  };
}

// 使用示例
searchProducts('华为旗舰', { category: '手机', priceMax: 8000 }, 1, 10)
  .then(r => console.log(JSON.stringify(r, null, 2)));

六、性能优化:让搜索飞起来

6.1 写入优化

# 1. 调整 refresh_interval(默认 1s,批量写入时调大)
PUT /products/_settings
{
  "index": { "refresh_interval": "30s" }
}
# 写入完成后恢复
PUT /products/_settings
{
  "index": { "refresh_interval": "1s" }
}

# 2. 批量写入用 Bulk API
POST /_bulk
{"index":{"_index":"products"}}
{"title":"商品A","price":100}
{"index":{"_index":"products"}}
{"title":"商品B","price":200}

# 3. 写入时禁用副本(仅大批量初始导入)
PUT /products/_settings
{
  "index": { "number_of_replicas": 0 }
}
# 导入完成后恢复副本
PUT /products/_settings
{
  "index": { "number_of_replicas": 1 }
}

6.2 查询优化

原则:filter 先行,must 后置——filter 不评分且可缓存,尽量把条件放到 filter 中。

避免深度分页:ES 默认限制 from + size ≤ 10000。深度分页用 search_after

# 第一页
GET /products/_search
{
  "size": 20,
  "query": { "match_all": {} },
  "sort": [{ "price": "asc" }, { "_id": "asc" }]
}

# 第二页:用上一页最后一条的排序值
GET /products/_search
{
  "size": 20,
  "query": { "match_all": {} },
  "sort": [{ "price": "asc" }, { "_id": "asc" }],
  "search_after": [5999, "product_123"]
}

预过滤器 Query Cache:对频繁使用的 filter 条件,ES 会自动缓存 bitset。确保 filter 中的 term/range 条件稳定,避免频繁变化。

6.3 索引冷热分离

# 节点配置冷热属性
# elasticsearch.yml (热节点)
node.attr.data_tier: hot

# elasticsearch.yml (冷节点)
node.attr.data_tier: cold

# 索引分配到热节点
PUT /logs-2026-07
{
  "settings": {
    "index.routing.allocation.require.data_tier": "hot"
  }
}

# 7天后迁移到冷节点(ILM 策略)
PUT _ilm/policy/logs_policy
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": { "rollover": { "max_age": "7d", "max_size": "50gb" } }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "forcemerge": { "max_num_segments": 1 },
          "shrink": { "number_of_shards": 1 }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "allocate": { "require": { "data_tier": "cold" } }
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": { "delete": {} }
      }
    }
  }
}

七、常见陷阱与排错

7.1 term 查询 text 字段匹配不到

这是最常见的坑:term 不会分词,但 text 字段在索引时已分词为小写 token。搜索 "华为" 在倒排索引中实际存储的可能是 "华""为"

# ❌ 错误:term 对 text 字段
{ "term": { "title": "华为旗舰" } }  # 匹配不到!

# ✅ 正确:用 match
{ "match": { "title": "华为旗舰" } }

# ✅ 或者用 keyword 子字段做精确匹配
{ "term": { "title.keyword": "华为 Mate 60 Pro 5G旗舰手机" } }

7.2 Mapping 类型无法修改

已经创建的 Mapping 字段类型不能修改(比如 text 改 keyword),只能用 Reindex:

# 创建新索引(新 Mapping)
PUT /products_v2
{
  "mappings": {
    "properties": {
      "title": { "type": "keyword" },  # 改为 keyword
      "price": { "type": "double" }
    }
  }
}

# 从旧索引迁移数据
POST /_reindex
{
  "source": { "index": "products" },
  "dest": { "index": "products_v2" }
}

# 切换别名
POST /_aliases
{
  "actions": [
    { "remove": { "index": "products", "alias": "products_search" } },
    { "add": { "index": "products_v2", "alias": "products_search" } }
  ]
}

7.3 集群状态排查

# 查看集群健康状态
GET /_cluster/health

# 查看节点资源使用
GET /_cat/nodes?v&h=name,heapPercent,cpu,load,diskPercent

# 查看索引大小和分片状态
GET /_cat/indices?v&s=store.size:desc

# 查看未分配分片原因
GET /_cluster/allocation/explain

# 查看慢查询日志配置
GET /products/_settings?filter_path=*.index.search.slowlog

慢查询日志配置

PUT /products/_settings
{
  "index.search.slowlog.threshold.query.warn": "5s",
  "index.search.slowlog.threshold.query.info": "2s",
  "index.search.slowlog.threshold.fetch.warn": "1s",
  "index.indexing.slowlog.threshold.indexing.warn": "10s"
}

八、ES 与其他搜索方案对比

维度ElasticsearchMeilisearchSolrMySQL FULLTEXT
部署复杂度中(需 JVM + 集群)低(单二进制)中(需 Tomcat)零(内置)
中文分词IK 插件成熟内置较好需配置ngram 简陋
分布式原生支持有限支持不支持
实时性近实时(1s)实时近实时实时
聚合分析非常强大基础强大不支持
适用场景大规模搜索+分析中小项目快速搜索传统企业搜索简单搜索

如果你的项目数据量在百万以内且不需要复杂聚合,Meilisearch 是更轻量的选择。一旦数据量超过千万或需要强大的聚合分析能力,Elasticsearch 就无可替代。

九、生产部署 Checklist

上线前逐项确认:

  1. JVM 堆内存:设为物理内存的 50%,不超过 32GB(越过 32G 压缩指针失效)
  2. 集群至少 3 节点:1 master + 2 data,保证高可用
  3. 磁盘类型:SSD,搜索场景对 IO 敏感
  4. refresh_interval:非实时场景调到 5-30s
  5. Mapping 预定义:禁用 dynamic mapping
  6. 分片规划:单分片 ≤ 50GB,总分片数 ≤ 3 × 数据节点数
  7. ILM 策略:日志类索引必须配置生命周期管理
  8. 慢查询日志:开启并设置告警阈值
  9. 安全:开启 X-Pack Security,配置 TLS 和认证
  10. 备份:配置 Snapshot Repository,定期备份
# 创建备份仓库
PUT /_snapshot/my_backup
{
  "type": "fs",
  "settings": {
    "location": "/mnt/es_backup"
  }
}

# 手动备份
PUT /_snapshot/my_backup/snapshot_20260707?wait_for_completion=true
{
  "indices": "products,logs-*",
  "ignore_unavailable": true
}

# 定时备份(添加到 crontab)
# 0 2 * * * curl -X PUT "localhost:9200/_snapshot/my_backup/snapshot_$(date +\%Y\%m\%d)"

总结

Elasticsearch 的核心优势在于倒排索引 + 分词 + BM25 评分 + 分布式的组合。从索引设计开始就要显式定义 Mapping、选好 Analyzer;查询时区分 filter 和 query、善用 bool 组合;大规模场景下做好分片规划、ILM 策略和冷热分离。避开 term 查 text 字段、深度分页、Mapping 不可修改这几个经典陷阱,你就能在生产环境中稳定运行一个高性能搜索服务。

记住一句话:filter 先行省算力,分词对齐才命中,分片规划定乾坤。用好这三条,ES 就不再只是"能搜",而是"搜得快、搜得准、搜得稳"。