1. 从“LIKE”到“Wildcard”:理解ES模糊查询的本质
在数据库的世界里,LIKE查询几乎是每个开发者入门时就会接触到的老朋友。无论是%keyword%还是_keyword_,它都能帮我们轻松搞定那些“记不全但记得一部分”的搜索需求。然而,当你踏入 Elasticsearch(ES)的领域,试图在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-2:0,3-5:1,>5:2)。这是一个很好的默认值。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_gram=2, max_gram=2) 会生成[“手”, “手机”, “机”]。“智能手机”会生成[“智”, “智能”, “能”, “能手”, “手机”, “机”]。可以看到,两者都包含了“手机”这个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查询,而是应该追问:“您说的模糊,具体是指哪种情况?” 把这个场景弄清楚了,技术方案自然就清晰了。