giftee Tech Blog

ギフティの開発を支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信しています。

gqlgen で高品質な GraphQL サーバーを作る【dataloader 編】

はじめに

こんにちは、ギフティでエンジニアをしている @memetics10 です。 前回の記事では、gqlgen でデータの過剰取得を避けつつ schema ベースの SSOT を実現する方法として、リゾルバー分割を紹介しました。

さて、リゾルバーを分割することで Eager fetch が Lazy fetch になり、DB アクセスの単位も分割されましたが、ここで新たに浮かび上がってくる有名な問題があります。N+1 クエリです。 GraphQL の N+1 クエリを dataloader で解決できることは有名ですが、複雑な要件に耐えうるような dataloader の実践的なプラクティスはあまり知られていないように思います。 そこで今回は、ギフティが dataloader を使うにあたってどのような課題に直面し、その課題をどのように解決したのかをご紹介していきます。

リゾルバー分割による N+1 クエリの問題

前回の記事では、User type の articles field について以下のようにリゾルバーを分割しました。

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
    return r.UserService.All()
}

func (r *userResolver) Articles(ctx context.Context, obj *model.User) ([]*model.Article, error) {
    return r.ArticleService.QueryByUserID(ctx, obj.ID)
}

このとき userResolver の Articles メソッドは、queryResolver の Users が返す User の数だけ実行されます。 UserService と ArticleService は DB アクセスを伴うので、1 度の GraphQL query 実行につき UserService で 1 回、User の数だけ ArticleService が N 回、合計 N+1 回のクエリを発行することになってしまいます。

dataloader による N+1 解決【基礎編】

N+1 クエリを解決する方法として、graph-gophers/dataloader という Go の dataloader 実装があります。 このライブラリは、gqlgen の documentで使い方が紹介されているので詳細は省きますが、 大まかには次のようなシンプルな仕組みで成立しています。

まず、dataloader のインスタンスは一定期間で受け付けた key をまとめて持っておくことができます。

// "user1", "user2", "user3" の key がまとめられる
loaders.ArticlesLoader.Load(ctx, "user1")
loaders.ArticlesLoader.Load(ctx, "user2")
loaders.ArticlesLoader.Load(ctx, "user3")

その後 dataloader インスタンスは、まとめた key を BatchFunc 関数に渡して実行します。

batchFn(ctx, ["user1", "user2", "user3"])

この BatchFunc 関数は、クライアント側が各々実装する仕様となっています。 RDB の N+1 クエリを解決する場合は、この関数で DB にアクセスすることによって、クエリを 1 度にまとめることができます。

さて、先ほどの resolver の実装例に戻ると、次のような実装で N+1 クエリを回避することができます。

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
    return r.UserService.All()
}

func (r *userResolver) Articles(ctx context.Context, obj *model.User) ([]*model.Article, error) {
    return loader.LoadArticles(ctx, obj.ID)
}
// 再帰的に呼び出され、key を集めていく。一定期間後、集めた key を BatchFunc 関数に渡して実行する
func LoadArticles(ctx context.Context, userID string) ([]*model.Article, error) {
    loaders := For(ctx)
    return loaders.ArticlesLoader.Load(ctx, userID)()
}

dataloader による N+1 解決【応用編】

前の節の ArticlesLoader は、dataloader key としてユーザー ID を受け取り、対応する Articles を値として返します。 一見問題なさそうな実装ではありますが、実はいくつか課題があります。

この課題を明らかにするために、まずは dataloader のキーが何であるかについてもう少し考えてみます。 dataloader の仕様としては、渡されるキーが具体的に何であるべきかは明示されていません。 なので例えば データソースが RDB の場合、レコード取得のキーの候補となるカラムはいくつか考えられます。

  • primary key
  • foreign key
  • unique key

値を取得するためのキーが複数あるとき、愚直に実装するとキーごとのローダーを用意することになるかもしれません。

For(ctx).FooByPrimaryKeyLoader.Load(ctx, key)
For(ctx).FooByForeignKeyLoader.Load(ctx, key)
For(ctx).FooByUniqueKeyLoader.Load(ctx, key)

