一、 从"搜不到"到"搜得慢",再到"搜得准"
讲个真实故事。

我们的电商平台上线第一年,商品SKU只有2万个,搜索功能非常简单——MySQL的LIKE '%关键词%'模糊查询,勉勉强强能用,响应时间大约300ms。

第二年,商家入驻量暴增,商品SKU突破500万。噩梦开始了:

搜索"手机":扫描500万条记录的title字段,耗时12秒,直接超时。

搜索"苹果":用户想找Apple手机,结果出来一堆"苹果醋"、“苹果脆片”、“红富士苹果”,完全不相关。

搜索"huawei p50":因为输入的是英文,MySQL压根匹配不到中文标题"华为P50",搜索结果为空。

运营后台的搜索报表:稍微复杂一点的组合条件,直接跑崩数据库。

客服每天收到最多的投诉是:“你们商城的搜索功能是摆设吗?”

那一刻我们意识到:搜索不是锦上添花,是电商系统的核心基础设施。 搜不到商品,用户就不会下单。

本文将完整复盘我们从MySQL模糊查询 → Elasticsearch基础版 → ES深度优化的搜索架构演进之路,重点分享索引设计、相关性优化以及性能调优的实战经验。

二、 为什么MySQL做不了"真正的搜索"?
先看一个对比:

维度 MySQL LIKE查询 Elasticsearch
分词能力 不支持,只能全匹配 支持智能分词(IK、ICU等),中文/英文/拼音全支持
相关性排序 无,只能按时间/价格排序 支持TF-IDF、BM25等算法,按相关性智能排序
模糊容错 不支持,必须完全匹配 支持拼写纠错、同义词、前缀匹配
聚合分析 GROUP BY扫描全表,巨慢 毫秒级聚合,支持多维度统计
扩展性 单表过亿性能急剧下降 水平扩展,支持PB级数据
实时性 实时(但性能差) 近实时(1秒延迟)
本质区别:MySQL是基于B+树索引的精确匹配,而Elasticsearch是基于倒排索引的全文检索。两者底层逻辑完全不同。

倒排索引原理(一句话版):

不是"关键词 → 去文章里找",而是"关键词 → 告诉我哪些文章包含它"。先建一个词典("手机"出现在文档1、3、7中),然后用户搜"手机"时直接查词典,瞬间定位。

三、 技术选型:Elasticsearch vs 其他搜索方案
我们对比了市面上主流方案:

方案 优点 缺点 结论
Elasticsearch 全文检索能力强、生态完善、支持分布式、实时性高 运维复杂、JVM调优门槛高 ✅ 首选
阿里云OpenSearch 开箱即用、免运维 有厂商绑定、价格昂贵(年费几十万起步) ❌ 不适合创业公司
Solr 成熟稳定、社区活跃 与ES相比性能稍逊、缺乏实时性 ❌ 略过时
自建Lucene 完全可控 开发成本极高(需要自己封装分布式、集群管理等) ❌ 不现实
最终选择:Elasticsearch 7.x + Kibana + 自研管理后台

四、 索引设计:如何定义一个"好"的索引?
索引设计是搜索系统的地基。地基没打好,上层再怎么优化都白搭。

4.1 Mapping(映射)设计核心要点
错误示例(我们踩过的坑):

json
// 所有字段都用 text 类型,全是默认分词
{
“mappings”: {
“properties”: {
“title”: { “type”: “text” },
“category”: { “type”: “text” },
“brand”: { “type”: “text” },
“price”: { “type”: “text” }, // 不应该用text!无法范围查询
“create_time”: { “type”: “text” } // 不应该用text!无法排序
}
}
}
正确示例(优化后):

