难度系数:⭐⭐⭐⭐⭐
实用指数:💯💯💯💯💯
📖 开篇:一次糟糕的购物体验
小明在某电商平台搜索"手机":
搜索结果:10000+ 商品
第1页:手机壳、充电器、手机贴膜...
小明:"我要的是手机,不是配件!😡"
筛选:
- 分类:手机
- 品牌:Apple
- 价格:3000-6000
- 内存:128GB以上
- 颜色:黑色
点击搜索... 等待5秒... 💀
结果:找不到符合条件的商品
小明:"算了,去京东买!👋"
同一天,小红在京东搜索"手机":
搜索结果:10000+ 商品
智能筛选:
✅ 自动识别品牌
✅ 价格区间智能推荐
✅ 销量/评价排序
✅ 实时更新可选项
0.2秒返回结果 ⚡
小红:"这才是购物体验!😊"
今天,我们就来实现一个强大的商品搜索系统! 🎯
🎯 搜索需求分析
1. 基础功能
| 功能 | 说明 | 优先级 |
|---|---|---|
| 关键词搜索 | 商品名称、描述 | P0 |
| 分类筛选 | 多级分类 | P0 |
| 品牌筛选 | 多品牌勾选 | P0 |
| 价格区间 | 自定义价格范围 | P0 |
| 属性筛选 | 颜色、尺寸、内存等 | P1 |
| 排序 | 综合/价格/销量/评价 | P0 |
| 分页 | 上拉加载/翻页 | P0 |
2. 高级功能
| 功能 | 说明 | 难度 |
|---|---|---|
| 搜索建议 | 输入提示 | ⭐⭐⭐ |
| 拼写纠错 | "苹果手机"→"iPhone" | ⭐⭐⭐⭐ |
| 同义词 | "手机"="移动电话" | ⭐⭐⭐ |
| 智能推荐 | 根据历史推荐 | ⭐⭐⭐⭐⭐ |
| 相关搜索 | "看了这个的人还看了" | ⭐⭐⭐ |
| 实时筛选 | 动态更新可选项 | ⭐⭐⭐⭐ |
🎨 技术选型
┌──────────────────────────────────────────────────────────┐
│ 搜索技术对比 │
└──────────────────────────────────────────────────────────┘
方案1: MySQL LIKE查询
- 适用:数据量<1万
- 性能:慢 🐌
- 功能:基础
❌ 不推荐
方案2: MySQL全文索引
- 适用:数据量<10万
- 性能:一般
- 功能:中等
⚠️ 可用,但有限制
方案3: ElasticSearch(推荐⭐⭐⭐⭐⭐)
- 适用:任意数据量
- 性能:快 ⚡
- 功能:强大
✅ 强烈推荐
方案4: Solr
- 适用:任意数据量
- 性能:快
- 功能:强大
✅ 适合企业级
方案5: Meilisearch
- 适用:中小型项目
- 性能:很快
- 功能:简单易用
✅ 轻量级方案
🚀 ElasticSearch实战
1. 索引设计
// 商品索引结构
PUT /products
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"ik_smart_pinyin": {
"type": "custom",
"tokenizer": "ik_smart",
"filter": ["lowercase", "pinyin"]
}
},
"filter": {
"pinyin": {
"type": "pinyin",
"keep_first_letter": true,
"keep_full_pinyin": false
}
}
}
},
"mappings": {
"properties": {
"id": {
"type": "long"
},
"name": {
"type": "text",
"analyzer": "ik_smart_pinyin",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"description": {
"type": "text",
"analyzer": "ik_smart"
},
"category_id": {
"type": "long"
},
"category_name": {
"type": "keyword"
},
"brand_id": {
"type": "long"
},
"brand_name": {
"type": "keyword"
},
"price": {
"type": "double"
},
"sales": {
"type": "long"
},
"rating": {
"type": "float"
},
"stock": {
"type": "integer"
},
"attributes": {
"type": "nested",
"properties": {
"name": {
"type": "keyword"
},
"value": {
"type": "keyword"
}
}
},
"tags": {
"type": "keyword"
},
"images": {
"type": "keyword",
"index": false
},
"create_time": {
"type": "date"
},
"update_time": {
"type": "date"
},
"status": {
"type": "integer"
}
}
}
}
2. Java实体类
@Data
@Document(indexName = "products")
public class ProductDocument {
@Id
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_smart_pinyin")
private String name;
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String description;
@Field(type = FieldType.Long)
private Long categoryId;
@Field(type = FieldType.Keyword)
private String categoryName;
@Field(type = FieldType.Long)
private Long brandId;
@Field(type = FieldType.Keyword)
private String brandName;
@Field(type = FieldType.Double)
private Double price;
@Field(type = FieldType.Long)
private Long sales;
@Field(type = FieldType.Float)
private Float rating;
@Field(type = FieldType.Integer)
private Integer stock;
@Field(type = FieldType.Nested)
private List<ProductAttribute> attributes;
@Field(type = FieldType.Keyword)
private List<String> tags;
@Field(type = FieldType.Keyword, index = false)
private List<String> images;
@Field(type = FieldType.Date, format = DateFormat.date_time)
private LocalDateTime createTime;
@Field(type = FieldType.Date, format = DateFormat.date_time)
private LocalDateTime updateTime;
@Field(type = FieldType.Integer)
private Integer status;
}
@Data
public class ProductAttribute {
private String name; // 属性名:颜色、尺寸、内存
private String value; // 属性值:黑色、128GB
}
3. 搜索Service
@Service
@Slf4j
public class ProductSearchService {
@Autowired
private ElasticsearchRestTemplate elasticsearchTemplate;
/**
* 综合搜索
*/
public PageResult<ProductDocument> search(ProductSearchDTO searchDTO) {
// 1. 构建查询
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 2. 关键词搜索
if (StringUtils.isNotBlank(searchDTO.getKeyword())) {
queryBuilder.withQuery(buildKeywordQuery(searchDTO.getKeyword()));
}
// 3. 分类筛选
if (searchDTO.getCategoryId() != null) {
queryBuilder.withFilter(QueryBuilders.termQuery("category_id", searchDTO.getCategoryId()));
}
// 4. 品牌筛选(支持多选)
if (searchDTO.getBrandIds() != null && !searchDTO.getBrandIds().isEmpty()) {
queryBuilder.withFilter(QueryBuilders.termsQuery("brand_id", searchDTO.getBrandIds()));
}
// 5. 价格区间
if (searchDTO.getMinPrice() != null || searchDTO.getMaxPrice() != null) {
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price");
if (searchDTO.getMinPrice() != null) {
rangeQuery.gte(searchDTO.getMinPrice());
}
if (searchDTO.getMaxPrice() != null) {
rangeQuery.lte(searchDTO.getMaxPrice());
}
queryBuilder.withFilter(rangeQuery);
}
// 6. 属性筛选(颜色、尺寸等)
if (searchDTO.getAttributes() != null && !searchDTO.getAttributes().isEmpty()) {
for (Map.Entry<String, List<String>> entry : searchDTO.getAttributes().entrySet()) {
String attrName = entry.getKey();
List<String> attrValues = entry.getValue();
// 使用nested查询
BoolQueryBuilder nestedBool = QueryBuilders.boolQuery();
nestedBool.must(QueryBuilders.termQuery("attributes.name", attrName));
nestedBool.must(QueryBuilders.termsQuery("attributes.value", attrValues));
NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery(
"attributes",
nestedBool,
ScoreMode.None
);
queryBuilder.withFilter(nestedQuery);
}
}
// 7. 排序
addSorting(queryBuilder, searchDTO.getSortType());
// 8. 分页
queryBuilder.withPageable(
PageRequest.of(searchDTO.getPage() - 1, searchDTO.getSize())
);
// 9. 高亮
if (StringUtils.isNotBlank(searchDTO.getKeyword())) {
queryBuilder.withHighlightFields(
new HighlightBuilder.Field("name").preTags("<em>").postTags("</em>"),
new HighlightBuilder.Field("description").preTags("<em>").postTags("</em>")
);
}
// 10. 执行查询
SearchHits<ProductDocument> searchHits = elasticsearchTemplate.search(
queryBuilder.build(),
ProductDocument.class
);
// 11. 处理高亮
List<ProductDocument> products = searchHits.getSearchHits().stream()
.map(hit -> {
ProductDocument product = hit.getContent();
// 处理高亮
Map<String, List<String>> highlightFields = hit.getHighlightFields();
if (highlightFields.containsKey("name")) {
product.setName(highlightFields.get("name").get(0));
}
return product;
})
.collect(Collectors.toList());
// 12. 返回结果
return PageResult.<ProductDocument>builder()
.total(searchHits.getTotalHits())
.page(searchDTO.getPage())
.size(searchDTO.getSize())
.data(products)
.build();
}
/**
* 构建关键词查询
*/
private QueryBuilder buildKeywordQuery(String keyword) {
return QueryBuilders.multiMatchQuery(keyword)
.field("name", 3.0f) // name权重3
.field("description", 1.0f) // description权重1
.field("brand_name", 2.0f) // brand_name权重2
.type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
.fuzziness(Fuzziness.AUTO); // 模糊匹配
}
/**
* 添加排序
*/
private void addSorting(NativeSearchQueryBuilder queryBuilder, String sortType) {
if (sortType == null) {
sortType = "default";
}
switch (sortType) {
case "price_asc":
queryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.ASC));
break;
case "price_desc":
queryBuilder.withSort(SortBuilders.fieldSort("price").order(SortOrder.DESC));
break;
case "sales":
queryBuilder.withSort(SortBuilders.fieldSort("sales").order(SortOrder.DESC));
break;
case "rating":
queryBuilder.withSort(SortBuilders.fieldSort("rating").order(SortOrder.DESC));
break;
case "new":
queryBuilder.withSort(SortBuilders.fieldSort("create_time").order(SortOrder.DESC));
break;
default:
// 综合排序(评分 + 销量)
queryBuilder.withSort(SortBuilders.scoreSort().order(SortOrder.DESC));
queryBuilder.withSort(SortBuilders.fieldSort("sales").order(SortOrder.DESC));
}
}
}
4. 搜索DTO
@Data
public class ProductSearchDTO {
private String keyword; // 关键词
private Long categoryId; // 分类ID
private List<Long> brandIds; // 品牌ID列表
private Double minPrice; // 最低价格
private Double maxPrice; // 最高价格
private Map<String, List<String>> attributes; // 属性筛选
// 例如:{"颜色": ["黑色", "白色"], "内存": ["128GB"]}
private String sortType = "default"; // 排序类型
// default/price_asc/price_desc/sales/rating/new
private Integer page = 1; // 页码
private Integer size = 20; // 每页数量
}
5. Controller
@RestController
@RequestMapping("/api/product")
public class ProductSearchController {
@Autowired
private ProductSearchService searchService;
/**
* 商品搜索
*/
@GetMapping("/search")
public Result<PageResult<ProductDocument>> search(ProductSearchDTO searchDTO) {
PageResult<ProductDocument> result = searchService.search(searchDTO);
return Result.success(result);
}
/**
* 获取筛选项(聚合查询)
*/
@GetMapping("/filters")
public Result<ProductFilters> getFilters(ProductSearchDTO searchDTO) {
ProductFilters filters = searchService.getFilters(searchDTO);
return Result.success(filters);
}
}
🎯 聚合统计(获取筛选项)
/**
* 获取可用的筛选项
*/
public ProductFilters getFilters(ProductSearchDTO searchDTO) {
// 1. 构建基础查询(不含需要聚合的字段)
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
if (StringUtils.isNotBlank(searchDTO.getKeyword())) {
queryBuilder.withQuery(buildKeywordQuery(searchDTO.getKeyword()));
}
// 2. 聚合:品牌
queryBuilder.addAggregation(
AggregationBuilders.terms("brands")
.field("brand_id")
.size(100)
.subAggregation(
AggregationBuilders.terms("brand_names")
.field("brand_name")
)
);
// 3. 聚合:分类
queryBuilder.addAggregation(
AggregationBuilders.terms("categories")
.field("category_id")
.size(100)
.subAggregation(
AggregationBuilders.terms("category_names")
.field("category_name")
)
);
// 4. 聚合:价格区间
queryBuilder.addAggregation(
AggregationBuilders.histogram("prices")
.field("price")
.interval(500) // 每500元一个区间
.minDocCount(1)
);
// 5. 聚合:属性(嵌套聚合)
queryBuilder.addAggregation(
AggregationBuilders.nested("attrs", "attributes")
.subAggregation(
AggregationBuilders.terms("attr_names")
.field("attributes.name")
.subAggregation(
AggregationBuilders.terms("attr_values")
.field("attributes.value")
)
)
);
// 6. 执行查询
SearchHits<ProductDocument> searchHits = elasticsearchTemplate.search(
queryBuilder.build(),
ProductDocument.class
);
// 7. 解析聚合结果
Aggregations aggregations = searchHits.getAggregations();
ProductFilters filters = new ProductFilters();
// 解析品牌
ParsedLongTerms brandsAgg = aggregations.get("brands");
List<BrandFilter> brands = brandsAgg.getBuckets().stream()
.map(bucket -> {
Long brandId = bucket.getKeyAsNumber().longValue();
ParsedStringTerms brandNamesAgg = bucket.getAggregations().get("brand_names");
String brandName = brandNamesAgg.getBuckets().get(0).getKeyAsString();
return new BrandFilter(brandId, brandName, bucket.getDocCount());
})
.collect(Collectors.toList());
filters.setBrands(brands);
// 解析分类...
// 解析价格区间...
// 解析属性...
return filters;
}
@Data
public class ProductFilters {
private List<BrandFilter> brands; // 品牌
private List<CategoryFilter> categories; // 分类
private List<PriceRange> priceRanges; // 价格区间
private Map<String, List<String>> attributes; // 属性
}
@Data
@AllArgsConstructor
public class BrandFilter {
private Long brandId;
private String brandName;
private Long count; // 商品数量
}
🎨 前端实现(Vue3)
<template>
<div class="product-search">
<!-- 搜索框 -->
<div class="search-bar">
<el-input
v-model="searchParams.keyword"
placeholder="请输入商品名称"
@keyup.enter="search"
>
<template #append>
<el-button icon="Search" @click="search">搜索</el-button>
</template>
</el-input>
</div>
<!-- 筛选器 -->
<div class="filters">
<!-- 分类 -->
<div class="filter-item">
<span class="label">分类:</span>
<el-radio-group v-model="searchParams.categoryId" @change="search">
<el-radio-button :label="null">全部</el-radio-button>
<el-radio-button
v-for="cat in filters.categories"
:key="cat.categoryId"
:label="cat.categoryId"
>
{{ cat.categoryName }} ({{ cat.count }})
</el-radio-button>
</el-radio-group>
</div>
<!-- 品牌 -->
<div class="filter-item">
<span class="label">品牌:</span>
<el-checkbox-group v-model="searchParams.brandIds" @change="search">
<el-checkbox
v-for="brand in filters.brands"
:key="brand.brandId"
:label="brand.brandId"
>
{{ brand.brandName }} ({{ brand.count }})
</el-checkbox>
</el-checkbox-group>
</div>
<!-- 价格 -->
<div class="filter-item">
<span class="label">价格:</span>
<el-radio-group v-model="priceRange" @change="onPriceChange">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="0-1000">0-1000元</el-radio-button>
<el-radio-button label="1000-3000">1000-3000元</el-radio-button>
<el-radio-button label="3000-5000">3000-5000元</el-radio-button>
<el-radio-button label="5000-">5000元以上</el-radio-button>
</el-radio-group>
<!-- 自定义价格 -->
<div class="custom-price">
<el-input-number v-model="searchParams.minPrice" :min="0" placeholder="最低价" />
<span>-</span>
<el-input-number v-model="searchParams.maxPrice" :min="0" placeholder="最高价" />
<el-button @click="search">确定</el-button>
</div>
</div>
<!-- 属性 -->
<div v-for="(values, name) in filters.attributes" :key="name" class="filter-item">
<span class="label">{{ name }}:</span>
<el-checkbox-group v-model="searchParams.attributes[name]" @change="search">
<el-checkbox v-for="value in values" :key="value" :label="value">
{{ value }}
</el-checkbox>
</el-checkbox-group>
</div>
</div>
<!-- 排序和结果数 -->
<div class="toolbar">
<div class="result-count">
找到 <span class="count">{{ total }}</span> 件商品
</div>
<div class="sort-buttons">
<el-button
:type="searchParams.sortType === 'default' ? 'primary' : ''"
@click="changeSort('default')"
>
综合排序
</el-button>
<el-button
:type="searchParams.sortType === 'sales' ? 'primary' : ''"
@click="changeSort('sales')"
>
销量
</el-button>
<el-button
:type="searchParams.sortType === 'price_asc' ? 'primary' : ''"
@click="changeSort('price_asc')"
>
价格↑
</el-button>
<el-button
:type="searchParams.sortType === 'price_desc' ? 'primary' : ''"
@click="changeSort('price_desc')"
>
价格↓
</el-button>
<el-button
:type="searchParams.sortType === 'rating' ? 'primary' : ''"
@click="changeSort('rating')"
>
评价
</el-button>
</div>
</div>
<!-- 商品列表 -->
<div v-loading="loading" class="product-list">
<div
v-for="product in products"
:key="product.id"
class="product-item"
@click="viewProduct(product.id)"
>
<img :src="product.images[0]" alt="">
<div class="info">
<div class="name" v-html="product.name"></div>
<div class="price">¥{{ product.price }}</div>
<div class="meta">
<span>销量{{ product.sales }}</span>
<span>评分{{ product.rating }}</span>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<el-pagination
v-model:current-page="searchParams.page"
v-model:page-size="searchParams.size"
:total="total"
layout="prev, pager, next, sizes"
@current-change="search"
@size-change="search"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { searchProducts, getFilters } from '@/api/product'
const loading = ref(false)
const products = ref([])
const total = ref(0)
const filters = reactive({
brands: [],
categories: [],
priceRanges: [],
attributes: {}
})
const searchParams = reactive({
keyword: '',
categoryId: null,
brandIds: [],
minPrice: null,
maxPrice: null,
attributes: {},
sortType: 'default',
page: 1,
size: 20
})
const priceRange = ref('all')
// 搜索
const search = async () => {
loading.value = true
try {
const res = await searchProducts(searchParams)
products.value = res.data.data
total.value = res.data.total
// 同时获取筛选项
const filtersRes = await getFilters(searchParams)
Object.assign(filters, filtersRes.data)
} catch (error) {
console.error('搜索失败', error)
} finally {
loading.value = false
}
}
// 改变排序
const changeSort = (sortType) => {
searchParams.sortType = sortType
search()
}
// 价格区间变化
const onPriceChange = () => {
if (priceRange.value === 'all') {
searchParams.minPrice = null
searchParams.maxPrice = null
} else if (priceRange.value === '5000-') {
searchParams.minPrice = 5000
searchParams.maxPrice = null
} else {
const [min, max] = priceRange.value.split('-').map(Number)
searchParams.minPrice = min
searchParams.maxPrice = max
}
search()
}
// 查看商品
const viewProduct = (id) => {
window.open(`/product/${id}`)
}
onMounted(() => {
search()
})
</script>
<style scoped>
.product-search {
padding: 20px;
}
.search-bar {
margin-bottom: 20px;
}
.filters {
background: #f5f5f5;
padding: 20px;
margin-bottom: 20px;
}
.filter-item {
margin-bottom: 15px;
}
.filter-item .label {
font-weight: bold;
margin-right: 10px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.result-count .count {
color: #ff6700;
font-weight: bold;
}
.product-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.product-item {
border: 1px solid #e0e0e0;
padding: 10px;
cursor: pointer;
transition: all 0.3s;
}
.product-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.product-item img {
width: 100%;
height: 200px;
object-fit: cover;
}
.product-item .name {
margin: 10px 0;
font-size: 14px;
height: 40px;
overflow: hidden;
}
.product-item .price {
color: #ff6700;
font-size: 18px;
font-weight: bold;
}
.product-item .meta {
font-size: 12px;
color: #999;
}
.product-item .meta span {
margin-right: 10px;
}
</style>
🚀 性能优化
1. 缓存热门搜索
@Service
public class SearchCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 缓存搜索结果(热门搜索)
*/
public PageResult<ProductDocument> getCachedResult(ProductSearchDTO searchDTO) {
String cacheKey = "search:" + MD5Util.md5(JSON.toJSONString(searchDTO));
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return (PageResult<ProductDocument>) cached;
}
return null;
}
public void cacheResult(ProductSearchDTO searchDTO, PageResult<ProductDocument> result) {
String cacheKey = "search:" + MD5Util.md5(JSON.toJSONString(searchDTO));
redisTemplate.opsForValue().set(cacheKey, result, Duration.ofMinutes(5));
}
}
2. 搜索建议(自动补全)
/**
* 搜索建议
*/
public List<String> suggest(String keyword) {
// 使用Completion Suggester
CompletionSuggestionBuilder suggestionBuilder =
SuggestBuilders.completionSuggestion("name.suggest")
.prefix(keyword)
.size(10);
SuggestBuilder suggestBuilder = new SuggestBuilder()
.addSuggestion("product-suggest", suggestionBuilder);
SearchRequest searchRequest = new SearchRequest("products");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.suggest(suggestBuilder);
searchRequest.source(sourceBuilder);
try {
SearchResponse response = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
Suggest suggest = response.getSuggest();
CompletionSuggestion suggestion = suggest.getSuggestion("product-suggest");
return suggestion.getOptions().stream()
.map(option -> option.getText().string())
.collect(Collectors.toList());
} catch (IOException e) {
log.error("搜索建议失败", e);
return Collections.emptyList();
}
}
📝 总结
关键要点 🎯
- 使用ElasticSearch - 专业搜索引擎
- 合理设计索引 - 分词器、字段类型
- 聚合统计 - 动态筛选项
- 性能优化 - 缓存、异步
- 用户体验 - 实时筛选、搜索建议
完整方案
前端:
- Vue3 + Element Plus
- 实时筛选
- 无限滚动/分页
后端:
- Spring Boot
- ElasticSearch
- Redis缓存
数据同步:
- Canal订阅MySQL binlog
- 实时同步到ES
监控:
- 搜索热词统计
- 慢查询分析
- 零结果搜索收集
让搜索快如闪电! ⚡⚡⚡