之前有个电商后台项目,商品库大概几十万条数据,运营那边要求在搜索框里输入商品名的任意片段,结果必须“秒出”。一开始图省事,直接用MySQL的LIKE '%关键词%',数据量上来之后接口动不动就一两秒起步,索引基本帮不上忙,因为前置通配符会让B+树索引直接失效,只能全表扫。后来换成SpringBoot集成Elasticsearch做全文检索,配合IK中文分词,把中文模糊搜索的响应时间压到了个位数毫秒。这篇就把整个改造过程、踩过的坑、还有实测压测数据一次性说清楚。
内容对两类人最有用:一类是后端开发,接手了类似“搜索要快”的需求但还没系统做过全文检索;另一类是已经装了ES但搜出来结果不理想、响应不稳定,想优化分词和查询逻辑的人。全文不涉及复杂架构,所有方案都是单集群、单应用可落地的水平。
1. 为什么用LIKE做中文模糊搜索会越来越慢
先说结论:LIKE查询慢不是MySQL不行,是这个场景本身就不适合用关系型数据库去做。我们要搞清楚慢的根源,才能理解为什么要换ES。
1.1 LIKE查询到底慢在哪
当你在MySQL里执行WHERE name LIKE '%牛奶%'时,如果字段没有索引,数据库只能走全表扫描,每一条记录都要做一次字符串匹配。几十万数据可能还能忍,几百万上千万的时候,IO和CPU开销直接爆炸。更尴尬的是,即使你在name字段上建了普通B+Tree索引,'%牛奶%'这种前后都带通配符的写法,优化器也没办法利用索引的有序性去快速定位,只能选择全表扫或者全索引扫。
这里还有个容易被忽略的性能点:字符串匹配本身是逐字符比较,如果字段是utf8mb4编码,中文字符每个占3到4个字节,匹配复杂度比英文高不少。再加上MySQL的LIKE匹配不会利用分词关系,它只做“包含”判断,无法理解“纯牛奶”和“牛奶饮品”之间的相关性,检索结果也谈不上质量。
1.2 中文检索为什么比英文麻烦
英文天然有空格分词,good milk两个词,按空格切开就能建索引。中文完全不是这个逻辑,“纯牛奶”三个字连在一起,计算机并不知道它是纯+牛奶还是纯牛+奶。如果用单个汉字做索引,搜“牛奶”会把“奶牛”也带出来,相关性一塌糊涂;如果用整句做索引,又没办法支持片段匹配。
这就是中文全文检索必须引入“分词器”的原因。分词器的作用是把一段中文文本按照语义切割成有意义的词条,切割的质量直接决定了搜索的准确率和召回率。比如“南京市长江大桥”这句话,好的分词器可以切成南京市/长江大桥或者南京/市长/江大桥,不同切法对应不同语义,搜索引擎要把这些可能性都索引进去,才能在用户输入模糊关键词时快速匹配。
1.3 全文检索方案对比选型
常见的全文检索技术栈主要有三个方向:
| 方案 | 优势 | 劣势 |
|---|---|---|
| MySQL全文索引(ngram) | 接入成本低 | 中文分词弱、扩展性差、高并发吞吐有限 |
| Elasticsearch | 分词生态成熟、查询DSL灵活、天然分布式 | 有学习成本、需要维护独立集群 |
| Redisearch模块 | 部署轻量、性能极强 | 分词能力弱、社区相对小众、持久化复杂 |
我个人最终选了Elasticsearch,核心原因是它的中文分词生态最成熟:IK分词器、HanLP都能直接接入,同时查询语法丰富,能搞定高亮、过滤、排序、聚合这些搜索常见需求。而且SpringBoot整合ES的资料非常多,团队后续接手成本低。单机ES在小数据量下已经能跑出非常好的性能,没必要一上来就搞复杂架构。
2. SpringBoot集成Elasticsearch的方案选型
集成ES的方式有几条路,但选错了后面会很难受,这块值得单独说一下。
2.1 三种客户端接入方式对比
第一是spring-boot-starter-data-elasticsearch,封装度高,提供Template和Repository让开发像操作JPA一样操作ES。问题是它抽象层太厚,很多底层查询能力被隐藏了,遇到复杂查询特别是中文分词相关的聚合分析,写起来反而别扭。
第二是RestHighLevelClient,这是ES官方在7.x时代主推的高级客户端,API设计得比较贴近原生DSL,自由度很高。但它在ES 8.x版本里被标记为废弃,官方推荐用新的Elasticsearch Java API Client。
第三是elasticsearch-rest-client加原生Java API Client,这种是当前的主流方向,代码写起来稍微多一点,但每个方法都有清晰语义,查询DSL长什么样,Java代码就长什么样,排查问题非常直观。
我采用的是第三种。如果你项目里已经用了Spring Data Elasticsearch也没必要推翻重来,但对于新项目,我更推荐直接用官方Java API Client,避免日后升级换客户端的痛苦。
2.2 版本匹配踩过的坑
ES的版本兼容问题是新手最容易栽跟头的地方。客户端版本和服务器端版本必须大版本一致或者保持兼容,比如服务端是7.17,客户端如果是8.x,就会报version conflict或连接握手失败。
我早期项目就是SpringBoot 2.7配ES 8.11,结果Spring Data自动注入的客户端版本和实际集群对不上,启动时一直报ElasticsearchStatusException。后来我干脆不依赖Spring Data的自动装配,直接用官方客户端类手动配置Bean,把版本控制权握在自己手里。
2.3 基础配置与连接池调优
如果你的ES跑在Docker里,先确保9200和9300端口映射出来了。SpringBoot里的核心配置如下:
spring: elasticsearch: uris: http://192.168.1.100:9200 connection-timeout: 5s read-timeout: 10s username: elastic password: changeme@Configuration public class ElasticsearchConfig { @Bean public ElasticsearchClient elasticsearchClient() { RestClientBuilder builder = RestClient.builder( new HttpHost("192.168.1.100", 9200, "http") ); builder.setRequestConfigCallback( req -> req.setConnectTimeout(5000) .setSocketTimeout(10000) ); builder.setHttpClientConfigCallback( http -> http.setMaxConnTotal(200) .setMaxConnPerRoute(50) ); return new ElasticsearchClient( new RestClientTransport(builder.build(), new JacksonJsonpMapper()) ); } }连接池这块多说一句,setMaxConnTotal(200)和setMaxConnPerRoute(50)是实测下来比较稳的参数。设太小高并发时会频繁等待连接;设太大单机内存压力增加。如果你的应用并发量不大,MaxConnTotal(100)也够用。连接池参数没有绝对标准,要根据压测结果调整。
注意:ES 8.x默认开启安全认证,如果集群没开启认证,username和password留空即可,但生产环境强烈建议开启。
3. 中文分词与索引映射设计:毫秒级响应的地基
很多人以为ES快是因为它用了什么黑科技,其实核心就是倒排索引。类比一下:书的末尾会有一个“关键词-页码”对照表,你想找“牛奶”在哪一页,直接查对照表就行,不用从头翻到到尾。ES的倒排索引就是这张对照表,只不过把页码换成了文档ID列表。而分词器决定了这张表里每一行写什么词。
3.1 IK分词器的安装与扩展词库
IK分词器是目前国内使用最广的中文分词插件,它在“细粒度切分”和“智能切分”之间做了权衡,既能保证召回,也能控制噪音。
安装步骤很直接:先在GitHub上找到和ES版本匹配的IK插件包,下载到服务器上,解压到ES安装目录的plugins/ik文件夹下,然后重启ES。如果版本对不上,插件会加载失败并报java.nio.file.NoSuchFileException。
重启之后建议先用_analyze接口验证分词效果:
POST /_analyze { "text": "纯牛奶250ml", "analyzer": "ik_max_word" }正常返回的分词结果应该包含纯牛奶、牛奶、250ml、ml等词条。如果发现某些行业专有词被切碎了,比如把“烘焙奶粉”切成“烘/焙/奶粉”,就需要在IK的自定义词典里加词。
IK的词典文件在plugins/ik/config目录下的IKAnalyzer.cfg.xml里配置,可以指定扩展词典:
<entry key="ext_dict">custom/mydict.dic</entry>每次改完词典文件,ES不会自动热加载,需要调用reload接口或者重启集群节点:
POST /_analyze实际调优中我发现,加词典是个持续迭代的过程。一开始只加了品牌词和产品线词,后来运营反馈“搜索草饲牛奶不返回纯牛奶”,我意识到同义词也是中文检索一个隐藏大坑。IK可以配置同义词词典,把纯牛奶、牛奶、牛乳映射到一起,这样用户搜任何一个词,都能召回包含另一个词的商品。
3.2 索引Mapping才是毫秒级的核心
在ES里,字符串字段默认会被映射成text类型,但只有text类型才会过分词器。如果你只是把字段设置成ES自动识别的类型,你会发现中文搜索会变成单字匹配,性能差、结果也乱。
一个经过实践检验的商品索引映射模板如下:
{ "mappings": { "properties": { "goodsId": { "type": "keyword" }, "goodsName": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart", "fields": { "keyword": { "type": "keyword" } } }, "categoryName": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "price": { "type": "double" }, "saleCount": { "type": "integer" }, "createTime": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" } } } }这里有个关键设计:analyzer用ik_max_word,search_analyzer用ik_smart。为什么索引和搜索要用不同的分词粒度?因为索引时用最大粒度切分,能最大程度保证“只要包含某个词就能被索引到”;搜索时用智能切分,能减少查询词的噪音,提升匹配精度。这是很多教程不会细讲但对中文搜索质量影响非常大的细节。
keyword子字段是为了支持精确匹配和排序。比如按销量排序、按价格过滤,这些场景需要 keyword 型字段,text 型字段不能直接用于排序和聚合。
3.3 倒排索引的数据写入细节
如果你是先建好ES索引,再往里面灌数据,会发现几百条数据一下子就进去了。但数据量到几十万条时,批量写入的速度就会成为瓶颈。
我这里用的方案是Bulk批量写入,每次攒够500条或者1MB数据再一次性提交,实测性能比逐条写入提升了至少5倍。核心代码逻辑如下:
BulkRequest request = new BulkRequest(); for (Goods goods : goodsList) { request.add(new IndexRequest("goods_index") .id(goods.getGoodsId()) .source(JSON.toJSONString(goods), XContentType.JSON)); } BulkResponse response = client.bulk(request, RequestOptions.DEFAULT); if (response.hasFailures()) { log.error("批量写入失败: {}", response.buildFailureMessage()); }写入的时候要注意:ES倒排索引的构建会消耗CPU和内存,量大时可以调大refresh_interval,比如从默认的1s改成30s,让数据在内存缓冲区内积累更久再落盘,能显著减轻写入压力。代价是实时性变差,如果业务对“秒级可见”要求高,就要权衡。
4. 搜索接口实现与查询DSL优化
索引建好了,分词也调好了,接下来就要写真正的查询代码。中文模糊搜索在ES里并不仅仅是wildcard通配符匹配,那是把ES当数据库用了,性能会很难看。正确的做法是基于分词后的词条做match查询,再叠加bool组合条件。
4.1 Java API Client查询代码实战
下面是我项目里商品搜索接口的核心实现,支持关键词命中名称或分类,并支持价格区间过滤和销量排序:
public SearchResponse<Goods> search(String keyword, Double minPrice, Double maxPrice, int page, int size) { BoolQuery.Builder boolQuery = QueryBuilders.bool(); if (StringUtils.hasText(keyword)) { boolQuery.must(q -> q.multiMatch() .fields("goodsName^3", "categoryName^2") .query(keyword) .type(QueryType.BestFields)); } if (minPrice != null || maxPrice != null) { boolQuery.filter(q -> q.range(r -> r.field("price") .gte(JSON.toJson(minPrice)) .lte(JSON.toJson(maxPrice)))); } return client.search(s -> s .index("goods_index") .query(boolQuery.build()._toQuery()) .from((page - 1) * size) .size(size) .sort(sort -> sort.field(f -> f.field("saleCount").order(SortOrder.Desc))), Goods.class ); }代码看着简单,但几个选择值得解释。
4.2 multi_match的字段权重技巧
fields("goodsName^3", "categoryName^2")这里的^3和^2是权重配置:商品名称命中,相关性分数乘以3;分类名命中,乘以2。这样一个“纯牛奶”在商品名或分类名里都会被识别为有效匹配,同时商品名称命中的结果会排在分类名命中的结果前面。
这个权重不是拍脑袋定的,是根据业务试出来的:用户搜商品,大部分预期是商品名直接命中,所以名称权重应当最高。如果你做的是文章搜索,标题和正文的权重关系就要重新设计。
4.3 为什么模糊搜索用match而不是wildcard
通配符查询*牛奶*会把所有包含“牛奶”两个字的文档都扫描一遍,这个过程中分词器完全没参与,整个索引的要被遍历,数据量一大很容易造成节点CPU飙高。
match查询走的是倒排索引:先把用户输入的纯牛奶分词成纯牛奶、牛奶,然后去倒排表里查这两个词对应的文档ID,再求交集或并集,最后算相关性分数。整个过程都是索引驱动的,速度快了几个数量级。
这就是“全文检索”和“数据库模糊匹配”的本质区别:前者查询词必须经过分词,但换来的是毫秒级查找;后者不做分词,代价是全量扫描。
注意:如果确实需要对短文本做后缀模糊匹配,比如订单号后几位查询,建议单独建一个keyword字段配合
wildcard,并且用index_prefixes前缀索引优化,不要直接对text字段跑通配符。
4.4 高亮显示与分页性能
搜索接口通常需要把命中的关键词高亮展示给用户,ES的高亮是在查询时实时计算的,代码实现也不复杂:
public SearchResponse<Goods> searchWithHighlight(String keyword, int page, int size) { Highlight highlight = new Highlight(); highlight.field("goodsName"); highlight.preTags("<em>"); highlight.postTags("</em>"); return client.search(s -> s .index("goods_index") .query(q -> q.multiMatch() .fields("goodsName", "categoryName") .query(keyword)) .highlight(h -> h.fields("goodsName", f -> f)), Goods.class); }分页这里要注意,ES的from + size只能用于浅分页。如果客户端要跳转到第100页,每页50条,from就是4950,这个深度查询性能会急剧下降。方案有两种:一是限制最大翻页深度,超过100页用滚动查询;二是用search_after游标方式翻页,只记录上一页最后一条数据的排序值。很多运营后台翻到后几十页时卡顿,就是这个原因。
5. 毫秒级的实测数据与调优过程
理论说再多,不如实际跑一遍数据。这一节的内容是真实压测记录,环境是一个4核8G的单机服务器,ES cluster部署在同一台机器上,数据集是大约30万条商品数据,每条数据包含商品名、分类、价格、销量等字段。
5.1 测试场景与压测工具
压测工具用的JMeter,线程组设置100个并发线程,循环次数100次,总请求量1万条。查询关键词选了几个有代表性的:纯牛奶、进口奶粉、酸奶,覆盖了常见词、复合词和品牌词。
压测前需要注意预热ES,因为ES的页缓存(filesystem cache)需要把索引文件读进内存,预热前和预热后的查询性能差异可以差出好几倍。我的预热方法是先把要查询的关键词循环搜索几十次,让操作系统缓存生效,然后再开始正式压测。
5.2 不同查询方式响应时间对比
直接看结果:
| 查询方式 | 平均响应时间 | P99响应时间 | QPS |
|---|---|---|---|
| MySQL LIKE '%牛奶%' | 812ms | 1560ms | 120 |
| ES wildcard查询 | 132ms | 380ms | 210 |
| ES match查询(未优化) | 18ms | 46ms | 890 |
| ES match查询 + 结果缓存 | 4ms | 12ms | 2400 |
从表格里能清晰看出优化效果:从MySQL的800多毫秒到match查询的18毫秒,是一次质的飞跃;加上缓存之后,平均响应时间降到了个位数毫秒。
其实第一次跑到18ms时我还不满意,因为运营提的需求是“秒出”,虽然18ms在体验上已经是瞬间了,但用JMeter压到高并发时,CPU和IO还是有波动。进一步压测发现瓶颈不在ES,而在应用层的JSON序列化和网络开销。
5.3 热点数据缓存优化
优化方案是给搜索接口加一层本地缓存,我用的是Caffeine。思路是:把搜索热词做成一个固定长度窗口,统计最近一段时间内高频搜索词,把这些词的搜索结果缓存起来,设置5秒过期时间。
@Configuration public class CacheConfig { @Bean public Cache<String, SearchResult> searchCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(5)) .build(); } }为什么设置5秒过期而不设置更久?因为商品价格、销量这些排序字段变化频繁,缓存太久会导致搜索结果和真实数据不一致。5秒既能挡住90%以上的热点查询压力,又不会让数据“看着过期”。
@Service public class GoodsSearchService { @Resource private Cache<String, SearchResult> searchCache; public SearchResult search(String keyword, int page, int size) { String cacheKey = keyword + "_" + page + "_" + size; SearchResult cached = searchCache.getIfPresent(cacheKey); if (cached != null) { return cached; } SearchResult result = doSearch(keyword, page, size); searchCache.put(cacheKey, result); return result; } }加入缓存后,P99从46ms降到了12ms,平均4ms,基本就是内存读数据的速度了。这时候搜索接口的整体耗时已经不再是瓶颈,日志里看到的耗时基本都在HTTP层。
5.4 连接池与JVM参数进一步调优
除了缓存,还有几个容易被忽视的调优点。第一个是ES客户端连接池,如果连接数不够,高并发时很多请求会阻塞在等待连接上,表现就是平均耗时不高,但P99很高。把MaxConnTotal从50调到200后,P99有明显回落。
第二个是JVM堆内存。ES节点默认堆内存是机器内存的一半,4G机器堆内存2G,但如果机器上同时跑着SpringBoot应用,内存竞争会很激烈。后来我把ES的堆内存调到1G,SpringBoot应用内存调到1.5G,整体稳定性反而更好了。ES堆内存不是越大越好,超过32G会有压缩指针问题,而且在单机场景下需要给操作系统留足够页缓存空间。
6. 常见问题与排查技巧实录
这部分是我在实际改造过程中遇到过的问题,整理成速查表,希望能帮大家少走弯路。
6.1 典型问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 搜“牛奶”能搜到但搜“纯牛奶”为空 | 分词器没生效,索引用的standard | 检查mapping里analyzer是否正确配置,需要重建索引 |
| 中文只能单字命中,比如搜“牛奶”出来的是“牛”相关结果 | ES把每个汉字当作一个term | 安装IK分词器,确认analyzer正确 |
查询中文报IllegalArgumentException | 字符串编码问题,JSON序列化使用了错误字符集 | 统一使用UTF-8编码,检查HTTP请求头 |
| 压测时P99突然拉高 | 连接池连接耗尽或者GC停顿 | 调大连接池、分析GC日志 |
| 搜索响应很快但结果不相关 | search_analyzer与analyzer不一致时,搜索词被错误拆分 | 检查查询解析过程,可能需要在查询时指定analyzer |
| 新增词典后不生效 | IK词典没有reload | 调用IK的reload接口,或者重启ES节点 |
| 索引数据量大,写入越来越慢 | 分片数或副本数配置不合理 | 单机场景下分片数设置为1~3个,副本数设置为0或1 |
| 搜索耗时10ms但接口整体耗时200ms | 瓶颈在应用层序列化或数据库回查 | 对搜索结果做精简,只返回必要字段;或者缓存结果 |
6.2 两个印象深刻的排障日志
排查问题一:同义词不生效。某次改完IK的扩展词典,把“酸奶”加入行业中,但搜索依然只能召回含“酸奶”两字的内容,搜“优酸乳”无结果。排查后发现我的同义词配置直接加在IK扩展词典里,但同义词需要配置在ES的filter层,而不是IK的扩展词库。后来在mapping里加了synonymfilter才解决。
{ "settings": { "analysis": { "filter": { "my_synonym": { "type": "synonym", "synonyms_path": "analysis/synonym.txt" } }, "analyzer": { "ik_synonym": { "type": "custom", "tokenizer": "ik_max_word", "filter": ["my_synonym"] } } } } }排查问题二:搜索“牛奶”比搜索“纯牛奶”还慢。直觉上短词应该更快,但实测发现“牛奶”这个词在倒排索引里对应的文档ID列表特别长,需要合并的结果集太大。后来通过filter条件缩小范围,同时进行结果裁剪,把不相关的分类先过滤掉,性能又提升了30%。
注意:中文搜索的性能瓶颈往往不在“搜索”而在“结果集的大小”。如果单个热门词命中了十几万条数据,即使倒排索引查询很快,分数计算和排序也会拖慢整体响应。遇到这种情况,优先考虑用filter先做条件裁剪,再用bool查询计算分数。
6.3 索引生命周期管理的避坑经验
最后一个建议,如果你维护的是长期运行的搜索服务,一定要规划好索引的生命周期。ES里的索引一旦创建,mapping就不能改了。像我第一次建的索引,因为没用IK分词器,后来发现搜索结果不对,只能新建一个索引,再用reindex把数据迁移过去。
POST /_reindex { "source": { "index": "goods_index_v1" }, "dest": { "index": "goods_index_v2" } }这个过程里如果数据量很大,reindex会有一定的耗时和资源消耗,建议在业务低峰期操作。最好的做法是从一开始就把索引名带上版本号,比如goods_index_v1,后续有mapping变更时直接建新版索引,然后切换别名,把旧索引下线。搜索服务里代码中引用索引名时尽量使用别名而非具体索引名,这样切换对业务无感。
这个方案上线之后跑了半年多,累计处理了几百万次搜索请求,最直观的感受就是:用户输入关键词后,搜索框下面的结果列表几乎是瞬间展开的,不再有“加载中”的等待。从最初MySQL的800毫秒降到现在的4毫秒,中间的过程让我对“技术选型要跟着场景走”这句话体会特别深。如果你现在也被中文模糊搜索的性能问题卡住,照这个思路去搭一套ES方案基本不会跑偏。另外提一句,ES索引里如果字段特别多,尽量只存储需要展示的字段,能把内存占用降下来不少,这也是我后来才琢磨出来的优化点。