背景
在针对线上ES集群进行运维值班的过程中,有用户反馈使用自建的最新的7.4.2版本的ES集群,索引的normalizer配置无法使用了,怎么配置都无法生效,而之前的6.8版本还是可以正常使用的。根据用户提供的索引配置进行了复现,发现确实如此。通过搜索发现github上有人已经针对这个问题提了issue: #48650, 并且已经有社区成员把这个issue标记为了bug, 但是没有进一步的讨论了,所以我就深入研究了源码,最终找到了bug产生的原因,在github上提交了PR:#48866,最终被merge到了master分支,在7.6版本会进行发布。
何为normalizer
normaizer 实际上是和analyzer类似,都是对字符串类型的数据进行分析和处理的工具,它们之间的区别是:
1. normalizer只对keyword类型的字段有效
2. normalizer处理后的结果只有一个token
3. normalizer只有char_filter和filter,没有tokenizer,也即不会对字符串进行分词处理
如下是一个简单的normalizer定义,并且把字段foo配置了normalizer:
PUT index
{
"settings": {
"analysis": {
"char_filter": {
"quote": {
"type": "mapping",
"mappings": [
"« => \"",
"» => \""
]
}
},
"normalizer": {
"my_normalizer": {
"type": "custom",
"char_filter": ["quote"],
"filter": ["lowercase", "asciifolding"]
}
}
}
},
"mappings": {
"properties": {
"foo": {
"type": "keyword",
"normalizer": "my_normalizer"
}
}
}
}
情景复现
首先定义了一个名为my_normalizer的normalizer, 处理逻辑是把该字符串中的大写字母转换为小写:
{
"settings": {
"analysis": {
"normalizer": {
"my_normalizer": {
"filter": [
"lowercase"
],
"type": "custom"
}
}
}
}}
通过使用_analyze api测试my_normalizer:
GET {index}/_analyze
{
"text": "Wi-fi",
"normalizer": "my_normalizer"
}
期望最终生成的token只有一个,为:"wi-fi", 但是实际上生成了如下的结果:
{
"tokens" : [
{
"token" : "wi",
"start_offset" : 0,
"end_offset" : 2,
"type" : "",
"position" : 0
},
{
"token" : "fi",
"start_offset" : 3,
"end_offset" : 5,
"type" : "",
"position" : 1
}
]
}
也就是生成了两个token: wi和fi,这就和前面介绍的normalizer的作用不一致了:normalizer只会生成一个token,不会对原始字符串进行分词处理。
为什么会出现这个bug
通过在6.8版本的ES上进行测试,发现并没有复现,通过对比_analyze api的在6.8和7.4版本的底层实现逻辑,最终发现7.0版本之后,_analyze api内部的代码逻辑进行了重构,把执行该api的入口方法TransportAnalyzeAction.anaylze()方法的逻辑有些问题:
public static AnalyzeAction.Response analyze(AnalyzeAction.Request request, AnalysisRegistry analysisRegistry,
IndexService indexService, int maxTokenCount) throws IOException {
IndexSettings settings = indexService == null ? null : indexService.getIndexSettings();
// First, we check to see if the request requires a custom analyzer. If so, then we
// need to build it and then close it after use.
try (Analyzer analyzer = buildCustomAnalyzer(request, analysisRegistry, settings)) {
if (analyzer != null) {
return analyze(request, analyzer, maxTokenCount);
}
}
// Otherwise we use a built-in analyzer, which should not be closed
return analyze(request, getAnalyzer(request, analysisRegistry, indexService), maxTokenCount);
}
analyze方法的主要逻辑为:先判断请求参数request对象中是否包含自定义的tokenizer, token filter以及char filter, 如果有的话就构建出analyzer或者normalizer, 然后使用构建出的analyzer或者normalizer对字符串进行处理;如果请求参数request对象没有自定义的tokenizer, token filter以及char filter方法,则使用已经在索引settings中配置好的自定义的analyzer或normalizer,或者使用内置的analyzer对字符串进行进行分析和处理。
我们复现的场景中,请求参数request中使用了在索引settings中配置好的normalizer,所以buildCustomAnalyzer方法返回空, 紧接着执行了getAnalyzer方法用于获取自定义的normalizer, 看一下getAnalyzer方法的逻辑:
private static Analyzer getAnalyzer(AnalyzeAction.Request request, AnalysisRegistry analysisRegistry, IndexService indexService) throws IOException {
if (request.analyzer() != null) {
...
return analyzer;
}
}
if (request.normalizer() != null) {
// Get normalizer from indexAnalyzers
if (indexService == null) {
throw new IllegalArgumentException("analysis based on a normalizer requires an index");
}
Analyzer analyzer = indexService.getIndexAnalyzers().getNormalizer(request.normalizer());
if (analyzer == null) {
throw new IllegalArgumentException("failed to find normalizer under [" + request.normalizer() + "]");
}
}
if (request.field() != null) {
...
}
if (indexService == null) {
return analysisRegistry.getAnalyzer("standard");
} else {
return indexService.getIndexAnalyzers().getDefaultIndexAnalyzer();
}
上述逻辑用于获取已经定义好的analyzer或者normalizer, 但是问题就出在与当request.analyzer()不为空时,正常返回了定义好的analyzer, 但是request.normalizer()不为空时,却没有返回,导致程序最终走到了最后一句return, 返回了默认的standard analyzer.
所以最终的结果就可以解释了,即使自定义的有normalizer, getAnalyer()始终返回了默认的standard analyzer, 导致最终对字符串进行解析时始终使用的是standard analyzer, 对"Wi-fi"的处理结果正是"wi"和"fi"。
单元测试没有测试到吗
通过查找TransportAnalyzeActionTests.java类中的testNormalizerWithIndex方法,发现对normalizer的测试用例太简单了:
public void testNormalizerWithIndex() throws IOException {
AnalyzeAction.Request request = new AnalyzeAction.Request("index");
request.normalizer("my_normalizer");
request.text("ABc");
AnalyzeAction.Response analyze
= TransportAnalyzeAction.analyze(request, registry, mockIndexService(), maxTokenCount);
List tokens = analyze.getTokens();
assertEquals(1, tokens.size());
assertEquals("abc", tokens.get(0).getTerm());
}
对字符串"ABc"进行测试,使用自定义的my_normalizer和使用standard analyzer的测试结果是一样的,所以这个测试用例通过了,导致这个bug没有及时没发现。
提交PR
在确认了问题的原因后,我提交了PR:#48866, 主要的改动点有:
- TransportAnalyzeAction.getAnalyzer()方法判断normalizer不为空时返回该normalizer
- TransportAnalyzeActionTests.testNormalizerWithIndex()测试用例中把用于测试的字符串修改我"Wi-fi", 确保自定义的normalizer能够生效。
改动的并不多,社区的成员在确认这个bug之后,和我经过了一轮沟通,认为应当对测试用例生成的结果增加注释说明,在增加了说明之后,社区成员进行了merge, 并表示会在7.6版本中发布这个PR。
总结
本次提交bug修复的PR,过程还是比较顺利的,改动点也不大,总结的经验是遇到新版本引入的bug,可以从单元测试代码入手,编写更加复杂的测试代码,进行调试,可以快速定位出问题出现的原因并进行修复。