Elasticsearch 聚合搜索技術深入

一. bucket和metric概念簡介

ES把分組後的數據分組稱爲bucket,對組內數據進行的一系列操作稱爲metric,操作可能有若干種類別,如:求和、最大值、最小值、平均值等。

二. 聚合統計案例

準備數據:

PUT /cars
{
	"mappings": {
			"properties": {
				"price": {
					"type": "long"
				},
				"color": {
					"type": "keyword"
				},
				"brand": {
					"type": "keyword"
				},
				"model": {
					"type": "keyword"
				},
				"sold_date": {
					"type": "date"
				},
                "remark" : {
                  "type" : "text",
                  "analyzer" : "ik_max_word"
                }
			}
	}
}
POST /cars/_bulk
{ "index": {}}
{ "price" : 258000, "color" : "金色", "brand":"大衆", "model" : "大衆邁騰", "sold_date" : "2017-10-28","remark" : "大衆中檔車" }
{ "index": {}}
{ "price" : 123000, "color" : "金色", "brand":"大衆", "model" : "大衆速騰", "sold_date" : "2017-11-05","remark" : "大衆神車" }
{ "index": {}}
{ "price" : 239800, "color" : "白色", "brand":"標誌", "model" : "標誌508", "sold_date" : "2017-05-18","remark" : "標誌品牌全球上市車型" }
{ "index": {}}
{ "price" : 148800, "color" : "白色", "brand":"標誌", "model" : "標誌408", "sold_date" : "2017-07-02","remark" : "比較大的緊湊型車" }
{ "index": {}}
{ "price" : 1998000, "color" : "黑色", "brand":"大衆", "model" : "大衆輝騰", "sold_date" : "2017-08-19","remark" : "大衆最讓人肝疼的車" }
{ "index": {}}
{ "price" : 218000, "color" : "紅色", "brand":"奧迪", "model" : "奧迪A4", "sold_date" : "2017-11-05","remark" : "小資車型" }
{ "index": {}}
{ "price" : 489000, "color" : "黑色", "brand":"奧迪", "model" : "奧迪A6", "sold_date" : "2018-01-01","remark" : "政府專用?" }
{ "index": {}}
{ "price" : 1899000, "color" : "黑色", "brand":"奧迪", "model" : "奧迪A 8", "sold_date" : "2018-02-12","remark" : "很貴的大A6。。。" }

2.1 分組統計數量

只是簡單的對數據進行聚合分組,計算每組中元素的個數,不做複雜的聚合統計。在ES中,最基礎的聚合就是terms,相當於SQL中的count。ES內默認會參考doc_count值,也就是參考_count元數據,根據每組元素的個數,執行降序排列,我們也可以根據_key元數據進行排序,_key指的是用來分組的字段對應的值(字典順序)。 terms不支持多字段聚合分組,原因在於多字段聚合分組,其實就是在一個聚合分組的基礎上再次執行聚合分組,terms無法統計組內的元素個數。

舉例: 在cars索引中根據color統計車輛的銷量情況,並按照元素個數進行倒序排序。

GET /cars/_search
{
  "size":0,
  "aggs": {
    "group_by_color_mmr": {
      "terms": {
        "field": "color",
        "order": {
          "_count": "desc"
        }
      }
    }
  }
}

得到的結果爲:

"aggregations" : {
    "group_by_color_mmr" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "黑色",
          "doc_count" : 3
        },
        {
          "key" : "白色",
          "doc_count" : 2
        },
        {
          "key" : "金色",
          "doc_count" : 2
        },
        {
          "key" : "紅色",
          "doc_count" : 1
        }
      ]
    }

如果使用_key進行排序,實際上就是在對"黑色"、“白色”、“金色”、"紅色"這幾個單詞進行排序。

2.2 多層嵌套聚合

先根據color進行分組,在此基礎之上,再對組內的元素針對brand進行分組,最後再通過price進行聚合統計,計算出每組品牌車輛的平均價格。嵌套多層聚合的手法可以實現多字段聚合。但在多層嵌套聚合時,需要注意不得非直系親屬的聚合字段進行排序。多次嵌套聚合的方式又被稱作下鑽分析,而水平定義就是在同一層指定多個分組方式,具體語法如下:

GET /index_name/type_name/_search
{
  "aggs" : {
    "定義分組名稱(最外層)": {
        "分組策略如:terms、avg、sum": {
            "field" : "根據哪一個字段分組",
            "其他參數" : ""
        },
        "aggs" : {
            "分組名稱1" : {},
            "分組名稱2" : {}
        }
    }
  }
}

