LoginSignup
11
5

More than 3 years have passed since last update.

TypeGraphQLでN+1問題を解決した話

Posted at

はじめに

GraphQLをサービスで使い始めて、N+1問題にぶち当たったのでその解決策を紹介する。

プロジェクト構成

  • Node.js
  • TypeScript
  • Express
  • GraphQL(Apollo, TypeGraphQL)

実際何が起こったか

DBにはとあるレコードが入っており、それぞれにuseridを保持している。
useridからユーザー名やメアドなどのユーザー情報を取り出すには、別の内部APIに問い合わせる必要がある。

GraphQLのスキーマはこのように定義している。

schema.gql
type Query {
  record(id: Int!): Record
  records(name: String): [Record!]!
}

type Record {
  id: Int!
  name: String!
  user: User
  userid: Int!
}

type User {
  userid: Int!
  username: String!
}

レコードはこのように取得しているとする。

RootResolver.ts
@Resolver()
export class RootResolver {
    @Query(returns => [Record])
    async records(): Promise<Record[]> {
        const records = await conn.query(`
            SELECT
                id,
                name,
                userid
            FROM
                xxx
        `);

        return records;
    }

    @Query(returns => Record, { nullable: true })
    async record(
        @Arg('id', type => Int) id: number
    ): Promise<Record | undefined> {
        const records = await conn.query(`
            SELECT
                id,
                name,
                userid
            FROM
                xxx
            WHERE
                id = ?
        `, [id]);

        return records[0];
    }
}

このとき、N+1問題を気にせずにuserリゾルバを書くことこのようになる。
fetchUsersは内部APIにリクエストを送ってユーザー情報を返す関数とする。

@Resolver(of => Record)
class RecordResolver {
    @FieldResolver()
    user(@Root() record: Record) {
        return fetchUsers([record.userid])[0];
    }
}

例えばRecordを1件だけ取得する場合は、内部APIへのリクエストは1回で済むが、
一覧画面などで100件取得する場合はfetchUsersがほぼ同時に100回呼ばれることとなり、内部APIサーバーやDBの負荷が上がってしまう。

10件取得した場合のログ

fetchUsers(0)
fetchUsers(1)
fetchUsers(2)
fetchUsers(3)
fetchUsers(4)
fetchUsers(5)
fetchUsers(6)
fetchUsers(7)
fetchUsers(8)
fetchUsers(9)

改善方法

リゾルバで即座にリクエストを送るのではなく、問い合わせたいIDを溜めて、バッチ処理で一つのリクエストに複数IDを載せて送ることでリクエストの量を削減させる。
※この場合、内部APIの方を複数IDに対応させる必要がある。

DataLoaderTypeGraphQL-DataLoaderを使うことでこれを簡単に実現できる。
DataLoaderは遅延読み込みをするためのFacebook製のライブラリで、TypeGraphQL-DataLoaderはDataLoaderをTypeGraphQLに適用させたライブラリである。

組み込み方法

ライブラリをインストールする。

npm i -S dataloader type-graphql-dataloader

プラグインを読み込む。

const server = new ApolloServer({
    schema: await makeSchema(),
    validationRules: [depthLimit(7)],
    plugins: [
        // これを追加
        ApolloServerLoaderPlugin({}),
    ]
});

userリゾルバをこのように修正する。

@Resolver(of => Record)
class RecordResolver {
    @FieldResolver()
    @Loader<number, User | undefined>(async (ids) => {
        const users = await fetchUsers([...ids]);
        return ids.map((id) => users.find((user) => user.userid === id));
    })
    user(@Root() record: Record) {
        return async (dataloader: DataLoader<number, User | undefined>) => {
            const user = await dataloader.load(record.userid);
            return user;
        };
    }
}

10件取得するとこのようなログになる。

fetchUsers(0,1,2,3,4,5,6,7,8,9)

おわりに

N+1問題は気づかずにDBや他のサーバーに負荷をかけてしまう可能性があるので注意して設計してほしい。
DataLoaderを使えば、意外と簡単に改善できるのでこれからも活用していきたい。

11
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
11
5