Elasticsearch 入门与实战:Spring Boot 3 + ES 8 从零搭建商品搜索服务

Elasticsearch 入门与实战:Spring Boot 3 + ES 8 从零搭建商品搜索服务

一、Elasticsearch 是什么

Elasticsearch 是一个基于 Apache Lucene 构建的分布式、RESTful 风格的搜索与分析引擎。它能够以近实时的方式对海量数据进行存储、搜索和分析,广泛应用于:

  • 全文搜索 --- 电商商品搜索、文档检索、日志搜索
  • 结构化搜索 --- 过滤、聚合、地理位置查询
  • 日志分析与APM --- ELK 技术栈的心脏
  • 向量搜索 --- 结合机器学习实现语义搜索(ES 8.x+)

核心概念

概念 类比关系型数据库 说明
Index Database 存储相关文档的逻辑容器
Type(已废弃) Table ES 7.x 起一个 Index 只存一种文档
Document Row 可被索引的基本信息单元,JSON 格式
Field Column 文档中的字段,可指定类型(text, keyword, integer 等)
Mapping Schema 定义字段的类型、分词器、是否索引等
Shard 分区 一个 Index 分成多个分片,分散到不同节点
Replica 副本 分片的副本,提高可用性和查询吞吐量

为什么选择 Elasticsearch

  • 高性能 --- 倒排索引结构使全文搜索速度极快
  • 分布式 --- 天然支持水平扩展,PB 级数据不在话下
  • RESTful API --- 任何语言都可以通过 HTTP 操作
  • 丰富的查询 DSL --- 从简单的 match 到复杂的 bool 组合、聚合分析
  • 生态成熟 --- Kibana 可视化、Logstash/Beats 数据采集、ELK 全家桶

二、项目背景

本文基于一个 Spring Boot 3 + Elasticsearch 8 Java Client 的实战项目,演示如何搭建一个完整的商品搜索服务,涵盖:

  • 索引管理与 Mapping 配置
  • 单文档 CRUD 与批量写入
  • 全文搜索(multi_match + 高亮)
  • 布尔组合过滤(分类/价格区间/标签)
  • 聚合分析(按分类统计)

项目源代码地址:GitHub 仓库链接


三、环境准备

3.1 启动 Elasticsearch 8

下载 ES 8.17.0(或更高版本),解压后启动:

bash 复制代码
# 解压目录下执行
bin/elasticsearch

验证服务是否正常:

bash 复制代码
curl http://localhost:9200

返回类似以下内容即表示启动成功:

json 复制代码
{
  "name": "DESKTOP-xxx",
  "cluster_name": "elasticsearch",
  "version": { "number": "8.17.0" },
  "tagline": "You Know, for Search"
}

注意 :ES 8 默认启用了安全认证(HTTPS + 密码)。本示例为简化演示,在 elasticsearch.yml 中关闭了安全认证:

yaml 复制代码
xpack.security.enabled: false

如需在生产环境使用,请配置 HTTPS 和认证,并在应用中配置 usernamepassword

3.2 技术栈版本

组件 版本
JDK 17
Spring Boot 3.5.0
elasticsearch-java 8.17.0
Maven 3.9+

四、项目搭建与配置

4.1 Maven 依赖

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.0</version>
</parent>

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Elasticsearch Java Client -->
    <dependency>
        <groupId>co.elastic.clients</groupId>
        <artifactId>elasticsearch-java</artifactId>
        <version>8.17.0</version>
    </dependency>

    <!-- JSON 处理(ES 客户端依赖) -->
    <dependency>
        <groupId>org.glassfish</groupId>
        <artifactId>jakarta.json</artifactId>
        <version>2.0.1</version>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

4.2 应用配置

yaml 复制代码
# src/main/resources/application.yml
server:
  port: 8080

elasticsearch:
  uris:
    - http://localhost:9200
  username:        # 无认证时留空
  password:
  connect-timeout: 5000
  socket-timeout: 60000
  max-connections: 100
  max-connections-per-route: 50
  index-name: product_index

