前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >干货 | 拆解一个 Elasticsearch Nested 类型复杂查询问题

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

作者头像
铭毅天下
发布2021-07-22 10:40:22
2.3K0
发布2021-07-22 10:40:22
举报
文章被收录于专栏:铭毅天下铭毅天下

1、线上实战问题

前置说明:本文是线上环境的实战问题拆解,涉及复杂 DSL,看着会很长,但强烈建议您耐心读完。

问题描述

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

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

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

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

这个是mapping结构 如下:

代码语言:javascript
复制
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 类型,其他保持不变。

代码语言:javascript
复制
# 新的 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 写法如下:

代码语言:javascript
复制
{
    "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 写法如下:

代码语言:javascript
复制
{
  "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 写法大致如下:

代码语言:javascript
复制
"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 写法如下:

代码语言:javascript
复制
     "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

代码语言:javascript
复制
POST index_personal_02/_search
{
  "size": 0,
  "query": {
    "bool": {
      "must": [
        {
          "nested": {
            "path": "time_label",
            "query": {
              "bool": {
                "must": [
                  {
                    "range": {
                      "time_label.time": {
                        "gte": 20210510,
                        "lte": 20210601
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "term": {
            "gender": {
              "value": "male"
            }
          }
        }
      ]
    }
  },
  "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"
              }
            }
          }
        },
        "count_bucket_filter": {
          "bucket_selector": {
            "buckets_path": {
              "totalcount": "resellers.sum_count"
            },
            "script": "params.totalcount >= 26"
          }
        }
      }
    }
  }
}

要强调的点是:

  • 第一:涉及 Nested 的 query 检索 以及 aggs 聚合,都需要明确指定 Nested Path。
  • 第二:复杂检索和聚合出错多数是:子聚合的位置放的不对、后括号和前括弧不匹配等,需要多在 Kibana 测试验证。
  • 第三:Kibana 的一键 DSL 美化快捷键:“ctrl + i” 要掌握和灵活使用。

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

3.3 查询后结果

代码语言:javascript
复制
"aggregations" : {
    "user_id_aggs" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "1",
          "doc_count" : 1,
          "top_sales_hits" : {
            "hits" : {
              "total" : {
                "value" : 1,
                "relation" : "eq"
              },
              "max_score" : 1.4418328,
              "hits" : [
                {
                  "_index" : "index_personal_02",
                  "_type" : "_doc",
                  "_id" : "1",
                  "_score" : 1.4418328,
                  "_source" : {
                    "gender" : "male",
                    "user_id" : "1"
                  }
                }
              ]
            }
          },
          "resellers" : {
            "doc_count" : 5,
            "sum_count" : {
              "value" : 41.0
            }
          }
        }
      ]
    }
  }
  • 由于检索 size = 0,所以,只返回了聚合结果,没有返回检索结果。
  • 由于二层聚合设置了 top_hits,所以返回结果里除了sum_count的聚合结果,还包含的其下钻数据字段:“gender”、“user_id” 信息,如果实际业务还有更多需要召回字段,可以一并 include 包含后返回即可。

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

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

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

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

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

搞一把试试。

代码语言:javascript
复制
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://stackoverflow.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 字段进行求和。

代码语言:javascript
复制
# 设定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 插入数据会覆盖掉之前的数据。

代码语言:javascript
复制

# 新插入数据
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 可以检索条件之一。

代码语言:javascript
复制


# 检索结果
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

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-07-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 铭毅天下Elasticsearch 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1、线上实战问题
  • 2、数据建模探讨
    • 2.1 原问题 Nested 模型
      • 2.2 宽表建模方案
        • 2.3 Nested 建模方案
        • 3、查询方案拆解
          • 3.1 分步骤拆解用户查询需求
            • 3.1.1 筛选出在20210510~20210610
            • 3.1.2 意向订单数总和为26的男性用户
            • 3.1.3 应该怎么写dsl语句?
          • 3.2 最终 DSL
            • 3.3 查询后结果
            • 4、有没有更简单的方案?
              • 4.1 扩展方案 1:脚本检索实战
                • 4.2 扩展方案 2:Ingest pipeline 方式实战
                  • 4.2.1 步骤 1——设置求和的 pipeline。
                  • 4.2.2 步骤 2——结合 pipeline 更新数据
                  • 4.2.3 步骤 3——结合文章开头要求进行检索
              • 5、小结
              相关产品与服务
              Elasticsearch Service
              腾讯云 Elasticsearch Service(ES)是云端全托管海量数据检索分析服务,拥有高性能自研内核,集成X-Pack。ES 支持通过自治索引、存算分离、集群巡检等特性轻松管理集群,也支持免运维、自动弹性、按需使用的 Serverless 模式。使用 ES 您可以高效构建信息检索、日志分析、运维监控等服务,它独特的向量检索还可助您构建基于语义、图像的AI深度应用。
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档