前回の記事(GoでGraphQLを話すサーバを作ってみた)をDBから取得するようにしてみたが、
どうにも気になる事象(タイトルの通りだがN+1問題)が発生したので更にメモ。
テーブル
users
| Field | Type | Null | Key |
|---|---|---|---|
| id | varchar(255) | NO | PRI |
| name | varchar(255) | NO |
todo
| Field | Type | Null | Key |
|---|---|---|---|
| id | varchar(255) | NO | PRI |
| user_id | varchar(255) | NO | |
| text | varchar(255) | NO |
N+1問題が発生していることを確認
mysql> select * from users;
+---------------------+------------+
| id | name |
+---------------------+------------+
| 8674665223082153551 | s-ichikawa |
+---------------------+------------+
1 row in set (0.00 sec)
mysql> select * from todos;
+----------------------+---------------------+-------+
| id | user_id | text |
+----------------------+---------------------+-------+
| T3916589616287113937 | 8674665223082153551 | Todo3 |
| T4037200794235010051 | 8674665223082153551 | Todo2 |
| T6129484611666145821 | 8674665223082153551 | Todo1 |
+----------------------+---------------------+-------+
3 rows in set (0.00 sec)
とレコードが登録されていたとして、
こんなGraphQLを叩く

すると以下のようにtodos全件を取得するクエリが1件とtodosに紐づくusersレコードを取得するクエリがtodosのレコード数分呼ばれる。
10 Query SELECT id, user_id, text FROM todos
12 Prepare SELECT id, name FROM users WHERE id = ?
10 Prepare SELECT id, name FROM users WHERE id = ?
12 Execute SELECT id, name FROM users WHERE id = '8674665223082153551'
10 Execute SELECT id, name FROM users WHERE id = '8674665223082153551'
12 Close stmt
10 Close stmt
13 Connect root@172.17.0.1 on gql_todo using TCP/IP
13 Prepare SELECT id, name FROM users WHERE id = ?
13 Execute SELECT id, name FROM users WHERE id = '8674665223082153551'
13 Close stmt
13 Quit
つまり典型的なN+1問題が発生していて、このまま本番環境で使うものとして採用すると場合によっては辛い未来が待っている事が予想される。
Deta Loaderを使ってみる
vektah/dataloadenというfacebook/dataloaderにインスパイアされて作成されたというGraphQLのためのData Loader Generatorがあります。
これで作ったData Loaderでtodosに紐づくuserを取得する際に1件づつSQLを発行するのではなく、最終的にデータを返す際にまとめてSQLを発行できるようにします。
まずはインストール
go get -u github.com/vektah/dataloaden
次にGenerateコマンドを実行します。
今回はusersを取得するためのキーがstringであるため-keys="string"オプションをつけています。
$ dataloaden -keys="string" github.com/s-ichikawa/gql-todo/graph.User
するとgraph.Userの定義に従ってuserloader_gen.goというファイルが作成されます。
これを使って、http.Handlerにかませるミドルウェアを書いていきます。
const userLoaderKey = "userloader"
func DataloaderMiddleware(db *model.DBModel, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userloader := UserLoader{
maxBatch: 100,
wait: 1 * time.Millisecond,
fetch: func(ids []string) ([]*User, []error) {
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i := 0; i < len(ids); i++ {
placeholders[i] = "?"
args[i] = i
}
rows, err := db.GetUsers(model.SearchUserCondition{
Ids: ids,
})
if err != nil {
panic(err)
}
var users []*User
for rows.Next() {
var user User
rows.Scan(&user.ID, &user.Name)
users = append(users, &user)
}
return users, nil
},
}
ctx := context.WithValue(r.Context(), userLoaderKey, &userloader)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
UserLoaderはdataloadenコマンドによって生成されたコードに含まれる構造体です。
macBachは1度に検索する最大数上の例だと100件毎にクエリが発行される。
waitは検索する間隔の指定
fetchはデータを解決するための関数
をそれぞれ指定・実装します。(他にもあったけど後で調べる)
これをcontextに持たせてResolver部分を改修していきます。
todosに紐づくusersを解決するためのResolver部分は現在以下のようになっているとして、
func (r *Resolver) Todo_user(ctx context.Context, obj *Todo) (*User, error) {
// DBからusersを取得する
users, err := r.getUsers(model.SearchUserCondition{
Ids: []string{obj.UserId},
})
if err != nil {
return &User{}, fmt.Errorf("Select error: %s", err)
}
return &users[0], nil
}
以下のようにcontextからData Loaderを呼び出して、そこからLoadすようにする。
func (r *Resolver) Todo_user(ctx context.Context, obj *Todo) (*User, error) {
return ctx.Value(userLoaderKey).(*UserLoader).Load(obj.UserId)
}
こうする事で全レコード数からmaxBench数を解決する毎にUserLoader.fetchが呼ばれその結果をキャッシュ。
以降必要なUserIdのデータをキャッシュから取得することが可能になる。
実際にクエリが発行される回数が減ったか確認してみる。
50 Connect root@172.17.0.1 on gql_todo using TCP/IP
50 Query SELECT id, user_id, text FROM todos
50 Prepare SELECT id, name FROM users WHERE id IN (?)
50 Execute SELECT id, name FROM users WHERE id IN ('8674665223082153551')
50 Close stmt
やったぜ。
一応コードはこちら
https://github.com/s-ichikawa/gql-todo