Elasticsearch 在 Java 中的深度实践——复杂搜索、聚合、性能调优
Elasticsearch 在 Java 中的深度实践——复杂搜索、聚合、性能调优
适读人群:使用 Elasticsearch 做搜索或数据分析的 Java 后端开发者 | 阅读时长:约20分钟 | 核心价值:掌握 ES 复杂查询构建与性能调优的核心技巧,解决生产环境常见问题
搜索接口响应 10 秒的惨烈现场
我曾经接手过一个商品搜索系统,前任开发在 ES 里存了大约 2000 万条商品数据。接手的时候,用户搜索的平均响应时间是 3-5 秒,高峰期甚至 8-10 秒。产品经理每隔几天就会过来问一次:"这个搜索还能优化吗?"
我花了两周时间把整个搜索链路重新梳理了一遍,最终把响应时间压到了平均 120ms,P99 500ms。
这篇文章把那次优化的核心思路和代码全部分享出来,加上后来两年里积累的更多实践。
ES Java 客户端选型
ES 官方现在推荐的 Java 客户端是 Elasticsearch Java Client(不是老的 High Level REST Client,已废弃):
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.11.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.host}")
private String host;
@Value("${elasticsearch.port:9200}")
private int port;
@Bean
public ElasticsearchClient elasticsearchClient() {
RestClient restClient = RestClient.builder(
new HttpHost(host, port, "http")
).setRequestConfigCallback(requestConfigBuilder ->
requestConfigBuilder
.setConnectTimeout(5000)
.setSocketTimeout(30000)
).build();
ElasticsearchTransport transport = new RestClientTransport(
restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
}复杂搜索:商品搜索实战
商品搜索需求
典型商品搜索场景:
- 关键词全文搜索(商品名称、描述)
- 多条件过滤(品牌、价格范围、分类)
- 排序(相关度、价格、销量)
- 高亮显示匹配词
- 分页
@Service
public class ProductSearchService {
@Autowired
private ElasticsearchClient esClient;
private static final String INDEX_NAME = "products";
public SearchResult<ProductDoc> searchProducts(ProductSearchRequest request)
throws IOException {
// 构建查询
SearchRequest searchRequest = SearchRequest.of(sr -> sr
.index(INDEX_NAME)
// 分页
.from(request.getPage() * request.getSize())
.size(request.getSize())
// 主查询
.query(buildQuery(request))
// 高亮
.highlight(buildHighlight())
// 排序
.sort(buildSort(request.getSortField(), request.getSortOrder()))
// 聚合(用于过滤项的计数)
.aggregations(buildAggregations())
);
SearchResponse<ProductDoc> response = esClient.search(searchRequest, ProductDoc.class);
return parseSearchResult(response);
}
/**
* 构建查询条件
*/
private Query buildQuery(ProductSearchRequest request) {
BoolQuery.Builder boolQuery = new BoolQuery.Builder();
// 关键词搜索(must:必须匹配)
if (StringUtils.hasText(request.getKeyword())) {
boolQuery.must(m -> m
.multiMatch(mm -> mm
.query(request.getKeyword())
// 多字段搜索,name 字段权重更高
.fields("name^3", "description^1", "brand^2")
// fuzziness 支持拼写错误容忍
.fuzziness("AUTO")
// 最小匹配词数(避免长关键词只匹配一个词就出结果)
.minimumShouldMatch("75%")
)
);
}
// 品牌过滤(filter:不计算相关度分数,性能更好)
if (!CollectionUtils.isEmpty(request.getBrands())) {
boolQuery.filter(f -> f
.terms(t -> t
.field("brand.keyword")
.terms(tv -> tv.value(request.getBrands().stream()
.map(FieldValue::of)
.collect(Collectors.toList())))
)
);
}
// 价格区间过滤
if (request.getMinPrice() != null || request.getMaxPrice() != null) {
boolQuery.filter(f -> f
.range(r -> {
var range = r.field("price");
if (request.getMinPrice() != null) {
range.gte(JsonData.of(request.getMinPrice()));
}
if (request.getMaxPrice() != null) {
range.lte(JsonData.of(request.getMaxPrice()));
}
return range;
})
);
}
// 分类过滤
if (StringUtils.hasText(request.getCategoryId())) {
boolQuery.filter(f -> f
.term(t -> t
.field("categoryId")
.value(request.getCategoryId())
)
);
}
// 排除下架商品
boolQuery.mustNot(mn -> mn
.term(t -> t.field("status").value("offline"))
);
return Query.of(q -> q.bool(boolQuery.build()));
}
/**
* 构建高亮配置
*/
private Highlight buildHighlight() {
return Highlight.of(h -> h
.preTags("<em class='highlight'>")
.postTags("</em>")
.fields("name", f -> f
.numberOfFragments(0) // 0表示返回整个字段
)
.fields("description", f -> f
.numberOfFragments(3)
.fragmentSize(150)
)
);
}
/**
* 构建聚合(用于展示各品牌、分类的商品数量)
*/
private Map<String, Aggregation> buildAggregations() {
Map<String, Aggregation> aggs = new HashMap<>();
// 品牌聚合
aggs.put("brand_agg", Aggregation.of(a -> a
.terms(t -> t
.field("brand.keyword")
.size(20) // 最多返回20个品牌
)
));
// 价格范围聚合
aggs.put("price_range_agg", Aggregation.of(a -> a
.range(r -> r
.field("price")
.ranges(
rv -> rv.to("100"),
rv -> rv.from("100").to("500"),
rv -> rv.from("500").to("2000"),
rv -> rv.from("2000")
)
)
));
return aggs;
}
}复杂聚合:商品销售数据分析
聚合分析是 ES 的强项,以下是一个按时间维度分析商品销售的例子:
public SalesAnalysisResult analyzeSales(String startDate, String endDate)
throws IOException {
SearchRequest request = SearchRequest.of(sr -> sr
.index("orders")
.size(0) // 不返回文档,只要聚合结果
.query(q -> q
.range(r -> r
.field("orderTime")
.gte(JsonData.of(startDate))
.lte(JsonData.of(endDate))
)
)
.aggregations("daily_sales", a -> a
// 按天分桶
.dateHistogram(dh -> dh
.field("orderTime")
.calendarInterval(CalendarInterval.Day)
)
// 每天内部聚合:销售总额、订单数
.aggregations("total_amount", subA -> subA
.sum(s -> s.field("amount"))
)
.aggregations("order_count", subA -> subA
.valueCount(vc -> vc.field("orderId"))
)
// 每天内部聚合:Top5 商品
.aggregations("top_products", subA -> subA
.terms(t -> t
.field("productId.keyword")
.size(5)
)
.aggregations("product_sales", subSubA -> subSubA
.sum(s -> s.field("amount"))
)
)
)
);
SearchResponse<Void> response = esClient.search(request, Void.class);
// 解析聚合结果
return parseSalesAggregation(response);
}性能调优实战
1. 索引设计优化
PUT /products
{
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1,
"index": {
"refresh_interval": "5s",
"max_result_window": 10000
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"price": { "type": "float" },
"categoryId": { "type": "keyword" },
"brand": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
},
"status": { "type": "keyword" },
"salesCount": { "type": "integer" },
"createTime": { "type": "date" }
}
}
}关键原则:
- 不需要全文搜索的字段用
keyword类型(精确匹配,性能更好) - 需要全文搜索的字段用
text+keyword多字段 - 中文搜索配置 IK 分词器
2. 查询性能优化
// ❌ 慢:deep pagination(深度分页)
// 要获取第 10000 页,ES 需要先获取前 10000 * pageSize 条记录,排序后丢弃
searchRequest.from(10000).size(20);
// ✅ 快:使用 search_after 游标分页
// 第一页
SearchResponse<ProductDoc> firstPage = esClient.search(sr -> sr
.index(INDEX_NAME)
.size(20)
.sort(s -> s.field(f -> f.field("_score").order(SortOrder.Desc)))
.sort(s -> s.field(f -> f.field("id").order(SortOrder.Asc))), // 保证排序稳定
ProductDoc.class
);
// 保存最后一个文档的排序值作为游标
List<FieldValue> lastSortValues = firstPage.hits().hits()
.get(firstPage.hits().hits().size() - 1)
.sort();
// 获取下一页
SearchResponse<ProductDoc> nextPage = esClient.search(sr -> sr
.index(INDEX_NAME)
.size(20)
.sort(s -> s.field(f -> f.field("_score").order(SortOrder.Desc)))
.sort(s -> s.field(f -> f.field("id").order(SortOrder.Asc)))
.searchAfter(lastSortValues), // 从上一页最后一条之后开始
ProductDoc.class
);3. 批量写入优化
@Service
public class ProductIndexService {
@Autowired
private ElasticsearchClient esClient;
/**
* 批量索引商品(使用 bulk API)
* 比单条写入快 10-50 倍
*/
public void bulkIndex(List<ProductDoc> products) throws IOException {
List<BulkOperation> operations = products.stream()
.map(product -> BulkOperation.of(op -> op
.index(idx -> idx
.index(INDEX_NAME)
.id(product.getId())
.document(product)
)
))
.collect(Collectors.toList());
BulkRequest bulkRequest = BulkRequest.of(br -> br
.operations(operations)
.refresh(Refresh.False) // 不立即刷新(提高写入性能)
);
BulkResponse response = esClient.bulk(bulkRequest);
if (response.errors()) {
// 处理部分失败
response.items().forEach(item -> {
if (item.error() != null) {
log.error("索引失败,id={}, error={}",
item.id(), item.error().reason());
}
});
}
}
}三大踩坑实录
坑一:分词不准导致搜索结果差
现象: 搜索"苹果手机",返回的结果里有大量水果类商品,前几名都是"苹果"(水果),手机类商品排得很靠后。
原因: IK 分词器把"苹果手机"分成了"苹果"和"手机"两个词,然后用 match 查询,默认是 OR 逻辑,只要包含"苹果"或"手机"其中一个词就匹配,大量水果商品因为有"苹果"就匹配了,而且这些商品的标题很短,TF-IDF 分数可能还挺高。
解法: 改用 match_phrase 或提高多词匹配的门槛:
// 使用 must 配合 should 和 minimum_should_match
boolQuery.must(m -> m
.multiMatch(mm -> mm
.query(keyword)
.fields("name^3", "description")
.type(TextQueryType.CrossFields) // 跨字段匹配
.minimumShouldMatch("100%") // 所有词都要匹配
)
);坑二:mapping 不合理导致聚合排序乱
现象: 按品牌聚合,返回的品牌列表里"Apple"出现了"Apple"和"apple"两个条目,而且有时候排序不稳定。
原因: 品牌字段用了 text 类型,聚合时对 text 字段聚合会用分词后的 token(小写的 apple),而不是原始值。
解法: 品牌字段用 text + keyword 多字段,聚合时用 brand.keyword:
.aggregations("brand_agg", a -> a
.terms(t -> t
.field("brand.keyword") // 用 keyword 字段聚合
.size(20)
)
)坑三:大量更新导致索引膨胀和查询变慢
现象: 运行了 3 个月后,搜索速度越来越慢,ES 磁盘使用量远超预期(存了 2000 万文档,占用了 300GB 磁盘)。
原因: 业务逻辑每次商品价格变动都会 UPDATE 一次 ES,而 ES 的 UPDATE 实际上是"标记旧文档为删除 + 写一个新文档",频繁更新导致大量"被删除但未清理"的文档占用空间,也拖慢了查询速度(merge 压力大)。
解法:
- 减少更新频率:对价格等高频变动字段,改用 Redis 缓存,只有当搜索时才实时读取价格,ES 不存储最新价格
- 手动触发 force merge(低峰期)减少 segment 数量
- 调整
index.merge.policy.max_merge_at_once参数让 ES 更积极地合并
# 低峰期手动 force merge
curl -X POST "es-host:9200/products/_forcemerge?max_num_segments=5"写在最后
ES 的性能优化是一门系统工程,从索引设计、查询写法、分片策略到硬件配置,每个环节都有优化空间。
我那次把搜索从 10 秒优化到 120ms,核心做了三件事:
- 重新设计 mapping,把该用 keyword 的字段改成 keyword
- 把
from + size深度分页改成search_after游标分页 - 用 filter 替代 query 做过滤条件(filter 不计算分数,有缓存)
如果你的 ES 搜索慢,先从这三个方向排查,往往能找到主要原因。
