Elasticsearch 支持检索能力完全指南

Elasticsearch 支持检索能力完全指南

一、Elasticsearch 是什么

Elasticsearch(简称 ES)是一个基于 Apache Lucene 构建的分布式全文搜索和分析引擎。它的核心能力:

  • 全文检索:对文本内容进行分词、倒排索引,支持模糊搜索、相关性排序
  • 结构化查询:精确匹配、范围查询、聚合统计
  • 近实时(NRT):数据写入后约 1 秒即可被搜索到
  • 分布式:自动分片和副本,水平扩展

二、核心概念

概念 类比 MySQL 说明
Index(索引) Database 一类文档的集合
Document(文档) Row 一条数据,JSON 格式
Field(字段) Column 文档中的一个属性
Mapping(映射) Schema 定义字段类型和分析规则
Shard(分片) Partition 数据水平拆分单元
Replica(副本) Slave 分片的冗余备份

倒排索引原理

复制代码
文档1: "Java Spring Boot 微服务开发"
文档2: "Spring Cloud 微服务架构"
文档3: "Java 并发编程实战"

倒排索引:
  "Java"    → [文档1, 文档3]
  "Spring"  → [文档1, 文档2]
  "微服务"  → [文档1, 文档2]
  "Boot"    → [文档1]
  "并发"    → [文档3]

搜索"Java 微服务"时,取交集或按相关性排序返回结果。


注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

三、Spring Boot 集成 Elasticsearch

3.1 依赖引入

Spring Boot 3.x + Elasticsearch 8.x:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

Spring Boot 2.x + Elasticsearch 7.x:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- 如需使用 RestHighLevelClient -->
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    <version>7.17.10</version>
</dependency>

3.2 配置

yaml 复制代码
# application.yml
spring:
  elasticsearch:
    uris: http://localhost:9200
    username: elastic
    password: your-password
    connection-timeout: 5s
    socket-timeout: 30s

3.3 配置类(Spring Boot 3.x + Elasticsearch Java Client)

java 复制代码
@Configuration
public class ElasticsearchConfig extends ElasticsearchConfiguration {

    @Override
    public ClientConfiguration clientConfiguration() {
        return ClientConfiguration.builder()
                .connectedTo("localhost:9200")
                .withBasicAuth("elastic", "your-password")
                .withConnectTimeout(Duration.ofSeconds(5))
                .withSocketTimeout(Duration.ofSeconds(30))
                .build();
    }
}

四、实体映射(Document Mapping)

java 复制代码
/**
 * 商品搜索文档.
 *
 */
@Data
@Document(indexName = "product")
@Setting(shards = 3, replicas = 1)
public class ProductDocument {

    @Id
    private String id;

    /**
     * 商品名称 - 使用 ik_max_word 分词器支持中文全文搜索
     */
    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String productName;

    /**
     * 商品描述
     */
    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String description;

    /**
     * 品牌 - keyword 类型,精确匹配
     */
    @Field(type = FieldType.Keyword)
    private String brand;

    /**
     * 分类ID - keyword 精确匹配 + 聚合
     */
    @Field(type = FieldType.Keyword)
    private String categoryId;

    /**
     * 价格 - 数值类型,支持范围查询
     */
    @Field(type = FieldType.Double)
    private Double price;

    /**
     * 销量 - 用于排序
     */
    @Field(type = FieldType.Integer)
    private Integer salesCount;

    /**
     * 上架状态
     */
    @Field(type = FieldType.Boolean)
    private Boolean onSale;

    /**
     * 标签 - 支持多值精确匹配
     */
    @Field(type = FieldType.Keyword)
    private List<String> tags;

    /**
     * 创建时间
     */
    @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
    private LocalDateTime updateTime;
}

字段类型选择指南

字段类型 适用场景 是否分词 是否支持聚合
Text 全文搜索(名称、描述) ❌(需配置 fielddata)
Keyword 精确匹配、过滤、排序、聚合
Integer/Long/Double 数值范围查询、排序
Date 时间范围查询
Boolean 布尔过滤
Nested 嵌套对象精确匹配 取决于子字段

五、数据访问层(Repository)

5.1 基础 Repository

java 复制代码
/**
 * 商品搜索 Repository.
 */
public interface ProductSearchRepository extends ElasticsearchRepository<ProductDocument, String> {

    /**
     * 按品牌查询
     */
    List<ProductDocument> findByBrand(String brand);

    /**
     * 按分类和上架状态查询
     */
    List<ProductDocument> findByCategoryIdAndOnSale(String categoryId, Boolean onSale);

    /**
     * 按价格范围查询
     */
    List<ProductDocument> findByPriceBetween(Double minPrice, Double maxPrice);

    /**
     * 按商品名称模糊搜索
     */
    List<ProductDocument> findByProductNameContaining(String keyword);

