🔍 商品搜索多条件过滤:搜索引擎的艺术

30 阅读8分钟

难度系数:⭐⭐⭐⭐⭐
实用指数:💯💯💯💯💯


📖 开篇:一次糟糕的购物体验

小明在某电商平台搜索"手机":

搜索结果: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();
    }
}

📝 总结

关键要点 🎯

  1. 使用ElasticSearch - 专业搜索引擎
  2. 合理设计索引 - 分词器、字段类型
  3. 聚合统计 - 动态筛选项
  4. 性能优化 - 缓存、异步
  5. 用户体验 - 实时筛选、搜索建议

完整方案

前端:
  - Vue3 + Element Plus
  - 实时筛选
  - 无限滚动/分页

后端:
  - Spring Boot
  - ElasticSearch
  - Redis缓存

数据同步:
  - Canal订阅MySQL binlog
  - 实时同步到ES

监控:
  - 搜索热词统计
  - 慢查询分析
  - 零结果搜索收集

让搜索快如闪电! ⚡⚡⚡