【架构实战】搜索系统架构:从LIKE查询到ES的演进

一、一个LIKE查询拖垮了数据库

2018年,我们的商品搜索用MySQL LIKE查询:

sql 复制代码
SELECT * FROM product WHERE name LIKE '%手机%';

结果:

  • 商品表100万条记录
  • LIKE查询耗时10秒
  • 数据库CPU飙到100%
  • 全站搜索功能不可用

从那以后,我们开始搭建专业的搜索系统。


二、搜索系统演进

2.1 阶段一:数据库LIKE查询

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    搜索系统 - 阶段一                              │
│                                                                  │
│    用户 → 搜索服务 → MySQL                                        │
│                    ↘ SELECT * FROM product                      │
│                       WHERE name LIKE '%手机%'                   │
│                                                                  │
│    问题:                                                         │
│    - 全表扫描,性能差                                             │
│    - 索引无效(前后通配)                                         │
│    - 占用数据库资源                                               │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

2.2 阶段二:数据库全文索引

sql 复制代码
-- MySQL全文索引
ALTER TABLE product ADD FULLTEXT INDEX ft_name(name);

-- 搜索查询
SELECT * FROM product WHERE MATCH(name) AGAINST('手机');

问题:

  • 中文分词差
  • 性能仍然有限
  • 功能简单

2.3 阶段三:Elasticsearch

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    搜索系统 - 阶段三                              │
│                                                                  │
│    用户 → 搜索服务 → Elasticsearch Cluster                        │
│                       ├── ES Node 1                              │
│                       ├── ES Node 2                              │
│                       └── ES Node 3                              │
│                                                                  │
│    MySQL → Canal → Kafka → ES (数据同步)                        │
│                                                                  │
│    特点:                                                         │
│    - 倒排索引,毫秒级响应                                         │
│    - 分布式,水平扩展                                             │
│    - 功能丰富(高亮、推荐、聚合)                                  │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

三、Elasticsearch核心原理

3.1 倒排索引

复制代码
正排索引(文档 → 词):
文档ID | 内容
-------|----------------
1      | 小米手机很好用
2      | 华为手机性价比高
3      | 苹果手机很流畅

倒排索引(词 → 文档):
词     | 文档ID列表
-------|------------
小米   | [1]
手机   | [1, 2, 3]
华为   | [2]
苹果   | [3]

搜索"小米手机":
1. 分词:小米、手机
2. 查倒排索引:
   - 小米 → [1]
   - 手机 → [1, 2, 3]
3. 交集:[1]
4. 返回文档1

3.2 分词器

