はじめに
GraphQLをサービスで使い始めて、N+1問題にぶち当たったのでその解決策を紹介する。
プロジェクト構成
- Node.js
- TypeScript
- Express
- GraphQL(Apollo, TypeGraphQL)
実際何が起こったか
DBにはとあるレコードが入っており、それぞれにuserid
を保持している。userid
からユーザー名やメアドなどのユーザー情報を取り出すには、別の内部APIに問い合わせる必要がある。
GraphQLのスキーマはこのように定義している。
typeQuery{record(id:Int!):Recordrecords(name:String):[Record!]!}typeRecord{id:Int!name:String!user:Useruserid:Int!}typeUser{userid:Int!username:String!}
レコードはこのように取得しているとする。
@Resolver()exportclassRootResolver{@Query(returns=>[Record])asyncrecords():Promise<Record[]>{constrecords=awaitconn.query(`
SELECT
id,
name,
userid
FROM
xxx
`);returnrecords;}@Query(returns=>Record,{nullable:true})asyncrecord(@Arg('id',type=>Int)id:number):Promise<Record|undefined>{constrecords=awaitconn.query(`
SELECT
id,
name,
userid
FROM
xxx
WHERE
id = ?
`,[id]);returnrecords[0];}}
このとき、N+1問題を気にせずにuser
リゾルバを書くことこのようになる。fetchUsers
は内部APIにリクエストを送ってユーザー情報を返す関数とする。
@Resolver(of=>Record)classRecordResolver{@FieldResolver()user(@Root()record:Record){returnfetchUsers([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
プラグインを読み込む。
constserver=newApolloServer({schema:awaitmakeSchema(),validationRules:[depthLimit(7)],plugins:[// これを追加ApolloServerLoaderPlugin({}),]});
user
リゾルバをこのように修正する。
@Resolver(of=>Record)classRecordResolver{@FieldResolver()@Loader<number,User|undefined>(async(ids)=>{constusers=awaitfetchUsers([...ids]);returnids.map((id)=>users.find((user)=>user.userid===id));})user(@Root()record:Record){returnasync(dataloader:DataLoader<number,User|undefined>)=>{constuser=awaitdataloader.load(record.userid);returnuser;};}}
10件取得するとこのようなログになる。
fetchUsers(0,1,2,3,4,5,6,7,8,9)
おわりに
N+1問題は気づかずにDBや他のサーバーに負荷をかけてしまう可能性があるので注意して設計してほしい。
DataLoaderを使えば、意外と簡単に改善できるのでこれからも活用していきたい。