請看下面這個例子:

GET cars/_search
{
  "size": 0,
  "aggs": {
    "group_by_color": {
      "terms": {
        "field": "color",
        "order": {
          "_key": "desc"
        }
      },
      "aggs": {
        "avg_by_price_color": {
          "avg": {
            "field": "price"
          }
        },
        "group_by_brand": {
          "terms": {
            "field": "brand",
            "order": {
              "avg_by_price": "desc"
            }
          },
          "aggs": {
            "avg_by_price": {
              "avg": {
                "field": "price"
              }
            }
          }
        }
      }
    }
  }
}

可以使用別的聚合字段作爲當前分組bucket的排序依據,但是一定不能跨代。比如group_by_color中不能使用avg_by_price進行排序,因爲已經隔了1代,不再是"直系親屬"的關係了。如果強行使用,會報錯:

“Invalid aggregator order path [avg_by_price]. The provided aggregation [avg_by_price] either does not exist, or is a pipeline aggregation and cannot be used to sort the buckets.”
無效的聚合排序字段[avg_by_price]. 打算用來排序的[avg_by_price]字段要麼不存在,要麼是管道聚合,不能被當前分組(bucket)用來排序。

2.3 統計最大、最小值以及總和

舉例,統計不同color中車輛價格的最大值、最小值以及價格總和。

GET cars/_search
{
  "size": 0,
  "aggs": {
    "group_by_color": {
      "terms": {
        "field": "color",
        "order": {
          "_count": "desc"
        }
      },
      "aggs": {
        "max_price": {
          "max": {
            "field": "price"
          }
        },
        "min_price": {
          "min": {
            "field": "price"
          }
        },
        "sum_price": {
          "sum": {
            "field": "price"
          }
        }
      }
    }
  }
}

2.4 分組後,對組內的數據進行排序,只取前幾條數據

分組後,可能需要對組內的數據進行排序,並選擇其中排名較高的數據,那麼可以使用top_hits來實現。top_hits中的屬性size代表每組中需要展示多少條數據(默認10條),sort代表組內使用什麼字段和規則進行排序(默認使用_doc的asc規則),_source代表結果中包含document中的哪些字段(默認包含全部字段)。
注意: _doc指的是數據真正存儲到ES中的順序,不一定是插入數據的順序,這個元數據字段由ES來維護。

對車輛品牌分組,爲每組中的車輛根據價格倒序排序,只取價格最高的那一臺車輛,並展示車輛的型號和價格。

GET cars/_search
{
  "size" : 0,
  "aggs": {
    "group_by_brand": {
      "terms": {
        "field": "brand"
      },
      "aggs": {
        "top_car": {
          "top_hits": {
            "size": 1,
            "sort": [
              {
                "price": {
                  "order": "desc"
                }
              }
            ],
            "_source": {
              "includes": ["model", "price"]
            }
          }
        }
      }
    }
  }
}

2.5 histogram 區間統計

如果說terms是通過某個字段進行分組,那麼histogram就是按照給定的區間進行分組。
比如以100萬爲一個區間間隔,統計不同範圍內車輛的銷售量和平均價格。那麼在使用histogram時,field指定爲price,區間範圍是100萬,此時ES會將price劃分成若干區間: [0,10000000),[10000000, 20000000)等等,依此類推,histogram和terms一樣,也會統計每個區間內數據的數量,也允許嵌套aggs實現其它聚合統計操作。

GET cars/_search
{
  "size" : 0,
  "aggs": {
    "histogram_by_price": {
      "histogram": {
        "field": "price",
        "interval": 1000000
      },
      "aggs": {
        "avg_by_price": {
          "avg": {
            "field": "price"
          }
        },
         "max_by_price": {
          "max": {
            "field": "price"
          }
        }
      }
    }
  }
}

2.6 date_histogram 區間分組

data_histogram用於對date類型的field進行分組,比如每年的銷量、每個月的銷量統計。
data_histogram包含以下屬性字段:

  1. field: 指定用於聚合分組的字段 必填。
  2. interval: 指定區間範圍 必填,可選值有: year, quarter, month, week, day, hour, minute, second。
  3. format: 指定日期的格式化方式 如 yyyy-MM-dd 可選填,默認值爲 yyyy-MM-dd HH:mm:ss.fff
  4. min_doc_count: 指定每個區間至少需要包含document的數量,可選填,默認爲0,也即,會顯示不包含document的bucket分組。
  5. extended_bounds: 指定起始時間和結束時間,可選填,默認爲所有文檔中該字段的最小值所在範圍的起始時間到最大值所在範圍的結束時間。比如index中date類型字段值最小和最大值分別是: {"_id": “1”, “date_field”: “2019-08-10”}和{"_id": “666”, “2020-04-06”},則extends_bound的默認值爲 2019-08-01 ~ 2020-04-30。