json 复制代码
// IK分词器配置
PUT /product
{
  "settings": {
    "analysis": {
      "analyzer": {
        "ik_smart_analyzer": {
          "type": "custom",
          "tokenizer": "ik_smart"
        },
        "ik_max_word_analyzer": {
          "type": "custom",
          "tokenizer": "ik_max_word"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "ik_max_word_analyzer",
        "search_analyzer": "ik_smart_analyzer"
      }
    }
  }
}

3.3 索引结构

json 复制代码
// 商品索引结构
PUT /product
{
  "mappings": {
    "properties": {
      "id": {
        "type": "long"
      },
      "name": {
        "type": "text",
        "analyzer": "ik_max_word",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "category": {
        "type": "keyword"
      },
      "brand": {
        "type": "keyword"
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "sales": {
        "type": "integer"
      },
      "status": {
        "type": "keyword"
      },
      "createTime": {
        "type": "date"
      },
      "suggest": {
        "type": "completion",
        "analyzer": "ik_smart"
      }
    }
  }
}

四、搜索核心功能

4.1 关键词搜索

java 复制代码
/**
 * 关键词搜索服务
 */
@Service
@Slf4j
public class SearchService {
    
    @Autowired
    private RestHighLevelClient esClient;
    
    /**
     * 关键词搜索
     */
    public SearchResult search(SearchRequest request) {
        // 1. 构建查询条件
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        
        // 关键词搜索(name字段)
        if (StringUtils.isNotBlank(request.getKeyword())) {
            boolQuery.must(QueryBuilders.matchQuery("name", request.getKeyword()));
        }
        
        // 分类筛选
        if (request.getCategory() != null) {
            boolQuery.filter(QueryBuilders.termQuery("category", request.getCategory()));
        }
        
        // 品牌筛选
        if (request.getBrand() != null) {
            boolQuery.filter(QueryBuilders.termsQuery("brand", request.getBrand()));
        }
        
        // 价格区间
        if (request.getMinPrice() != null || request.getMaxPrice() != null) {
            RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price");
            if (request.getMinPrice() != null) {
                rangeQuery.gte(request.getMinPrice());
            }
            if (request.getMaxPrice() != null) {
                rangeQuery.lte(request.getMaxPrice());
            }
            boolQuery.filter(rangeQuery);
        }
        
        // 状态筛选
        boolQuery.filter(QueryBuilders.termQuery("status", "ON_SALE"));
        
        // 2. 构建搜索请求
        org.elasticsearch.action.search.SearchRequest searchRequest = 
            new org.elasticsearch.action.search.SearchRequest("product");
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        sourceBuilder.query(boolQuery);
        
        // 分页
        sourceBuilder.from((request.getPage() - 1) * request.getSize());
        sourceBuilder.size(request.getSize());
        
        // 排序
        if ("sales".equals(request.getSort())) {
            sourceBuilder.sort("sales", SortOrder.DESC);
        } else if ("price_asc".equals(request.getSort())) {
            sourceBuilder.sort("price", SortOrder.ASC);
        } else if ("price_desc".equals(request.getSort())) {
            sourceBuilder.sort("price", SortOrder.DESC);
        } else {
            sourceBuilder.sort("_score", SortOrder.DESC);
        }
        
        // 高亮
        HighlightBuilder highlightBuilder = new HighlightBuilder();
        highlightBuilder.field("name");
        highlightBuilder.preTags("<em>");
        highlightBuilder.postTags("</em>");
        sourceBuilder.highlight(highlightBuilder);
        
        searchRequest.source(sourceBuilder);
        
        // 3. 执行搜索
        try {
            org.elasticsearch.action.search.SearchResponse response = 
                esClient.search(searchRequest, RequestOptions.DEFAULT);
            
            // 4. 解析结果
            List<Product> products = new ArrayList<>();
            for (SearchHit hit : response.getHits()) {
                Product product = JSON.parseObject(hit.getSourceAsString(), Product.class);
                
                // 处理高亮
                Map<String, HighlightField> highlightFields = hit.getHighlightFields();
                if (highlightFields.containsKey("name")) {
                    product.setName(highlightFields.get("name").fragments()[0].string());
                }
                
                products.add(product);
            }
            
            return new SearchResult(
                products,
                response.getHits().getTotalHits().value,
                request.getPage(),
                request.getSize()
            );
        } catch (IOException e) {
            log.error("搜索失败", e);
            throw new BusinessException("搜索失败,请稍后重试");
        }
    }
}

4.2 搜索建议

java 复制代码
/**
 * 搜索建议服务
 */
@Service
public class SuggestService {
    
    @Autowired
    private RestHighLevelClient esClient;
    
    /**
     * 搜索建议(自动补全)
     */
    public List<String> suggest(String keyword) {
        SearchRequest request = new SearchRequest("product");
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        
        // 补全建议
        SuggestionBuilder suggestionBuilder = SuggestBuilders
            .completionSuggestion("suggest")
            .prefix(keyword)
            .size(10);
        
        sourceBuilder.suggest(new SuggestBuilder()
            .addSuggestion("product_suggest", suggestionBuilder));
        
        request.source(sourceBuilder);
        
        try {
            SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
            
            CompletionSuggestion suggestion = response.getSuggest()
                .getSuggestion("product_suggest");
            
            return suggestion.getEntries().stream()
                .flatMap(entry -> entry.getOptions().stream())
                .map(option -> option.getText().toString())
                .distinct()
                .collect(Collectors.toList());
        } catch (IOException e) {
            log.error("搜索建议失败", e);
            return Collections.emptyList();
        }
    }
}

4.3 聚合统计

java 复制代码
/**
 * 聚合统计服务
 */
@Service
public class AggregationService {
    
    /**
     * 搜索结果聚合(品牌、分类)
     */
    public SearchAggregation aggregate(String keyword) {
        SearchRequest request = new SearchRequest("product");
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        
        // 关键词查询
        sourceBuilder.query(QueryBuilders.matchQuery("name", keyword));
        
        // 品牌聚合
        TermsAggregationBuilder brandAgg = AggregationBuilders
            .terms("brand_agg")
            .field("brand")
            .size(20);
        
        // 分类聚合
        TermsAggregationBuilder categoryAgg = AggregationBuilders
            .terms("category_agg")
            .field("category")
            .size(20);
        
        // 价格区间聚合
        RangeAggregationBuilder priceAgg = AggregationBuilders
            .range("price_agg")
            .field("price")
            .addRange(0, 100)
            .addRange(100, 500)
            .addRange(500, 1000)
            .addUnboundedFrom(1000);
        
        sourceBuilder.aggregation(brandAgg);
        sourceBuilder.aggregation(categoryAgg);
        sourceBuilder.aggregation(priceAgg);
        
        request.source(sourceBuilder);
        
        try {
            SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
            
            // 解析品牌聚合
            List<BrandCount> brands = new ArrayList<>();
            Terms brandTerms = response.getAggregations().get("brand_agg");
            for (Terms.Bucket bucket : brandTerms.getBuckets()) {
                brands.add(new BrandCount(bucket.getKeyAsString(), bucket.getDocCount()));
            }
            
            // 解析分类聚合
            List<CategoryCount> categories = new ArrayList<>();
            Terms categoryTerms = response.getAggregations().get("category_agg");
            for (Terms.Bucket bucket : categoryTerms.getBuckets()) {
                categories.add(new CategoryCount(bucket.getKeyAsString(), bucket.getDocCount()));
            }
            
            return new SearchAggregation(brands, categories);
        } catch (IOException e) {
            log.error("聚合查询失败", e);
            return new SearchAggregation();
        }
    }
}

五、数据同步方案

5.1 Canal + Kafka同步

java 复制代码
/**
 * 商品数据同步服务
 */
@Service
@Slf4j
public class ProductSyncService {
    
    @Autowired
    private RestHighLevelClient esClient;
    
    @Autowired
    private ProductMapper productMapper;
    
    /**
     * 监听Canal消息(通过Kafka)
     */
    @KafkaListener(topics = "canal-product")
    public void onMessage(String message) {
        CanalEntry entry = JSON.parseObject(message, CanalEntry.class);
        
        for (CanalEntry.RowData rowData : entry.getRowDataList()) {
            if (entry.getEventType() == CanalEntry.EventType.INSERT) {
                // 新增
                Product product = convertToProduct(rowData.getAfterColumnsList());
                indexProduct(product);
            } else if (entry.getEventType() == CanalEntry.EventType.UPDATE) {
                // 更新
                Product product = convertToProduct(rowData.getAfterColumnsList());
                updateProduct(product);
            } else if (entry.getEventType() == CanalEntry.EventType.DELETE) {
                // 删除
                Long productId = getProductId(rowData.getBeforeColumnsList());
                deleteProduct(productId);
            }
        }
    }
    
    /**
     * 索引商品
     */
    private void indexProduct(Product product) {
        try {
            IndexRequest request = new IndexRequest("product");
            request.id(String.valueOf(product.getId()));
            request.source(JSON.toJSONString(product), XContentType.JSON);
            esClient.index(request, RequestOptions.DEFAULT);
            log.info("索引商品成功: productId={}", product.getId());
        } catch (IOException e) {
            log.error("索引商品失败: productId={}", product.getId(), e);
        }
    }
    
    /**
     * 全量同步
     */
    @Scheduled(cron = "0 0 3 * * ?")  // 每天凌晨3点
    public void fullSync() {
        log.info("开始全量同步商品数据...");
        
        int pageSize = 1000;
        int pageNo = 1;
        int total = 0;
        
        while (true) {
            List<Product> products = productMapper.selectByPage(pageNo, pageSize);
            if (products.isEmpty()) {
                break;
            }
            
            BulkRequest bulkRequest = new BulkRequest();
            for (Product product : products) {
                IndexRequest request = new IndexRequest("product");
                request.id(String.valueOf(product.getId()));
                request.source(JSON.toJSONString(product), XContentType.JSON);
                bulkRequest.add(request);
            }
            
            try {
                esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
                total += products.size();
                log.info("已同步{}条商品", total);
            } catch (IOException e) {
                log.error("批量同步失败", e);
            }
            
            pageNo++;
        }
        
        log.info("全量同步完成,共{}条", total);
    }
}

六、踩坑实录

坑1:深分页性能问题

问题:分页查询深度越大,性能越差。

踩坑场景

  • 查询第1000页,每页10条
  • ES需要从每个分片查询10010条数据,再排序取10条
  • 性能急剧下降

解决方案

java 复制代码
// 方案1:限制分页深度
if (request.getPage() > 100) {
    throw new BusinessException("最多只能查看前100页");
}

// 方案2:使用scroll API(适合导出)
SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId);
scrollRequest.scroll(TimeValue.timeValueMinutes(1));

// 方案3:使用search_after(适合无限滚动)
sourceBuilder.searchAfter(new Object[]{lastSortValue});

坑2:字段类型错误

问题:字符串字段默认是text,不能用于聚合。

踩坑场景

对品牌字段聚合,报错"Fielddata is disabled on text fields"。

解决方案

json 复制代码
{
  "brand": {
    "type": "text",
    "fields": {
      "keyword": {
        "type": "keyword"
      }
    }
  }
}

// 聚合时使用brand.keyword
AggregationBuilders.terms("brand_agg").field("brand.keyword");

坑3:分词不一致

问题:索引和搜索使用不同分词器,导致搜索不到。

解决方案

json 复制代码
{
  "name": {
    "type": "text",
    "analyzer": "ik_max_word",       // 索引时:最大化分词
    "search_analyzer": "ik_smart"    // 搜索时:智能分词
  }
}

坑4:数据同步延迟

问题:MySQL更新后,ES数据未及时同步。

解决方案

java 复制代码
// 1. 关键数据实时同步(双写)
@Transactional
public void updateProduct(Product product) {
    // 更新MySQL
    productMapper.update(product);
    
    // 同步更新ES(可能失败)
    try {
        esClient.update(...);
    } catch (Exception e) {
        // 失败则发送补偿消息
        mqTemplate.send("es-sync", product.getId());
    }
}

// 2. 定时任务补偿
@Scheduled(fixedDelay = 60000)
public void syncCheck() {
    // 对比MySQL和ES数据,差异则同步
}

坑5:集群脑裂

问题:网络分区导致集群分裂,数据不一致。

解决方案

yaml 复制代码
# ES配置
discovery.zen.minimum_master_nodes: 2  # 至少2个master节点
discovery.zen.ping_timeout: 3s         # 心跳超时时间
discovery.zen.join_timeout: 60s        # 加入集群超时

七、最佳实践

7.1 索引设计规范

markdown 复制代码
# ES索引设计规范:

1. 索引命名
   - 格式:{业务}_{版本}
   - 示例:product_v1

2. 字段类型
   - 精确匹配:keyword
   - 全文搜索:text
   - 数值:integer/long/scaled_float
   - 日期:date

3. 分片数量
   - 单分片不超过50GB
   - 每个节点不超过20个分片

4. 副本数量
   - 生产环境至少1个副本
   - 高可用场景2个副本

5. 刷新间隔
   - 实时性要求高:1s
   - 批量导入:30s

八、总结

搜索系统核心要点:

方案 QPS 延迟 功能 复杂度
LIKE查询 100 秒级 简单
全文索引 1000 毫秒级 中等
ES 10000+ 毫秒级 丰富

血的教训:

搜索是用户找商品的入口,性能差直接影响转化率。


个人观点,仅供参考