Elasticsearch:以 “Painless” 方式保护你的映射

1,753 阅读9分钟

Elasticsearch 是一个很棒的工具,可以从各种来源收集日志和指标。 它为我们提供了许多默认处理,以便提供最佳用户体验。 但是,在某些情况下,默认处理可能不是最佳的(尤其是在生产环境中); 因此,今天我们将深入探讨避免 “映射污染” 的方法。

什么是“映射”,为什么会有污染?

与任何数据存储解决方案一样,必须有一个模式(schema)等效设置来说明如何处理数据字段(例如存储和处理/分析); 此设置在 Elasticsearch 下称为 “映射(mapping)”。

与大多数不同,如果未指定,Elasticsearch 会为模式设置提供默认处理。 举个例子,当我们创建一个文档并引入一个全新的数据索引时,Elasticsearch 会尝试为我们猜测正确的字段映射。 是的这是一个猜测! 因此,在大多数情况下,我们会毫无问题地摄取该文档,并自动完成。 干杯但是等等……

  • 猜测的映射可能未优化(例如,任何整数字段都将映射到占用 64 位内存的数据类型 “long”,但是如果你的整数字段范围仅从 0~5 ......可能是 “byte” 或 “short” 就足够了,只需要 8 到 16 位内存)
  • 有些字段对我们来说毫无意义,因此我们应该在摄取之前排除它们以免引入映射污染

如何避免映射污染 —— Painless 脚本处理器方法

我们可以使用脚本处理器创建一个摄取管道,以在最终摄取发生之前删除目标字段。

用例:删除名称长度超过 15 个字符的字段。

也许我们的数据与随机生成的 UUID(超过 15 个字符长)下的一些元数据一起出现,并且不知何故我们永远不需要这些元数据。 因此,为避免映射污染,我们需要在一开始就排除此类字段。 以下是检查文档中可能字段长度的示例:



1.  # pipeline to script and loop around all fields in a 
2.  # context
3.  POST _ingest/pipeline/_simulate
4.  {
5.    "pipeline": {
6.      "processors": [
7.        {
8.          "script": {
9.            "source": """

11.              boolean flag = false;
12.              java.util.Set keys = ctx.keySet();
13.              for (String key : keys) {
14.                if (key.length()>15) {
15.                  flag = true;
16.                }
17.              }
18.              ctx["has_long_fields"] = flag;

20.            """
21.          }
22.        }
23.      ]
24.    },
25.    "docs": [
26.      {
27.        "_source": {
28.          "very_very_long_field": "balabalabalabalabala"
29.        }
30.      },
31.      {
32.        "_source": {
33.          "age": 12
34.        }
35.      }
36.    ]
37.  }


上面的命令的响应为:



1.  {
2.    "docs": [
3.      {
4.        "doc": {
5.          "_index": "_index",
6.          "_id": "_id",
7.          "_version": "-3",
8.          "_source": {
9.            "very_very_long_field": "balabalabalabalabala",
10.            "has_long_fields": true
11.          },
12.          "_ingest": {
13.            "timestamp": "2023-03-01T06:40:53.22560943Z"
14.          }
15.        }
16.      },
17.      {
18.        "doc": {
19.          "_index": "_index",
20.          "_id": "_id",
21.          "_version": "-3",
22.          "_source": {
23.            "has_long_fields": false,
24.            "age": 12
25.          },
26.          "_ingest": {
27.            "timestamp": "2023-03-01T06:40:53.225907596Z"
28.          }
29.        }
30.      }
31.    ]
32.  }


我们可以清楚地看到 2 个测试文档的结果:第一个包含一个字段 very_very_long_field,结果为 true,而第二个只包含一个 age 字段,结果为 false。

这里的技巧是 ctx.keySet() 方法。 此方法返回一个 Set 接口,其中包含文档下可用的所有 “field-names”。 获得 Set 后,我​​们可以开始迭代它并应用我们的匹配逻辑。

一个棘手的事情是......这个集合还包含元数据字段,如 _index 和 _id,因此当我们应用一些字段匹配逻辑时,也要注意这些字段。

下一个示例将说明如何从我们的文档上下文中删除相应的字段:



1.  # remove long fields... 
2.  POST _ingest/pipeline/_simulate
3.  {
4.    "pipeline": {
5.      "processors": [
6.        {
7.          "script": {
8.            "source": """

10.              boolean flag = false;
11.              java.util.Set keys = ctx.keySet();
12.              java.util.List fields = new java.util.ArrayList();
13.              for (String key : keys) {
14.                if (!key.startsWith("_") && key.length() > 10) {
15.                  fields.add(key); 
16.                }
17.              }

19.              // look through and delete those long field(s)
20.              if (fields.size() > 0) {
21.                for (String field: fields) {
22.                  ctx.remove(field);
23.                }
24.                flag = true;
25.              }
26.              ctx["has_removed_long_fields"] = flag;

28.            """          
29.          }
30.        }
31.      ]
32.    },
33.    "docs":[
34.      {
35.        "_source": {
36.          "very_very_long_field": "balabalabalabalabala",
37.          "another_long_field": "wakkakakakaka",
38.          "age": 13,
39.          "name": "Felis"
40.        }
41.      },
42.      {
43.        "_source": {
44.          "desc": "dkjfdkjfkdjfkdfjk",
45.          "address": "wakkakakakaka",
46.          "age": 13,
47.          "name": "Felis"
48.        }
49.      }
50.    ]
51.  }


