Elasticsearch模糊查询实战:从Wildcard陷阱到高性能方案设计 1. 从“LIKE”到“Wildcard”理解ES模糊查询的本质在数据库的世界里LIKE查询几乎是每个开发者入门时就会接触到的老朋友。无论是%keyword%还是_keyword_它都能帮我们轻松搞定那些“记不全但记得一部分”的搜索需求。然而当你踏入 ElasticsearchES的领域试图在GET /_search的请求体里寻找LIKE的影子时往往会感到一阵迷茫。ES 的查询 DSL 语法里并没有一个叫做LIKE的查询类型。这并不意味着 ES 不支持模糊查询恰恰相反它提供了一套更强大、但也更需谨慎使用的工具集其中wildcard查询就是最接近传统 SQLLIKE语义的那一个。很多从关系型数据库转型过来的开发者第一个直觉就是用wildcard去实现LIKE ‘%xxx%’。这个直觉是对的但这条路布满了性能陷阱。wildcard查询尤其是以通配符*或?开头的查询其执行过程与LIKE有本质区别。在传统数据库中如果字段有索引LIKE ‘keyword%’前缀匹配通常可以利用索引而LIKE ‘%keyword%’前后缀匹配则会导致全表扫描。在 ES 中情况类似但更复杂wildcard查询需要对倒排索引中的每个词项进行模式匹配这个过程无法利用索引的排序特性尤其是在大数据集上它可能变得极其缓慢甚至拖垮整个集群。因此在 ES 中实现“LIKE模糊查询”首先是一个“选择与权衡”的问题。你需要的真的是一个通用的、无差别的%value%查询吗还是说你的业务场景可以分解为更具体、对 ES 更友好的查询模式比如用户输入“张”是想找所有姓“张”的人前缀匹配还是想找名字中带“张”字的人中缀匹配前者可以用更高效的prefix查询或edge_ngram分词后者才可能需要考虑wildcard或更重量级的方案。理解这一点是避免在 ES 中滥用模糊查询、保障搜索性能的第一步。2. Wildcard查询详解用法、原理与性能警示wildcard查询是 ES 中实现模式匹配的直接工具它使用*匹配零个或多个字符使用?匹配单个字符。其语法非常直观几乎可以无缝映射 SQL 中的LIKE语句。2.1 基础语法与示例假设我们有一个products索引其中有一个name字段我们想查找名称中包含“手机”的所有商品。在 SQL 中我们会写SELECT * FROM products WHERE name LIKE ‘%手机%’。在 ES 中对应的wildcard查询如下GET /products/_search { query: { wildcard: { name: { value: *手机* } } } }同样如果你想匹配像 “iPhone 13 Pro” 这样的模式其中“13”是可变的一位或两位数可以用?{ query: { wildcard: { name: { value: iPhone ? Pro } } } }这个查询会匹配 “iPhone 13 Pro”但不会匹配 “iPhone 13 Pro Max”因为?只匹配一个字符。2.2 底层原理与性能瓶颈为什么wildcard查询尤其是以通配符开头的查询会成为性能杀手这需要从 ES 的倒排索引说起。倒排索引可以简单理解为一个“词项到文档”的映射表。例如对于文本 “Elasticsearch is great”经过分词可能得到[“elasticsearch”, “is”, “great”]。倒排索引会记录每个词项出现在哪些文档中。当进行term查询精确匹配时ES 可以像查字典一样直接定位到“elasticsearch”这个词项瞬间找到所有包含它的文档效率极高。然而wildcard查询 “search” 时ES 无法直接定位。它需要遍历倒排索引中所有的词项对每一个词项检查是否符合 “search” 这个模式。这就像让你在一本没有按字母顺序排列的、包含百万词汇的词典里找出所有包含“search”这个片段的单词你只能一页一页、一个词一个词地看。这个过程被称为“词项枚举”其时间复杂度与索引中唯一词项的数量成正比。如果字段是text类型且内容很长、词项很多这个查询的代价将非常高昂。更糟糕的是以通配符开头如*xxx的查询无法利用词项字典的前缀压缩等优化结构性能最差。即使是以通配符结尾如xxx*虽然比前者稍好但依然无法达到prefix查询那样的优化程度prefix查询有专门的优化逻辑。注意wildcard查询默认是大小写敏感的这与 SQLLIKE的行为可能不同取决于数据库配置。如果你需要大小写不敏感必须在索引映射或查询时进行标准化处理如使用lowercase过滤器。2.3 实战中的性能调优与限制鉴于其性能风险ES 官方对wildcard查询的使用持非常谨慎的态度。在实际应用中你必须遵循以下准则避免在用户输入的搜索框直接使用永远不要将用户输入的字符串直接加上*然后扔进wildcard查询。这相当于敞开了一个DoS攻击的大门。一个恶意用户输入**********a就可能瞬间消耗大量CPU资源。使用rewrite参数wildcard查询支持rewrite参数它决定了查询如何被重写和执行。常见的选项有constant_score_boolean(默认)为每个匹配的词项生成一个布尔子句。在词项很多时可能会生成一个巨大的布尔查询影响性能。constant_score_filter使用过滤器上下文执行可以利用过滤器缓存。对于重复的模糊查询模式性能更好。scoring_boolean/top_terms_N用于需要相关性评分的场景但更复杂。 对于大多数只关心“是否存在”的过滤场景使用“rewrite”: “constant_score_filter”是个好习惯。设置超时和分页使用timeout参数为查询设置一个合理的超时时间如“timeout”: “1s”防止单个慢查询阻塞整个系统。同时务必使用from/size进行分页避免一次性拉取海量数据。控制字段长度尽量避免在长文本字段如文章内容上使用wildcard。如果业务必须考虑专门建立一个较短的、用于模式匹配的字段如提取关键词。了解index-prefixes特性从 ES 7.9 版本开始text字段可以配置index_prefixes参数。这会在索引时自动为词项的前缀如2到5个字符建立额外的索引。当执行wildcard或prefix查询时如果能匹配上前缀查询速度会显著提升。但这会增加索引体积和索引时间需要权衡。// 在 mapping 中启用 index_prefixes PUT /my_index { mappings: { properties: { product_name: { type: text, index_prefixes: { min_chars: 2, max_chars: 5 } } } } }3. 超越Wildcard更优的模糊查询方案选型明智的开发者不会把wildcard当作实现模糊查询的唯一锤子。ES 提供了多种工具每种工具适用于不同的“模糊”场景。选择正确的工具往往能带来数量级的性能提升。3.1 前缀匹配Prefix Query与Edge N-Gram分词场景自动补全Auto-complete。例如搜索框输入“elast”提示“elasticsearch”、“elastic”、“elastics”。prefix查询这是最直接的方案。查询“prefix”: {“field”: “elast”}会匹配所有以“elast”开头的词项。它的性能优于wildcard因为它可以利用词项字典的有序性进行范围查找但依然需要扫描匹配前缀的所有词项。对于高频前缀性能尚可对于低频或唯一前缀开销也不小。edge_ngram分词器 match查询这是实现前缀补全的推荐方案。其核心思想是在索引时就将词项切分成前缀片段。索引阶段使用edge_ngram分词器。对于词项 “elasticsearch”设置min_gram: 2,max_gram: 5会生成[“el”, “ela”, “elas”, “elast”, “la”, “las”, “last”, …]注意这里是从词项开头生成的。搜索阶段用户输入 “elast”。这个输入不再使用任何通配符查询而是直接作为一个普通的match或term查询。因为索引中已经存在词项 “elast”所以查询会像精确匹配一样快速。优势将查询时的计算成本转移到了索引时用空间换时间。搜索速度极快体验流畅。配置示例PUT /autocomplete_index { settings: { analysis: { analyzer: { autocomplete_analyzer: { tokenizer: autocomplete_tokenizer } }, tokenizer: { autocomplete_tokenizer: { type: edge_ngram, min_gram: 2, max_gram: 10, token_chars: [letter, digit] } } } }, mappings: { properties: { product_name: { type: text, analyzer: autocomplete_analyzer, // 索引时使用 search_analyzer: standard // 搜索时使用标准分词器 } } } }实操心得edge_ngram的max_gram需要根据业务中最长的前缀匹配长度来设定设得太大索引会膨胀。通常10到15对于大多数补全场景已经足够。search_analyzer设为standard或simple很重要这能确保用户输入的查询词不会被再次切分成 n-gram从而保证匹配的准确性。3.2 容错匹配Fuzzy Query与编辑距离场景纠错或容忍拼写错误。例如搜索 “elasticsearch” 时也能匹配到用户误输入的 “elasticserch”。fuzzy查询基于编辑距离Levenshtein Distance即一个单词变成另一个单词所需的最少单字符编辑插入、删除、替换次数。你可以通过fuzziness参数控制容错程度。{ query: { fuzzy: { product_name: { value: elasticserch, fuzziness: AUTO // 或具体数字如 1, 2 } } } }fuzziness: “AUTO”ES 会根据词项长度自动决定编辑距离长度0-203-5152。这是一个很好的默认值。fuzziness: 1允许最多1个字符的差异。原理与限制fuzzy查询会生成所有在指定编辑距离内的可能词项然后对这些词项进行查询。它的性能开销比wildcard小但比精确匹配大。它不适用于中文字符的模糊匹配因为编辑距离是针对字母语言的。对于中文更适合使用下一节的方法。3.3 中文近似语义匹配Match Query with Fuzziness不用分词场景中文模糊查询如搜索“手机”希望匹配“智能手机”、“手机壳”、“华为手机”。对于中文wildcard和fuzzy都不是好选择。wildcard性能差fuzzy不适用单字编辑。中文模糊查询的核心在于分词。最基础方案标准分词 match查询使用standard或ik_smart分词器搜索“手机”时会对查询词和文档都进行分词。“智能手机”会被切分成[“智能”, “手机”]。使用match查询它会匹配包含“手机”这个词项的文档。这已经实现了最基本的“包含”语义且性能很好。但这依赖于分词器是否能正确切分出“手机”这个词。更灵活的方案N-Gram分词如果你想实现更“模糊”的、不依赖分词准确性的匹配例如输入“手环”也能匹配到“智能手环”可以考虑使用ngram分词器不是edge_ngram。索引阶段ngram会将文本切分成连续的字符片段。例如“手机” (min_gram2, max_gram2) 会生成[“手”, “手机”, “机”]。“智能手机”会生成[“智”, “智能”, “能”, “能手”, “手机”, “机”]。可以看到两者都包含了“手机”这个bi-gram。搜索阶段对搜索词“手机”也进行同样的ngram分词得到[“手”, “手机”, “机”]然后进行查询。优势完全不受分词词典限制能实现字符级别的“包含”匹配。代价索引体积会急剧膨胀因为生成了大量词项查询性能也会下降。它更适合短文本字段如商品名称、品牌名绝不适合长文本。推荐方案结合使用一个常见的实践是多字段映射“product_name”: { “type”: “text”, “analyzer”: “ik_max_word”, // 主字段用于精准和语义搜索 “fields”: { “ngram”: { “type”: “text”, “analyzer”: “my_ngram_analyzer” // 子字段用于容错模糊匹配 } } }在查询时使用multi_match同时查询主字段和 ngram 子字段并给主字段更高的权重。3.4 正则表达式匹配Regexp Query场景需要比wildcard的*和?更复杂的模式匹配。例如匹配特定格式的产品编码如 “PROD-2023-XXXXX”。regexp查询允许使用正则表达式进行匹配功能强大但性能代价比wildcard更高因为正则引擎通常更复杂。{ “query”: { “regexp”: { “product_code”: { “value”: “PROD-202[0-3]-[A-Z]{5}” } } } }使用建议除非业务必须否则尽量避免。如果要用务必使正则表达式尽可能具体避免以.*开头并严格控制查询范围。4. 实战构建一个健壮的模糊搜索系统理论说完了我们来看一个综合性的实战案例。假设我们要为一个电商平台构建商品搜索需要支持名称的精确匹配和中文分词匹配高权重。名称的容错模糊匹配低权重用于弥补分词不准或用户输错。品牌或型号的自动补全。4.1 索引映射设计首先我们设计一个兼顾性能和功能的映射PUT /products { “settings”: { “analysis”: { “analyzer”: { “autocomplete_analyzer”: { “tokenizer”: “autocomplete_tokenizer”, “filter”: [“lowercase”] }, “ngram_analyzer”: { “tokenizer”: “ngram_tokenizer”, “filter”: [“lowercase”] } }, “tokenizer”: { “autocomplete_tokenizer”: { “type”: “edge_ngram”, “min_gram”: 2, “max_gram”: 10, “token_chars”: [“letter”, “digit”, “cjk”] }, “ngram_tokenizer”: { “type”: “ngram”, “min_gram”: 2, “max_gram”: 3, “token_chars”: [“letter”, “digit”, “cjk”] } } }, “index”: { “max_ngram_diff”: 50 // 确保 ngram 的 max_gram 和 min_gram 差值允许 } }, “mappings”: { “properties”: { “id”: { “type”: “keyword” }, “name”: { “type”: “text”, “analyzer”: “ik_max_word”, // 主字段用于中文语义搜索 “fields”: { “keyword”: { “type”: “keyword” }, // 用于精确匹配、聚合 “ngram”: { // 用于容错模糊匹配 “type”: “text”, “analyzer”: “ngram_analyzer” }, “autocomplete”: { // 用于前缀补全 “type”: “text”, “analyzer”: “autocomplete_analyzer”, “search_analyzer”: “simple” } } }, “brand”: { “type”: “text”, “analyzer”: “autocomplete_analyzer”, // 品牌名也做补全 “search_analyzer”: “simple” }, “price”: { “type”: “float” }, “category”: { “type”: “keyword” } } } }4.2 复合查询设计当用户在前端搜索框输入“华为手”时我们可能希望优先展示名称精确匹配或高度相关的商品。其次展示名称模糊匹配的商品。同时提供品牌补全建议。我们可以构建一个bool查询结合should子句来满足这些需求GET /products/_search { “query”: { “bool”: { “should”: [ { “match”: { “name”: { // 主字段分词匹配权重最高 “query”: “华为手”, “boost”: 10 } } }, { “match”: { “name.autocomplete”: { // 前缀补全匹配 “query”: “华为手”, “boost”: 5 } } }, { “match”: { “name.ngram”: { // N-Gram容错匹配权重最低 “query”: “华为手”, “boost”: 1 } } }, { “prefix”: { // 品牌前缀匹配用于补全建议 “brand”: { “value”: “华为手” } } } ], “minimum_should_match”: 1 // 至少满足一个 should 条件 } }, “highlight”: { “fields”: { “name”: {}, “name.ngram”: {} } }, “aggs”: { “brand_suggestions”: { “terms”: { “field”: “brand”, “size”: 5 } } }, “size”: 20 }这个查询的巧妙之处在于主字段 (name)使用ik_max_word分词能很好地处理“华为手机”被分成[“华为”, “手机”]并与查询词“华为手”进行匹配“华为”匹配“手”可能与“手机”部分匹配。相关性评分高。补全字段 (name.autocomplete)在索引时“华为手机”被切分成[“华”, “华为”, “华为手”, “华为手机”, …]。搜索词“华为手”作为整体词项去匹配能精确匹配到返回结果快用于补全和前缀强相关匹配。容错字段 (name.ngram)“华为手机”被切分成[“华”, “华为”, “为手”, “手机”, “机”, …]。“华为手”被切分成[“华”, “华为”, “为手”]。两者通过“华为”和“为手”这两个 bi-gram 产生匹配。即使用户输入“为手”或“为手”也能匹配到容错性最强。boost参数通过权重控制不同匹配方式的优先级确保结果排序符合业务预期。聚合同时提供品牌建议。4.3 性能监控与调优建议即使设计了看似完美的方案上线后仍需严密监控。使用 Profile API 分析查询对于慢查询使用Profile API查看每个查询组件的耗时。GET /products/_search { “profile”: true, “query”: { … } // 你的查询 }重点关注wildcard、regexp或涉及大量词项匹配的查询组件的time_in_nanos。监控热点字段如果name.ngram字段的查询延迟始终很高考虑是否需要调整min_gram和max_gram比如从(2,3)调整为(2,2)只使用 bi-gram或者是否应该只对更短的字段如 SKU 编码使用 ngram。设置查询频率限制在应用层对用户尤其是未登录用户的模糊搜索频率进行限制防止恶意爬取或攻击。考虑异步搜索对于非常复杂、耗时的模糊查询如结合了多个模糊条件的海量数据查询可以考虑使用 ES 的async_search提交异步搜索任务避免阻塞 HTTP 连接。冷热数据分离将历史订单、日志等查询频率低但可能需要进行模糊查询的数据存放在使用机械硬盘的“冷”节点上而将热商品数据存放在 SSD “热”节点上。通过索引生命周期管理ILM或自定义路由实现。我在多个项目中实践这套方案后发现几乎没有场景需要直接使用wildcard “*value*”。通过将“模糊查询”这个需求拆解为“前缀补全”、“中文包含”、“容错纠错”等具体场景并组合使用edge_ngram、match、ngram分词和查询总能找到性能更好、更可控的解决方案。当产品经理再提出“要支持模糊搜索”时我们的第一反应不应该是去写一个wildcard查询而是应该追问“您说的模糊具体是指哪种情况” 把这个场景弄清楚了技术方案自然就清晰了。