    /**
     * 按销量降序排列
     */
    List<ProductDocument> findByOnSaleTrueOrderBySalesCountDesc();
}

5.2 自定义复杂查询(Service 层)

java 复制代码
/**
 * 商品搜索 Service.
 */
public interface ProductSearchService {

    /**
     * 综合搜索(关键词 + 过滤 + 排序 + 分页)
     */
    SearchResultDto<ProductDocument> search(ProductSearchParamDto param);

    /**
     * 搜索建议(自动补全)
     */
    List<String> suggest(String prefix);

    /**
     * 聚合统计(按品牌/分类统计数量)
     */
    Map<String, Long> aggregateByField(String fieldName);
}
/**
 * 商品搜索 Service 实现.
 *
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductSearchServiceImpl implements ProductSearchService {

    private final ElasticsearchOperations elasticsearchOperations;

    @Override
    public SearchResultDto<ProductDocument> search(ProductSearchParamDto param) {
        // 1. 构建布尔查询
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // 2. 关键词搜索(多字段匹配)
        if (StringUtils.isNotBlank(param.getKeyword())) {
            boolQuery.must(QueryBuilders.multiMatchQuery(param.getKeyword(),
                    "productName", "description")
                    .type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
                    .fuzziness(Fuzziness.AUTO)
                    .minimumShouldMatch("75%"));
        }

        // 3. 过滤条件(不参与打分,可缓存)
        if (StringUtils.isNotBlank(param.getBrand())) {
            boolQuery.filter(QueryBuilders.termQuery("brand", param.getBrand()));
        }
        if (StringUtils.isNotBlank(param.getCategoryId())) {
            boolQuery.filter(QueryBuilders.termQuery("categoryId", param.getCategoryId()));
        }
        if (param.getMinPrice() != null || param.getMaxPrice() != null) {
            RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price");
            if (param.getMinPrice() != null) {
                rangeQuery.gte(param.getMinPrice());
            }
            if (param.getMaxPrice() != null) {
                rangeQuery.lte(param.getMaxPrice());
            }
            boolQuery.filter(rangeQuery);
        }
        // 只查上架商品
        boolQuery.filter(QueryBuilders.termQuery("onSale", true));

        // 4. 构建搜索请求
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder()
                .withQuery(boolQuery)
                .withPageable(PageRequest.of(param.getPageNo() - 1, param.getPageSize()));

        // 5. 排序
        if ("price_asc".equals(param.getSortBy())) {
            queryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.ASC));
        } else if ("price_desc".equals(param.getSortBy())) {
            queryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.DESC));
        } else if ("sales".equals(param.getSortBy())) {
            queryBuilder.withSort(SortBuilders.fieldSort("salesCount").order(SortOrder.DESC));
        } else {
            // 默认按相关性排序
            queryBuilder.withSort(SortBuilders.scoreSort().order(SortOrder.DESC));
        }

        // 6. 高亮
        queryBuilder.withHighlightBuilder(new HighlightBuilder()
                .field("productName")
                .field("description")
                .preTags("<em>")
                .postTags("</em>"));

        // 7. 执行搜索
        NativeSearchQuery searchQuery = queryBuilder.build();
        SearchHits<ProductDocument> searchHits = elasticsearchOperations.search(
                searchQuery, ProductDocument.class);

        // 8. 组装结果
        List<ProductDocument> results = searchHits.getSearchHits().stream()
                .map(hit -> {
                    ProductDocument doc = hit.getContent();
                    // 处理高亮
                    Map<String, List<String>> highlightFields = hit.getHighlightFields();
                    if (highlightFields.containsKey("productName")) {
                        doc.setProductName(highlightFields.get("productName").get(0));
                    }
                    return doc;
                })
                .collect(Collectors.toList());

        SearchResultDto<ProductDocument> resultDto = new SearchResultDto<>();
        resultDto.setList(results);
        resultDto.setTotal(searchHits.getTotalHits());
        resultDto.setPageNo(param.getPageNo());
        resultDto.setPageSize(param.getPageSize());
        return resultDto;
    }

    @Override
    public List<String> suggest(String prefix) {
        // 前缀匹配建议
        NativeSearchQuery query = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.matchPhrasePrefixQuery("productName", prefix)
                        .maxExpansions(10))
                .withPageable(PageRequest.of(0, 10))
                .build();

        SearchHits<ProductDocument> hits = elasticsearchOperations.search(
                query, ProductDocument.class);

        return hits.getSearchHits().stream()
                .map(hit -> hit.getContent().getProductName())
                .collect(Collectors.toList());
    }

    @Override
    public Map<String, Long> aggregateByField(String fieldName) {
        NativeSearchQuery query = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.matchAllQuery())
                .withAggregations(AggregationBuilders.terms("agg_" + fieldName)
                        .field(fieldName)
                        .size(50))
                .withMaxResults(0) // 不需要文档,只要聚合结果
                .build();

        SearchHits<ProductDocument> hits = elasticsearchOperations.search(
                query, ProductDocument.class);

        // 解析聚合结果
        Map<String, Long> result = new LinkedHashMap<>();
        // 根据实际返回的聚合类型解析
        // ...
        return result;
    }
}

六、搜索请求/响应 DTO

java 复制代码
/**
 * 商品搜索请求参数.
 */