上面命令的返回值为:



1.  {
2.    "docs": [
3.      {
4.        "doc": {
5.          "_index": "_index",
6.          "_id": "_id",
7.          "_version": "-3",
8.          "_source": {
9.            "name": "Felis",
10.            "age": 13,
11.            "has_removed_long_fields": true
12.          },
13.          "_ingest": {
14.            "timestamp": "2023-03-01T06:45:46.164088718Z"
15.          }
16.        }
17.      },
18.      {
19.        "doc": {
20.          "_index": "_index",
21.          "_id": "_id",
22.          "_version": "-3",
23.          "_source": {
24.            "name": "Felis",
25.            "address": "wakkakakakaka",
26.            "age": 13,
27.            "desc": "dkjfdkjfkdjfkdfjk",
28.            "has_removed_long_fields": false
29.          },
30.          "_ingest": {
31.            "timestamp": "2023-03-01T06:45:46.164113593Z"
32.          }
33.        }
34.      }
35.    ]
36.  }


魔法是 ctx.remove(“fieldname”)。 很简单不是吗? 另请注意,我们对字段匹配逻辑 !key.startsWith(“_”) && key.length()>10 应用了更精确的规则,因此不会考虑所有元字段(例如 _index)。

还引入了一个 ArrayList 来存储目标字段名称。 你可能会问为什么我们不在循环期间直接从文档上下文中删除该字段? 原因是如果我们尝试这样做,则会爆发一个异常,描述对文档上下文的并发修改。 因此,我们需要延迟删除过程,并且此 ArrayList 会跟踪这些字段名称。

最后,还有另一种情况,我们的文档可能涉及多个级别/层次结构。 以下示例说明如何确定字段是 “leaf” 字段还是 “branch” 字段:



1.  # remove the inner field
2.  POST _ingest/pipeline/_simulate
3.  {
4.    "pipeline": {
5.      "processors": [
6.        {
7.          "script": {
8.            "source": """
9.                java.util.Set keys = ctx.keySet();
10.                java.util.ArrayList fields = new java.util.ArrayList();
11.                for (String key : keys) {
12.                  // access the value; check the type
13.                  if (key.startsWith("_")) {
14.                    continue;
15.                  }
16.                  Object value = ctx[key];
17.                  if (value != null) {
18.                    // it is a MAP (equivalent to the "object" structure of a json field)
19.                    if (value instanceof java.util.Map) {
20.                      // inner fields loop
21.                      java.util.Map innerObj = (java.util.Map) value;
22.                      for (String innerKey: innerObj.keySet()) {
23.                        if (innerKey.length() > 10) {
24.                          //Debug.explain("a long field >> "+innerKey);
25.                          fields.add(key+"."+innerKey);
26.                        }
27.                      }
28.                    } else {
29.                      if (key.length() > 10) {
30.                        fields.add(key);
31.                      }
32.                    }
33.                  }
34.                }

36.                if (fields.size()>0) {
37.                  for (String field:fields) {
38.                    // is it an inner field?
39.                    int idx = field.indexOf(".");
40.                    if (idx != -1) {
41.                      ctx[field.substring(0, idx)].remove(field.substring(idx+1));
42.                    } else {
43.                      ctx.remove(field);  
44.                    }
45.                  }
46.                }

48.            """
49.          }
50.        }
51.      ]
52.    },
53.    "docs": [
54.      {
55.        "_source": {
56.          "age": 13,
57.          "very_very_very_long_field": "to be removed",
58.          "outer": {
59.            "name": "Felix",
60.            "very_very_long_field": "dkjfdkjfkdjfkdfjk"
61.          }
62.        }
63.      }
64.    ]
65.  }


上面的响应为:



1.  {
2.    "docs": [
3.      {
4.        "doc": {
5.          "_index": "_index",
6.          "_id": "_id",
7.          "_version": "-3",
8.          "_source": {
9.            "outer": {
10.              "name": "Felix"
11.            },
12.            "age": 13
13.          },
14.          "_ingest": {
15.            "timestamp": "2023-03-01T06:54:51.610568095Z"
16.          }
17.        }
18.      }
19.    ]
20.  }


一个很长的代码……为了检查该字段是 “leaf” —— 普通字段还是 “branch” —— 另一层字段(例如对象); 我们需要检查字段值的类型 java.util.Map 的值实例。

 instanceof 方法有助于验证提供的值是否与特定的 Java 类类型匹配。

接下来,我们需要再次迭代 Set of inner-object fields 以应用我们的匹配规则。 使用 ArrayList 的相同技术将应用于跟踪目标字段名称,以便在稍后阶段删除。