logging:
  level:
    com.example.esdemo: debug

4.3 配置绑定类

java 复制代码
@Getter @Setter
@ConfigurationProperties(prefix = "elasticsearch")
public class ElasticsearchProperties {
    private List<String> uris = new ArrayList<>();
    private String username = "";
    private String password = "";
    private int connectTimeout = 5000;
    private int socketTimeout = 60000;
    private int maxConnections = 100;
    private int maxConnectionsPerRoute = 50;
    private String indexName = "product_index";
}

4.4 创建 ES 客户端 Bean

java 复制代码
@Configuration
public class ElasticsearchConfig {

    @Bean(destroyMethod = "close")
    public ElasticsearchClient elasticsearchClient(
            ElasticsearchProperties props, ObjectMapper objectMapper) {

        HttpHost[] hosts = props.getUris().stream()
                .map(HttpHost::create)
                .toArray(HttpHost[]::new);

        RestClientBuilder builder = RestClient.builder(hosts)
                .setRequestConfigCallback(config -> config
                        .setConnectTimeout(props.getConnectTimeout())
                        .setSocketTimeout(props.getSocketTimeout()));

        builder.setHttpClientConfigCallback(clientBuilder -> {
            if (StringUtils.hasText(props.getUsername())) {
                CredentialsProvider credentials = new BasicCredentialsProvider();
                credentials.setCredentials(AuthScope.ANY,
                        new UsernamePasswordCredentials(props.getUsername(), props.getPassword()));
                clientBuilder.setDefaultCredentialsProvider(credentials);
            }
            return clientBuilder
                    .setMaxConnTotal(props.getMaxConnections())
                    .setMaxConnPerRoute(props.getMaxConnectionsPerRoute());
        });

        RestClient restClient = builder.build();
        ElasticsearchTransport transport =
                new RestClientTransport(restClient, new JacksonJsonpMapper(objectMapper));
        return new ElasticsearchClient(transport);
    }
}

五、数据模型

java 复制代码
@Data
public class Product {
    @JsonProperty("id")
    private String id;               // 文档 ID,不传则自动生成 UUID

    private String name;             // 商品名称(text 类型)
    private String description;      // 商品描述(text 类型)
    private String category;         // 分类(keyword 类型)
    private BigDecimal price;        // 价格(double 类型)
    private Integer stock;           // 库存(integer 类型)
    private List<String> tags;       // 标签(keyword 数组)

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonProperty("created_at")
    private LocalDateTime createdAt;  // 创建时间(date 类型,自动填充)
}

Mapping 映射

索引创建时自动配置字段类型:

字段 ES 类型 用途
name text 全文搜索
description text 全文搜索
category keyword 精确过滤 + 聚合
price double 范围查询
stock integer 精确匹配
tags keyword[] 精确过滤
created_at date 排序 + 范围查询

六、核心功能实现

6.1 索引管理

java 复制代码
// 创建索引(含 mapping)
public boolean createIndex() throws IOException {
    boolean exists = client.indices().exists(e -> e.index(index)).value();
    if (exists) return false;

    client.indices().create(c -> c.index(index)
            .mappings(m -> m
                .properties("name", p -> p.text(t -> t))
                .properties("description", p -> p.text(t -> t))
                .properties("category", p -> p.keyword(k -> k))
                .properties("price", p -> p.double_(d -> d))
                .properties("stock", p -> p.integer(i -> i))
                .properties("tags", p -> p.keyword(k -> k))
                .properties("created_at", p -> p.date(d -> d.format("yyyy-MM-dd HH:mm:ss")))
            ));
    return true;
}

// 删除索引
public boolean deleteIndex() throws IOException {
    // 先判断是否存在,防止报错
    boolean exists = client.indices().exists(e -> e.index(index)).value();
    if (!exists) return false;

    client.indices().delete(d -> d.index(index));
    return true;
}

6.2 文档 CRUD