@Data
public class ProductSearchParamDto {

    /** 搜索关键词 */
    private String keyword;

    /** 品牌过滤 */
    private String brand;

    /** 分类过滤 */
    private String categoryId;

    /** 最低价格 */
    private Double minPrice;

    /** 最高价格 */
    private Double maxPrice;

    /** 标签过滤 */
    private List<String> tags;

    /** 排序方式:price_asc / price_desc / sales / relevance */
    private String sortBy;

    /** 页码(从1开始) */
    @Min(1)
    private Integer pageNo = 1;

    /** 每页数量 */
    @Min(1)
    @Max(100)
    private Integer pageSize = 20;
}
/**
 * 搜索结果通用包装.
 */
@Data
public class SearchResultDto<T> {

    /** 结果列表 */
    private List<T> list;

    /** 总命中数 */
    private Long total;

    /** 当前页码 */
    private Integer pageNo;

    /** 每页数量 */
    private Integer pageSize;

    /** 聚合结果(可选) */
    private Map<String, Object> aggregations;
}

七、数据同步策略

ES 不是主数据库,需要将 MySQL 中的数据同步到 ES。常见方案:

方案对比

方案 实时性 一致性 侵入性 适用场景
双写(代码同步) 弱(可能不一致) 简单业务
MQ 异步同步 秒级 最终一致 推荐方案
Binlog 监听 秒级 最终一致 大规模数据
定时全量刷新 分钟级 对实时性要求不高

MQ 异步同步示例(推荐)

java 复制代码
/**
 * 商品变更事件监听器 - 同步数据到 ES.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductSyncConsumer {

    private final ProductSearchRepository productSearchRepository;
    private final ProductMapper productMapper;

    @KafkaListener(topics = "product-change-topic", groupId = "es-sync-group")
    public void onProductChange(String message) {
        ProductChangeEvent event = JSON.parseObject(message, ProductChangeEvent.class);
        log.info("收到商品变更事件, productId: {}, action: {}", event.getProductId(), event.getAction());

        try {
            switch (event.getAction()) {
                case "CREATE":
                case "UPDATE":
                    syncToEs(event.getProductId());
                    break;
                case "DELETE":
                    productSearchRepository.deleteById(event.getProductId());
                    break;
                default:
                    log.warn("未知的变更动作: {}", event.getAction());
            }
        } catch (Exception e) {
            log.error("同步商品到ES失败, productId: {}", event.getProductId(), e);
            // 可发送到死信队列,后续补偿
            throw e;
        }
    }

    private void syncToEs(String productId) {
        // 从 MySQL 查询最新数据
        ProductEntity entity = productMapper.selectById(productId);
        if (entity == null) {
            productSearchRepository.deleteById(productId);
            return;
        }
        // 转换为 ES 文档并写入
        ProductDocument document = convertToDocument(entity);
        productSearchRepository.save(document);
        log.info("商品同步到ES完成, productId: {}", productId);
    }

    private ProductDocument convertToDocument(ProductEntity entity) {
        ProductDocument doc = new ProductDocument();
        doc.setId(entity.getId());
        doc.setProductName(entity.getName());
        doc.setDescription(entity.getDescription());
        doc.setBrand(entity.getBrand());
        doc.setCategoryId(entity.getCategoryId());
        doc.setPrice(entity.getPrice().doubleValue());
        doc.setSalesCount(entity.getSalesCount());
        doc.setOnSale(entity.getStatus() == 1);
        doc.setCreateTime(entity.getCreateTime());
        doc.setUpdateTime(entity.getUpdateTime());
        return doc;
    }
}

八、索引管理

8.1 索引创建(JSON Mapping)

json 复制代码
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "analysis": {
      "analyzer": {
        "ik_smart_pinyin": {
          "type": "custom",
          "tokenizer": "ik_smart",
          "filter": ["lowercase", "pinyin_filter"]
        }
      },
      "filter": {
        "pinyin_filter": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "productName": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "fields": {
          "pinyin": {
            "type": "text",
            "analyzer": "ik_smart_pinyin"
          },
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "brand": { "type": "keyword" },
      "categoryId": { "type": "keyword" },
      "price": { "type": "double" },
      "salesCount": { "type": "integer" },
      "onSale": { "type": "boolean" },
      "tags": { "type": "keyword" },
      "createTime": { "type": "date", "format": "yyyy-MM-dd'T'HH:mm:ss" }
    }
  }
}

8.2 索引别名(零停机重建)

bash 复制代码
# 创建新索引
PUT /product_v2

# 数据迁移
POST /_reindex
{
  "source": { "index": "product_v1" },
  "dest": { "index": "product_v2" }
}

# 切换别名(原子操作)
POST /_aliases
{
  "actions": [
    { "remove": { "index": "product_v1", "alias": "product" } },
    { "add": { "index": "product_v2", "alias": "product" } }
  ]
}

代码中始终使用别名 product 而非具体版本号,重建索引时业务无感知。


九、中文分词

IK 分词器

ES 中文搜索必备插件,提供两种分词模式:

模式 说明 "中华人民共和国" 的分词结果
ik_max_word 最细粒度分词(索引时用) 中华人民共和国、中华人民、中华、华人、人民共和国、人民、共和国、共和、国
ik_smart 最粗粒度分词(搜索时用) 中华人民共和国

安装

bash 复制代码
# 版本必须与 ES 版本一致
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.11.0/elasticsearch-analysis-ik-8.11.0.zip

自定义词典

复制代码
# config/analysis-ik/custom.dic
春风得意
人工智能
大数据
微服务

十、性能优化

写入优化

策略 说明
批量写入 使用 _bulk API,单次 5-15MB
调整刷新间隔 refresh_interval: 30s(默认1s)
关闭副本后批量导入 大量导入时临时设置 replicas: 0
合理设置分片数 单个分片 10-50GB,过多分片影响性能

查询优化

策略 说明
使用 filter 而非 query filter 不计算得分,可被缓存
避免深度分页 search_after 替代 from + size > 10000
限制返回字段 使用 _source: ["field1", "field2"]
预热缓存 高频查询使用 preference: _local
合理使用 keyword 不需要分词的字段用 keyword 类型

深度分页示例(search_after)

java 复制代码
/**
 * 基于 search_after 的深度分页.
 */
