问题背景
今天早上,接到开发那边一个特殊的查询需求,在 Kibana 中搜索一个 json 类型日志中值为一个空大括号的键值对, 具体的日志示例如下:
{
"clientIp": "10.111.121.51",
"query": "{}",
"serviceUrl": "/aaa/bbb/cc",
}
也就是说针对这个类型的日志过滤出 query 值为空的请求 "query": "{}", 开发同学测试了直接在 kibana 中查询这个字符串 "query": "{}" 根本查不到我们想要的结果。 我们使用的是 ELK 8.3 的全家桶, 这个日志数据使用的默认 standard analyzer 的分词器。
初步分析
我们先对这个要查询的字符串进行下分词测试:
GET /_analyze
{
"analyzer" : "standard",
"text": "\"query\":\"{}\""
}
结果不出所料,我们想要空大括号在分词的时候直接就被干掉了,仅保留了 query 这一个 token:
{
"tokens": [{
"token": "query",
"start_offset": 1,
"end_offset": 6,
"type": "<ALPHANUM>",
"position": 0
}]
}
我们使用的 standard analyzer 在数据写入分词时直接抛弃掉{}等特殊字符,看来直接搜索 "query": "{}" 关键词这条路肯定是走不通。
换个思路
在网上搜索了一下解决的办法,有些搜索特殊字符的办法,但需要修改分词器,我们已经写入的日志数据量比较大,不太愿意因为这个搜索请求来修改分词器再 reindex。 但是我们的日志格式是固定的,serviceUrl 这个键值对总是在 query 后面的,那么我们可以结合前后文实现相同的 搜索效果:
GET /_analyze
{
"analyzer" : "standard",
"text": "\"query\":\"{}\",\"serviceUrl\""
}
可以看到这段被分为 2 个相邻的单词
{
"tokens": [{
"token": "query",
"start_offset": 1,
"end_offset": 6,
"type": "<ALPHANUM>",
"position": 0
},
{
"token": "serviceurl",
"start_offset": 14,
"end_offset": 24,
"type": "<ALPHANUM>",
"position": 1
}
]
}
那么通过搜索 query 和 serviceUrl 为相邻的 2 个字是完全可以实现 query 的值为空的同样的查询效果。 为了确认在我们已经写入的数据中 query 和 serviceurl 也是相邻的,我们通过 ES termvectors API 确认了已经在 es 中的数据和我们这里测试的情况相同:
GET /<index>/_termvectors/<_id>?fields=message
"query" : {
"term_freq" : 1,
"tokens" : [
{
"position" : 198,
"start_offset" : 2138,
"end_offset" : 2143
}
]
},
"serviceurl" : {
"term_freq" : 1,
"tokens" : [
{
"position" : 199,
"start_offset" : 2151,
"end_offset" : 2161
}
]
},
这里我们可以看到 query 在 message 字段里面出现一次,其 end_offset 和 serviceurl 的 start_offset 之前也是相差 8, 和我们测试的结果相同。 这个时候我们就将原来的查询需求,转化为了对 "query serviceurl" 进行按顺序的精准查询就行了, 使用 match_phrase 可以达到我们的目的。
GET /_search
"query": {
"match_phrase": {
"message": {
"query": "query serviceurl",
"slop" : 0
}
}
}
这里顺便说一下,slop 这个参数,slop=n 表示,表示可以隔 n 个字(英文词)进行匹配, 这里设置为 0 就强制要求 query 和 serviceurl 这 2 个单词必须相邻,0 也是 slop 的默认值,在这个请求中是可以省略的,这是为什么 match_phrase 是会获得精准查询的原因之一。 好了,我们通过 console 确定了有效的 query 之后,对于开发同学查看日志只需要在 Kibana 的搜索栏中直接使用双引号引起来的精确搜索 "query serviceurl" 就可以了。
继续深挖一下,ngram 分词器
虽然开发同学搜索的问题解决了,但我仍然不太满意,毕竟这次的问题我们的日志格式是固定的,如果我们一定要搜索到 "query": "{}" 这个应该怎么办呢? 首先很明确,使用我们默认的 standard analyzer 不修改任何参数肯定是不行的,"{}" 这些特殊字符都直接被干掉了, 参考了网上找到的这篇文章,https://blog.csdn.net/fox_233/article/details/127388058 按照这个 ngram 分词器的思路,我动手对我们的需求进行了下测试
首先先看看我们使用 ngram 分词器的分词效果, 我们这里简化了一下,去掉了原来的双引号,以避免过多 \
:
GET _analyze
{
"tokenizer": "ngram",
"text": "query:{}"
}
{
"tokens" : [
{
"token" : "q",
"start_offset" : 0,
"end_offset" : 1,
"type" : "word",
"position" : 0
},
...
{
"token" : "{",
"start_offset" : 6,
"end_offset" : 7,
"type" : "word",
"position" : 12
},
{
"token" : "{}",
"start_offset" : 6,
"end_offset" : 8,
"type" : "word",
"position" : 13
},
{
"token" : "}",
"start_offset" : 7,
"end_offset" : 8,
"type" : "word",
"position" : 14
}
]
}
可以很明显的看到大括号被成功的分词了,果然是有戏。 直接定义一个 index 实战一下搜索效果
PUT specialchar_debug
{
"settings": {
"analysis": {
"analyzer": {
"specialchar_analyzer": {
"tokenizer": "specialchar_tokenizer"
}
},
"tokenizer": {
"specialchar_tokenizer": {
"type": "ngram",
"min_gram": 1,
"max_gram": 2
}
}
}
},
"mappings": {
"properties": {
"text": {
"analyzer": "specialchar_analyzer",
"type": "text"
}
}
}
}
插入几条测试数据:
PUT specialchar_debug/_doc/1
{ "text": "query:{},serviceUrl"
}
PUT specialchar_debug/_doc/2
{ "text": "query:{aaa},serviceUrl"
}
PUT specialchar_debug/_doc/3
{ "text": "query:{bbb}, ccc, serviceUrl"
}
我们再测试一下搜索效果,
GET specialchar_debug/_search
{
"query": {
"match_phrase": {
"text": "query:{}"
}
}
}
结果完全是我们想要的,看来这个方案可行
"hits" : [
{
"_index" : "specialchar_debug",
"_id" : "1",
"_score" : 2.402917,
"_source" : {
"text" : "query:{},serviceUrl"
}
}
]
小结
对于日志系统,我们一直在使用 ES 默认的 standard analyzer 的分词器, 基本上满足我们生产遇到的 99% 的需求,但面对特殊字符的这种搜索请求,确实比较无奈。这次遇到的空键值对的需求,我们通过搜索 2 个相邻的键绕过了问题。 如果一定要搜索这个字符串的话,我们也可以使用 ngram 分词器重新进行分词再进行处理, 条条大路通罗马。
作者介绍
卞弘智,研发工程师,10 多年的 SRE 经验,工作经历涵盖 DevOps,日志处理系统,监控和告警系统研发,WAF 和网关等系统基础架构领域,致力于通过优秀的开源软件推动自动化和智能化基础架构平台的演进。
本文地址:http://elasticsearch.cn/article/14950