Elasticsearch Painless スクリプトを動かす際の注意点について確かめてみた

こんにちは。最近は技術負債解消や開発効率向上に携わっているエンジニアの古矢です。

弊社ではキーワード検索エンジンに Elasticsearch を使用しており、システム内部では様々なクエリが発行されています。 Elasticsearch には Painless という Java VM ベースの DSL があり、Painless スクリプトを書くことによって柔軟なクエリを書くことができます。 本記事では、公式で推奨されている使い方だけでなく、あえて非効率になりやすい書き方を試すことで、どの程度パフォーマンスに差が出るのかを検証してみました。

実験

今回は以下を確かめることを目的とします。

  • Painless と通常クエリで速度はどの程度変わるのか?
  • params を使わないとどうなるのか?
  • Painless のコンパイルはいつ走るのか?
  • Painless のキャッシュはどんな仕組みなのか?

検証パターン

パターン 内容
A Painless なし(通常の range クエリ)
B Painless あり(固定値)
C Painless + params(値は params 経由で渡す)
D Painless + 値埋め込み(毎回異なるスクリプト)

検証環境

Docker 上で Elasticsearch 8.11.0 を起動し、ランダム生成で 10 万件の商品データを投入しました。 各パターン開始前にコンテナを立ち上げ、その直後に 10 回ずつ API を実行して計測しています。

データスキーマは以下の通りです。

{
  "name": "商品1",
  "price": 5000,
  "discount_rate": 0.15,
  "stock": 200,
  "category": "electronics"
}

使用したクエリ

パターン A: Painless なし(通常の range クエリ)

※シェルスクリプトで毎回値を変えていため、これは一例です

{
  "query": {
    "range": {
      "price": {
        "gte": 1100,
        "lte": 3100
      }
    }
  }
}

パターン B: Painless あり(固定値)

{
  "query": {
    "bool": {
      "filter": {
        "script": {
          "script": {
            "source": "doc['price'].value * 0.9 >= 1000 && doc['price'].value * 0.9 <= 3000",
            "lang": "painless"
          }
        }
      }
    }
  }
}

パターン C: Painless + params(値は params 経由で渡す)

※シェルスクリプトで毎回値を変えていため、これは一例です

{
  "query": {
    "bool": {
      "filter": {
        "script": {
          "script": {
            "source": "doc['price'].value * (1 - doc['discount_rate'].value) >= params.min_price && doc['price'].value * (1 - doc['discount_rate'].value) <= params.max_price",
            "lang": "painless",
            "params": {
              "min_price": 1100,
              "max_price": 3100
            }
          }
        }
      }
    }
  }
}

パターン D: 値埋め込み(毎回変更)

※シェルスクリプトで毎回値を変えていため、これは一例です

{
  "query": {
    "bool": {
      "filter": {
        "script": {
          "script": {
            "source": "doc['price'].value >= 1100 && doc['price'].value <= 3100",
            "lang": "painless"
          }
        }
      }
    }
  }
}

github.com

検証結果

パターン 1回目(ms) 2-10回目(ms)
A: Painless なし 18 3, 2, 1, 1, 2, 1, 1, 5, 1
B: Painless(固定値) 264 12, 3, 5, 4, 12, 3, 5, 3, 4
C: Painless + params 218 8, 5, 5, 5, 6, 6, 6, 5, 5
D: 値埋め込み(毎回変更) 212 25, 18, 29, 24, 19, 18, 20, 20, 28

分かったこと

通常クエリが最も早い

パターン A, C の 2〜10 回目の実行時間の中央値を比較すると、数 ms 程度の差がありました(A: 1〜3ms、C: 5〜8ms)。 通常クエリの場合はインデックスが効いてきますが、それが結果に現れていると見れます。 このことより、まずは通常クエリでの実装を検討し、複雑な計算が必要な場合に Painless を選択するのがよさそうです。

params を使うとスクリプトキャッシュが効く

パターン B, C の 2〜10 回目が 2〜6 ms と高速だったことより、スクリプトが同じなら params の値を変えてもキャッシュが活用されるようです。

キャッシュの仕組み(ScriptCache.java)を見てみると以下のような仕組みでした。

  • キャッシュキー: (lang, idOrCode, context, options) の組み合わせ
    • idOrCodeid を受け取れるのは、おそらく stored script 用と思われます(今回は未使用のため、キーは code)
  • キャッシュヒットしたらコンパイル済みスクリプトを返す
  • キャッシュミスした場合はscriptEngine.compile() を呼び出してコンパイル

JVM バイトコードの実行自体は高速なため、2 回目以降はスクリプトがキャッシュされ、実行時間が大きく短縮されています。

なお、 params を使うことは Elasticsearch 公式ドキュメント で推奨されています。

Painless は初回コンパイルに時間がかかる

パターンB, C, D では初回呼び出しにおよそ 200 ms 程度かかりました。

Painless scripts are parsed and compiled using the ANTLR4 and ASM libraries. Scripts are compiled directly into Java Virtual Machine (JVM) byte code and executed against a standard JVM. https://www.elastic.co/docs/reference/scripting-languages/painless/painless-language-specification

とあるように、初回実行時は Painless から JVM バイトコードへのコンパイルが走るためと考えられます。初回のみのコストなので大きな問題にならないかもしれませんが、回避したい場合は暖機運転が有効そうです。

また、パターン D の 2〜10 回目は 15〜38 ms と、200 ms よりは短いものの明らかに遅くなっています。 これは毎回スクリプトがコンパイルされることによる純粋なオーバーヘッドと考えられます。 なお、1 回目より 2 回目以降の方が約 170 ms 短い点については、Painless コンパイラ自体の初期化処理が初回に含まれているためだと思われます。

終わりに

今回は、公式ドキュメントに書かれていることを定量的な値で確認することで、より具体的な感触を得ることができました。 また、AI に単に意見を聞くのと、実際に実験した事実を発見するのとでは意見の厚みに大きく差が現れることを改めて実感させられました。 現代のチーム開発において変更に納得できるかどうかを素早く判断することはより重要性を増していると感じてきています。 AI によるコーディング速度の向上は多くの人が実感している一方で、こうした「なぜそう言えるのか」を裏付けるアカウンタビリティの向上にも目を向けていきたいところです。