最后,通过 ctx.remove(“fieldname”) 删除字段。 但是这次,我们还需要检查这个字段是 leaf 还是 branch 字段。 对于 branch 字段,它将以 outer-object-name.inner-field-name 的格式出现。 我们需要先提取 outer-object-name 并在删除 inner-field-name 之前访问其上下文 -> ctx[field.substring(0, idx)].remove(field.substring (idx+1))

举个例子:outer.very_very_long_field

  • idx(“.”分隔符所在的 index) = 5
  • field.substring(0, idx) = "outer"
  • field.substring(idx+1) = "very_very_long_field"
  • 因此…… ctx[field.substring(0, idx)].remove(field.substring(idx+1)) = ctx[“outer”].remove(“very_very_long_field”)

干得好 ~ 这是避免贴图污染的 “Painless” 脚本方法。 

如何避免映射污染 —— 索引的动态设置方法

有时,我们可能不介意引入一个映射污染; 然而~我们不希望那些无意义的字段是可搜索或可聚合的。 只是我们让那些无意义的字段充当虚拟对象,你可以看到它们(在 _source 字段下可用)但永远无法对它们应用任何操作。 如果是这样的话……我们可以更改索引的动态设置。



1.  PUT test_dynamic 
2.  {
3.    "mappings": {
4.      "dynamic": "false",
5.      "properties": {
6.        "name": {
7.          "type": "text"
8.        },
9.        "address": {
10.          "dynamic": "true",
11.          "properties": {
12.            "street": {
13.              "type": "keyword"
14.            }
15.          }
16.        },
17.        "work": {
18.          "dynamic": "strict",
19.          "properties": {
20.            "department": {
21.              "type": "keyword"
22.            },
23.            "post": {
24.              "type": "keyword"
25.            }
26.          }
27.        }
28.      }
29.    }  
30.  }


我们通过如下的方法来摄入一些数据:



1.  # all good, everything matches the mapping
2.  POST test_dynamic/_doc
3.  {
4.    "name": "peter parker",
5.    "address": {
6.      "street": "20 Ingram Street",
7.      "state": "NYC"
8.    },
9.    "work": {
10.      "department": "daily bugle"
11.    }
12.  }

14.  # added a non-searchable "age" and a searchable field "address.post_code"
15.  POST test_dynamic/_doc
16.  {
17.    "age": 45,
18.    "name": "Edward Elijah",
19.    "address": {
20.      "post_code": "344013"
21.    }
22.  }


很显然,在上面,age 不在之前的映射中定义。由于我们在映射中设置 dynamic 为 false,age 这个字段将不能被用于搜索:

dynamicdoc indexed?fields searchablefields indexed?mapping updated?
trueYesYesYesYes
runtimeYesYesNo        No
falseYesNoNoNo
strictNo


1.  GET test_dynamic/_search
2.  {
3.    "query": {
4.      "match": {
5.        "age": 45
6.      }
7.    }
8.  }


上面搜索的结果为空。有关动态映射的文章,请详细阅读文章 “Elasticsearch:Dynamic mapping”。

我们接着进行如下的搜索:



1.  GET test_dynamic/_search
2.  {
3.    "query": {
4.      "match": {
5.        "address.post_code": "344013"
6.      }
7.    }
8.  }


上面的搜索显示的是一个文档:

 1.      "hits": [
2.        {
3.          "_index": "test_dynamic",
4.          "_id": "Z2X8m4YBRPmzDW_iGJOb",
5.          "_score": 0.2876821,
6.          "_source": {
7.            "age": 45,
8.            "name": "Edward Elijah",
9.            "address": {
10.              "post_code": "344013"
11.            }
12.          }
13.        }
14.      ]

我们执行如下的命令:



1.  # exception as work.salary is forbidden
2.  POST test_dynamic/_doc
3.  {
4.    "age": 45,
5.    "name": "Prince Tomas",
6.    "address": {
7.      "post_code": "344013"
8.    },
9.    "work": {
10.      "salary": 10000000
11.    }
12.  }


上面命令返回的结果为:



1.  {
2.    "error": {
3.      "root_cause": [
4.        {
5.          "type": "strict_dynamic_mapping_exception",
6.          "reason": "mapping set to strict, dynamic introduction of [salary] within [work] is not allowed"
7.        }
8.      ],
9.      "type": "strict_dynamic_mapping_exception",
10.      "reason": "mapping set to strict, dynamic introduction of [salary] within [work] is not allowed"
11.    },
12.    "status": 400
13.  }


这是因为 work 字段的属性为 strict。我们不可以为这个字段添加任何新的属性。

如何避免映射污染 —— 通过预处理方法删除字段

这里讨论的最后一种方法是在传递给 Elasticsearch 之前删除无意义的字段......以及如何? 嗯……自己写程序,对文档进行预处理~:)))))

这确实是一种有效的方法,但可能并不适合所有人; 因为需要一些编程知识。 有时,如果我们在传递给 Elasticsearch 之前对文档进行预处理,它可能会更加灵活,因为我们可以完全控制文档的修改(由于编程语言的功能)。