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

2021-07-22 10:40:22 浏览数 (1)

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

0 人点赞