干货 | 拆解一个 Elasticsearch Nested 类型复杂查询问题

问题描述:


有个复杂的场景涉及到按照求和后过滤,user_id是用户编号,gender是性别,time_label是时间标签,时间标签是nested结构,intent_order_count是意向订单数量,time是对应时间。


现在要筛选出在20210510~20210610,意向订单数总和为26的男性用户,请问应该怎么写dsl语句?


感觉这个场景很复杂,涉及到array判断后求和,然后求和结果做筛选条件。


请帮忙看看有什么好的dsl语句,或者改变现有mapping结构。


这个是mapping结构 如下:


PUT index_personal

{

 "mappings": {

   "properties": {

     "time_label": {

       "type": "nested",

       "properties": {

         "intent_order_count": {

           "type": "long"

         },

         "time": {

           "type": "long"

         }

       }

     },

     "user_id": {

       "type": "keyword"

     },

     "gender": {

       "type": "keyword"

     }

   }

 }

}

下面是我构造的数据:

PUT index_personal/_doc/1

{

 "user_id": "1",

 "gender": "male",

 "time_label": [

   {

     "time": 20210601,

     "intent_order_count": 3

   },

   {

     "time": 20210602,

     "intent_order_count": 2

   },

   {

     "time": 20210605,

     "intent_order_count": 20

   },

   {

     "time": 20210606,

     "intent_order_count": 1

   },

   {

     "time": 20210611,

     "intent_order_count": 15

   }

 ]

}

PUT index_personal/_doc/2

{

 "user_id": "2",

 "gender": "female"

}

PUT index_personal/_doc/3

{

 "user_id": "3",

 "gender": "male",

 "time_label": [

   {

     "time": 20210102,

     "intent_order_count": 12

   },

   {

     "time": 20210202,

     "intent_order_count": 33

   }

 ]

}

问题扩展解释:


1、"intent_order_count"代表:是订单数,不过都可以抽象成这个用户某个时间买了几个。


比如第三条数据,表示用户编号为 3 的用户,是男性用户,曾经在 20210102 时有12个意向订单(跟订单一个意思),在 20210202 有 33 个意向订单,


2、每个用户除了性别还有很多属性,篇幅受限,没有列出。


问题来源:https://t.zsxq.com/FmEeaIY


2、数据建模探讨

2.1 原问题 Nested 模型

原有数据,以 Nested 建模,存储结构如下:


user_id gender time_label {time:intent_order_count}

1 male [ {20210601:3} {20210602:2}{20210605:20}{20210606:1}{20210611:15}]

2 female

3 male { 20210102:12}{20210202:33}

以上表示并不严谨,仅是为了更直观的阐述问题。


2.2 宽表建模方案

拿到问题后,我的第一反应:建模可能有问题。


第一:time 存储的是日期,应该是日期类型:date。


第二:宽表拉平存储是不是更好?!也就是说:针对:“user_id” 的用户,一个时间数据,对应一个 document 文档。


原有的 nested 结构,改成如下的一条条的记录,也就是“宽表”,类似简化存储如下:


user_id gender time intent_order_count

1 male 20210601 3

1 male 20210602 2

1 male 20210605 20

1 male 20210606 1

1 male 20210611 15

2 female  

3 male 20210102 12

3 male 20210202 33

“宽表”是典型的以空间换时间的方案,我们肉眼看到的:对于 user_id=1 的 用户,user_id, gender 信息会存储 N 份(每多一次 time,就多存储一次)。


如前所述,每个用户除了性别还有很多属性,也就是属性非常多的话,会产生大量的冗余存储。


宽表方案优缺点如下:


优点:更利用用户理解,写入和更新非常方便且效率高。


缺点:存在大量冗余存储,耗费空间大。


针对“宽表”方案,问题提出者球友的反馈如下:


“这确实也是个思路。但是我的这个场景下,每个用户除了性别还有很多属性,这样会每天都会产生大量的冗余数据。


是否有办法将一个用户的时间信息聚集到一个文档下,然后也能够查询,对查询效率要求不高。”


所以,还得从 Nested 建模角度基础上,考虑如何实现查询?


2.3 Nested 建模方案

