最適化されたデータの取得をしてくれるDataloaderの仕組みを調べてみた

author potpro(ぼとぷろ)
2021/02/26

最適化されたデータの取得をしてくれるDataloaderの仕組みを調べてみた

Dataloaderとは?

graphql/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を採用する場合は相性は非常に良いと思いますので、是非使いこなしたいと思いますね。