java 复制代码
// 新增/覆盖文档
public Product indexDoc(Product product) throws IOException {
    if (product.getId() == null || product.getId().isBlank()) {
        product.setId(UUID.randomUUID().toString());
    }
    if (product.getCreatedAt() == null) {
        product.setCreatedAt(LocalDateTime.now());
    }
    IndexResponse resp = client.index(i -> i
            .index(index).id(product.getId()).document(product));
    product.setId(resp.id());
    return product;
}

// 按 ID 查询
public Optional<Product> getById(String id) throws IOException {
    GetResponse<Product> resp = client.get(
            g -> g.index(index).id(id), Product.class);
    if (!resp.found() || resp.source() == null) {
        return Optional.empty();
    }
    Product product = resp.source();
    product.setId(resp.id());
    return Optional.of(product);
}

// 更新文档
public Product updateById(String id, Product product) throws IOException {
    product.setId(id);
    client.update(u -> u.index(index).id(id).doc(product), Product.class);
    return getById(id).orElse(null);
}

// 删除文档
public boolean deleteById(String id) throws IOException {
    DeleteResponse resp = client.delete(d -> d.index(index).id(id));
    return resp.result() == Result.Deleted;
}

6.3 批量写入

java 复制代码
public int bulkIndex(List<Product> products) throws IOException {
    List<BulkOperation> ops = products.stream().map(p -> {
        if (p.getId() == null || p.getId().isBlank()) {
            p.setId(UUID.randomUUID().toString());
        }
        if (p.getCreatedAt() == null) {
            p.setCreatedAt(LocalDateTime.now());
        }
        return BulkOperation.of(b -> b.index(op -> op
                .index(index).id(p.getId()).document(p)));
    }).toList();

    BulkResponse resp = client.bulk(b -> b.index(index).operations(ops));
    long failed = resp.items().stream().filter(item -> item.error() != null).count();
    return (int) (resp.items().size() - failed);
}

6.4 全文搜索(高亮 + 分页)

java 复制代码
public SearchResult<Product> search(String keyword, int from, int size) throws IOException {
    SearchResponse<Product> resp = client.search(s -> s
            .index(index)
            .from(from)
            .size(size)
            .sort(st -> st.field(f -> f.field("created_at").order(SortOrder.Desc)))
            .query(q -> q.multiMatch(m -> m
                    .query(keyword)
                    .fields("name", "description", "category")))
            .highlight(hl -> hl
                    .preTags("<em>")
                    .postTags("</em>")
                    .fields("name", f -> f)
                    .fields("description", f -> f)),
        Product.class);
    return toSearchResult(resp);
}

关键点

  • multiMatch 同时对 name、description、category 三个字段进行搜索
  • highlight 返回匹配片段,用 <em> 标签包裹
  • created_at 倒序排列,展示最新商品

6.5 布尔组合过滤

java 复制代码
public SearchResult<Product> boolSearch(
        String category, Double minPrice, Double maxPrice,
        String tag, int from, int size) throws IOException {

    SearchResponse<Product> resp = client.search(s -> {
        s.index(index).from(from).size(size)
         .sort(st -> st.field(f -> f.field("price").order(SortOrder.Asc)));

        BoolQuery.Builder boolBuilder = new BoolQuery.Builder();
        if (StringUtils.hasText(category)) {
            boolBuilder.filter(f -> f.term(t -> t.field("category").value(category)));
        }
        if (StringUtils.hasText(tag)) {
            boolBuilder.filter(f -> f.term(t -> t.field("tags").value(tag)));
        }
        if (minPrice != null) {
            boolBuilder.filter(f -> f.range(r -> r.number(n -> n.field("price").gte(minPrice))));
        }
        if (maxPrice != null) {
            boolBuilder.filter(f -> f.range(r -> r.number(n -> n.field("price").lte(maxPrice))));
        }
        s.query(q -> q.bool(boolBuilder.build()));
        return s;
    }, Product.class);
    return toSearchResult(resp);
}

