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

公開:2020/10/12
更新:2020/10/13
4 min読了の目安(約3900字TECH技術記事

ページングなんて簡単でしょ。そう考えていた時期がオレにもありました。
ページングは奥が深い♧
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と同じ見た目のページングがやりやすくわかりやすい
  • 更新頻度が多くないならこれがよさそう
    • こうあるべきケースもありそうな気がしないでもないので一概に駄目とは言えません
  • ソート条件の変更に強い(後述)
  • 前回のリクエストのことを覚えていなくていい(多くのウェブアプリはステートレスで前回のことを覚えるのはひと手間必要)

デメリット

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

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 のパラメータは直感的ではなく意味がわからない
  • リクエストするとき、前回のレスポンスリを覚えておく必要がある

備考: _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 を使うのもよいでしょう。

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