はじめに
GraphQLをサービスで使い始めて、N+1問題にぶち当たったのでその解決策を紹介する。
プロジェクト構成
- Node.js
- TypeScript
- Express
- GraphQL(Apollo, TypeGraphQL)
実際何が起こったか
DBにはとあるレコードが入っており、それぞれにuserid
を保持している。
userid
からユーザー名やメアドなどのユーザー情報を取り出すには、別の内部APIに問い合わせる必要がある。
GraphQLのスキーマはこのように定義している。
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!
}
レコードはこのように取得しているとする。
@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に対応させる必要がある。
DataLoaderとTypeGraphQL-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を使えば、意外と簡単に改善できるのでこれからも活用していきたい。