之前有个电商后台项目商品库大概几十万条数据运营那边要求在搜索框里输入商品名的任意片段结果必须“秒出”。一开始图省事直接用MySQL的LIKE %关键词%数据量上来之后接口动不动就一两秒起步索引基本帮不上忙因为前置通配符会让B树索引直接失效只能全表扫。后来换成SpringBoot集成Elasticsearch做全文检索配合IK中文分词把中文模糊搜索的响应时间压到了个位数毫秒。这篇就把整个改造过程、踩过的坑、还有实测压测数据一次性说清楚。内容对两类人最有用一类是后端开发接手了类似“搜索要快”的需求但还没系统做过全文检索另一类是已经装了ES但搜出来结果不理想、响应不稳定想优化分词和查询逻辑的人。全文不涉及复杂架构所有方案都是单集群、单应用可落地的水平。1. 为什么用LIKE做中文模糊搜索会越来越慢先说结论LIKE查询慢不是MySQL不行是这个场景本身就不适合用关系型数据库去做。我们要搞清楚慢的根源才能理解为什么要换ES。1.1 LIKE查询到底慢在哪当你在MySQL里执行WHERE name LIKE %牛奶%时如果字段没有索引数据库只能走全表扫描每一条记录都要做一次字符串匹配。几十万数据可能还能忍几百万上千万的时候IO和CPU开销直接爆炸。更尴尬的是即使你在name字段上建了普通BTree索引%牛奶%这种前后都带通配符的写法优化器也没办法利用索引的有序性去快速定位只能选择全表扫或者全索引扫。这里还有个容易被忽略的性能点字符串匹配本身是逐字符比较如果字段是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: changemeConfiguration 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 keyext_dictcustom/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_wordsearch_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 SearchResponseGoods 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 SearchResponseGoods 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响应时间QPSMySQL LIKE %牛奶%812ms1560ms120ES wildcard查询132ms380ms210ES match查询未优化18ms46ms890ES match查询 结果缓存4ms12ms2400从表格里能清晰看出优化效果从MySQL的800多毫秒到match查询的18毫秒是一次质的飞跃加上缓存之后平均响应时间降到了个位数毫秒。其实第一次跑到18ms时我还不满意因为运营提的需求是“秒出”虽然18ms在体验上已经是瞬间了但用JMeter压到高并发时CPU和IO还是有波动。进一步压测发现瓶颈不在ES而在应用层的JSON序列化和网络开销。5.3 热点数据缓存优化优化方案是给搜索接口加一层本地缓存我用的是Caffeine。思路是把搜索热词做成一个固定长度窗口统计最近一段时间内高频搜索词把这些词的搜索结果缓存起来设置5秒过期时间。Configuration public class CacheConfig { Bean public CacheString, SearchResult searchCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(5)) .build(); } }为什么设置5秒过期而不设置更久因为商品价格、销量这些排序字段变化频繁缓存太久会导致搜索结果和真实数据不一致。5秒既能挡住90%以上的热点查询压力又不会让数据“看着过期”。Service public class GoodsSearchService { Resource private CacheString, 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的堆内存调到1GSpringBoot应用内存调到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索引里如果字段特别多尽量只存储需要展示的字段能把内存占用降下来不少这也是我后来才琢磨出来的优化点。
阅读完成 · 觉得有帮助?