json
{
“mappings”: {
“properties”: {
// 1. 商品标题:ik_max_word(最细粒度分词,提高召回率)
“title”: {
“type”: “text”,
“analyzer”: “ik_max_word”,
“search_analyzer”: “ik_smart”,
“fields”: {
“keyword”: { “type”: “keyword”, “ignore_above”: 256 },
“pinyin”: { “type”: “text”, “analyzer”: “pinyin_analyzer” }
}
},
// 2. 商品副标题:ik_smart(粗粒度,提高精度)
“subtitle”: {
“type”: “text”,
“analyzer”: “ik_smart”
},
// 3. 类目和品牌:精准匹配用 keyword
“category_id”: { “type”: “keyword” },
“category_name”: { “type”: “keyword” },
“brand_id”: { “type”: “keyword” },
“brand_name”: { “type”: “keyword” },
// 4. 价格:数值类型,支持范围查询
“price”: { “type”: “double” },
“original_price”: { “type”: “double” },
// 5. 库存状态:布尔型
“in_stock”: { “type”: “boolean” },
// 6. 时间:date类型,支持排序
“create_time”: { “type”: “date”, “format”: “yyyy-MM-dd HH:mm:ss” },
“update_time”: { “type”: “date”, “format”: “yyyy-MM-dd HH:mm:ss” },
// 7. 销量和评分:用于排序
“sale_count”: { “type”: “integer” },
“rating”: { “type”: “double” },
// 8. 嵌套对象(多规格)
“skus”: {
“type”: “nested”,
“properties”: {
“sku_id”: { “type”: “keyword” },
“spec_name”: { “type”: “keyword” },
“spec_value”: { “type”: “keyword” },
“price”: { “type”: “double” }
}
}
}
}
}
4.2 分词器的选择(最关键的一步)
中文分词是全文检索的灵魂。

分词器 特点 适用场景
ik_smart 粗粒度,分词数量少,精度高 搜索框输入(用户一般输入短词)
ik_max_word 细粒度,分词数量多,召回率高 索引时的标题/描述分析
pinyin 支持拼音检索 用户输入拼音(如"huawei"搜"华为")
自定义词典 支持行业术语、品牌名、新词 电商领域专用词(如"国潮"、“新中式”)
自定义词典配置(关键步骤):

text

IKAnalyzer.cfg.xml

<?xml version="1.0" encoding="UTF-8"?> IK Analyzer 扩展配置 custom/ecommerce_dict.dic custom/synonyms.txt 自定义词典内容(ecommerce_dict.dic):

text
国潮
新中式
防晒衣
冲锋衣
露营装备
扫地机器人
洗地机
预制菜
同义词配置(synonyms.txt):

text
手机, 移动电话, 手提电话, 智能手机
电脑, 计算机, 笔记本电脑, 笔记本
衣服, 服装, 衣物, 服饰
华为, huawei, HUAWEI
苹果, apple, Apple, iPhone
效果对比:

用户搜索词 无自定义词典 有自定义词典
“冲锋衣” 搜不到(分词成"冲锋"+“衣”) ✅ 精准匹配"冲锋衣"商品
“扫地机器人” 搜成"扫地"+“机器人” ✅ 精准匹配
“huawei” 搜不到(英文) ✅ 通过拼音分词匹配"华为"
五、 数据同步:MySQL到ES的三种方案
商品数据存储在MySQL,需要同步到ES,我们对比了三种方案:

方案一:双写(同步写入)
流程:业务代码在写入MySQL后,同步写入ES。

java
@Transactional
public void saveProduct(Product product) {
// 1. 写入MySQL
productDao.insert(product);
// 2. 同步写入ES(如果ES写入失败,整体回滚?不现实)
esClient.index(product); // 问题:ES写入失败会阻塞主流程
}
优点:实时性高(毫秒级)。
缺点:

ES写入失败会影响主流程(耦合度高)。

写入性能受ES影响。

结论:不适合高并发场景。

方案二:Canal(异步监听binlog)✅ 我们最终选用
流程:

text
[MySQL binlog] → [Canal监听] → [MQ] → [ES消费者] → [写入ES]
优点:

业务代码零侵入。

解耦,ES写入失败不影响MySQL写入。

支持全量同步+增量同步。

缺点:

需要额外部署Canal和MQ组件。

有一定的延迟(约500ms-1秒)。

Canal配置示例:

yaml

canal.properties

canal.id = 1
canal.instance.master.address = 127.0.0.1:3306
canal.instance.dbUsername = canal
canal.instance.dbPassword = 123456

监听商品表

canal.instance.filter.regex = shop_db.t_product,shop_db.t_product_sku

MQ目标

canal.mq.topic = product-sync-topic
ES消费者代码:

java
@Component
@RocketMQMessageListener(topic = “product-sync-topic”, consumerGroup = “es-consumer”)
public class EsSyncConsumer implements RocketMQListener {

@Autowired
private ElasticsearchRestTemplate esTemplate;

@Override
public void onMessage(String message) {
    CanalMessage canalMsg = JSON.parseObject(message, CanalMessage.class);
    
    for (CanalEntry entry : canalMsg.getEntries()) {
        if (entry.getEntryType() != EntryType.ROWDATA) {
            continue;
        }
        
        RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
        if (rowChange.getEventType() == EventType.DELETE) {
            // 删除文档
            esTemplate.delete(entry.getTableName(), entry.getPrimaryKey());
        } else {
            // INSERT 或 UPDATE
            ProductDocument doc = convertToEsDoc(entry);
            esTemplate.save(doc);
        }
    }
}

}
方案三:定时任务(全量拉取)
流程:每隔1小时,从MySQL全量拉取商品数据,批量写入ES。

优点:实现简单。
缺点:延迟高(1小时),数据不及时。

结论:只适合冷备或初始化全量索引。

最终方案:Canal(主) + 定时任务(兜底)
主链路:Canal监听binlog,实时同步增量数据(延迟<1秒)。

兜底链路:每天凌晨3点,定时任务比对MySQL和ES数据总量,发现差异则触发全量修复。

六、 搜索性能优化:从3秒到30ms的进化
6.1 问题:首次搜索慢(冷启动)
现象:第一次搜索"手机",耗时3.2秒;第二次同样的搜索,耗时80ms。

原因:ES的文件系统缓存(OS Cache)是懒加载的。第一次查询时,数据从磁盘加载到内存;第二次查询命中缓存,速度飞快。

解决方案:强制预热

java
// 应用启动或凌晨定时执行
@Component
public class SearchCacheWarmer {

@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点预热
public void warmSearchCache() {
    // 预热热门搜索词列表(从搜索日志中统计Top 100)
    List<String> hotKeywords = getHotKeywords();
    
    for (String keyword : hotKeywords) {
        // 执行一次搜索,将数据加载到OS Cache
        searchService.search(keyword, 1, 20);
    }
    log.info("搜索缓存预热完成,预热词数:{}", hotKeywords.size());
}

}
6.2 问题:深分页性能差
场景:用户翻页到第100页(from=1980, size=20),ES需要从排序结果中截取前2000条,再取最后20条,随着页数增大,性能线性下降。

解决方案:使用Scroll或Search After

java
// ❌ 错误:深分页
SearchRequest request = new SearchRequest(“product”);
SearchSourceBuilder source = new SearchSourceBuilder()
.query(QueryBuilders.matchQuery(“title”, “手机”))
.from(1980).size(20); // 性能差!

// ✅ 正确:Search After(基于游标)
SearchRequest request = new SearchRequest(“product”);
SearchSourceBuilder source = new SearchSourceBuilder()
.query(QueryBuilders.matchQuery(“title”, “手机”))
.size(20)
.sort(“sale_count”, SortOrder.DESC) // 必须指定排序字段
.sort(“_id”, SortOrder.ASC); // 加一个唯一字段保证稳定性

// 第一次查询
SearchResponse response = client.search(request);
// 从结果中取最后一个文档的排序值作为下一次的Search After参数
Object[] lastSortValues = response.getHits().getHits()
.get(response.getHits().getHits().length - 1).getSortValues();
source.searchAfter(lastSortValues); // 下一页从这里开始
6.3 问题:聚合查询响应慢
场景:搜索"手机"时,左侧筛选栏需要展示"品牌聚合"、“价格区间聚合”、"屏幕尺寸聚合"等多个聚合,耗时1.2秒。

