Yujun's Blog

ElasticSearch入门(一):商品搜索系统

May 31, 2025 (1mo ago)SD

ElasticSearch入门(一):商品搜索系统 🚀

ElasticSearch (简称ES)是一个非常重要的搜索引擎,对于Java后端开发来说是必须掌握的技能。代码是最好的老师,接下来我们基于一个简单的场景来进行初步的学习和认识。

想像一下,现在我们有一个在线商店,商品信息存储在MySQL中。我们的目标是,让用户能够通过商品名称、描述、价格范围、分类等条件快速搜索到商品。🛍️

🛠️使用到的技术栈如下:

  • Spring Boot:快速搭建Java后端应用。
  • MyBatis:操作MySQL数据库。
  • MySQL:存储商品原始数据。
  • Elasticsearch:为搜索而生,提供强大的全文搜索和分析能力。🔍
  • Kibana:可视化Elasticsearch中的数据,并进行查询调试。📊

📁项目结构概览

elasticsearch-demo/
        ├── pom.xml
        ├── src/
        │   ├── main/
        │   │   ├── java/
        │   │   │   └── com/example/elasticsearchdemo/
        │   │   │       ├── ElasticsearchDemoApplication.java
        │   │   │       ├── config/
        │   │   │       │   └── ElasticsearchConfig.java  // ES客户端配置
        │   │   │       ├── controller/
        │   │   │       │   └── ProductController.java    // API接口
        │   │   │       ├── entity/
        │   │   │       │   └── Product.java              // 商品实体 (同时用于MySQL和ES)
        │   │   │       ├── mapper/
        │   │   │       │   └── ProductMapper.java        // MyBatis Mapper接口
        │   │   │       ├── repository/
        │   │   │       │   └── ProductEsRepository.java  // Spring Data ES Repository
        │   │   │       └── service/
        │   │   │           └── ProductService.java       // 业务逻辑
        │   │   └── resources/
        │   │       ├── application.yml           // Spring Boot配置
        │   │       ├── mybatis/
        │   │       ├─────mapper/
        └── ProductMapper.xml     // MyBatis SQL映射
        │   │       ├─────config/
        │   │       │   └── mybatis-config.xml     // MyBatis 配置文件
        │   │       └── static/
        │   │       └── templates/
        │   └── test/
        └── Docker-compose.yml                  // 用于快速启动MySQL, ES, Kibana

⚙️核心依赖及配置文件

pom.xml (部分核心依赖):

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.1</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
<java.version>8</java.version>
<!-- 统一管理 Elasticsearch 版本 -->
<elasticsearch.version>7.17.12</elasticsearch.version>
</properties>

<dependencies>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.1.4</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
    <version>8.0.30</version> <!-- 与你的MySQL版本匹配 -->
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
</dependencies>
        </project>

温馨提示​💡:spring-boot-starter-data-elasticsearch 会引入 elasticsearch-rest-high-level-client。如果你使用的 ES 版本是 8.x+,官方推荐使用新的 elasticsearch-java 客户端。为了简单起见,我们这里使用与 Spring Boot 2.7.x 兼容性较好的 RestHighLevelClient。如果你要用新的客户端,依赖和配置方式会有所不同。

application.yml

server:
  port: 8080

spring:
  application:
    name: elasticsearch-demo
  datasource:
    url: jdbc:mysql://localhost:3306/es_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: your_mysql_password
    driver-class-name: com.mysql.cj.jdbc.Driver
  elasticsearch:
    # rest: # Spring Boot 2.x 使用 rest.uris
    uris: http://localhost:9200 # ES的地址
    # username: # 如果你的ES有密码保护
    # password:
  # 对于 Spring Boot 3.x 和新的 Java Client,配置会是这样:
  # elasticsearch:
  #   client:
  #     uris: http://localhost:9200

