Elasticsearchでのページング。from, search_after, scroll APIどれを使えばいい?
ページングなんて簡単でしょ。そう考えていた時期がオレにもありました。
ページングは奥が深い♣
size
と from
を覚えたくらいでいい気になるなよ♠
今回扱う例
例えば、Twitterのページングを考えてみます。
以下のような感じです。新しい順に並べました。
sizeとfromによるページング
Elasticsearchには from
、size
とのパラメータがあります。
シンプルにそれぞれ、 どこから
、 何件
取るか指定します。
リクエスト例
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がつけたスコアが高い順にソートします。
以下のように、 _score
を desc
でソートすると、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