首先是需求场景介绍,上个月的需求用HIVE做数据清洗写了个T+1的任务,处理每天的退款订单数据,并将数据写入ElasticSearch中提供给业务系统进行查询及导出相关功能的开发。

ElasticSearch中的数据结构为子单纬度的,这里为了描述方便一下用sub_id替代说明。同时每条sub_id记录上关联了一个主订单id,下面的描述中主订单我们用main_id代替说明。每个sub_id都会关联一个main_id,且只能关联一个main_id,但是一个main_id可以关联多个sub_id,即非常常见标准的一个主订单关联多个子订单的1对N的结构模式。

在业务场景中,根据业务需求开发订单展示的页面功能,页面上以main_id纬度为基准进行展示,所以在这里,我们做如下方式的开发

  1. 根据页面查询入参条件先对main_id去重分页查询,同时统计总数量
  2. 根据main_id去重查询的分页结果,取得当页的main_id数据,并把这些main_id连通之前本次查询的参数一起作为新的参数,用这些新的参数重新去ElasticSearch中查询当前这些main_id下对应的sub_id的数据
  3. 取得这些sub_id数据后,在内存中对这些数据按照main_id分组聚合,并通过接口返回给前端

这样我们便实现了从main_id维度的分页查询功能,具体用到的相关ElasticSearch查询DSL语句大致如下

GET /refund_order_index/refund_order_type/_search
{
  "size": 20,
  "from": 0,
  "query": {
    "bool": {
      "filter": [
        {}
      ]
    }
  },
  "collapse": {
    "field": "main_id"
  },
  "_source": {
    "includes": [
      "main_id"
    ],"excludes": []
  },
  "aggs": {
    "total_count": {
      "cardinality": {
        "field": "main_id"
      }
    }
  }
}

ElasticSearch查询返回结果如下,内容和字段等我已做脱敏处理

{
  "took": 6487,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  },
  "hits": {
    "total": 28838909,
    "max_score": null,
    "hits": [
      {
        "_index": "refund_order_index",
        "_type": "refund_order_type",
        "_id": "130**********096334",
        "_score": 1.0,
        "_source": {
          "main_id": "214**********093463"
        },
        "fields": {
          "main_id": [
            "214**********093463"
          ]
        }
      }
      ......
    ]
  },
  "aggregations": {
    "total_count": {
      "value": 28020881
    }
  }
}

查询DSL语句中,“total_count”用于指定返回去重统计数量的字段名,“cardinality”用于指定索引中需要进行去重统计的字段。

"aggs": {
    "total_count": {
      "cardinality": {
        "field": "main_id"
      }
    }
  }

而“collapse”用于表示进行折叠查询,对多个相同的只取其中一个展示,对指定的字段进行折叠。下面的“includes”用于指定查询数据的返回字段限制

"collapse": {
  "field": "main_id"
},
"_source": {
  "includes": [
    "main_id"
  ],"excludes": []
}

至此,一切看起来都很美好,需求实现了、代码敲完了、版本发布上线了,直到昨天在排查一个数据问题的时候,我发现了一点……

异常

在DSL中拼装了一些查询条件后,我得到了如下这样的一个奇怪的结果(结果信息已脱敏),根据条件参数查询得到总结果数13564,而根据main_id进行去重统计后的结果数量为13754,甚至比没去重前的数量还大。这就很匪夷所思了

{
  "took": 799,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  },
  "hits": {
    "total": 13564,
    "max_score": null,
    "hits": [
      ......
    ]
  },
  "aggregations": {
    "total_count": {
      "value": 13745
    }
  }
}

随后,这一问题在官方文档中得到了解答

https://www.elastic.co/guide/cn/elasticsearch/guide/current/cardinality.html

根据官方文档给出的结论:

cardinality 度量是一个近似算法。 它是基于 HyperLogLog++ (HLL)算法的。 HLL 会先对我们的输入作哈希运算,然后根据哈希运算的结果中的 bits 做概率估算从而得到基数。
  1. cardinality去重统计的结果是一个估算值,并不是精确去重的结果
  2. 当数据量非常小的时候精确度非常高
  3. 另外可以通过配置参数precision_threshold来提升精度,代价是更多的内存使用,precision_threshold 范围在 0~40000之内,大于40000的值也会被当做40000来处理。
  4. 5.4 版本中设置的是默认值 precision_threshold=3000

在确认这些信息之后,我们修改了参数,重新查询得到了一个结果”total_count = 13555″

"aggs": {
  "total_count": {
    "cardinality": {
      "field": "main_id",
      "precision_threshold":20000
    }
  }
}
"aggregations": {
  "total_count": {
    "value": 13555
  }
}

同时,官方文档也给出了另外一个优化提升的方案建议,可以新增一个hash字段存储所需字段的hash值,之后去重统计的是对应这个hash字段,具体可以看下官方文档下面的内容

但是本质上来说只是把各条数据的hash过程分散在了各条数据自己创建索引的时候,而如果不这么做只是在查询的时候进行hash计算,这是一个在业务开发中需要权衡的问题。