Administrator
发布于 2020-07-25 / 569 阅读
14

Elasticsearch 倒排索引原理与第一次调优

接手搜索需求:MySQL like 已经撑不住了

七月份产品提了个需求:商品搜索要支持关键词模糊匹配、按分类筛选、按价格排序、还要高亮。我们当时是这么查的:

SELECT * FROM t_item
WHERE title LIKE CONCAT('%', #{keyword}, '%')
  AND category_id = #{categoryId}
ORDER BY price ASC
LIMIT 0, 20;

商品表 380 万行。title 上建了普通索引,但 LIKE '%xxx%' 前置通配符导致索引完全失效,全表扫描,explain 出来是 type=ALL,单次查询 2.8~4.2 秒。压测 20 并发直接把 MySQL 打满。

于是上 Elasticsearch。我们用的是 7.8.0,三个节点的小集群(8C 16G,堆各 8GB),Docker Compose 起的。

倒排索引到底是怎么回事

我一开始的理解就停留在"ES 快,因为它是倒排索引",直到踩了坑才去认真看这一层。

正排索引是"文档 ID → 文档内容",倒排索引反过来,是"词项 → 包含这个词的文档 ID 列表"。假设有三条商品标题:

doc1: 小米 10 手机 全网通
doc2: 华为 手机 保护壳
doc3: 小米 移动电源

分词之后建出来的倒排表长这样:

词项(term)文档 ID 列表(posting list)词频
小米[1, 3]2
手机[1, 2]2
华为[2]1
10[1]1
全网通[1]1
保护壳[2]1
移动电源[3]1

搜"小米手机",流程是:把查询串也拿去分词,得到 小米手机;分别取出 posting list [1,3][1,2];求交集得 [1]。整个过程是有序链表的归并,复杂度跟词项长度有关,跟文档总数基本无关。

这解释了一个我以前不理解的现象:为什么 ES 里 LIKE 那种"中间匹配"反而慢。因为倒排索引的粒度是"词",不是"字符"。要找包含"米"字的所有标题,没法直接查倒排表,只能把词表全扫一遍。

分词器:中文这一关必须先过

ES 自带的 standard 分词器对中文的处理是逐字切分。我一开始没装插件,直接用它,结果是这样的:

POST _analyze
{
  "analyzer": "standard",
  "text": "小米10手机全网通"
}
{
  "tokens" : [
    { "token" : "小", "start_offset" : 0, "end_offset" : 1 },
    { "token" : "米", "start_offset" : 1, "end_offset" : 2 },
    { "token" : "10", "start_offset" : 2, "end_offset" : 4 },
    { "token" : "手", "start_offset" : 4, "end_offset" : 5 },
    { "token" : "机", "start_offset" : 5, "end_offset" : 6 },
    { "token" : "全", "start_offset" : 6, "end_offset" : 7 },
    { "token" : "网", "start_offset" : 7, "end_offset" : 8 },
    { "token" : "通", "start_offset" : 8, "end_offset" : 9 }
  ]
}

每个汉字一个词项。两个后果:倒排表巨大(我们的 380 万商品,用 standard 索引完 12GB,用 IK 之后 4.1GB);搜索"手机"会把"手表""手机壳"全匹配上,准确率崩了。

装 IK 分词器:

# 版本必须和 ES 严格一致
$ ./bin/elasticsearch-plugin install \
    https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.8.0/elasticsearch-analysis-ik-7.8.0.zip
# 每个节点都装,装完重启

换成 ik_max_word 再看:

POST _analyze
{ "analyzer": "ik_max_word", "text": "小米10手机全网通" }
{ "token" : "小米",    "start_offset" : 0,  "end_offset" : 2 },
{ "token" : "10",      "start_offset" : 2,  "end_offset" : 4 },
{ "token" : "手机",    "start_offset" : 4,  "end_offset" : 6 },
{ "token" : "全网通",  "start_offset" : 6,  "end_offset" : 9 },
{ "token" : "全网",    "start_offset" : 6,  "end_offset" : 8 },
{ "token" : "网通",    "start_offset" : 7,  "end_offset" : 9 }

IK 有两种模式,用法不一样:

  • ik_max_word:穷举所有可能的词,切得细。适合建索引时用,让倒排表尽可能全,提高召回。
  • ik_smart:只切一次,最粗粒度。适合查询时用,减少干扰项,提高准确率。

mapping 设计:一开始没设计好,后面要重建索引

这是我踩的最大的坑。第一次建索引我图省事,直接往里塞数据让 ES 动态映射,结果:

PUT /item
{
  "mappings": { }        // 空的,全靠动态映射
}

动态映射会做两件很坑的事:所有字符串都被识别成 text 并额外生成一个 keyword 子字段.keyword,ignore_above 256);数字字段被识别成 long 而不是 integer。我们有个 status 字段是 0/1,用 long 存纯浪费。

更麻烦的是 title 被默认用了 standard 分词器(那时 IK 还没装)。后来换了 IK,必须重建索引——已经写进去的倒排表是按 standard 切的词,改分词器配置不会重新分词历史数据。

重建用的 aliases 切流法,线上无感知:

# 1. 建新索引 item_v2,指定好 mapping
PUT /item_v2
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "refresh_interval": "30s",
    "analysis": {
      "analyzer": {
        "ik_index":  { "type": "custom", "tokenizer": "ik_max_word" },
        "ik_search": { "type": "custom", "tokenizer": "ik_smart" }
      }
    }
  },
  "mappings": {
    "properties": {
      "itemId":    { "type": "keyword" },
      "title":     { "type": "text", "analyzer": "ik_index", "search_analyzer": "ik_search" },
      "categoryId":{ "type": "integer" },
      "brandId":   { "type": "integer" },
      "price":     { "type": "scaled_float", "scaling_factor": 100 },
      "status":    { "type": "byte" },
      "sales":     { "type": "integer" },
      "createTime":{ "type": "date", "format": "yyyy-MM-dd HH:mm:ss||epoch_millis" }
    }
  }
}

几个设计点值得说:

  • pricescaled_float 而不是 float。价格是 99.99 这种两位小数,转成整数 9999 存,精度和范围都比 float 好,排序也不会有浮点误差。
  • 明确关掉不需要检索的字段的索引。有些字段只是为了把数据带回来展示,比如 imageUrl,加 "index": false 能省下不少倒排空间。
  • 主分片数建好就改不了(除非 reindex)。我们 3 个分片是按"单分片 20~40GB"这个经验值算的,380 万商品约 4GB 数据,3 个分片偏多,但考虑到未来两年增长,留点余量。
  • refresh_interval 从默认 1s 调到 30s。刷新越频繁 segment 越多,查询时要遍历的 segment 越多。搜索场景对实时性要求没那么高,30s 完全能接受,写入吞吐量提升明显。
# 2. 全量重建(380 万条跑了 14 分钟)
POST _reindex?wait_for_completion=false
{
  "source": { "index": "item", "size": 2000 },
  "dest":   { "index": "item_v2" }
}

# 3. 切换别名,应用代码一直用 item_alias,无需改动
POST _aliases
{
  "actions": [
    { "remove": { "index": "item",    "alias": "item_alias" } },
    { "add":    { "index": "item_v2", "alias": "item_alias" } }
  ]
}

wildcard 查询:把集群 CPU 打到 90% 的元凶

上线一周后,ES 节点的 CPU 从平时的 15% 涨到 90%,查询 TP99 从 40ms 涨到 2.1 秒。看 slow log:

[2020-07-24T15:42:11,338][WARN ][index.search.slowlog.query] [node-1] [item_v2][2]
took[2.1s], took_millis[2104], total_hits[15],
types[], stats[],
search_type[QUERY_THEN_FETCH], total_shards[3],
source[{"from":0,"size":20,"query":{"bool":{"filter":[{"wildcard":{"title":{"wildcard":"*保温杯*","boost":1.0}}}]}}}]

这个 wildcard: *保温杯* 是运营后台加的一个"高级搜索"功能,直接把用户输入包了星号丢过来。

为什么慢?前面说过倒排索引的粒度是词。*保温杯* 这种两头通配的查询,ES 没法查倒排表,只能遍历词项字典里的每一个 term,逐个做字符串匹配。ES 7.8 里的实现是把 wildcard 转成自动机(DFA)后线性扫描 term dictionary。我们的 title 字段有 240 万个唯一词项,扫一遍就是 2 秒。

解决办法:

第一,把 wildcard 换成 match_phrase。用户输入"保温杯",用 IK 分词后能切出"保温杯"这个词项,直接查倒排表即可:

GET item_alias/_search
{
  "query": {
    "bool": {
      "must": [
        { "match_phrase": { "title": { "query": "保温杯", "slop": 2 } } }
      ],
      "filter": [
        { "term": { "status": 1 } },
        { "range": { "price": { "gte": 50, "lte": 500 } } }
      ]
    }
  },
  "highlight": {
    "fields": { "title": { "pre_tags": ["<em>"], "post_tags": ["</em>"] } }
  },
  "sort": [ { "price": "asc" } ],
  "from": 0, "size": 20
}

同样的语义,耗时从 2.1 秒降到 23ms。

第二,如果确实需要前后模糊匹配,用 ngram 分词器在建索引时就把子串切出来,用空间换时间。这个是后来补的方案,只用在 sku 编码这种短字段上:

"sku_code": {
  "type": "text",
  "analyzer": "ngram_analyzer",
  "search_analyzer": "standard"
}
// ngram_analyzer: min_gram=2, max_gram=8, token_chars=[letter, digit]

长文本字段千万别开 ngram,索引会膨胀十几倍。

第三,在网关层做兜底。我在搜索服务里加了个校验,单个 wildcard 的 pattern 长度少于 2 个字符或包含多个星号的,直接拒绝,避免有人手抖输入一个 * 把整个集群拖垮。

优化后的数据

场景MySQL LIKEES 初版ES 优化后
关键词搜索 P503100ms38ms21ms
关键词搜索 P994200ms2100ms64ms
索引大小6.2GB12GB4.1GB
CPU(20 并发)MySQL 98%ES 90%ES 22%

先到这

《Elasticsearch 倒排索引原理与第一次调优》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考