开源商城 Shoptnt 的搜索引擎之心:基于 Elasticsearch 的高性能商品搜索实现

发布于:2025-09-12 ⋅ 阅读:(13) ⋅ 点赞:(0)

在当今的电子商务环境中,一个快速、准确、功能丰富的搜索功能不再是锦上添花,而是必不可少的核心体验。在我的开源商城项目 Shoptnt 中,我们选择了 Elasticsearch 作为搜索引擎,来应对海量商品数据下的复杂查询与聚合需求。

本文将深入剖析 Shoptnt 是如何设计商品索引结构、实现数据的同步与维护,并构建出一个支持关键词、分类、品牌、属性、价格等多维度检索的商品搜索系统。

项目地址: https://gitee.com/bbc-se/shoptnt
官方地址: www.shoptnt.cn

一、核心索引结构设计 (Mapping)

一切搜索的基石在于良好的索引设计。我们定义了 GoodsIndex 类来映射 Elasticsearch 中的文档结构。

java

@Document(indexName = "#{esConfig.indexName}_"+ EsSettings.GOODS_INDEX_NAME)
@Data
public class GoodsIndex {
    @JestId
    private Long goodsId; // 商品ID
    @Field(type = FieldType.Text, analyzer = EsSettings.IK_MAX_WORD)
    private String name; // 商品名称(使用IK分词)
    // ... 其他字段(价格、销量、商家等)
    @Field(type = FieldType.Nested, index = true, store = true)
    private List<IndexParam> params; // 商品参数(Nested类型!)
}

设计要点:

  1. 动态索引名: #{esConfig.indexName}_goods 支持多环境隔离。

  2. 分词策略: 对商品名 name 字段使用 ik_max_word 分词器,力求最细粒度拆分,保证召回率。

  3. Nested 类型: 商品参数 params 是一个列表,内含参数名和值。将其定义为 nested 类型是关键决策。这确保了参数数组中的每个对象是独立索引和查询的,避免了传统对象类型导致的“交叉匹配”问题。

  4. 多数据类型: 精确匹配的字段(如 ID、状态码)使用 keyword 或 long,需搜索的文本使用 text,数值范围过滤使用 integer/double

二、数据同步:保证 ES 与 DB 的最终一致性

数据库是源 of truth,而 ES 是搜索专用视图。如何高效、可靠地将数据变更同步到 ES 是关键。

我们采用了 事件驱动 的模式,通过 RabbitMQ 进行解耦。

1. 消息生产者: 任何导致商品信息变更的业务操作(增删改、上下架、审核通过等),都会发送一个 GoodsChangeMsg 消息事件。

2. 消息消费者: GoodsChangeIndexConsumer 监听这些消息。

java

@Service
public class GoodsChangeIndexConsumer implements GoodsChangeEvent {
    @Autowired
    private GoodsIndexClient goodsIndexClient;

    @Override
    public void goodsChange(GoodsChangeMsg goodsChangeMsg) {
        Long[] goodsIds = goodsChangeMsg.getGoodsIds();
        int operationType = goodsChangeMsg.getOperationType();

        if (isUpdateOperation(operationType)) {
            // 新增或更新:重新导入整个商品文档
            goodsIndexClient.saveByGoodsIds(Arrays.asList(goodsIds));
        } else if (operationType == GoodsChangeMsg.DEL_OPERATION) {
            // 删除:从索引中移除文档
            goodsIndexClient.deleteBatchByGoodsIds(Arrays.asList(goodsIds));
        }
    }
}

同步逻辑:

  • 更新/新增: 收到消息后,根据商品 ID 从数据库查询最新数据,重新构建 GoodsIndex 对象并 index 到 ES。这是一个 替换(Repace) 操作,而非部分更新,简化了逻辑,保证了数据一致性。

  • 删除: 直接从 ES 中删除对应文档。

这种模式的优势在于异步和解耦,避免了在业务事务中直接操作 ES 带来的性能瓶颈和复杂性,保证了最终的数据一致性。

三、搜索与聚合:构建丰富查询功能

搜索的核心实现位于 GoodsSearchManagerImpl 类中。

1. 构建复杂查询 (BoolQuery):

createQuery 方法动态地构建一个 BoolQueryBuilder,将各种筛选条件以 must 子句的形式组合起来,形成最终的查询。

java

BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();

