ES搜索提示优化
618活动当天,公司的搜索提示服务请求突增,瞬间导致ES集群CPU打满。
问题
查看了一下搜索提示的代码实现,发现使用了Prefix前缀term查询,符合业务需求。
“搜索提示”功能就是在进行前缀匹配,比如有一条记录是:”耐克珍藏球鞋”,那么只有搜索下面这些搜索词才能匹配:
- 耐
- 耐克
- 耐克珍
- 耐克珍藏
- 耐克珍藏球
- 耐克珍藏球鞋
ES实现这个功能,在索引时需要按keyword索引(也就是不要分词),在查询时按照term Prefix做查询。
当执行term Prefix查询时,会拿着待搜索的term,在倒排索引中找到所有以搜索词term为前缀的倒排索引term,这是一个索引遍历+字符串比对的过程。
当请求量上升时,这种遍历匹配term前缀的大量字符串比较计算的操作打高了CPU,所以ES无法继续响应后续请求。
优化
可以想到,如果我们可以把”耐克珍藏球鞋”按前缀直接拆成上述的多个term,然后都建立倒排索引,那么搜索的时候就可以直接定位到一个term,无需再遍历倒排索引进行字符串比较。
ES内置了一个filter过滤器edge_ngram,它可以将一个term按照前缀组合出多个前缀term,比如对于term:”耐克珍藏球鞋”,经过edge_ngram后会进一步拆成下面这些term:
- 耐
- 耐克
- 耐克珍
- 耐克珍藏
- 耐克珍藏球
- 耐克珍藏球鞋
全部建立倒排索引,指向当前的文档。
当我们搜索”耐克珍藏球”时候,可以直接找到倒排中的”耐克珍藏球”,直接返回对应的文档。
实例
配置分词器
配置一个过滤器,使用内置的edge_ngram做term的前缀拆分,建议高效的倒排索引。
配置一个分析器,使用上述过滤器,tokenizer使用内置的keyword,也就是把字段整体当做一个term(经过过滤器后会变成多个前缀term)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
$zfilter->indices()->create([ 'index' => 'suggest_demo', 'body' => [ 'settings' => [ 'number_of_shards' => 10, 'number_of_replicas' => 1, 'analysis' => [ 'analyzer' => [ 'ik_edge_ngram' => [ "type" => "custom", "tokenizer" => "keyword", 'filter' => [ 'sug_edge_ngram', 'lowercase' ] ], ], 'filter' => [ 'sug_edge_ngram' => [ "type" => "edge_ngram", "min_gram" => 1, "max_gram" => 20, ] ] ] ], 'mappings' => [ 'doc' => [ 'dynamic' => FALSE, 'properties' => [ 'keyword' => [ 'type' => 'text', 'analyzer' => 'ik_edge_ngram' ] ] ] ] ], ]); |
测试分词
1 2 3 4 5 6 7 8 9 |
$zfilter = $this->load->elasticsearch('zfilter'); $ret = $zfilter->indices()->analyze([ 'index' => 'suggest_demo', 'body' => [ 'analyzer' => 'ik_edge_ngram', 'text' => '耐克珍藏球鞋', ] ]); |
“耐克珍藏球鞋”被分词后,有6个前缀term被倒排索引:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
{ "tokens": [{ "token": "耐", "start_offset": 0, "end_offset": 6, "type": "word", "position": 0 }, { "token": "耐克", "start_offset": 0, "end_offset": 6, "type": "word", "position": 0 }, { "token": "耐克珍", "start_offset": 0, "end_offset": 6, "type": "word", "position": 0 }, { "token": "耐克珍藏", "start_offset": 0, "end_offset": 6, "type": "word", "position": 0 }, { "token": "耐克珍藏球", "start_offset": 0, "end_offset": 6, "type": "word", "position": 0 }, { "token": "耐克珍藏球鞋", "start_offset": 0, "end_offset": 6, "type": "word", "position": 0 }] } |
可见,当我们搜索一个属于上述前缀之一的term时,ES可以高效的完成检索。
我们插入一个文档用于测试:
1 2 3 4 5 6 7 8 |
$ret = $zfilter->index([ 'index' => 'suggest_demo', 'type' => 'doc', 'id' => 1, 'body' => [ 'keyword' => '耐克珍藏球鞋', ] ]); |
搜索
索引时使用edge_ngram虽然导致倒排索引变大,但是对查询优化效果显著,是空间与时间的权衡。
现在可以直接使用term过滤,精准的在倒排中找到对应的前缀term。
搜索词”耐克珍”可以高效的被检索:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
$zfilter = $this->load->elasticsearch('zfilter'); // 搜索请求 $search = new \ONGR\ElasticsearchDSL\Search(); $boolQuery = new \ONGR\ElasticsearchDSL\Query\Compound\BoolQuery(); $boolQuery->add( new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('keyword', '耐克珍'), \ONGR\ElasticsearchDSL\Query\Compound\BoolQuery::FILTER ); $search->addQuery($boolQuery); $ret = $zfilter->search([ 'index' => 'suggest_demo', 'type' => 'doc', 'body' => $search->toArray(), ]); |
返回值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
{ "took": 1, "timed_out": false, "_shards": { "total": 10, "successful": 10, "skipped": 0, "failed": 0 }, "hits": { "total": 1, "max_score": 0, "hits": [{ "_index": "suggest_demo", "_type": "doc", "_id": "1", "_score": 0, "_source": { "keyword": "耐克珍藏球鞋" } }] } } |
参考
- 低效方案:prefix 前缀查询。
- 高效方案:edge_ngram前缀分词
后记:确认是ES5.X使用的lucene在prefix检索时存在BUG被人攻击,相关ISSUE:https://github.com/elastic/elasticsearch/issues/24553。
如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~

学习了 的确非常有用 ,感谢博主