背景
在针对线上 ES 集群进行运维值班的过程中, 有用户反馈使用自建的最新的 7.4.2 版本的 ES 集群, 索引的 normalizer 配置无法使用了, 怎么配置都无法生效, 而之前的 6.8 版本还是可以正常使用的. 根据用户提供的索引配置进行了复现, 发现确实如此. 通过搜索发现 GitHub 上有人已经针对这个问题提了 issue: #48650, 并且已经有社区成员把这个 issue 标记为了 bug, 但是没有进一步的讨论了, 所以我就深入研究了源码, 最终找到了 bug 产生的原因, 在 GitHub 上提交了 PR:#48866, 最终被 merge 到了 master 分支, 在 7.6 版本会进行发布.
何为 normalizer
normaizer 实际上是和 analyzer 类似, 都是对字符串类型的数据进行分析和处理的工具, 它们之间的区别是:
normalizer 只对 keyword 类型的字段有效
normalizer 处理后的结果只有一个 token
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" : "<ALPHANUM>",
- "position" : 0
- },
- {
- "token" : "fi",
- "start_offset" : 3,
- "end_offset" : 5,
- "type" : "<ALPHANUM>",
- "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<AnalyzeAction.AnalyzeToken> 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, 可以从单元测试代码入手, 编写更加复杂的测试代码, 进行调试, 可以快速定位出问题出现的原因并进行修复.
来源: https://www.qcloud.com/developer/article/1557896