Bool Query 的四种子句

  • must --- 必须匹配(贡献算分)
  • filter --- 必须匹配(不贡献算分,可缓存,性能更好)
  • should --- 至少满足一个(可配置 minimum_should_match)
  • must_not --- 必须不匹配

本示例全部使用 filter,因为分类和价格过滤不需要算分,性能更优。

6.6 聚合统计

java 复制代码
public Map<String, Long> categoryAggregation() throws IOException {
    SearchResponse<Product> resp = client.search(s -> s
            .index(index)
            .size(0)   // 不返回文档,只返回聚合结果
            .aggregations("by_category", a -> a.terms(t -> t.field("category").size(10))),
        Product.class);

    Map<String, Long> result = new LinkedHashMap<>();
    Aggregate agg = resp.aggregations().get("by_category");
    if (agg != null && agg.sterms() != null) {
        agg.sterms().buckets().array()
            .forEach(bucket -> result.put(bucket.key().stringValue(), bucket.docCount()));
    }
    return result;
}

七、API 接口一览

启动项目后,所有接口均暴露在 http://localhost:8080/api/products 下。

方法 路径 说明
POST /api/products/index-init 创建索引及 mapping
DELETE /api/products/index 删除索引
POST /api/products 新增单个文档
POST /api/products/bulk 批量写入
GET /api/products/{id} 按 ID 查询
PUT /api/products/{id} 按 ID 更新
DELETE /api/products/{id} 按 ID 删除
GET /api/products/search?keyword=xxx&from=0&size=10 全文搜索
GET /api/products/search/bool?category=phone&minPrice=1000&maxPrice=8000&tag=5g 布尔组合过滤
GET /api/products/agg/category 分类聚合统计

统一响应格式:

json 复制代码
{
  "code": 200,
  "message": "success",
  "data": { ... }
}

八、实战调用演示

8.1 初始化索引

bash 复制代码
curl -X POST http://localhost:8080/api/products/index-init
# 响应: {"code":200,"message":"success","data":true}

8.2 写入测试数据

bash 复制代码
# 批量写入多条商品
curl -X POST http://localhost:8080/api/products/bulk \
  -H "Content-Type: application/json" \
  -d '[
    {"name":"iPhone 16 Pro","description":"Apple flagship smartphone","category":"phone","price":7999,"stock":100,"tags":["apple","5g","flagship"]},
    {"name":"MacBook Air M3","description":"Apple lightweight laptop","category":"laptop","price":10999,"stock":50,"tags":["apple","m3"]},
    {"name":"Xiaomi 15","description":"Xiaomi smartphone with Snapdragon 8 Gen 4","category":"phone","price":4499,"stock":200,"tags":["5g","android"]},
    {"name":"ThinkPad X1 Carbon","description":"Lenovo business laptop","category":"laptop","price":12999,"stock":30,"tags":["business","windows"]},
    {"name":"AirPods Pro 2","description":"Apple wireless earbuds with ANC","category":"accessory","price":1899,"stock":500,"tags":["apple","wireless"]}
  ]'

8.3 全文搜索

搜索关键词 "apple",返回匹配的商品,带高亮片段:

bash 复制代码
curl "http://localhost:8080/api/products/search?keyword=apple&from=0&size=10"

响应示例:

json 复制代码
{
  "code": 200,
  "message": "success",
  "data": {
    "total": 3,
    "records": [
      { "id": "xxx", "name": "iPhone 16 Pro", "price": 7999, ... },
      { "id": "xxx", "name": "MacBook Air M3", "price": 10999, ... },
      { "id": "xxx", "name": "AirPods Pro 2", "price": 1899, ... }
    ],
    "highlights": {
      "xxx": ["<em>Apple</em> flagship smartphone"],
      "yyy": ["<em>Apple</em> lightweight laptop"],
      "zzz": ["<em>Apple</em> wireless earbuds with ANC"]
    }
  }
}

8.4 组合过滤