GET cars/_search
{
  "size": 0,
  "aggs": {
    "histogram)by_date": {
      "date_histogram": {
        "field": "sold_date",
        "interval": "month",
        "min_doc_count": 1,
        "format": "yyyy-MM-dd",
        "extended_bounds": {
          "min": "2017-01-01",
          "max": "2020-04-06"
        }
      },
      "aggs": {
        "sum_by_price": {
          "sum": {
            "field": "price"
          }
        }
      }
    }
  }
}

如果min_doc_count等於0或不設置,將會把不含元素的bucket也展示出來,結果如下:

"aggregations" : {
    "histogram)by_date" : {
      "buckets" : [
        {
          "key_as_string" : "2017-01-01",
          "key" : 1483228800000,
          "doc_count" : 0,
          "sum_by_price" : {
            "value" : 0.0
          }
        },
        {
          "key_as_string" : "2017-02-01",
          "key" : 1485907200000,
          "doc_count" : 0,
          "sum_by_price" : {
            "value" : 0.0
          }
        },
        {
          "key_as_string" : "2017-03-01",
          "key" : 1488326400000,
          "doc_count" : 0,
          "sum_by_price" : {
            "value" : 0.0
          }
        },
        ...

2.7 global bucket

在聚合統計時,有時需要需要對比部分數據和總體數據。global用於定義一個全局的bucket,這個bucket分組會忽略query條件,在所有的document中進行聚合統計,寫法: “global”: {}
舉例,統計大衆品牌下的車輛平均價格和所有車輛車輛的平均價格。

GET cars/_search
{
  "size": 0,
  "query": {
    "match": {
      "brand": "大衆"
    }
  },
  "aggs": {
    "dazhong_avg_price": {
      "avg": {
        "field": "price"
      }
    },
    "all_avg_price": {
      "global": {},
      "aggs": {
        "all_of_price": {
          "avg": {
            "field": "price"
          }
        }
      }
    }
  }
}

得到的結果如下:

"hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "dazhong_avg_price" : {
      "value" : 793000.0
    },
    "all_avg_price" : {
      "doc_count" : 8,
      "all_of_price" : {
        "value" : 671700.0
      }
    }
  }

2.8 aggs order

對聚合統計的數據進行排序。默認情況下,一般是根據聚合統計的doc_value和key聯合實現排序的,其中doc_value降序,而key升序。
如果有多層aggs,那麼在執行下鑽聚合時,也可以根據子代的聚合數據實現排序,注意,一定不要跨組排序。

GET /cars/_search
{
  "aggs": {
    "group_by_brand": {
      "terms": {
        "field": "brand"
      },
      "aggs": {
        "group_by_color": {
          "terms": {
            "field": "color",
            "order": {
              "sum_of_price": "desc"
            }
          },
          "aggs": {
            "sum_of_price": {
              "sum": {
                "field": "price"
              }
            }
          }
        }
      }
    }
  }
}

2.9 search aggs

search搜索相當於sql中的where條件,aggs相當於group,顯然這兩個語法使可以結合起來使用的。
比如: 搜索"大衆"品牌中,每個季度的銷售量和銷售總額。

GET cars/_search
{
  "query": {
    "match": {
      "brand": "大衆"
    }
  },
  "aggs": {
    "date_of_group": {
      "date_histogram": {
        "field": "sold_date",
        "format": "yyyy-MM-dd",
        "calendar_interval": "quarter",
        "min_doc_count": 1,
        "order": {
          "_count": "desc"
        }
      }, 
      "aggs": {
        "sum_by_price": {
          "sum": {
            "field": "price"
          }
        }
      }
    }
  }
}

2.10 filter+aggs

filter可以和aggs連用,實現相對複雜的過濾聚合分析。
比如,統計售價在10~50萬之間的車輛的平均價格。

GET cars/_search
{
  "query": {
    "constant_score": {
      "filter": {
        "range": {
          "price": {
            "gte": 100000,
            "lte": 500000
          }
        }
      }
    }
  },
  "aggs": {
      "avg_by_price": {
        "avg": {
          "field": "price"
        }
      }
    }
}

2.11 在aggs中使用filter

filter可以寫在aggs中,能夠實現在query搜索的範圍內進行過濾後,再執行聚合操作。filter的範圍決定了聚合的範圍。
比如,統計"大衆"品牌汽車最近一年的銷售總額。 now是ES的內部變量,代表當前時間, y對應年,M對應月,d對應日。(yyyy-MM-dd HH:mm:ss 有需要自己對應着往上套用)

GET cars/_search
{
  "query": {
    "match": {
      "brand": "大衆"
    }
  },
  "aggs": {
    "filter_sold_date": {
      "filter": {
        "range": {
          "sold_date": {
            "gte": "now-3y"
          }
        }
      },
      "aggs": {
        "sum_price": {
          "sum": {
            "field": "price"
          }
        }
      }
    }
  }
}

2.12 去除重複 cardinality

在ES中,如果需要去重重複,可以使用cardinality,類似sql中的distinct。
比如,統計每年銷售的汽車的品牌數量。

GET cars/_search
{
  "size": 0,
  "aggs": {
    "group_by_date": {
      "date_histogram": {
        "field": "sold_date",
        "format": "yyyy-MM-dd",
        "interval": "year",
        "min_doc_count": 1
      },
      "aggs": {
        "count_of_brand": {
          "cardinality": {
            "field": "brand"
          }
        }
      }
    }
  }
}

cardinality有一定的錯誤率,它的執行性能非常高,一般都在100毫秒以內(不用考慮數據量級),其錯誤率也可以通過參數來進行控制。
cardinality語法中可以增加precision_threshold,這個參數用於定義去除重複字段中unique value的數量,默認值爲100。

比如現在有50萬臺車,希望通過brand(品牌)進行去重。如果把precision_threshold設置成100,則代表最多有100個不同的品牌。若真實情況下不同品牌的數量不超過100,則去重後計算出的結果幾乎不會有任何誤差。(反之,超過的越多則誤差越大)

cardinality算法會佔用一定的內存空間,佔用空間的大小大致參考以下公式:

佔用的內存空間 = precision_threshold * 8(Byte)

根據公式,上述案例中,cardinality算法佔用的空間大小爲: 100 * 8 = 800個字節。我們可以根據真實服務器的配置來調整precision_threshold,這個數值的取值範圍是0~40000,超過40000則按照40000來處理。(因此,不能爲了避免誤差,盲目的填寫precision_threshold)

經官方測試,當precision_threshold=100,對應過濾字段的unique value的數量爲百萬級別時,最終過濾的錯誤率爲5%以內。

如何優化cardinality算法呢?

cardinality底層使用的算法是HyperLogLog++算法,簡稱HLL算法,它本質上就是對所有的unique value計算hash值,再通過hash值使用類似distinct的手法計算最終去重的結果。由於不能保證hash值絕對不重複,所以cardinality計算的結果可能會有誤差。

所謂的對cardinality進行優化,實際上就是想辦法優化HLL算法的性能。算法本身雖然沒有辦法優化,但我們可以調整計算hash值的時機。比如在創建index時,專門創建一個字段,用於存放hash值。新增document時,直接對待過濾的字段計算hash值並保存到hash字段中。這樣一來,在使用cardinality進行去重時,就不需要再次計算待去重字段的hash值了,從而提升了cardinality的性能。(雖然這麼做會降低數據寫入的效率,且並不會對誤差有所改善)

所以是否使用cardinality優化,需要我們在數據的寫入損耗與過濾時節省的hash計算時長之間做出權衡。

以上優化的方案需要通過安裝插件提供支持: mapper-murmur3 plugin。該插件可以從官網獲取,注意插件與ES的版本號保持一致。
官方介紹地址: 點我 下載路徑: 點我

根據實際的安裝路徑,在每一個ES節點下,執行以下命令(比如windows):

bin\elasticsearch-plugin install file:///C:/mapper-murmur3-xxx.zip

執行後需要重啓ES。

卸載命令:

bin\elasticsearch-plugin remove file:///C:/mapper-murmur3-xxx.zip

使用時,需要在定義index時新增類型爲murmur3的子字段。具體寫法如下:

PUT /cars
{
	"mappings": {
		"sales": {
			"properties": {
				"brand": {
					"type": "keyword",
					"fields": {
					  "custom_brand_hash" : {
					    "type": "murmur3"
					  }
					}
				},
				... 省略其他字段
			}
		}
	}
}

新增和修改數據的方式照舊,只不過在進行去重聚合時,不再使用"brand"字段,而是使用子字段"custom_brand_hash“。

GET /cars/_search
{
  "aggs": {
    "group_by_date": {
      "date_histogram": {
        "field": "sold_date",
        "interval": "month",
        "min_doc_count": 1
      },
      "aggs": {
        "count_of_brand": {
          "cardinality": {
            "field": "brand.custom_brand_hash"
          }
        }
      }
    }
  }
}

2.13 百分比算法

ES有percentile api,用於計算百分比數據,在項目中一般用於統計請求的響應時長。
比如PT99: 指的是99%的請求能達到多少毫秒的響應時長,計算時會抽樣99%的數據進行統計。

這裏很容易陷入PT99比PT50計算出的響應時長長的陷阱。實際場景中,請求的響應時長不會均勻分佈,可能有10%的請求響應平均時長爲1000毫秒,50%的請求響應平均時長爲300毫秒,40%的請求響應平均時長爲50毫秒。統計時會對數據進行抽樣對比,現在考慮PT50和PT99,請問是抽樣獲取50%的數據時包含50毫秒響應時長請求的概率大,還是抽樣獲取99%的數據時概率大?顯然後者概率更大,由於抽樣獲取響應時長短的數據概率更大,壓低了整體數據的響應時長,所以通常而言,PT99比PT50計算出的響應時長更短。(就算有人會反問,如果90%的響應時長是1000毫秒,10%的請求響應時長爲50毫秒怎麼辦?沒關係,此時PT99還是比PT50響應時長短,因爲PT99抽樣獲取到50毫秒請求的概率要比PT50大得多,壓低了整體數據的響應時長)

舉例, 統計出50%,90%以及99%的請求花費的響應時長。

初始化數據:

PUT /test_percentiles
{
    "mappings": {
      "properties": {
          "latency": {
              "type": "long"
          },
          "province": {
              "type": "keyword"
          },
          "timestamp": {
              "type": "date"
          }
      }
    }
}

POST /test_percentiles/_bulk
{ "index": {}}
{ "latency" : 105, "province" : "江蘇", "timestamp" : "2016-10-28" }
{ "index": {}}
{ "latency" : 83, "province" : "江蘇", "timestamp" : "2016-10-29" }
{ "index": {}}
{ "latency" : 92, "province" : "江蘇", "timestamp" : "2016-10-29" }
{ "index": {}}
{ "latency" : 112, "province" : "江蘇", "timestamp" : "2016-10-28" }
{ "index": {}}
{ "latency" : 68, "province" : "江蘇", "timestamp" : "2016-10-28" }
{ "index": {}}
{ "latency" : 76, "province" : "江蘇", "timestamp" : "2016-10-29" }
{ "index": {}}
{ "latency" : 101, "province" : "新疆", "timestamp" : "2016-10-28" }
{ "index": {}}
{ "latency" : 275, "province" : "新疆", "timestamp" : "2016-10-29" }
{ "index": {}}
{ "latency" : 166, "province" : "新疆", "timestamp" : "2016-10-29" }
{ "index": {}}
{ "latency" : 654, "province" : "新疆", "timestamp" : "2016-10-28" }
{ "index": {}}
{ "latency" : 389, "province" : "新疆", "timestamp" : "2016-10-28" }
{ "index": {}}
{ "latency" : 302, "province" : "新疆", "timestamp" : "2016-10-29" }

發起請求:

GET /test_percentiles/_search
{
  "size": 0,
  "aggs": {
    "percentiles_latency": {
      "percentiles": {
        "field": "latency",
        "percents": [
          50,
          90,
          99
        ]
      }
    }
  }
}

聚合結果,50%的請求響應時長爲108.5毫秒,90%的請求響應時長約爲468毫秒, 而99%的請求響應時長約爲654毫秒:

"aggregations" : {
    "percentiles_latency" : {
      "values" : {
        "50.0" : 108.5,
        "90.0" : 468.5000000000002,
        "99.0" : 654.0
      }
    }
  }

商業項目中,一般要求PT99最好能達到200毫秒以內,PT90要求500毫秒,而PT50要求1秒。

2.14 percentile_ranks - SLA統計

SLA是Service Level Agreement的縮寫,意思是提供服務的標準。大家談論的網站延遲的SLA,指的是這個網站中所有請求的訪問延時。一般而言,大型公司要求SLA保持在200毫秒以內。如果延時超過1秒,通常會被標記成A級故障,代表這個網站有嚴重的性能問題。

percentile_ranks與上一個章節中的percentile的統計方向正好相反,前者指定請求延時時長,希望統計出滿足延時標準的請求數量百分比,而後者指定了請求數量百分比,希望統計出給定範圍內能夠達到的請求延時時長。

keyed參數,默認值爲true,用於拼接聚合條件和統計結果,起到簡化輸出的作用,具體應用方式請看下面的例子。

舉例,分別統計訪問延時在200毫秒和1000毫秒以內的請求數量佔比。

請求語法,以下語法中會將latency劃分成三個區間,[0,200), [200, 1000), [1000,∞)

GET test_percentiles/_search
{
  "size": 0,
  "aggs": {
    "percentile_ranks_latency": {
      "percentile_ranks": {
        "field": "latency",
        "values": [
          200,
          1000
        ],
        "keyed": true
      }
    }
  }
}

執行結果:

"aggregations" : {
    "percentile_ranks_latency" : {
      "values" : [
        {
          "key" : 200.0,
          "value" : 64.57055214723927
        },
        {
          "key" : 1000.0,
          "value" : 100.0
        }
      ]
    }
  }

如果將keyed設置成true或不寫,則輸出以下結果:

"aggregations" : {
    "percentile_ranks_latency" : {
      "values" : {
        "200.0" : 64.57055214723927,
        "1000.0" : 100.0
      }
    }
  }
}

