5
5

More than 5 years have passed since last update.

GraphQLとかN+1とかその辺りの話 with Go

Posted at

前回の記事(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を叩く
スクリーンショット 2018-07-01 15.11.44.png

すると以下のようにtodos全件を取得するクエリが1件とtodosに紐づくusersレコードを取得するクエリがtodosのレコード数分呼ばれる。

/var/log/mysql/general.log
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のデータをキャッシュから取得することが可能になる。

ということで試してみる
スクリーンショット 2018-07-01 15.11.44.png

実際にクエリが発行される回数が減ったか確認してみる。

/var/log/mysql/general.log
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

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5