搜索手机分类、价格 3000~8000 之间、含 "5g" 标签的商品:

bash 复制代码
curl "http://localhost:8080/api/products/search/bool?category=phone&minPrice=3000&maxPrice=8000&tag=5g"

8.5 分类聚合

统计各分类的商品数量:

bash 复制代码
curl http://localhost:8080/api/products/agg/category
json 复制代码
{
  "code": 200,
  "message": "success",
  "data": {
    "phone": 2,
    "laptop": 2,
    "accessory": 1
  }
}

九、项目结构

复制代码
src/main/java/com/example/esdemo
├── ElasticsearchDemoApplication.java   # 启动类
├── config
│   ├── ElasticsearchConfig.java        # ES 客户端 Bean 装配
│   └── ElasticsearchProperties.java    # 配置属性绑定
├── controller
│   ├── SearchController.java           # REST 接口层
│   └── GlobalExceptionHandler.java     # 全局异常处理
├── dto
│   ├── ApiResponse.java                # 统一响应封装
│   └── SearchResult.java               # 搜索结果(含高亮)
├── entity
│   └── Product.java                    # 商品文档实体
└── service
    └── ProductService.java             # 核心业务逻辑

十、常见问题

Q1:连接被拒绝

确认 ES 已启动,且 elasticsearch.uris 中的地址与端口正确。

Q2:返回 401/403

ES 8 默认启用了安全认证。解决方案:

  • 关闭安全认证:xpack.security.enabled: false
  • 或配置用户名密码及 HTTPS(需处理自签名证书)

Q3:搜索不到刚写入的数据

ES 默认 1 秒 refresh 间隔,写入后不会立即可见。如需立即搜索,可手动 refresh:

bash 复制代码
curl -X POST http://localhost:9200/product_index/_refresh

Q4:客户端版本匹配

elasticsearch-java 8.x 对应 ES 8.x 服务端。升级到 ES 9 时需同步升级客户端版本,API 可能有细微变化。


十一、总结

本文从 Elasticsearch 的核心概念出发,结合 Spring Boot 3 + elasticsearch-java 8 客户端,完整演示了如何搭建一个商品搜索服务。项目涵盖了实际开发中最常用的场景:

  • 索引管理与 Mapping 设计
  • 文档 CRUD 与批量操作
  • 全文搜索与高亮展示
  • 布尔组合过滤(生产搜索的标配)
  • 聚合分析(分类统计、报表等)

这套代码可以直接作为微服务搜索模块的脚手架,在此基础上扩展排序规则、分面搜索、推荐搜索等高级功能也十分方便。

全文代码已开源,欢迎 Star 和 PR!


Happy Searching!

相关推荐
给AI剪纸的打工人14 分钟前
OpenCode怎么接第三方API?自定义Provider与Responses配置实战
linux·数据库·人工智能
隔窗听雨眠22 分钟前
ARM架构下Logstash与Elasticsearch集群部署完全指南:从环境适配到生产验证
arm开发·elasticsearch·架构
裕晟资质规划25 分钟前
西安政务信息化项目涉密系统集成资质准入解析:甲级/乙级承接边界、等保差异与合规承接路径
大数据·运维·数据库·人工智能·安全·政务
yandong63436 分钟前
redis添加到win服务
数据库·redis·缓存
我叫小米粒38 分钟前
可维护的智能体协作空间:字段建模、提示约束、测试集和上线检查
数据库·aigc·agent·智能体交付
平原201840 分钟前
AI岗位需求增长244%背后:从任务重组到可验证学习闭环
大数据·人工智能·学习
数智启示录1 小时前
Flink CDC 机制精讲(四):一条 SQLServer CDC 事件如何保持顺序 【面试宝典】
数据库·面试·sqlserver·flink
小芒果_011 小时前
git常用命令速查
大数据·git·elasticsearch
张文君1 小时前
ubuntu26.04从ext4到raid1+lvm启动-最终版-有这个方法系统损坏还焦虑个啥
数据库