Chapter 15無料公開

✅DataLoaderでN+1問題に対処する

たった
たった
2021.06.24に更新
このチャプターの目次
アプリケーションコード
src
├── middleware     ... 認証・認可とGraphQLのコンテキスト
├── domain         ... ビジネスロジックの共通化
├── usecases       ... アプリケーションロジック
├── infrastructure ... 外部サービスとのやりとり
├── entities       ... エンティティとGraphQLのフィールド
>├── resolvers      ... GraphQLのリゾルバー
└── inversify.config.ts ... 依存性の注入(以下、DI)の設定

このチャプターで使用するライブラリ

  • TypeGraphQL
  • type-graphql-dataloader

Webアプリでは時々、N+1問題が発生しパフォーマンスを著しく低下させます。特にGraphQLはこの問題が発生しやすい言語です。このチャプターでは、N+1問題の発生と解決について解説します。

このチャプターの内容は、前のチャプター「RelationとFieldResolverを使いこなす」の内容の発展です。未読の方はぜひそちらを先に読まれることをおすすめします。

発生

1つ前のチャプター「ユーザが複数枚の画像を登録できる」仕様を追加する場合を考えます。

先程のように手なりで実装すると、Queryはこのようになりました。

be/src/resolvers/UserResolver.ts
import { UserUsecase } from 'src/usecases/UserUsecase.ts'

@injectable()
@Resolver(() => User)
export class UserResolver {
  constructor(
    @inject(UserUsecase) private readonly usecase: UserUsecase
  ) {}
  
  @Query(() => [User])
  async users() {
    return await this.usecase.getUsers()
  }

  /* 略 */
  
  @FieldResolver(() => [Photo], { nullable: true })
  async photos(@Root() user: User) {
    return await user.photos
  }
}

このQueryを実行すると結果が取れるわけですが...

query users {
  id
  name
  email
  role
  photos {
    url
  }
}

この時ヒットしたUserのIDの数だけphotosテーブルにselect文が流れます。

select p.url from photos p where p.user_id = 1
select p.url from photos p where p.user_id = 2
select p.url from photos p where p.user_id = 3
select p.url from photos p where p.user_id = 4
....

これがN+1問題です。ヒットするレコード数が増えれば増えるほど、データベースのメモリを圧迫しパフォーマンスに影響を与えます。

ではこの問題の解決策を見ていきましょう。

解決

この問題自体はFacebookが提供しているDataLoaderというライブラリを用いて解決できます。

ただ、TypeGraphQLは公式にDataLoaderを組み込んでいないので、type-graphql-dataloaderというライブラリを使います。[1]
アノテーションを書くだけで簡単にN+1問題が解決できます。

be/src/entities/user.ts
+ import { TypeormLoader } from 'type-graphql-loader'

@Entity()
@ObjectType()
export class User implements IUser {
  @PrimaryGeneratedColumn()
  @Field(() => ID)
  @IsNumber()
  readonly id: number

  /* 略 */  
  
  @Field(() => [Photo])
  @OneToMany(() => Photo, (photos) => photos.user, { lazy: true })
+  @TypeormLoader()
  photos: Promise<Photo[]>
}
be/src/resolvers/UserResolver.ts
import R from 'ramda'
+ import { Loader } from 'type-graphql-dataloader'
import { UserUsecase } from 'src/usecases/UserUsecase.ts'

@injectable()
@Resolver(() => User)
export class UserResolver {
  constructor(
    @inject(UserUsecase) private readonly usecase: UserUsecase
  ) {}

  /* 略 */
  
  @FieldResolver(() => [Photo], { nullable: true })
+  @Loader<number, Photo[]>(async (ids, { context }) => {
+    const conn = getConnection()
+    const photos = await conn.getRepository(Photo).find({
+      where: { userId: In([...ids]) },
+    })
+    const photosById = R.groupBy(
+      (photo: Photo) => `${photo.userId}`
+    )(photos)
+    return ids.map((id) => photosById[id] || [])
+  })
  async photos(@Root() user: User) {
-    return await user.photos
+    return async (
+      dataloader: DataLoader<number, Photo[]>
+    ): Promise<Photo[]> => await dataloader.load(user.id)
  }
}

先ほどと同じQueryを実行すると...

query users {
  id
  name
  email
  role
  photos {
    url
  }
}

処理がバッチ化されて1つのselect文が流れるようになります!

select p.url from photos p where p.user_id in (1, 2, 3, 4, ...)
脚注
  1. feature requestとしてissueにあがっている ↩︎