そのScriptクエリは本当に避けるべきなのか

# はじめに
こんにちは。aucfanのエンジニアの@tmot です。

aucfanでは大量のオークションデータをElasticsearch(以下ES)に保存しており、このデータの検索結果や集計によってサービスに価値を提供しています。
我々が提供する集計機能においては、対象の全データについて合計や平均を算出する単純な集計だけでなく、特定のデータのみを対象とするような若干複雑な集計も必要となってきます。このような場合には[Scriptクエリ](https://www.elastic.co/guide/en/elasticsearch/reference/7.15/query-dsl-script-query.html)(スクリプトの記述によって条件を指定する方法)が非常に頼りになるのですが、検索速度のチューニングの記事では[可能なら避けた方がよい](https://www.elastic.co/guide/en/elasticsearch/reference/7.13/tune-for-search-speed.html#_avoid_scripts)とされています。

今回は、落札率の集計 を例に、Scriptクエリ を使用する場合とそうでない場合でどの程度パフォーマンスが違うのかを実験してみたいと思います。

# デモデータを使用した実験
ES内のオークションデータを出品者ごとに分類し、落札率を集計します。
落札率の算出方法は以下の通りです。

```
落札率 = 落札された出品数 / 総出品数
```

ESにはKibanaを介してリクエストを送り、レスポンス速度などを観測します。

## 環境
Elasticsearch: 7.13.2
Kibana: 7.13.2

## データセット
オークションデータを単純化したデータセット(レコード数 1000万件)をESに作成しました。

以下のフィールドを持ちます。

| フィールド名 | データ型 | 説明 | 備考 |
| -- | -- | -- | -- |
| auctionId | keyword | 出品ID | `auction-000000000`~`auction-009999999`で重複なし |
| sellerId | keyword | 出品者ID | `seller-00` ~ `seller-50`の51通りのランダム |
| isSold | boolean | 落札されたかどうか | 落札された場合はtrue <br> 今回は出品者によらず全体の3割程度がtrueになる |
| price | scaled_float | 価格 | `scaled_factor` = 100 |


``` json-doc:データの例
{
  "auctionId" : "auction-000000000",
  "isSold" : false,
  "price" : 0,
  "sellerId" : "seller-13"
}
```

## パターン1: Scriptクエリ を使用する場合
### クエリ
sellerIdごとにバケットを作成し、各バケット内のレコード総数 (`_count`)と落札されたレコード数 (`soldFilter`内の`_count`)からScriptで落札率を算出します。

```console:Scriptクエリでの落札率集計リクエスト
GET test-index-000001/_search?request_cache=false
{
  "size": 0,
  "aggs": {
    "sellerAggregation": {
      "composite": { // sellerIdごとのbucket aggregation
        "sources": [
          {
            "sellers": {
              "terms": {
                "field": "sellerId"
              }
            }
          }
        ],
        "size": 100
      },
      "aggs": {
        "soldFilter": {
          "filter": {
            "term": {
              "isSold": true
            }
          }
        },
        "bidRate": { // 落札率をscriptで集計
          "bucket_script": {
            "buckets_path": {
              "itemCount": "_count",
              "soldCount": "soldFilter>_count"
            },
            "script": """
            if(params.itemCount == 0) {return 0;}
            (float)params.soldCount / (float)params.itemCount;
            """
          }
        },
        "sortedBy": { // 落札率の降順でソート
          "bucket_sort": {
            "sort": [
              {
                "bidRate": {
                  "order": "desc"
                }
              }
            ],
            "size": 51,
            "from": 0
          }
        }
      }
    }
  }
}
```

### レスポンス内容
以下のようなレスポンスが得られます。

```json-doc:レスポンス
{
    ...
    "aggregations" : {
        "sellerAggregation" : {
            "after_key" : {
                "sellers" : "seller-50"
            },
            "buckets" : [
            {
                "key" : {
                    "sellers" : "seller-08"
                },
                "doc_count" : 196610,
                "soldFilter" : {
                    "doc_count" : 59321
                },
                "bidRate" : {
                    "value" : 0.30171912908554077
                }
            },
            {
                "key" : {
                    "sellers" : "seller-21"
                },
                "doc_count" : 196345,
                "soldFilter" : {
                    "doc_count" : 59203
                },
                "bidRate" : {
                    "value" : 0.30152538418769836
                }
            },
            {
                "key" : {
                    "sellers" : "seller-06"
                },
                "doc_count" : 196061,
                "soldFilter" : {
                    "doc_count" : 59101
                },
                "bidRate" : {
                    "value" : 0.30144190788269043
                }
            },
            {
                "key" : {
                    "sellers" : "seller-23"
                },
                "doc_count" : 196118,
                "soldFilter" : {
                    "doc_count" : 59114
                },
                "bidRate" : {
                    "value" : 0.30142056941986084
                }
            },
            {
                "key" : {
                    "sellers" : "seller-24"
                },
                "doc_count" : 195351,
                "soldFilter" : {
                    "doc_count" : 58860
                },
                "bidRate" : {
                    "value" : 0.30130380392074585
                }
            },
            ...
            ]
        }
    }
}
```

`aggregations.sellerAggregation.buckets`のリストの要素が出品者ごとの集計結果となっており、`bidRate`がその出品者のデータの落札率です。

```json
{
  "key" : {
    "sellers" : "seller-08"
  },
  "doc_count" : 196610,
  "soldFilter" : {
    "doc_count" : 59321
  },
  "bidRate" : {
    "value" : 0.30171912908554077
  }
}
```

## パターン2: Scriptクエリ を使用しない場合
### クエリ
今回の設定だと、`isSold`のboolean値を0,1で表した値の平均値 が落札率と一致するので、`avg`を使って以下のようにも書けそうです。

```console:真偽値のavgによる落札率集計リクエスト
GET test-index-000001/_search?request_cache=false
{
  "size": 0,
  "aggs": {
    "sellerAggregation": {
      "composite": { // sellerIdごとのbucket aggregation
        "sources": [
          {
            "sellers": {
              "terms": {
                "field": "sellerId"
              }
            }
          }
        ],
        "size": 100
      },
      "aggs": {
        "bidRate": { // 落札率をisSold(真偽値)の平均値として集計
          "avg": {
              "field": "isSold"
          }
        },
        "sortedBy": { // 落札率の降順でソート
          "bucket_sort": {
            "sort": [
              {
                "bidRate": {
                  "order": "desc"
                }
              }
            ],
            "size": 51,
            "from": 0
          }
        }
      }
    }
  }
}
```

### レスポンス内容
以下のような結果が返ってきます。

``` json-doc:レスポンス
{
    ...
    "aggregations" : {
        "sellerAggregation" : {
            "after_key" : {
                "sellers" : "seller-50"
            },
            "buckets" : [
            {
                "key" : {
                    "sellers" : "seller-08"
                },
                "doc_count" : 196610,
                "bidRate" : {
                    "value" : 0.30171913941305123,
                    "value_as_string" : "true"
                }
            },
            {
                "key" : {
                    "sellers" : "seller-21"
                },
                "doc_count" : 196345,
                "bidRate" : {
                    "value" : 0.3015253762509868,
                    "value_as_string" : "true"
                }
            },
            {
                "key" : {
                    "sellers" : "seller-06"
                },
                "doc_count" : 196061,
                "bidRate" : {
                    "value" : 0.30144189818474865,
                    "value_as_string" : "true"
                }
            },
            {
                "key" : {
                    "sellers" : "seller-23"
                },
                "doc_count" : 196118,
                "bidRate" : {
                    "value" : 0.30142057332830235,
                    "value_as_string" : "true"
                }
            },
            {
                "key" : {
                    "sellers" : "seller-24"
                },
                "doc_count" : 195351,
                "bidRate" : {
                    "value" : 0.3013038069935654,
                    "value_as_string" : "true"
                }
            },
            ...
            ]
        }
    }
}
```

## 実行時間の比較
両パターンのクエリ を10回ずつ (`_cache/clear`を挟みながら)実行し、ES内部での処理時間を比較します。

|| パターン1の処理時間 (ms)<br>Scriptクエリ を使用する場合 | パターン2の処理時間 (ms)<br>Scriptクエリ を使用しない場合 |
| -- | -- | -- |
| 1回目 | 389 | 359 |
| 2回目 | 300 | 341 |
| 3回目 | 334 | 336 |
| 4回目 | 307 | 396 |
| 5回目 | 265 | 280 |
| 6回目 | 539 | 372 |
| 7回目 | 256 | 292 |
| 8回目 | 268 | 315 |
| 9回目 | 266 | 276 |
| 10回目 | 268 | 302 |
| **平均** | 319.2 | 326.9 |
| **標準偏差** | 83.02 | 38.59 |

この結果でt検定(両側・非等分散)をするとP値が0.805となり、有意差があるとは言えませんでした。

# 実データを使用した実験
デモデータではあまりにもデータ数が少ないと思われたので、オークファンのサービスで実際に使われている実際のデータの1週間分に対して同等の集計を実施してみました。対象データ数が増えることで差が顕れるでしょうか。

## 実行時間の比較

|| パターン1の処理時間 (ms)<br>Scriptクエリ を使用する場合 | パターン2の処理時間 (ms)<br>Scriptクエリ を使用しない場合 |
| -- | -- | -- |
| 1回目 | 6024 | 6833 |
| 2回目 | 6591 | 6904 |
| 3回目 | 7282 | 6472 |
| 4回目 | 7072 | 7850 |
| 5回目 | 5581 | 7199 |
| 6回目 | 6552 | 6345 |
| 7回目 | 6923 | 6189 |
| 8回目 | 5989 | 7868 |
| 9回目 | 6527 | 6955 |
| 10回目 | 6735 | 6388 |
| **平均** | 6527.6 | 6900.3 |
| **標準偏差** | 500.92 | 565.27 |

t検定(両側・等分散)のP値は0.156となり、こちらも有意差があるとは言えません。。

# おわりに
今回の落札率の集計の設定においては、Scriptクエリを使う場合とそうでない場合の応答速度の差は見られませんでした。
実際の業務ではScriptクエリ をなくして速度が改善したケースもあれば、Scriptを使わないようにするためにデータ構造を変えてまで対応してもあまり良い効果が得られなかったケースもあるので、そのScriptクエリは本当に避けるべきなのかという勘所を掴んで上手に付き合っていくのがよいかなと思います。