解决方案:Filter Aggregation + 并行聚合

java
// 使用CompletableFuture并行执行多个聚合
CompletableFuture brandAgg = CompletableFuture.supplyAsync(() ->
aggService.brandAgg(query)
);
CompletableFuture priceAgg = CompletableFuture.supplyAsync(() ->
aggService.priceAgg(query)
);
CompletableFuture sizeAgg = CompletableFuture.supplyAsync(() ->
aggService.sizeAgg(query)
);

// 等待所有聚合完成
CompletableFuture.allOf(brandAgg, priceAgg, sizeAgg).join();
优化效果:并行执行后,总耗时从1.2秒降低到400ms(单个最慢的聚合时间)。

6.4 问题:索引膨胀
现象:商品数据500万条,索引占用的磁盘空间竟然达到了200GB,远超MySQL的存储(40GB)。

原因:

ES的倒排索引、正向索引、doc_values等存储了多份数据。

_source字段存储了完整的原始JSON文档。

未设置合理的index_options和norms参数。

解决方案:

json
// 字段级优化
{
“title”: {
“type”: “text”,
“index_options”: “docs”, // 只存文档ID,不存词频和位置(节省空间)
“norms”: false // 不需要归一化因子(节省空间)
},
// 不需要搜索的字段,设置 “index”: false
“desc_detail”: {
“type”: “text”,
“index”: false // 不建索引,只存储_source
}
}
压缩效果:索引大小从200GB压缩至80GB,节省60% 存储空间。

七、 相关性优化:让"苹果"搜索出iPhone
搜索不只是"能搜到",更要"搜得准"。

7.1 自定义评分权重
不同字段的权重不同:

json
// Function Score Query:自定义评分
{
“query”: {
“function_score”: {
“query”: {
“match”: {
“title”: “手机”
}
},
“functions”: [
{
“filter”: { “range”: { “in_stock”: { “gte”: 1 } } },
“weight”: 10 // 有库存的权重+10
},
{
“filter”: { “range”: { “sale_count”: { “gte”: 1000 } } },
“weight”: 5 // 爆款商品权重+5
},
{
“field_value_factor”: {
“field”: “rating”,
“factor”: 2,
“modifier”: “log1p”
}
}
],
“score_mode”: “sum”, // 所有分数累加
“boost_mode”: “multiply” // 与原有相关性分数相乘
}
}
}
7.2 拼写纠错(Suggest)
当用户输入"华伪手机"时,自动纠错为"华为手机":

java
// 拼写纠错
SearchRequest request = new SearchRequest(“product”);
SearchSourceBuilder source = new SearchSourceBuilder()
.query(QueryBuilders.matchQuery(“title”, “华伪手机”))
.suggest(new SuggestBuilder()
.addSuggestion(“spell_check”,
SuggestBuilders.termSuggestion(“title”)
.text(“华伪手机”)
.suggestMode(SuggestMode.ALWAYS)
.maxEdits(2) // 最多编辑距离2
.size(1)
)
);
7.3 搜索结果的高亮显示
java
// 高亮配置
HighlightBuilder highlightBuilder = new HighlightBuilder()
.field(“title”)
.field(“subtitle”)
.preTags(“”)
.postTags(“
”)
.fragmentSize(100)
.requireFieldMatch(false);

sourceBuilder.highlighter(highlightBuilder);
八、 集群运维:线上ES的"三大天坑"
坑1:分片未分配(UNASSIGNED)
现象:集群状态变成yellow或red,部分分片未分配。

原因:节点宕机、磁盘满、分片数超过节点数。

解决:

bash

查看未分配的分片

GET _cat/shards?h=index,shard,prirep,state,unassigned.reason

手动分配(需谨慎)

POST _cluster/reroute
{
“commands”: [
{
“allocate_replica”: {
“index”: “product”,
“shard”: 0,
“node”: “es-node-2”
}
}
]
}

根本解决:增加节点或扩容磁盘

