📃

Elasticsearch でページングすると 10000 件までしか取れないの? 回避策は?

2022/12/19に公開

Elastcisearch を使っていると「全件数取得したい」というケースがまれによくあります。
検索方法によっては 10000 件しかヒットしなかったりで難しいものです。
今回はそんな問題に向き合ってみます。

サンプルデータの紹介

環境は 5 分でできる Elastic stack(Elasticsearch, Logstash, Kibana)環境構築 を参照ください。

検索や index 作成の操作は Kibana で行うと簡単です。

今回は test_index をという名前にしました。

DELETE /test_index #すでにある場合は消す
PUT /test_index

Pure ruby でバルクインサートのコードを書き、百万件用意しました。
id は連番になっており、これでソートして何件目なのか判断しやすくしています。
ちなみに MacBook Pro (13-inch, M1, 2020) で 6.1s で挿入できました。

require "net/http"
require "uri"
require "json"
require "date"

DOCUMENTS_SIZE = 1000000
BULK_SIZE = 10000
# 予め Kibana で以下のindexを作成しておいてください
# PUT /test_index
ENDPOINT = "http://localhost:9200/test_index"

uri = URI.parse("#{ENDPOINT}/_bulk")
http = Net::HTTP.new(uri.host, uri.port)
req = Net::HTTP::Post.new(uri.request_uri)
req["Content-Type"] = "application/json"

id = 0
from = Date.parse('2022-01-01')
to = Date.parse('2022-01-30')
(DOCUMENTS_SIZE / BULK_SIZE).to_i.times do
  body = []
  BULK_SIZE.times do
    id += 1
    body.push({ index: { _id: id.to_s } }.to_json)
    body.push({ id: id, name: "user#{id}", date: rand(from..to).to_s }.to_json)
  end
  req.body = body.join("\n") + "\n"
  res = http.request(req)
  if res.response.code.to_i >= 400
    p res.body
    exit -1
  end
  p "bulk insert response: ..#{id}"
end
GET /test_index/_count

{
  "count": 1000000,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  }
}

count API で件数を確認しておきます。

今回はページングと深く関わることですので、Elasticsearch でのページング。from, search_after, scroll API どれを使えばいい? も参照いただけると幸いです。

本当に 10000 件までしか得られないのか試してみましょう!

size と from で 10001 件目を探してみる

id 昇順でソートし、10000 件から検索してみます。

POST /test_index/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "id": {
        "order": "asc"
      }
    }
  ],
  "from": 10000,
  "size": 1
}
{
  "error": {
    "root_cause": [
      {
        "type": "illegal_argument_exception",
        "reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [11000]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."
      }
    ],

エラーになりました。

max_result_window を変更してみる

最大件数は max_result_window で設定されています。変更してみましょう。

PUT /test_index/_settings
{
  "index": {
    "max_result_window": 1000000
  }
}

size, from を指定して検索してみます。

POST /test_index/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "id": {
        "order": "asc"
      }
    }
  ],
  "from": 900000,
  "size": 1000
}

# response
{
  "took": 446,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 10000,
      "relation": "gte"
    },
    "max_score": null,
    "hits": [
      {
        "_index": "test_index",
        "_id": "900001",
        "_score": null,
        "_source": {
          "id": 900001,
          "name": "user900001",
          "date": "2022-01-10"
        },
        "sort": [
          900001
        ]
      },

10000 件以上取得できました!
やったぁーっ! やりましたっ! 私っ、嬉しい! これからも頑張りますね!
というわけで 12/19 のアドベントカレンダーでした!

ちょ…まてよ!

問題点

index 設定変更でサクッと対応できましたが、以下のような問題がありそうです。

プロダクトオーナーの利根川さんがニヤニヤしながら近づいてこんなことを言いました。

「我々のサービスはまだまだ成長途中で 1000000 件以上も考えられる。
どうかそのことを諸君らも思い出していただきたい。つまり…我々がその気になればユーザの増加は 10 倍 100 倍ということもあり得るだろう…ということ…!」

また、上のリクエストでは "took": 446 とあからさまに遅くなっています。
10 倍、100 倍に耐えられるであろうか…?
考え始めると不安で夜しか眠れません。

データ数は1万以上だが上限はせいぜいxxくらい、など上限が限られてるようなケースはこれでも良さそうですね。

search_after で探してみる

from パラメータを取っ払い、 search_after で探します。

POST /test_index/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "id": {
        "order": "asc"
      }
    }
  ],
  "size": 10000
}
{
  "_index": "test_index",
  "_id": "10000",
  "_score": null,
  "_source": {
    "id": 10000,
    "name": "user10000",
    "date": "2022-01-17"
  },
  "sort": [10000]
}

"sort": [10000] が得られるようになりました。
今回は id のソートなので search_after のパラメータもシンプルですね。

POST /test_index/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "id": {
        "order": "asc"
      }
    }
  ],
  "search_after": [10000],
  "size": 10000
}
  {
    "_index": "test_index",
    "_id": "10001",
    "_score": null,
    "_source": {
      "id": 10001,
      "name": "user10001",
      "date": "2022-01-15"
    },
    "sort": [
      10001
    ]
  },

というわけで一番シンプルな解決はこれでしょうね。

Scroll API

POST /_search/scroll
{
  "scroll": "1h",
  "scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdSLW1EN2ZBVHdxSG81NllaUzAycmcAAAAAAAAOhxZ5SW9CZlpTTFNPNjJ3YzVmdjIzYWJ3"
}
{
  "_scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdSLW1EN2ZBVHdxSG81NllaUzAycmcAAAAAAAAOhxZ5SW9CZlpTTFNPNjJ3YzVmdjIzYWJ3",
  "took": 24,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1000000,
      "relation": "eq"
    },
    "max_score": null,
    "hits": [
      {
        "_index": "test_index",
        "_id": "10001",
        "_score": null,
        "_source": {
          "id": 10001,
          "name": "user10001",
          "date": "2022-01-15"
        },
        "sort": [
          10001
        ]
      },

total が 1000000 になっていますね。

次のページを取得するには全く同じリクエストをするだけです。

POST /_search/scroll
{
  "scroll": "1h",
  "scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdSLW1EN2ZBVHdxSG81NllaUzAycmcAAAAAAAAOhxZ5SW9CZlpTTFNPNjJ3YzVmdjIzYWJ3"
}

1h どれくらい検索結果を保持するかの設定です。どこかしらにキャッシュしたりするのでしょうから、バッチ向きで、リクエスト数に気をつけねばいけないかもしれません。

We no longer recommend using the scroll API for deep pagination. If you need to preserve the index state while paging through more than 10,000 hits, use the search_after parameter with a point in time (PIT).

公式ドキュメントによると、大量のページングをしたい場合は search_after を使えとの警告があります。

まとめ

ページングもそれの件数も様々な方法があり、一長一短です。
ビジネス要件に合うものを選ぶ際の参考になれば幸いです。

Discussion