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中关闭了安全认证:
yamlxpack.security.enabled: false如需在生产环境使用,请配置 HTTPS 和认证,并在应用中配置
username和password。
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!