一、一个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+ | 毫秒级 | 丰富 | 高 |
血的教训:
搜索是用户找商品的入口,性能差直接影响转化率。
个人观点,仅供参考