percentile_ranks的很常用,經常用於區域佔比統計,比如電商中的價格區間統計(某一個品牌中,不同價格區間的手機數量佔比)。

2.15 優化percentiles和percentiles_ranks(TDigest算法)

percentiles和percentiles_ranks的底層都是採用TDigest算法,就是用很多的節點來執行百分比計算,計算過程也是一種近似估計,有一定的錯誤率。節點越多,結果越精確(內存消耗越大)。
ES提供了參數compression用於限制節點的最大數目,限制爲: 20 * compression。這個參數的默認值爲100,也就是默認提供2000個節點。一個節點大約使用32個字節的內存,所以在最壞的情況下(例如有大量數據有序的存入),默認設置會生成一個大小爲64KB的TDigest算法空間。在實際應用中,數據會更隨機,也就沒有必要用這麼多的節點,所以TDigest使用的內存會更少。

GET test_percentiles/_search
{
  "size": 0, 
  "aggs": {
    "group_by_province": {
      "terms": {
        "field": "province"
      },
      "aggs": {
        "percentile_ranks_latency": {
          "percentile_ranks": {
            "field": "latency",
            "values": [
              200,
              1000
            ],
            "keyed" : false,
            "tdigest" : {
              "compression" : 200
            }
          }
        },
        "percentiles_latency" : {
          "percentiles": {
            "field": "latency",
            "percents": [
              50,
              90,
              99
            ],
            "keyed" : false,
            "tdigest" : {
              "compression" : 200
            }
          }
        }
      }
    }
  }
}