mybatis:
  mapper-locations: classpath:/mybatis/mapper/*.xml
  config-location: classpath:/mybatis/config/mybatis-config.xml

logging:
  level:
    org.springframework.data.elasticsearch.client.WIRE: TRACE # 打印ES请求日志,方便调试
    com.example.elasticsearchdemo: DEBUG

库表数据准备

库表数据很简单,我们只需要一个库和一个商品表 product

CREATE TABLE `product` (
                           `id` BIGINT NOT NULL AUTO_INCREMENT,
                           `name` VARCHAR(255) NOT NULL,
                           `description` TEXT,
                           `price` DECIMAL(10, 2) NOT NULL,
                           `category` VARCHAR(100),
                           `tags` VARCHAR(255) COMMENT '逗号分隔的标签',
                           `stock` INT DEFAULT 0,
                           `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                           `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                           PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

API介绍

本节提供的API主要有两部分,一部分负责直接操作我们后端MySQL数据库,这部分对于Java后端的老兵们来说,应该是我们的舒适区,所以我们会快速过一遍,让大家有个印象即可。具体细节可看仓库代码。

POST /api/products/db: 新增商品到MySQL

GET /api/products/db/{id}: 根据ID从MySQL查询商品

GET /api/products/db: 获取MySQL中所有商品

PUT /api/products/db/{id}: 根据ID更新MySQL中的商品

DELETE /api/products/db/{id}: 根据ID从MySQL删除商品

另一部分负责直接与Elasticsearch进行交互。这是我们本篇博客重点要学习的地方。

ES高级商品搜索🕵️‍♂️📊

在我们深入了解这个强大的搜索接口之前,让我们可以思考一个问题:为什么不用MySQL中的 SELECT 语句来搜索呢?ES中的搜索比它更好更快吗?以便为后续学习原理做准备。

public List<Product> searchProductsAdvanced(String keyword, String category, BigDecimal minPrice, BigDecimal maxPrice, Integer page, Integer size) {
        // 任务开始
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
        // 用来装载各种搜索条件
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // --- 步骤1: 处理用户输入的关键词
        if (keyword != null && !keyword.trim().isEmpty()) {
            logger.debug("用户输入的关键词是: '{}'。准备在商品名称和描述中大海捞针!🌊", keyword);
            boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "name", "description"));
        }
        if (category != null && !category.trim().isEmpty()) {
            logger.debug("用户指定了分类: '{}'。准备进行精确打击!🎯", category);
            /
            boolQuery.filter(QueryBuilders.termQuery("category", category)); 
        }
        BoolQueryBuilder priceBoolQuery = QueryBuilders.boolQuery();
        boolean hasPriceCondition = false; 
        if (minPrice != null) {
            logger.debug("用户设置了最低价格: {}", minPrice);
            priceBoolQuery.must(QueryBuilders.rangeQuery("price").gte(minPrice.doubleValue()));
            hasPriceCondition = true; // 有价格条件啦!
        }
        if (maxPrice != null) {
            logger.debug("用户设置了最高价格: {}", maxPrice);
            priceBoolQuery.must(QueryBuilders.rangeQuery("price").lte(maxPrice.doubleValue()));
            hasPriceCondition = true; // 又一个价格条件!
        }
        if (hasPriceCondition) {
            logger.debug("价格区间条件已设定,加入到主查询中。💰");
          
            boolQuery.filter(priceBoolQuery);
        }   
        if (!boolQuery.hasClauses()) {
            logger.info("用户未提供任何搜索条件。ES将根据其默认行为处理(可能匹配所有,或根据索引设置)。");
        }
        queryBuilder.withQuery(boolQuery);
        if (page != null && size != null && page >= 0 && size > 0) {
            logger.debug("分页设置:查看第 {} 页,每页 {} 条。", page, size);
            queryBuilder.withPageable(PageRequest.of(page, size));
        } else {
            
            logger.debug("用户未指定分页,使用默认设置:查看第 0 页,每页 10 条。");
            queryBuilder.withPageable(PageRequest.of(0, 10));
        }
        NativeSearchQuery searchQuery = queryBuilder.build();
        logger.debug("发往Elasticsearch的最终查询DSL: {}", searchQuery.getQuery().toString());
    
        logger.info("正在向Elasticsearch发送搜索请求...");
        SearchHits<Product> searchHits = elasticsearchOperations.search(searchQuery, Product.class, IndexCoordinates.of(INDEX_NAME));
        logger.info("Elasticsearch返回了 {} 条匹配的结果。", searchHits.getTotalHits());
        return searchHits.getSearchHits().stream()
                .map(SearchHit::getContent) // 从每个SearchHit中提取出Product对象
                .collect(Collectors.toList()); // 汇集成一个List<Product>
    }

总结上述核心步骤如下:

  1. 首先准备好 ES搜索指令构建器NativeSearchQueryBuilder,这是我们用来组装最终发送给ES完整搜索命令的工具。
  2. 再准备一个条件逻辑的组合器BoolQueryBuilder,这是用来灵活组合各种“必须满足”(must)、“应该满足”(should)、“必须不满足”(must_not)以及“过滤”(filter)条件的容器。
  3. 接下来就可以进行搜索条件的组装,逐一分析入参并添加搜索线索
    1. 分类筛选🏷️:如果用户指定了商品分类,就构建一个 term 查询,指示ES精确匹配该分类,并将其作为过滤 (filter) 条件加入“条件逻辑组合器”(过滤条件不影响得分,效率更高)。
    2. 价格区间筛选 💰:如果用户设定了最低价或最高价(或两者都有),就构建一个 range 查询,指示ES筛选出价格在此区间的商品,并将其也作为过滤 (filter) 条件加入。
    3. 将所有条件打包:将包含所有已添加条件的“条件逻辑组合器” (BoolQueryBuilder) 设置为“ES搜索指令构建器” (NativeSearchQueryBuilder) 的主查询体 (withQuery)。
  4. 添加分页与排序:根据用户提供的页码 (page) 和每页数量 (size)(或使用默认值),配置分页参数 (withPageable),告诉ES我们想看结果的哪一部分。如果需要,可以添加排序规则 (withSort),比如按价格升序或按相关性(默认)排序。
  5. 构建完整的ES搜索命令:调用ES搜索指令构建器的 build() 方法,生成一个最终的、原生的Elasticsearch查询对象 (NativeSearchQuery)。这个对象完整地描述了我们要执行的搜索操作。
  6. 向ES发送指令:使用 elasticsearchOperations(Spring Data ES提供的与ES交互的核心工具)的 search() 方法,将构建好的 NativeSearchQuery 发送给ES服务。ES执行查询后,返回一个包含搜索结果的 SearchHits 对象,里面有匹配到的文档、总命中数、得分等信息。
  7. 处理ES返回的结果:从 SearchHits 中提取出实际的商品数据(Product 对象),忽略ES返回的其他元信息(如得分、高亮等,除非需要),并将它们收集成一个 List,使用 Stream API。将这个最终的商品列表返回给调用方(通常是Controller)。

对于第一次接触的同学来说可能比较复杂,熟能生巧,简单来说,核心就是 : 收集条件,构建查询(DSL),配置分页/ 排序,执行搜索,处理结果。

🔄 同步数据到ES

接下来我们手动将MySQL数据库中product表里的所有商品数据同步到ES的products索引中,逻辑也很简单:

  1. 从Mysql数据库中获取所有商品数据。
  2. 将从数据库获取的所有商品数据批量保存到ES中。
public long syncAllProductsToEs() {
        List<Product> allProductsFromDb = productMapper.findAll();
        if (allProductsFromDb.isEmpty()) {
            logger.info("No products found in DB to sync.");
            return 0;
        }
	// 同步的核心步骤,将数据库中的数据列表 批量保存到ES中
        productEsRepository.saveAll(allProductsFromDb);
        logger.info("Successfully synced {} products from DB to Elasticsearch.", allProductsFromDb.size());
        return allProductsFromDb.size();
    }

productEsRepository 是 Spring Data Elasticsearch 的 Repository 接口,它负责与Elasticsearch进行交互。saveAll 是 ElasticsearchRepository 提供的一个标准方法,用于一次性保存多个文档到ES,它比逐条保存效率更高。

具体来说,productEsRepository 会先将列表中的每个对象转换成ES能理解的JSON格式,然后发送给Elasticsearch服务,ES会将这些文档存储到名为 products 的索引中。 注: 这个索引名是在 Product.java 实体类的 @Document(indexName = "products") 注解中定义的。


关于同步策略的碎碎念 🗣️

这里我们为了让大家先对整个流程有个初步的理解,采用了一种在生产环境中几乎不会使用的“笨办法”:全量同步(Full Synchronization)。即每次调用都会把Mysql中的所有商品数据都尝试写入ES中,这对于数据量不大的情况或者初始化同步是可行的👌。

但对于数据量非常大且频繁变动的生产环境,那这种全量同步的方式就会变得非常缓慢和低效!😫 。通常会采用更高效的增量同步策略

在后续的博客中,我们会介绍更加优雅、高效的增量同步策略,敬请期待!😉

🚀 小实验:MySQL vs Elasticsearch 性能初探 ⏱️

理论说了这么多,Elasticsearch到底在搜索性能上比直接查MySQL快多少呢?口说无凭,我们来做一个简单的小实验直观感受一下。

实验目标: 对比在一定数据量下,针对特定搜索条件,直接查询MySQL和通过Elasticsearch进行搜索的响应时间。具体步骤如下:

  1. 引入EasyRandom并批量插入数据 为了让对比更有意义,我们需要一些像样的数据量。手动一条条插太慢了,这里我们使用EasyRandom库,来进行随机对象的生成。

  2. 同步数据到Elasticsearch 🚛 首先,调用我们前面创建的API,向MySQL插入 10000 条数据,等待其完成。然后,调用同步API,将MySQL中的所有数据同步到ES。

  3. 定义搜索条件 🎯 我们将选择一个能体现ES全文搜索和多条件组合能力的场景:

    • 关键词: "笔记本" (期望能在 namedescription 中模糊匹配)
    • 分类: 假设我们随机生成的数据中,有些商品分类是 "数码产品"
    • 价格范围: 假设我们想找价格在 100 到 800 之间的。
  4. 计时开始:分别测试MySQL和ES的查询接口⏱️ 我们将使用工具(ApiFox)多次调用以下两个接口,并记录平均响应时间。

  5. 成绩揭晓:分析结果与结论📊🏆

可以看到结果为125ms和40ms。(读者可自行测试,结果可能会有偏差)

当然,这并不意味着MySQL一无是处。MySQL在事务处理、关系维护、数据一致性等方面依然是基石。这里的实验只是为了凸显在“搜索”这一特定领域,ES作为专业搜索引擎的强大之处。在实际项目中,我们通常会将两者结合使用,MySQL作为主数据存储,ES作为搜索引擎,通过数据同步机制保持两者数据的一致性(或最终一致性)。


本节内容对应的代码仓库地址: es-demo。仓库里有完整的代码,欢迎大家Clone下来动手实践。💻

Comments