potpro (ぽとぷろ)
Full-stuck engineer(Not Full-stack)
JS/PHP/Go/Docker/Nginxなど。技術または趣味寄りの発信ブログです。
全 85 記事最適化されたデータの取得をしてくれるDataloaderの仕組みを調べてみた
最適化されたデータの取得をしてくれるDataloaderの仕組みを調べてみた
Dataloaderとは?
Dataloaderは、アプリケーションのデータフェッチレイヤーの一部として使用される汎用ユーティリティであり、バッチ処理やキャッシュを介して、データベースやWebサービスなどのさまざまなリモートデータソースに対して簡素化された一貫性のあるAPIを提供するライブラリです。
DataloaderはNode.js(JavaScript)のコードが元となっていますが、OSSで多くの言語に移植されていて、いろいろな言語で使用することができます。
このツールはGraphQL Foundationの持ち物のため、元々GraphQLのN+1問題を解消するためのツールと思われがちです。
しかし、このDataloaderはGraphQLに密結合となっているライブラリではありません。そのため、通常のデータベース取得の薄いインターフェースとしても、Rest APIで使用するときのライブラリとしても使用が可能です。
このDataloaderの最初の説明文にも、
DataLoaderは、Node.jsサービス用のJavaScriptに実装されたこの元のアイデアの簡略版です。 DataLoaderは、graphql-jsサービスを実装するときによく使用されますが、他の状況でも広く役立ちます。
と書いてあり、Batch Scheduling
の欄には、
実際、これは2010年のFacebookの元のPHP実装で使用された動作と同じです。
と書いてあるように、元々Facebookで使用されていたデータ処理アーキテクチャを1つのライブラリとして簡素化された物となっています。
つまりこのライブラリは、FacebookのWebページで取り扱うレベルの規模の大量のデータを効率的に取得し、扱うことができるアーキテクチャを持つライブラリということなのです。
実際のところ、色々調べてみた結果としてかなり効率的にデータの取得ができ、特定のデータストアに依存する作りではないため、どんなWebサービスにも導入可能なライブラリと感じました。
これを使っているみたいな話はGraphQL以外だとまず聞かないので、どのようなアーキテクチャになっているかを詳しく紹介する、そして自分の知識を深めるのが目的です。
Dataloaderが実現することとして
Dataloaderが実現することに関して、要点が3つあります。
- KVSによるデータのキャッシュ(メモ)化
- Batch FunctionによるN+1問題の解消
- Batch Functionによる非同期かつ一括データ取得処理
これだけではわからないと思うので、一つずつ例コードを交えて説明していきます。
また、今回は本家ではなくGo言語移植版のgraph-gophers/dataloaderのコードで例を書いています。
Go言語の方が勉強したかったので・・・なので本家とは微妙に異なる点がある可能性もあります。
KVSによるデータのキャッシュ(メモ)化
Dataloaderは内部にデータを貯めるためのKVSのようなストアをメモリ上に持っています。
これはそのまま、RedisやMemcachedでなどキャッシュするのと役割は同じですが、RedisやMemcachedなどのアプリケーションレベルのKVSを置き換えるものではないと言っており、あくまでデータ読み込みを効率化するためのものです。
DataLoaderキャッシュは、Redis、Memcache、またはその他の共有アプリケーションレベルのキャッシュに置き換わるものではありません。 DataLoaderは何よりもまずデータ読み込みメカニズムであり、そのキャッシュは、アプリケーションへの単一のリクエストのコンテキストで同じデータを繰り返し読み込まないという目的にのみ役立ちます。これを行うために、単純なメモリ内のメモ化キャッシュを維持します
例コードとしてはこんな感じ。
SQLはそれっぽく出力しているだけで、アクセスはしていません。
また、エスケープなどもやってないです。
package main
import (
"context"
"fmt"
"log"
"strings"
dataloader "github.com/graph-gophers/dataloader/v6"
)
func main() {
log.Println("NewBatchedLoader")
loader := dataloader.NewBatchedLoader(batchFunc)
log.Println("Load id:1")
result, _ := loader.Load(context.TODO(), dataloader.StringKey("1"))()
fmt.Println("GET:", result)
log.Println("Load id:1")
result2, _ := loader.Load(context.TODO(), dataloader.StringKey("1"))()
fmt.Println("GET:", result2)
}
func batchFunc(_ context.Context, keys dataloader.Keys) []*dataloader.Result {
log.Println("batchFunc", keys)
var results []*dataloader.Result
var keysString []string
for _, key := range keys {
keysString = append(keysString, key.String())
}
log.Println("SQL Query: SELECT name FROM user WHERE id in (", strings.Join(keysString, ","), ")")
for _, s := range keysString {
results = append(results, &dataloader.Result{Data: "VALUE-" + s})
}
return results
}
実行結果はこうなります。
[LOG] NewBatchedLoader
[LOG] Load id:1
[LOG] batchFunc [1]
[LOG] SQL Query: SELECT name FROM user WHERE id in ( 1 )
GET: VALUE-1
[LOG] Load id:1
GET: VALUE-1
2回目のloadでは、SQL Queryが飛んでいない(Batch Functionが実行されていない)のがわかります。
既に読み込みが終わっているキーは、キャッシュ(メモ)化されて、使いまわします。 全く同じ結果が返されるであろう値を、複数回アクセスするのは非効率です。Dataloaderはデータストアを持つことによってこれを防止することができます。
また、キャッシュの無効化も可能で、キャッシュのアルゴリズム変更なども、基本的にはインターフェースを実装することによって変更できるようになっています。(時間で削除するTTLCache、溢れた古いものから削除するLRUCacheなど)
Batch FunctionによるN+1問題の解消
一般のデータ取得アーキテクチャと違うところは、データの取得の実装はBatch Functionを通して行われるというところです。
このBatch Functionはデータ取得の実コードになるわけですが、渡される引数は配列(Goであればslice)となっています。
1個だけの取得でも、配列として渡されるわけです。
そのため、基本的にBatch Functionでの処理は複数の値を前提としたデータ取得を行う必要があります。
ここでbatchFunc
をもう一度見てみます。
func batchFunc(_ context.Context, keys dataloader.Keys) []*dataloader.Result {
log.Println("batchFunc", keys)
var results []*dataloader.Result
var keysString []string
for _, key := range keys {
keysString = append(keysString, key.String())
}
log.Println("SQL Query: SELECT name FROM user WHERE id in (", strings.Join(keysString, ","), ")")
for _, s := range keysString {
results = append(results, &dataloader.Result{Data: "VALUE-" + s})
}
return results
}
dataloader.Keys型は、[]dataloader.Key型のエイリアスとなっています。
基本的に配列でキーが渡されるため、SQL Queryを発行するときはWHERE id = ?
ではなく、WHERE id in (?)
を使う必要があります。
N+1問題は、ソースコードの設計上、WHERE id = ?
というようなSQL Queryが複数回飛んでしまいパフォーマンスが落ちてしまうことを指すと思います。Dataloaderでは複数個でのデータ取得が原則のため、上記のようなN+1問題は(for文で回すような実装をしなければ)起こらないということです。
そして、先ほどのキャッシュ機能と併用して使用されるため、Batch Functionに渡される配列は、キャッシュ化されていないものだけとなっています。
つまり、ID:1,2,3を取得しようとしたとき、ID:1が既にキャッシュされていれば、Batch Functionの引数は[]Key{2,3}
が渡され、1はキャッシュの値を利用して返却します。
log.Println("Load1")
result, _ := loader.Load(context.TODO(), dataloader.StringKey("1"))()
fmt.Println("GET:", result)
log.Println("LoadMany")
resultM, _ := loader.LoadMany(context.TODO(), dataloader.Keys{
dataloader.StringKey("1"),
dataloader.StringKey("2"),
dataloader.StringKey("3"),
})()
fmt.Println("GET:", resultM)
実行結果はこうなります。
[LOG] Load1
[LOG] batchFunc [1]
[LOG] SQL Query: SELECT name FROM user WHERE id in ( 1 )
GET: VALUE-1
[LOG] LoadMany
[LOG] batchFunc [3 2]
[LOG] SQL Query: SELECT name FROM user WHERE id in ( 3,2 )
GET: [VALUE-1 VALUE-2 VALUE-3]
このようにして、DataloaderはN+1問題を解消しつつ、必要なものだけデータを再取得するように最適化されています。
Batch Functionによる非同期かつ一括データ取得処理
Batch Functionは、即時取得を行わず、非同期で取得するように設計されています。
そのため、実はloader.Load()
を実行させた時に取得しません。
コードをよく見てみるとわかりますが、 loader.Load()
の返り値はdataloader.Thunk
となっています。これはfunc() (interface{}, error)
のエイリアスとなっており、この関数を実行させた時に取得したいデータが返り値となります。
この部分は実装されている言語で変わり、元になったNode.jsだとPromise型です。
そして、Batch Functionですが、この名がついているように実はバッチとして動作します。
loader.Load()
を実行させた後に一定間隔でバッチとして登録され、一定時間が過ぎた後に全ての取得する処理を一括で取得します。
取得するときはKVSにデータが格納されているため、格納されるまで待ち、取得するような挙動をします。
バッチがまだ実行されていない場合は、データを取得しようとしても待たされるようになります。一定間隔はオプションで設定でき、graph-gophers/dataloaderの場合はデフォルト16msのようです。
例コードはこちらとなります。
log.Println("Load10")
t1 := loader.Load(context.TODO(), dataloader.StringKey("10"))
log.Println("Load20")
t2 := loader.Load(context.TODO(), dataloader.StringKey("20"))
// 1秒停止させる
time.Sleep(time.Second * 1)
log.Println("Load30")
t3 := loader.Load(context.TODO(), dataloader.StringKey("30"))
result10, _ := t1()
result20, _ := t2()
result30, _ := t3()
fmt.Println("GET:", result10, result20, result30)
実行結果はこうなります。
17:31:00.821687 Load10
17:31:00.822678 Load20
17:31:00.839679 batchFunc [10 20]
17:31:00.839679 SQL Query: SELECT name FROM user WHERE id in ( 10,20 )
17:31:01.823347 Load30
17:31:01.839354 batchFunc [30]
17:31:01.839354 SQL Query: SELECT name FROM user WHERE id in ( 30 )
GET: KEYS:10 KEYS:20 KEYS:30
途中で1秒停止させているので、ID:10,20を取得するSQL Query、ID:30を取得するSQL Query、とbatchFuncが2回呼ばれていることがわかります。
また、バッチが実行されるまで16msほどかかっているのもわかると思います。
この機能で、複数のLoadもDataloaderが自動的に一つにまとめてくれて、効率的にデータ取得をすることが可能です。
しかし、非同期プログラミングが原則の機能なので、既存のものに適用させるのはちょっと厳しいかもしれません。
最後に
以上、Dataloaderの紹介というか、自分の知識を深めるために書きました。
たくさんのデータを取得してWebに表示するようなサイトでは、単体でもかなり使えるライブラリだと思います。
ただ、非同期プログラミングのアーキテクチャをうまくやるには、既存のフレームワークなどでは厳しい場合もあるので、場合によっては採用は厳しいかなあと思うところでした(Node.jsであれば比較的容易かなと思います)。
GraphQLを採用する場合は相性は非常に良いと思いますので、是非使いこなしたいと思いますね。