原有建模问题无大碍,只需将:time 字段由 long 类型改为 date 类型,其他保持不变。


# 新的 Mapping 结构(微调)

PUT index_personal_02

{

"mappings": {

 "properties": {

  "time_label": {

   "type": "nested",

   "properties": {

    "intent_order_count": {

     "type": "long"

    },

    "time": {

     "type": "date"

    }

   }

  },

  "user_id": {

   "type": "keyword"

  },

  "gender": {

   "type": "keyword"

  }

 }

}

}

# 还是原来的构造数据,改成bulk,占据行数更少

PUT index_personal_02/_bulk

{"index":{"_id":1}}

{"user_id":"1","gender":"male","time_label":[{"time":20210601,"intent_order_count":3},{"time":20210602,"intent_order_count":2},{"time":20210605,"intent_order_count":20},{"time":20210606,"intent_order_count":1},{"time":20210611,"intent_order_count":15}]}

{"index":{"_id":2}}

{"user_id":"2","gender":"female"}

{"index":{"_id":3}}

{"user_id":"3","gender":"male","time_label":[{"time":20210102,"intent_order_count":12},{"time":20210202,"intent_order_count":33}]}

良好的数据建模就好比盖大楼的地基,地基自然是越稳、越实、越牢靠越好!


3、查询方案拆解

3.1 分步骤拆解用户查询需求

问题拆解成如下几个部分:


3.1.1 筛选出在20210510~20210610

铭毅拆解:这是个范围查询,range query 搞定。


DSL 写法如下:


{

   "nested": {

     "path": "time_label",

     "query": {

       "bool": {

         "must": [

           {

             "range": {

               "time_label.time": {

                 "gte": 20210510,

                 "lte": 20210601

               }

             }

           }

         ]

       }

     }

   }

 }

正常写 Query 不会涉及 Nested,只有涉及 Nested 数据类型,才必须在检索的前半部分加上 Nested 声明,其目的无非告诉 Elasticsearch 后台,这是针对 Nested 类型的检索。


Path 指定的Nested 最外层,在本文指定的是:time_label。


3.1.2 意向订单数总和为26的男性用户

铭毅拆解:


关于男性用户,这里可以基于性别检索做过滤。


DSL 写法如下:


{

 "term": {

   "gender": {

     "value": "male"

   }

 }

}

关于意向订单:对于 user_id = 1 的用户,意向订单总数就等于 3 + 2 + 20 + 1 + 15 = 41。


要实现类似的求和,得需要借助 sum Metric 指标聚合实现。


sum Metric 聚合的前提是:针对某一特定用户形成一个结果,所以其外层是基于用户维度(本文使用:user_id)层面的terms聚合。


为了显示出除了聚合结果之外的其他属性列,需要借助 top_hits 的 _source 中的 include 实现。


DSL 写法大致如下:


"aggs": {

   "user_id_aggs": {

     "terms": {

       "field": "user_id"

     },

     "aggs": {

       "top_sales_hits": {

         "top_hits": {

           "_source": {

             "includes": [

               "user_id",

               "gender"

             ]

           }

         }

       },

       "resellers": {

         "nested": {

           "path": "time_label"

         },

         "aggs": {

           "sum_count": {

             "sum": {

               "field": "time_label.intent_order_count"

             }

           }

         }

       }

如上:


最外层 terms 聚合:是基于 user_id 的分桶聚合,每个 user_id 的结果聚成一桶。


内层的聚合包含两个,两个是平级的。


其一:top_hits 指标聚合,用于显示聚合结果之外的字段。


其二:sum 指标聚合,用于对“time_label.intent_order_count”统计结果求和。


除了上面的两层聚合,又涉及总和结果和 26 进行比较,所以要基于聚合的聚合,也就是子聚合的实现。


DSL 写法如下:


    "count_bucket_filter": {

         "bucket_selector": {

           "buckets_path": {

             "totalcount": "resellers.sum_count"

           },

           "script": "params.totalcount >= 26"

         }

       }

文中给的实际例子没有满足 26 的文档,所以,这里为了直观显示结果,使用了 >= 26 实现。


3.1.3 应该怎么写dsl语句?

铭毅拆解:


基于上面几个步骤整合到一起,即可实现。


查询 DSL ——即用户最终期望。查询 DSL 就类似“图纸”、“导航”或“路径”,给出了达到给定目的的可行性路径,后面无非就是:java 或者 Python 代码的“堆砌”实现。


3.2 最终 DSL

感谢读者【深红色水杯】2021-07-21 16:45提供的优化DSL建议,已参考修复如下(加了一层aggs 时间过滤)


GET index_personal_02/_search

{

 "size": 0,

 "query": {

   "bool": {

     "must": [

       {

         "nested": {

           "path": "time_label",

           "query": {

             "bool": {

               "must": [

                 {

                   "range": {

                     "time_label.time": {

                       "gte": 20210605,

                       "lte": 20210610

                     }

                   }

                 }

               ]

             }

           }

         }

       },

       {

         "term": {

           "gender": {

             "value": "male"

           }

         }

       }

     ]

   }

 },

 "aggs": {

   "user_id_aggs": {

     "terms": {

       "field": "user_id"

     },

     "aggs": {

       "top_sales_hits": {

         "top_hits": {

           "_source": {

             "includes": [

               "user_id",

               "gender"

             ]

           }

         }

       },

       "time_aggs": {

         "nested": {

           "path": "time_label"

         },

         "aggs": {

           "filter_aggs": {

             "filter": {

               "range": {

                 "time_label.time": {

                   "gte": 20210605,

                   "lte": 20210610

                 }

               }

             },

             "aggs": {

               "sum_aggs": {

                 "sum": {

                   "field": "time_label.intent_order_count"

                 }

               }

             }

           }

         }

       },

       "count_bucket_filter": {

         "bucket_selector": {

           "buckets_path": {

             "totalcount": "time_aggs>filter_aggs.sum_aggs"

           },

           "script": "params.totalcount >= 20"

         }

       }

     }

   }

 }

}

要强调的点是:


第一:涉及 Nested 的 query 检索 以及 aggs 聚合,都需要明确指定 Nested Path。


第二:复杂检索和聚合出错多数是:子聚合的位置放的不对、后括号和前括弧不匹配等,需要多在 Kibana 测试验证。


第三:Kibana 的一键 DSL 美化快捷键:“ctrl + i” 要掌握和灵活使用。


相信经过上面的拆解,这个相对“复杂”的 DSL 会变得非但不那么“复杂”,反而非常容易读懂。


3.3 查询后结果

"aggregations" : {

   "user_id_aggs" : {

     "doc_count_error_upper_bound" : 0,

     "sum_other_doc_count" : 0,

     "buckets" : [

       {

         "key" : "1",

         "doc_count" : 1,

         "time_aggs" : {

           "doc_count" : 5,

           "filter_aggs" : {

             "doc_count" : 2,

             "sum_aggs" : {

               "value" : 21.0

             }

           }

         },

         "top_sales_hits" : {

           "hits" : {

             "total" : {

               "value" : 1,

               "relation" : "eq"

             },

             "max_score" : 1.4700036,

             "hits" : [

               {

                 "_index" : "index_personal_02",

                 "_type" : "_doc",

                 "_id" : "1",

                 "_score" : 1.4700036,

                 "_source" : {

                   "gender" : "male",

                   "user_id" : "1"

                 }

               }

             ]

           }

         }

       }

     ]

   }

 }

}

干货 | 拆解一个 Elasticsearch Nested 类型复杂查询问题

由于检索 size = 0,所以,只返回了聚合结果,没有返回检索结果。


由于二层聚合设置了 top_hits,所以返回结果里除了sum_count的聚合结果,还包含的其下钻数据字段:“gender”、“user_id” 信息,如果实际业务还有更多需要召回字段,可以一并 include 包含后返回即可。


4、有没有更简单的方案?

第 3 小节的实现是基于聚合,但实际文档是 Nested 类型的,基于 userr_id 聚合显得非常的多余。


这里自然想到,用检索能否实现?


如果简单检索不行,那么脚本检索呢?


4.1 扩展方案 1:脚本检索实战

搞一把试试。


GET index_personal_02/_search

{

 "query": {

   "bool": {

     "must": [

       {

         "nested": {

           "path": "time_label",

           "query": {

             "bool": {

               "must": [

                 {

                   "range": {

                     "time_label.time": {

                       "gte": 20210510,

                       "lte": 20210613

                     }

                   }

                 },

                 {

                   "script": {

                     "script": """

                       int sum = 0;

                       for (obj in doc['time_label.intent_order_count']) {

                         sum += obj;

                       }

                       sum >= 10;"""

                   }

                 }

               ]

             }

           }

         }

       },

       {

         "term": {

           "gender": {

             "value": "male"

           }

         }

       }

     ]

   }

 }

}

如上逻辑看似非常严谨的脚本,实际是行不通的。


sum += obj; 本质上只求了一个值。


Elastic 官方工程师给出了详细的解释:“无法在查询时访问脚本中所有嵌套对象的值。脚本查询一次仅适用于一个嵌套对象。”


详细讨论参见:


https://*.com/questions/64140179/elasticsearch-sum-up-nested-object-field


https://discuss.elastic.co/t/help-for-painless-iterate-nested-fields/162394


结论:脚本检索不适用 Nested 嵌套对象求和。


官方推荐用 Ingest pipeline 预处理方式实现,那就再搞一把。


4.2 扩展方案 2:Ingest pipeline 方式实战

4.2.1 步骤 1——设置求和的 pipeline。

sum_pipeline 用途:将 nested 嵌套的 intent_order_count 字段进行求和。


# 设定pipeline,统计计数总和

PUT _ingest/pipeline/sum_pipeline

{

 "processors": [

   {

     "script": {

       "source": """

         ctx.sum_count = ctx.time_label.stream()

           .mapToInt(thing -> thing.intent_order_count)

           .sum()

         """

     }

   }

 ]

}

4.2.2 步骤 2——结合 pipeline 更新数据

注意一下:nested 添加数据需要借助 script 实现,不能直接指定 id 插入。


若指定 id 插入数据会覆盖掉之前的数据。


# 新插入数据

POST index_personal_02/_update_by_query?pipeline=sum_pipeline

{

 "query":{

   "term": {

     "user_id": {

       "value": "1"

     }

   }

 },

 "script": {

   "source": "ctx._source.time_label.add(params.newlabel)",

   "params": {

     "newlabel": {

       "time": 20210702,

       "intent_order_count": 88

     }

   }

 }

}

4.2.3 步骤 3——结合文章开头要求进行检索

借助 pipeline 新增的字段 sum_count 可以检索条件之一。


# 检索结果

GET index_personal_02/_search

{

 "query": {

   "bool": {

     "must": [

       {

         "nested": {

           "path": "time_label",

           "query": {

             "bool": {

               "must": [

                 {

                   "range": {

                     "time_label.time": {

                       "gte": 20210510,

                       "lte": 20210601

                     }

                   }

                 }

               ]

             }

           }

         }

       },

       {

         "term": {

           "gender": {

             "value": "male"

           }

         }

       },

       {

         "range": {

           "sum_count": {

             "gte": 26

           }

         }

       }

     ]

   }

 }

}

Ingest pipeline 方案小结:


通过预处理管道新增字段,以空间换时间。


新增的字段作为检索的条件之一,不再需要聚合。


5、小结

分解是计算思维的核心思想之一,“大事化小,逐个击破”。本文的拆解思路也是基于分解的思想一步步拆解。


本文针对线上问题,抛转引玉,给出了方案拆解和完整的步骤实现。


共探索出两种可行的方案:


方案一:聚合实现。


方案一本质:两重嵌套聚合(terms分桶 + 分桶内 sum 指标聚合)+ 子聚合(基于聚合的聚合 bucket_selector)实现。


方案二:预处理管道 pipeline 实现。


方案二本质:新增求和字段,以空间换时间。


实战环境类似本文问题,铭毅推荐使用方案二。


细节问题待进一步结合线上需求进行扩展修改 DSL。


欢迎就问题及方案进行留言,说一下您的思考和思路反馈。


https://discuss.elastic.co/t/script-processor-ingest-pipelines-on-nested-fields/172092/2

上一篇:通过开发者服务完成数据积累后 极光掘金移动大数据


下一篇:K/3 Cloud Web API接口说明文