ES通配符引起的故障排查

许多有RDBMS/SQL背景的开发者,在初次踏入ElasticSearch世界的时候,很容易就想到使用通配符(Wildcard Query)来实现模糊查询,因为这是和SQL里like操作最相似的查询方式,用起来感觉非常舒适。不过,滥用Wildcard query可能带来灾难性的后果。

问题复现

创建一个只有一条文档的索引

POST test_index/type1/?refresh=true
{
  "foo": "bar"
} 

使用wildcard query执行一个首尾带有通配符*的长字符串查询

POST /test_index/_search
{
  "query": {
    "wildcard": {
      "foo": {
        "value": "轻轻的我走了,正如我轻轻的来;我轻轻的招手,作别西天的云彩。
                  那河畔的金柳,是夕阳中的新娘;波光里的艳影,在我的心头荡漾。
                  软泥上的青荇,油油的在水底招摇;在康河的柔波里,我甘心做一条水草!
                  那榆荫下的一潭,不是清泉,是天上虹;揉碎在浮藻间,沉淀着彩虹似的梦。
                  寻梦?撑一支长篙,向青草更青处漫溯;满载一船星辉,在星辉斑斓里放歌。
                  但我不能放歌,悄悄是别离的笙箫;夏虫也为我沉默,沉默是今晚的康桥!
                  悄悄的我走了,正如我悄悄的来;我挥一挥衣袖,不带走一片云彩。"
          }
       }
   }
}

查看结果

{
    "took": 3445,
    "timed_out": false,
    "_shards": {
        "total": 5,
        "successful": 5,
        "failed": 0
        },
     "hits": {
        "total": 0,
        "max_score": null,
        "hits":
        } 
}

即使no hits,耗时却是惊人的3.4秒 (测试机是macbook pro, i7 CPU),并且执行过程中,CPU有一个很高的尖峰。

线上的查询比这个范例要复杂得多,会同时查几个字段,实际测试下来,一个查询可能会执行十几秒钟。再有比较多长字符串查询的时候,集群可能就DOS了。

探查深层次根源

为什么对只有一条数据的索引做这个查询开销这么高? 直觉上应该是瞬间返回结果才对!回答这个问题前,可以再做个测试,如果继续加大查询字符串的长度,到了一定长度后,ES直接抛异常了,服务ES里异常给出的cause如下:
Caused by: org.apache.lucene.util.automaton.TooComplexToDeterminizeException: 
  Determinizing automaton with 22082 states and 34182 transitions would result in more than 10000 states.
  at org.apache.lucene.util.automaton.Operations.determinize(Operations.java:741) ~[lucene-core-6.4.1.jar:6.4.1
该异常来自org.apache.lucene.util.automaton这个包,异常原因的字面含义是说“自动机过于复杂而无法确定状态: 由于状态和转换太多,确定一个自动机需要生成的状态超过10000个上限"。
原来,为了加速通配符和正则表达式的匹配速度,Lucene4.0开始会将输入的字符串模式构建成一个DFA (Deterministic Finite Automaton),带有通配符的pattern构造出来的DFA可能会很复杂,开销很大。比如a*bc构造出来的DFA就像下面这个图一样。

查看Lucene的里相关的代码,构建过程大致如下:

1、org.apache.lucene.search.WildcardQuery里的toAutomaton方法,遍历输入的通配符pattern,将每个字符变成一个自动机(automaton),然后将每个字符的自动机链接起来生成一个新的自动机。

public static Automaton toAutomaton(Term wildcardquery) {
    List<Automaton> automata = new ArrayList<>();
    
    String wildcardText = wildcardquery.text();
    
    for (int i = 0; i < wildcardText.length();) {
      final int c = wildcardText.codePointAt(i);
      int length = Character.charCount(c);
      switch(c) {
        case WILDCARD_STRING: 
          automata.add(Automata.makeAnyString());
          break;
        case WILDCARD_CHAR:
          automata.add(Automata.makeAnyChar());
          break;
        case WILDCARD_ESCAPE:
          // add the next codepoint instead, if it exists
          if (i + length < wildcardText.length()) {
            final int nextChar = wildcardText.codePointAt(i + length);
            length += Character.charCount(nextChar);
            automata.add(Automata.makeChar(nextChar));
            break;
          } // else fallthru, lenient parsing with a trailing \
        default:
          automata.add(Automata.makeChar(c));
      }
      i += length;
    }
    
    return Operations.concatenate(automata);
  }

此时生成的状态机是不确定状态机,也就是Non-deterministic Finite Automaton(NFA)。

2、org.apache.lucene.util.automaton.Operations类里的determinize方法则会将NFA转换为DFA

/**
   * Determinizes the given automaton.
   * <p>
   * Worst case complexity: exponential in number of states.
   * @param maxDeterminizedStates Maximum number of states created when
   *   determinizing.  Higher numbers allow this operation to consume more
   *   memory but allow more complex automatons.  Use
   *   DEFAULT_MAX_DETERMINIZED_STATES as a decent default if you don't know
   *   how many to allow.
   * @throws TooComplexToDeterminizeException if determinizing a creates an
   *   automaton with more than maxDeterminizedStates
   */
  public static Automaton determinize(Automaton a, int maxDeterminizedStates){ 
代码注释里说这个过程的时间复杂度最差情况下是状态数量的指数级别!为防止产生的状态过多,消耗过多的内存和CPU,类里面对最大状态数量做了限制
/**
* Default maximum number of states that {@link Operations#determinize} should create.
*/
public static final int DEFAULT_MAX_DETERMINIZED_STATES = 10000;
在有首尾通配符,并且字符串很长的情况下,这个determinize过程会产生大量的state,甚至会超过上限。
至于NFA和DFA的区别是什么? 如何相互转换?一个粗浅的理解是: NFA在输入一个条件的情况下,可以从一个状态转移到多种状态,而DFA只会有一个确定的状态可以转移,因此DFA在字符串匹配时速度更快。DFA虽然搜索的时候快,但是构造方面的时间复杂度可能比较高,特别是带有首部通配符+长字符串的时候。Elasticsearch官方文档里对于Wildcard query有特别说明,要避免使用通配符开头的term。
" Note that this query can be slow, as it needs to iterate over many terms. 
In order to prevent extremely slow wildcard queries, a wildcard term should not start with one of the wildcards * or ?."

小结:

Wildcard query应杜绝使用通配符打头,实在不得已要这么做,就一定需要限制用户输入的字符串长度。最好换一种实现方式,通过在index time做文章,选用合适的分词器,比如nGram tokenizer预处理数据,然后使用更廉价的term query来实现同等的模糊搜索功能。对于部分输入即提示的应用场景,可以考虑优先使用completion suggester, phrase/term suggeter一类性能更好,模糊程度略差的方式查询,待suggester没有匹配结果的时候,再fall back到更模糊但性能较差的wildcard, regex, fuzzy一类的查询。
那么,regex, fuzzy query是否有同样的问题呢?答案是有,原因在于他们底层和wildcard一样,都是通过将pattern构造成DFA来加速字符串匹配速度的。

解决方案

解决这种问题很简单,对关键词做限制,百度、淘宝也的确是这么处理的。
如果一定要给用户一点东西,可以找出一些热词分析出来,或者给点热搜商品兜底。
posted @ 2021-12-05 12:22  晨煦风清  阅读(828)  评论(0编辑  收藏  举报