ですがこういったキーごとのローダー実装には、次の 3 つの課題があります。

  • Cache が使えない問題

    • dataloader には Batch とは別にもう一つ、Cache の機能があります。これは Batch に登録されるキーと値をメモ化してくれる仕組みです。 キーと値をセットでメモ化するので、値が同じであってもキーが異なっていると Cache を参照することができなくなってしまいます。 なので同一の値を取得するローダーがキーごとにある場合、ローダーを横断して Cache を参照することはできません。 ただし、そもそも Cache が解決する課題よりも Batch が解決する課題のほうが大きいため、実際のところこれはあまり大きなデメリットではないかもしれません。 なおギフティでは現時点で Cache の機能を利用していません。
  • SSOT が実現できなくなる問題

    • これは Resolver が分割されていない状態と似ている問題です。 GraphQL の object を取得するローダーがキーごとにあるとき、キーごとに object 取得の実装が行われることになるでしょう。 これは単に実装が大変なだけでなく、全てのローダーが同じ object を返すように実装されていることを保証できません。 「同じ object を返すがキー種別が異なるローダー」が増えていくことで、ローダーの管理自体が負債になっていったり、schema 変更時にバグを埋め込んでしまう懸念があります。
  • 並び替えや絞り込みに対応できなくなるケースがある問題

    • dataloader は原則キー以外のパラメータを受け付けず、任意の並び替え・絞り込み条件を受け付ける仕様にはなっていません。 そのため、1:N の relation を取得するローダー(1 側をキーにして N 側を値にする)では、N 側の並び替えや絞り込みが原則できなくなってしまいます。 例えば UserID をキーに Articles の値を取得するローダーがあったとき、Articles の件数や並び順は固定化されてしまいます。

さて、私たちはこれらの問題に対する解決方法として dataloader キーを primary key に統一するようにしました。 ただし当然ながら、親 resolver から渡された object が必ずしも primary key を持っているとは限りません。そのような場合は、primary key を取得する Loader を別途用意します。 例えば最初の例でお見せした resolver は、次のような実装にします。

func (r *userResolver) Articles(ctx context.Context, obj *model.User) ([]*model.Article, error) {
  ids, err := loader.LoadArticleIDs(ctx, obj.ID)
  if err != nil {
    ...
  }

    return loader.LoadArticles(ctx, ids)
}
func LoadArticleIDs(ctx context.Context, userID string) ([]string, error) {
    loaders := For(ctx)
    return loaders.ArticleIDsLoader.Load(ctx, userID)()
}

func LoadArticles(ctx context.Context, ids []string) ([]*model.Article, error) {
    loaders := For(ctx)
    return loaders.ArticlesLoader.LoadMany(ctx, ids)()
}

このようにすることで、当初の問題は次のように解決されます。

  • Cache が使えない問題

    • Cache として登録されるキーが primary key になるので、Cache ヒットさせられるようになります。
  • SSOT が実現できなくなる問題

    • object を取得する関数が一つだけに統一されます。キーごとに object を取得する実装を書く必要はありません。
  • 並び替えや絞り込みに対応できなくなるケースがある問題

    • 並び替えや絞り込み、あるいはページネーションをする場合は、検索条件を受け取り primary key を返す検索用関数を用意します。
func (r *userResolver) Articles(ctx context.Context, obj *model.User, searchCondition *model.ArticlesSearchCondition) ([]*model.Article, error) {
  ids, err := r.ArticleService.Search(ctx, obj.UserID, searchCondition)
  if err != nil {
    ...
  }

    return loader.LoadArticles(ctx, ids)
}

ただし dataloader がキーのみを受け付ける仕様であるため、検索用関数に dataloader を噛ませることは原則できません。 したがって ID 取得部分はやはり N+1 クエリを免れないことに注意する必要があります。 それでも ID 取得部分にはインデックスオンリースキャンが効く可能性が高く、object 取得全体が N+1 クエリになるよりはよいと考えています。

最後に

今回は dataloader を使った N+1 クエリの解決についてお話しさせていただきました。 dataloader はとても素晴らしい仕組みですが、いざ現場の開発で使ってみるとつまずくポイントがいくつかあります。 この記事で、似たようなつまずきが少しでも解消されると幸いです。 dataloader の実践的な使い方を模索するにあたって、graphql/dataloader の GitHub Issues が大変参考になりました。 特に参考になった Issue へのリンクを以下に記載して、この記事の締めとさせていただきます。 最後までご覧いただきありがとうございました!

Loading by an alternative key

How does pagination work with dataloader?

Question: dataloader on N to N relationship?