坑2:查询超时导致集群熔断
现象:大促期间搜索流量暴增,复杂查询(多字段、大分页、多重聚合)导致ES节点CPU飙升,触发熔断断路器(Circuit Breaker)。

解决方案: 在网关层限制搜索请求的复杂度和频率。

yaml

搜索请求限流

  • 单用户搜索频率:10次/分钟
  • 单次搜索最多返回:100条
  • 聚合查询最多:3个维度
  • 分页最大深度:100页(超过则拒绝)
    坑3:索引碎片化
    现象:每天新增一个索引(product_2024-01-01),一年下来365个索引,ES集群管理元数据压力巨大。

解决方案:使用ILM(Index Lifecycle Management)

json
{
“policy”: {
“phases”: {
“hot”: {
“min_age”: “0ms”,
“actions”: {
“rollover”: {
“max_size”: “50gb”, // 单个索引最大50GB
“max_age”: “7d” // 或7天滚动一次
}
}
},
“warm”: {
“min_age”: “7d”,
“actions”: {
“shrink”: { “number_of_shards”: 1 }, // 压缩分片
“forcemerge”: { “max_num_segments”: 1 } // 合并段
}
},
“delete”: {
“min_age”: “90d”,
“actions”: {
“delete”: {} // 90天后自动删除
}
}
}
}
}
九、 最终成果与数据
经过两轮迭代优化,最终成果如下:

指标 优化前(MySQL) 优化后(ES) 优化幅度
搜索响应时间(TP99) 12秒(超时) 35ms 提升 340倍
召回率 约60%(很多商品搜不到) 98.5% 提升 64%
相关性满意度(用户点击率) 12% 28% 提升 133%
拼写纠错准确率 无 92% 从无到有
多维度聚合响应时间 不支持 120ms 从无到有
搜索结果相关性(Top3点击率) 32% 67% 提升 109%
索引数据量(500万商品) 40GB(MySQL) 80GB(ES) 可接受(换性能)
业务成果:

搜索功能使用率从35% 提升到82%(用户开始信任搜索)。

搜索转化率(搜索→加购→下单)从8% 提升到21%。

客诉中"搜索相关"的占比从45% 降到3%。

十、 总结:搜索架构演进的"三个认知升级"
回顾整个搜索架构演进,最大的收获不是技术本身,而是对"搜索"这件事的认知变化:

认知阶段 误区 正确理解
V1.0 阶段 “搜索不就是like查询吗?” 搜索是全文检索,底层是倒排索引,和B+树索引是两码事
V2.0 阶段 “ES装好了就能用” 搜索系统的核心是分词器 + 词典 + 相关性策略,技术选型只占20%,运营迭代占80%
V3.0 阶段 “搜索主要是技术活” 搜索本质是产品活。需要结合搜索日志分析、点击率反馈、人工干预持续优化
给正在构建搜索系统的团队的几点建议:

分阶段投入,不要一上来就搞大而全:

第一阶段:ES + ik分词 + 基础搜索(能搜到就行)。

第二阶段:自定义词典 + 同义词 + 拼音(提高召回率)。

第三阶段:相关性评分调优 + 拼写纠错 + 搜索推荐(提高精准度)。

搜索日志是金矿:

每天分析搜索无结果词(搜索了但没返回结果),这些就是你的"漏网之鱼"。

将这些词加入自定义词典或同义词库,持续迭代。

搜索和推荐要一体化思考:

搜索结果页不只是"展示匹配结果",更是"引导用户发现商品"的入口。

将搜索无结果时的"推荐商品"功能做成强逻辑,可以有效挽回流失用户。

监控比优化更重要:

监控指标:搜索响应时间、搜索成功率、搜索无结果率、Top搜索词点击率。

每周出一次搜索质量报告,持续跟踪优化效果。

文末互动:
你们的搜索系统用的是什么技术栈?遇到过哪些"搜不到"的奇葩问题?有没有自定义词典的"翻车"经历?欢迎在评论区分享,一起打造更好的搜索体验!

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