2.16 正排索引與聚合分析的內部原理

ES內部是如何執行聚合的?是否是通過倒排索引實現的聚合分析?

在ES中,進行聚合統計的時候,是不使用倒排索引的,因爲使用倒排索引實現聚合統計的代價太高。
比如針對custom_field字段 有如下倒排索引:

Term Doc_1 Doc_2 Doc_3
brown x x
dog x x
fox x x

如果我們想獲得包含brown的文檔列表,且對custom_field字段分組統計文檔數量,則可以使用如下搜索語句:

GET /my_index/_search
{
  "query" : {
    "match" : {
      "custom_field" : "brown"
    }
  },
  "aggs" : {
    "popular_terms": {
      "terms" : {
        "field" : "custom_field"
      }
    }
  }
}

查詢部分簡單又高效。我們首先在倒排索引中找到 brown ,然後找到包含 brown 的文檔。我們可以快速看到 Doc_1 和 Doc_2 包含 brown 這個 token。對於聚合部分,我們需要找到 Doc_1 和 Doc_2 裏所有唯一的詞項。 如果用倒排索引來實現這個功能,代價很高,因爲需要把所有的Terms全部掃描一遍,挨個檢查詞條是否在Doc_1或Doc_2的custom_field字段中存在,隨着詞條數量和文檔數量的增加,代價會越來越大,執行的時間也會越來越長。

