背景
从 KStack 上看到一些讲解 ES 性能优化的文章,其中关于通配查询给出的建议是 ngram 分词或者使用 7.10 版本以上的 wildcard 字段类型,遂继续在 KStack 上搜索 wildcard 关键字,发现并没有相关讲解文章
xx 作为一个 B 端业务,搜索用户名是一个非常常规的需求,这个场景刚好就可以使用 wildcard 类型来做
鉴于其他 ES 优化建议 KStack 上已经有很多文章了,所以本篇文章就不多赘述了,只写一下和 wildcard 相关的内容
倒排索引结构
Term Index 不会包含所有的 term,只包含一些 term 前缀。通过 Term Index 可以快速地定位到 Term Dictionary 的某个 offset,然后从这个位置再往后顺序查找
其中 Term Index 在内存中存储,Term Dictionary 和 Posting List 在磁盘中。一次搜索的步骤就是:
搜索 Term Index 树找到对应 Term Dictionary 中的 offset,因为匹配到的可能只是一个前缀,需要再顺序往后找,直到匹配
通过 Term Dictionary 找到对应的 Posting List
Wildcard 字段类型原理
那么 wildcard 字段类型究竟是如何实现高效的通配符搜索的呢?
关于这一点网上能搜到的资料也不多,我就尽我所能将我搜到的资料整合并加一些自己的理解来分析一下,如有不对还请见谅
新的通配符字段通过两种数据结构自动加速通配符和正则表达式搜索
所有字符串中出现的 3 个字符序列的 "n-gram" 索引
完整原始文档值的 "二进制文档值" 存储
第一个数据结构用于快速但粗略地缩小候选对象范围
第二个数据结构则用于通过自动机查询验证由 n-gram 索引筛选出的候选匹配对象
这样,既保证了搜索的准确性,又提高了搜索效率
怎么存
详解一下上述的第一条规则
通配符字段类型会将字段值拆分(用 n-gram)为长度 <= 3 的子串,并将这些子串写入索引
例如:字符串 "test" 被拆分为字符串 "t"、"te"、"tes"、"e"、"es" 和 "est" 这六个字串写入 ES 中
怎么查
在搜索时,ES 同样将搜索词按照上述规则拆分
例如:搜索 "test" 时,ES 会对 "tes" AND "est" 进行索引搜索
如果搜索项包含少于三个字符,ES 将使用长度为一或两个字符的字符子串。
例如:搜索 "t" 时,ES 会对 "t" 进行搜索,搜索 "te" 时,ES 会对 "te" 进行搜索、
粗略搜索完后在和保存的原始值对比看看是不是真的符合
Keyword 和 Wildcard 的对比
略慢一些,因为文档值是从压缩的 32 个块中检索出来的
略慢一些,因为使用 n-gram 进行的近似匹配需要验证
Keyword 字段只访问每个唯一值一次,而 Wildcard 字段会评估每个可能的值
如果启用了 "允许昂贵查询" 的设置
取决于公共前缀 —— Keyword 字段基于公共前缀进行压缩,而 Wildcard 字段则是整个值的 LZ4 压缩
具体会因内容而异,测试索引日志文件时,Wildcard 字段花费了 499 秒,而 Keyword 字段则为 365 秒
什么时候用 Keyword 什么时候用 Wildcard
放到 xx 业务来说,用户名搜索虽然长度不长,但确实有百万以上的唯一值,并且搜索位置可能是任意位置,所以是适合使用 Wildcard 字段类型的
评估是否使用时的测试
测试环境
条件有限,测试不严谨,主要是为了可行性验证
构建测试数据
在 ES 中创建 3 个索引
test1 使用 keyword
test2 使用 keyword 数组存储自行切词,例如:王小明就存储:王小明,小明,明 这三个值,这样是可以利用前缀索引的
test3 使用 wildcard 字段类型
构建测试数据用的代码
构建了 1000W 随机中文字符,每个字符串长度在 5 - 10 个
public void generateTestData() {
List<Test1> list1 = new ArrayList<>(10000);
List<Test2> list2 = new ArrayList<>(10000);
List<Test3> list3 = new ArrayList<>(10000);
for (int i = 0; i < 1000; i++) {
list1.clear();
list2.clear();
list3.clear();
for (int j = 0; j < 10000; j++) {
StringBuilder sb = new StringBuilder();
for (int k = 0; k < RandomUtil.randomInt(5, 10); k++) {
char c = RandomUtil.randomChinese();
sb.append(c);
}
list1.add(new Test1(sb.toString()));
list3.add(new Test3(sb.toString()));
List<String> inner = new ArrayList<>();
for (int k = 0; k < sb.length(); k++) {
inner.add(sb.substring(k, sb.length()));
}
list2.add(new Test2(inner));
}
operations.save(list1);
operations.save(list2);
operations.save(list3);
}
}
硬盘占用
可以看到占用硬盘为 前缀切词 > wildcard > keyword
搜索速度
单字符
test1 和 test3 搜索 *潼*
test2 搜索 潼*
双字符
test1 和 test3 搜索 *縇潼*
test2 搜索 縇潼*
本来还想在多测一些字符长度的情况的,但后来 docker 容器误删了 X_X
大致可以看出,wildcard 在硬盘空间占用和搜索性能之间做到了不错的平衡
线上测试
由于线上没有 keyword 和前缀切词,直接用了 wildcard 所以就没有和其他类型的对比了,大概看下在这样的数据量下,搜索一次的耗时
单索引数据量是 8209269
搜索 *福* 耗时 36ms
搜索 *幸福一生* 耗时 12ms
参考文章
https://opensearch.org/docs/latest/field-types/supported-field-types/wildcard/