🔎

Elasticsearchでのページング。from, search_after, scroll APIどれを使えばいい?

2020/10/13に公開

ページングなんて簡単でしょ。そう考えていた時期がオレにもありました。
ページングは奥が深い♣
sizefrom を覚えたくらいでいい気になるなよ♠

今回扱う例

例えば、Twitterのページングを考えてみます。
以下のような感じです。新しい順に並べました。

sizeとfromによるページング

Elasticsearchには fromsize とのパラメータがあります。
シンプルにそれぞれ、 どこから何件 取るか指定します。

リクエスト例

1ページ目

{
  "sort": [
    {
      "timestamp": {
        "order": "desc"
      }
    }
  ],
  "size": 3,
  "from": 0
}

2ページ目

{
  "sort": [
    {
      "timestamp": {
        "order": "desc"
      }
    }
  ],
  "size": 3,
  "from": 3
}

やったぜ! 完全勝利!
ロジックもシンプルでわかりやすい!

しかし、Twitterの更新頻度は半端なく1ページ目を読んでいる間に新たなツイートが生まれ、2ページ目をリクエストしたらこうなりました。

メリット

  • 何よりシンプルでわかりやすい
  • Googleと同じ見た目のページングがやりやすくわかりやすい
  • 更新頻度が多くないならこれがよさそう
    • 一概に駄目とは言えません。例えばランキングであればこの動きが良いと思います。10ごとのページングでランキング20以内に入ってくるか見るようなケースです。
  • ソート条件の変更に強い(後述)
  • 前回のリクエストのことを覚えていなくていい(多くのウェブアプリはステートレスで前回のことを覚えるのはひと手間必要)

デメリット

  • ページングの途中で前ページ部分に追加、削除が入るとずれる

search_after によるページング

size, from には上記のような問題がありました。
「前回の続きから欲しい!」だけなのに。
そこで search_after ですよ。

sort パラメータを指定すると、検索結果に sort 配列が得られます。
(後述: _score 降順のソートだと得られません)
これはソートに使われたパラメータを示す配列でソート条件の数だけ得られます。

レスポンス例

{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 10000,
      "relation" : "gte"
    },
    "max_score" : null,
    "hits" : [
      {
        "_index" : "tweet",
        "_type" : "_doc",
        "_id" : "hoge",
        "_score" : null,
        "_source" : {
         "中略"
        },
        "sort" : [
          1590537600000
        ]
      },
      {
        "_index" : "tweet",
        "_type" : "_doc",
        "_id" : "muga",
        "_score" : null,
        "_source" : {
         "中略"
        },
        "sort" : [
          1590364800000
        ]
      }
    ]
  }
}

リクエスト例

{
  "sort": [
    {
      "timestamp": {
        "order": "desc"
      }
    }
  ],
  "search_after": [1590364800000],
  "size": 3
}

メリット

  • カーソル風(前回の続きから)のリクエストができる
  • size, from のデメリットが解消できる

デメリット

  • ソート条件が増えるとページングも修正が必要
  • ぱっと見 search_after のパラメータは直感的ではなく意味がわからない
  • リクエストするとき、前回のレスポンスを覚えておく必要がある。ページの途中からアクセスできない。
  • Google風のページング(1,2,3...)ができない

備考: _score descでのページング

Elasticsearchはソートを指定しなかった場合、Elasticsearchがつけたスコアが高い順にソートします。
以下のように、 _scoredesc でソートすると、search_after で使用する sort が得られません。

"sort": [
  {
    "_score": {
      "order": "desc"
    }
  }
],

その場合、以下のように第2ソートを指定すると sort が得られます。

"sort": [
  {
    "_score": {
      "order": "desc"
    }
  },
  {
    "tweet_id": {
      "order": "desc"
    }
  }
],

なんだかなぁ、という感じがしますので、OSSへの貢献のチャンスかも知れませんね。

Scroll API

今回の用途には不向きなのですがもう一つ、Scroll APIがあります。

URLパラメータに、scrollの有効期限を指定してリクエストします。

GET YOUR_INDEX/_search?scroll=1h
{
  "sort": [
    {
      "timestamp": {
        "order": "desc"
      }
    }
  ],
  "size": 3
}
{
  "_scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFFk5bWJIWFVCTnpDZTdsM1F2MW1UAAAAAAAABwUWLXItRFhaQ0ZRTC1oOXY4SGl5dFNMQQ==",

スクロールIDが得られます。

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

次ページのリクエスト scroll_id を指定するだけで他のパラメータは要りません。

メリット

  • 重複、漏れが発生しない
  • 集計時刻がクリティカルなケースのページングに最適
    • 例えば、ランキング上位1000名に報奨を与える場合、集計中にランキングが狂わない
  • バッチ処理向け
  • APIがシンプル

デメリット

  • キャッシュに対してページングするため、ユーザの検索のページングには不向き(ユーザ全員のリクエストをページングするためのリソースは…?)
  • ユーザのページングには向かない(ユーザのリクエストごとにキャッシュを作る必要がある)
  • 有効期限が切れると 404 になるため、ユーザ向けの検索では使いづらい。

まとめ

いろんなページング方法があり、一長一短です。
一番わかりやすいのは size, from だと思います。
なかなかTwitterレベルまでサービスが成長させられるのはまれかもしれませんので、わかりやすさを優先するのはありです。

GraphQLのRelay形式といい、最近はカーソル方式のAPIが主流になってきたような気もしますので、ちょっと頑張って search_after を使うのもよいでしょう。

ためになったらサポートして僕の承認欲求を満たしてくれると嬉しいです!(笑)

Discussion