Doc values 通過轉置兩者間的關係來解決這個問題。倒排索引將詞條映射到包含它們的文檔,doc values(正排索引) 將文檔映射到它們包含的詞條:

Doc Values
Doc_1 brown fox
Doc_2 brown dog
Doc_3 dog fox

當數據被轉置之後,想要收集到 Doc_1 和 Doc_2 的唯一 token 會非常容易。獲得每個文檔行,獲取所有的詞項,然後求兩個集合的並集。
因此,搜索和聚合是相互緊密纏繞的。搜索使用倒排索引查找文檔,聚合操作收集和聚合 doc values 裏的數據。

2.17 doc values特徵總結

如果字段的數據類型是Long,Date或keyword等,那麼在數據錄入時,ES會爲這些字段自動創建正排索引(index-time)。正排索引和倒排索引類似,也有緩存應用(內存級別的緩存、OS Cache),如果內存不足時,doc values會寫入磁盤文件。

ES大部分的操作都是基於系統緩存(OS Cache)進行的,而不是JVM。ES官方建議不要給JVM分配太多的內存空間,這樣會導致GC的開銷過大(需要到達一定的數據量,GC纔會進行回收,此外,數據越多,那麼新生代老年代的數據也就越多,GC每次需要掃描的數據也會變多。)通常來說,給JVM分配的內存不要超過服務器物理內存的1/4,剩餘的內存空間供lucence作爲OS Cache使用。畢竟ES中的倒排索引和正排索引都可以使用OS Cache來緩存,OS Cache越大,能夠緩存的熱數據越多,ES的搜索性能提升的越明顯。