// 关键词查询(IK分词,AND操作符)
if (!StringUtil.isEmpty(keyword)) {
    QueryStringQueryBuilder queryString = new QueryStringQueryBuilder(keyword)
            .field("name").analyzer(EsSettings.IK_SMART).defaultOperator(Operator.AND);
    boolQueryBuilder.must(queryString);
}

// 分类查询(利用Path的Wildcard查询)
if (cat != null) {
    boolQueryBuilder.must(QueryBuilders.wildcardQuery("categoryPath", encodedCatPath + "*"));
}

// Nested 参数查询!
if (!StringUtil.isEmpty(prop)) {
    // ... 解析出参数名和值
    boolQueryBuilder.must(QueryBuilders.nestedQuery("params",
            QueryBuilders.boolQuery()
                    .must(QueryBuilders.termQuery("params.name", name))
                    .must(QueryBuilders.termQuery("params.value", value)),
            ScoreMode.None));
}

// 范围查询(价格)
if (!StringUtil.isEmpty(price)) {
    boolQueryBuilder.must(QueryBuilders.rangeQuery("price").from(min).to(max));
}

// 固定条件:上架、审核通过、未删除
boolQueryBuilder.must(QueryBuilders.termQuery("isAuth", "1"));
boolQueryBuilder.must(QueryBuilders.termQuery("marketEnable", "1"));
boolQueryBuilder.must(QueryBuilders.termQuery("disabled", "1"));

2. 聚合 (Aggregation) 生成筛选器:

为了生成左侧的“分类”、“品牌”、“参数”等筛选项,我们使用了 ES 的聚合功能。

java

private void setAggregationQuery(SearchSourceBuilder searchSourceBuilder) {
    // 分类聚合
    AggregationBuilder categoryAgg = AggregationBuilders.terms("categoryAgg").field("categoryId").size(Integer.MAX_VALUE);
    // 品牌聚合
    AggregationBuilder brandAgg = AggregationBuilders.terms("brandAgg").field("brand").size(Integer.MAX_VALUE);
    // 参数聚合是嵌套的:先按参数名聚合,再按参数值聚合
    AggregationBuilder valuesAgg = AggregationBuilders.terms("valueAgg").field("params.value");
    AggregationBuilder nameAgg = AggregationBuilders.terms("nameAgg").field("params.name").subAggregation(valuesAgg);
    AggregationBuilder paramsAgg = AggregationBuilders.nested("paramsAgg", "params").subAggregation(nameAgg);

    searchSourceBuilder.aggregation(categoryAgg);
    searchSourceBuilder.aggregation(brandAgg);
    searchSourceBuilder.aggregation(paramsAgg);
}

一次搜索请求既返回了商品结果,也返回了所有的聚合结果,前端可以据此渲染出丰富的筛选界面。

四、管理功能:索引与词库的维护

1. 全量索引重建: GoodsIndexManagerImpl.initAll()
这是一个后台管理功能,用于首次部署或索引结构变更时重建整个商品索引。它会分页扫描数据库中的所有商品,批量生成索引,并显示进度条,体验非常好。

2. 搜索词库:
系统会通过 analyzer 方法提取所有商品名称的分词,并将其存入一个单独的词库(可能是 ES 或 DB),用于实现搜索提示(AutoComplete)功能。updateGoodsWords 方法用于全量更新这个词库。

总结与最佳实践

通过 Shoptnt 的 ES 实现,我们可以总结出一些电商搜索的通用最佳实践:

  1. 精心设计 Mapping: 特别是 nested 类型的使用,是处理多值属性的关键。

  2. 事件驱动同步: 采用 MQ 实现异步解耦,保证最终一致性,对业务性能影响最小。

  3. 查询与聚合结合: 一次请求完成搜索和筛选面板数据的获取,高效且减少网络开销。

  4. 中文分词: 选择合适的分词器(如 IK),并根据场景选择 ik_smart 或 ik_max_word

  5. 后台管理工具: 提供全量索引、词库更新的工具,便于运维。

Elasticsearch 为 Shoptnt 提供了强大的搜索能力,使其能够应对复杂的电商查询场景。这套实现方案稳定、高效且功能完备,希望能为正在构建类似功能的开发者提供一些参考和思路。

欢迎访问 Gitee 查看项目源码,并提出宝贵的意见和贡献!


网站公告

今日签到

点亮在社区的每一天
去签到