public List<ProductDocument> scrollSearch(Object[] searchAfterValues, int size) {
    NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder()
            .withQuery(QueryBuilders.matchAllQuery())
            .withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
            .withSort(SortBuilders.fieldSort("id").order(SortOrder.ASC))
            .withPageable(PageRequest.of(0, size));

    if (searchAfterValues != null) {
        queryBuilder.withSearchAfter(List.of(searchAfterValues));
    }

    SearchHits<ProductDocument> hits = elasticsearchOperations.search(
            queryBuilder.build(), ProductDocument.class);

    return hits.getSearchHits().stream()
            .map(SearchHit::getContent)
            .collect(Collectors.toList());
}

十一、监控与运维

常用运维命令

bash 复制代码
# 集群健康状态
GET /_cluster/health

# 索引状态
GET /_cat/indices?v&s=store.size:desc

# 查看分片分配
GET /_cat/shards/product?v

# 查看慢查询日志
PUT /product/_settings
{
  "index.search.slowlog.threshold.query.warn": "5s",
  "index.search.slowlog.threshold.query.info": "2s"
}

# 强制合并段(低峰期执行)
POST /product/_forcemerge?max_num_segments=1

Spring Boot Actuator 集成

yaml 复制代码
management:
  health:
    elasticsearch:
      enabled: true
  endpoint:
    health:
      show-details: always

十二、常见问题与解决

问题 原因 解决方案
中文搜索无结果 未安装 IK 分词器或分析器配置错误 安装 IK 插件,确认 mapping 中使用 ik_max_word
搜索结果不准确 minimum_should_match 过低 调整为 75% 以上
写入后搜不到 refresh_interval 过长 调整为 1s 或手动 refresh
深度分页超时 from + size > 10000 使用 search_after 或 scroll API
聚合结果不全 terms 聚合默认只返回 10 个 设置 size: 50 或更大
内存溢出 fielddata 加载了 text 字段 text 字段不做聚合,使用 keyword 子字段
集群 Yellow/Red 副本无法分配 检查节点数是否 >= 副本数 + 1

十三、项目中的最佳实践总结

实践 建议
索引命名 使用别名 + 版本号:product_v1 → 别名 product
分词策略 索引用 ik_max_word,搜索用 ik_smart
字段设计 需要搜索的用 Text,需要过滤/聚合的用 Keyword,两者都需要的用 multi-field
数据同步 优先 MQ 异步同步,保证最终一致性
分页 浅分页用 from/size,深度翻页用 search_after
容错 ES 查询失败时降级到 MySQL 兜底查询
监控 接入 Actuator 健康检查 + 慢查询日志
索引重建 使用别名切换实现零停机