ES爲了能夠在緩存中儘可能的保存多的doc value和倒排索引,會使用壓縮技術來實現doc value和倒排索引的數據壓縮。技術手段有許多種,如: 合併相同值、table encoding壓縮、最大公約數、offset壓縮等等。

如果確定索引絕對不需要doc values,可以在創建索引時關閉doc values,但要保證索引絕對不會做聚合、排序、父子關係處理以腳本處理。關閉的方法如下:

PUT test_index
{
  "mappings": {
    "my_type" : {
      "properties": {
        "custom_field" : {
          "type": "keyword",
          "doc_values" : false
        }
      }
    }
  }
}

2.18 使用fielddata處理text類型字段的聚合分析

如果document中field的類型是text,那麼在默認情況下是不能執行聚合分析的。比如下面的例子中,remark的類型是text:
Tips: 再次說明,size指的是分組後組的數量,也可以說是bucket的數量。

GET /cars/_search
{
  "size": 0,
  "aggs": {
    "group_of_remark": {
      "terms": {
        "field": "reark",
        "size": 2
      }
    }
  }
}

執行後的部分錯誤信息爲:

Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead. Alternatively, set fielddata=true on [remark] in order to load field data by uninverting the inverted index. Note that this can use significant memory.
text類型字段沒有對聚合、排序等操作做相應的優化,因此這些操作在默認情況下是不能對text類型的字段使用的。請使用keyword字段來代替text類型字段。還有一種做法,就是將對應字段的fielddata的值設置成true,以便通過倒排索引來加載該字段的數據。請注意,這樣做可能會佔用大量的內存。

如果必須在text類型的字段上使用聚合操作,則有兩種實現方案:

  1. 爲text類型字段增加一個keyword類型的子字段,執行聚合操作時,使用子字段。(利用正派索引實現聚合) 推薦方案
  2. 爲text類型字段設置fielddata=true,通過fielddata,輔助完成聚合分析。(在倒排索引的基礎上實現聚合)

第一種方案不過是在創建索引並設置mapping時,增加keyword子字段,這裏不再贅述。下面看第二種方案的實現方法(重點看remark字段):

DELETE cars
PUT /cars
{
	"mappings": {
		"properties": {
		  "price": {
		    "type": "long"
		  },
		  "color": {
		    "type": "keyword"
		  },
		  "brand": {
		    "type": "keyword"
		  },
		  "model": {
		    "type": "keyword"
		  },
		  "sold_date": {
		    "type": "date"
		  },
		  "remark": {
		    "type": "text",
		    "analyzer": "ik_max_word",
		    "fielddata": true
		  }
		}
	}
}

