接手搜索需求: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" }
}
}
}
几个设计点值得说:
price用scaled_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 LIKE | ES 初版 | ES 优化后 |
|---|---|---|---|
| 关键词搜索 P50 | 3100ms | 38ms | 21ms |
| 关键词搜索 P99 | 4200ms | 2100ms | 64ms |
| 索引大小 | 6.2GB | 12GB | 4.1GB |
| CPU(20 并发) | MySQL 98% | ES 90% | ES 22% |
先到这
《Elasticsearch 倒排索引原理与第一次调优》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。