比較的簡単にできるGraphQLのパフォーマンス改善
はじめに
最近フロントエンドの通信をGraphQLに統一すべく頑張っているのですが、元々フロントエンドメインで開発していたのもありパフォーマンス度外視のBFF実装をしてしまいました。今回はGraphQLのパフォーマンス改善のために取り組んだこと2つをまとめていきたいと思います。2つしか書かない分できるだけ丁寧に書いたつもりです。普段BFFをGo言語で実装してるためサンプルコードはGo言語で書きますが、Goがわからない人にもわかりやすく説明するので最後までお付き合いいただけると幸いです。
触れること
- 取得するフィールドの制限してオーバーフェッチングを防ぐ
- Dataloaderを利用したN+1問題の解決
触れないこと
- GraphQLの基礎的なこと
- スキーマ設計とか
- 外部ツールを導入して計測をしたりだとか
- 現状そこまでしてパフォーマンス改善を行なった経験がないため
- 導入コストが高そうなので今回は割愛
フィールドの制限してオーバーフェッチングを防ぐ
なぜオーバーフェッチが発生するのか
GraphQLの最大のメリットはフロントエンドで必要な情報を必要な分だけ取得できることです。例えば下記のスキーマを見てみましょう。
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
type User {
id: ID!
name: String!
}
type Query {
todos: [Todo!]!
}
上記のQueryでは
- Todoの一覧が取得できる
- そのTodoに紐づくUser情報が取得できる
といった具合になります。
次に上記を満たすためのリゾルバーを見てみましょう。ここではGoのgql-genを使ってみます。gql-genではtype Query
の内容をもとに「todo一覧を取得するためのリゾルバの雛形」を自動生成してくれます。
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
// TODO: 実装
}
todo一覧を取得するための処理をリゾルバの内部に書いていきます。データソースはDBやgRPCサーバー、外部APIなど色々あると思いますが、今回は分かりやすくDBから取得するようにします。
func GetTodos()[]*model.Todo {
// 省略するけどSQL的にはこんな感じ
// SELECT * FROM todos;
}
これだとtodosテーブルにはUserの情報は含まれていないので、Userの情報を取得する関数も実装します。
func GetUserById(id string)[]*model.User {
// 省略するけどSQL的にはこんな感じ
// SELECT * FROM users WHERE id=${id}
}
ここまでできると「Todoの一覧と、そのTodoに紐づくUser情報」が取得できるので、リゾルバーに組み込んでいきましょう。
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
todos := GetTodos() // Todoの一覧を取得
for _, todo := range todos {
user := GetUserById(todo.UserID) // Todoに紐づくUser情報
todo.User = user
}
return todos, nil
}
これで query Todos
を実現するための準備は整いました。実際にフロントエンドから「todo一覧と、それに紐づくユーザー情報」リクエストするときは下記のようになります。
query {
todos {
id
text
done
user {
id
name
}
}
}
「ユーザー情報が必要ない場合」は下記のようになります。
query {
todos {
id
text
done
# userは取得しないのでクエリには含めない
}
}
ここまでできて一件落着、、のように見えますが。現状の実装には大きな問題があります。
もう一度リゾルバーの実装を見てみましょう。
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
todos := GetTodos() // Todoの一覧を取得
for _, todo := range todos {
user := GetUserById(todo.UserID) // Todoに紐づくUser情報
todo.User = user
}
return todos, nil
}
ここで問題になってくるのが下記の箇所です。
for _, todo := range todos {
user := GetUserById(todo.UserID) // Todoに紐づくUser情報
todo.User = user
}
この実装だと、「ユーザー情報が必要ではない場合でもユーザー情報を取得する処置」が実行、つまり余計なリクエストが発生してしまいます。件数が数件程度だとそこまで問題にならないかもしれませんが、システムの規模が増えて何万件のデータを扱うとなると「余計なリクエスト」はできるだけ避けたいですよね。
オーバーフェッチを防ぐには?
上記において理想的な状態は
- ユーザー情報が必要な場合はユーザー取得のための処理を実行する
- ユーザー情報が必要ない場合はユーザー取得のための処理を実行しない
ですよね。GraphQLではフィールドごとのリゾルバーを実装することで実現できます。正式名称をなんというのかわからないのですが、この記事では「フィールドリゾルバー」と呼ぶことにします。
フィールドリゾルバーの実装
今回はGo言語のgql-genを使いますが、NestjsやRubyにもある(はず)なので各自で調べていただけると幸いです🙇♂️ gql-genの場合ではgqlgen.ymlに下記の設定を追加することで生成できます。
models:
# 省略…
Todo:
fields:
user: # フィールドリゾルバーを実装したいフィールドの名前を指定
resolver: true
上記設定を追加し、コマンドを実行すると下記のように「フィールドリゾルバー」を自動生成してくれます。
// User is the resolver for the user field.
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
# TODO:実装
}
ここに先ほどのユーザーを取得する処理を追加してみましょう。
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
return GetUserById(todo.UserID)
}
先ほどのTodosのリゾルバーは下記のようにし、ユーザー情報は取得しないようにします。
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
return GetTodos() // Todoの一覧を取得
}
こうすることで
- ユーザー情報が必要な場合はユーザー取得のための処理を実行する
- ユーザー情報が必要ない場合はユーザー取得のための処理を実行しない
が実現できるようになります。
Dataloaderを利用したN+1問題を防ぐ
なぜN+1問題が起こるのか
上記までの実装でオーバーフェッチを防ぐことができ、パフォーマンス的には問題ないように見えます。しかし現状の実装ではN+1問題という有名な問題を抱えています。
Todos一覧を取得するクエリを叩いてみましょう。その際に GetUserByIdにログを追加してみましょう。
func GetUserById(id string)[]*model.User {
fmt.Println("user id", id) // 追加
}
実行すると下記のようなログが吐かれます。
user id: tknr1216
user id: tknr1216
user id: hanako43
user id: takashi003
user id: hanako43
user id: tknr1216
user id: hanako43
user id: taro115
user id: tknr1216
user id: tknr1216
// Todosの数だけ繰り返される。。
上のログを見ると、同じユーザーIDに対して何度もデータ取得のリクエストが発生していることがわかります。例えば、takashi003やhanako43に対して、それぞれ複数回のデータベースアクセスが行われています。これは非常に非効率であり、データベースへのアクセス回数が多くなりすぎると、パフォーマンスに大きなボトルネックが生じる原因となります。このような問題は「N+1問題」としてよく知られており、GraphQLのリゾルバの中で特に注意が必要な部分です。
N+1問題を防ぐには?
DataLoaderはこのN+1問題を解決するためのツールです。短い時間内に同じリソースへのアクセスを「バッチ化」することで、複数のリクエストを一度のリクエストにまとめて処理し、データベースへのアクセス回数を大幅に削減することが可能となります。
DataLoaderを導入する前に、「複数のUserIdを用いてデータを取得する関数」を実装しましょう。
func GetUsersByIDs(ids []string) ([]*model.User, error) {
// 詳しい実装は省略。SQL的にはこんな感じ
// SELECT * FROM users WHERE IN (ids)
}
ここまでできたら Dataloaderを実装します。今回は下記ライブラリを使います
DataLoaderの実装方法も言語やライブラリによって異なるので各自調べていただけると助かります。
実装するとこんな感じになります。Go特有の部分なので説明は割愛します。。
// DataLoader初期化
var userLoader = newLoader()
// バッチ関数
func batchFn(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
userIDs := keysToStrings(keys)
users := GetUsersByIds(userIDs) // この関数はIDのスライスを入力として受け取り、IDをキーとしたユーザーオブジェクトのマップを返します。
results := make([]*dataloader.Result, len(keys))
for i, key := range keys {
userID := key.String()
results[i] = &dataloader.Result{Data: users[userID], Error: nil}
}
return results
}
// ヘルパー関数: dataloader.Keysを[]stringに変換
func keysToStrings(keys dataloader.Keys) []string {
strs := make([]string, len(keys))
for i, key := range keys {
strs[i] = key.String()
}
return strs
}
先ほど実装してフィールドリゾルバを、DataLoaderを使って取得する形に書き換えます。
// Before
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
return GetUserById(todo.UserID)
}
// After
func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
user, _ := userLoader.Load(ctx, dataloader.StringKey(obj.UserID)).(*model.User)
return user, nil
}
ここまでやると「余計なリクエストを減らし、重複するUserIdはまとめてリクエスト」することができるようになります。ログを仕込んでみましょう。
func GetUsersByIDs(ids []string) ([]*model.User, error) {
fmt.Println("user ids: ", ids)
// 略
}
下記のようなログがはかれ、リクエストが1回しか実行されていないことが確認できます。
user ids: [takashi003 yamada234 taro115 tknr1216 hanako43]
おわりに
今回は比較的簡単に実装できるGraphQLのパフォーマンス改善の方法をご紹介しました。自分は元々フロントエンドしかわからない状態でBFF実装をしたのでN+1や余計なリクエストが発生してしまい苦戦してしまいました。この記事が少しでも誰かの役に立てば嬉しいです。
Discussion