POST /cars/_bulk
{ "index": {}}
{ "price" : 258000, "color" : "金色", "brand":"大衆", "model" : "大衆邁騰", "sold_date" : "2017-10-28","remark" : "大衆中檔車" }
{ "index": {}}
{ "price" : 123000, "color" : "金色", "brand":"大衆", "model" : "大衆速騰", "sold_date" : "2017-11-05","remark" : "大衆神車" }
{ "index": {}}
{ "price" : 239800, "color" : "白色", "brand":"標誌", "model" : "標誌508", "sold_date" : "2017-05-18","remark" : "標誌品牌全球上市車型" }
{ "index": {}}
{ "price" : 148800, "color" : "白色", "brand":"標誌", "model" : "標誌408", "sold_date" : "2017-07-02","remark" : "比較大的緊湊型車" }
{ "index": {}}
{ "price" : 1998000, "color" : "黑色", "brand":"大衆", "model" : "大衆輝騰", "sold_date" : "2017-08-19","remark" : "大衆最讓人肝疼的車" }
{ "index": {}}
{ "price" : 218000, "color" : "紅色", "brand":"奧迪", "model" : "奧迪A4", "sold_date" : "2017-11-05","remark" : "小資車型" }
{ "index": {}}
{ "price" : 489000, "color" : "黑色", "brand":"奧迪", "model" : "奧迪A6", "sold_date" : "2018-01-01","remark" : "政府專用?" }
{ "index": {}}
{ "price" : 1899000, "color" : "黑色", "brand":"奧迪", "model" : "奧迪A 8", "sold_date" : "2018-02-12","remark" : "很貴的大A6。。。" }

此時,再次使用remark字段進行聚合,就不會報錯了,執行結果如下:

"aggregations" : {
    "group_of_remark" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 18,
      "buckets" : [
        {
          "key" : "車",
          "doc_count" : 4
        },
        {
          "key" : "大衆",
          "doc_count" : 3
        },
        {
          "key" : "的",
          "doc_count" : 3
        },
        {
          "key" : "車型",
          "doc_count" : 2
        },
        {
          "key" : "6",
          "doc_count" : 1
        },
        {
          "key" : "a6",
          "doc_count" : 1
        },
        {
          "key" : "上市",
          "doc_count" : 1
        },
        {
          "key" : "專用",
          "doc_count" : 1
        },
        {
          "key" : "中檔",
          "doc_count" : 1
        },
        {
          "key" : "中檔車",
          "doc_count" : 1
        }
      ]
    }
  }

2.18.1 fielddata特徵總結

在默認情況下,不分詞的字段類型(data、long、keyword)會自動創建正排索引,所以支持聚合分析、排序、父子數據關係以及腳本操作等。但text類型字段不會創建正排索引,因爲分詞後再創建正排索引,需要佔用的空間太大,由於沒有正排索引的支持,text類型字段也就不支持聚合分析了。

如果爲text類型的字段開啓fielddata,那麼在對這個字段進行聚合分析時,ES會一次性將倒排索引逆轉並加載到內存中,建立一份類似doc values的fielddata正排索引,最後,基於這個內存中的正排索引來進行聚合分析。

正常情況下,remark(keyword)正排索引,如下圖所示:

Doc Values
1 大衆中檔車
2 大衆神車
3 標誌品牌全球上市車型

但基於fielddata創建的正排索引,如下圖所示(猜測,尚未驗證):

Doc Values(車) Values(大衆) Values(車型)
1 大衆
2 大衆
3 車型

以上Values只寫了一部分,隨着remark中數據量增多,內容越來越豐富,會導致基於fielddata創建的正排索引的Values越來越多(從表格上來看就是列越來越多)。

fielddata存儲在內存中,從結構上來看,就可以很容易的發現,這種數據結構很佔用內存。此外,如果fielddata使用磁盤來進行存儲,會因爲數據量和數據結構的原因,產生非常多的segment file,搜索或聚合使用時,爲了打開這些文件,IO的開銷也會非常大,因此不推薦使用fielddata實現text類型聚合操作。

fielddata是在針對這個字段進行聚合分析時,纔會逆轉倒排索引並加載到內存中,因此是一個在查詢時生成的正排索引(query-time)。

2.18.2 內存控制

由於fielddata對內存(堆內存)的開銷非常大,因此ES提供了相關參數來設置內存限制,在每一個ES節點的配置文件config/elasticsearch.yml中增加配置:

indices.fielddata.cache.size:30%

在這裏插入圖片描述

默認情況下,ES對fielddata沒有任何限制,如果對fielddata增加了配置信息,代表一旦fieldata在內存中的佔比超過了限制,則ES會藉助GC清除內存中所有的fielddata數據,也就伴隨着頻繁的evict和reload(清除內存和重新加載fieldata數據至內存),由於數據本身存儲在segment file中,爲了取出數據,還需要打開並讀取文件,因此IO開銷增大,又由於頻繁使用GC,內存碎片也會增多,但如果不配置參數,又會嚴重的消耗堆內存,最終拋出OutOfMemoryError。所以fielddata不推薦使用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章