17
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Docker環境のNestJSアプリの検索パフォーマンスを確認する方法

17
Posted at

はじめに

Webアプリを作るために、backendでDBの設定をするときに、「インデックスを貼る」という行為を行います。こちらは検索のパフォーマンスのために非常に重要な行為です。

しかし、初学者にとってはパフォーマンスよりちゃんと動くことが重要なため、蔑ろにされがちな項目でもあると思います。私も今までちゃんと確認したことはありませんでした。

今回は、NestJSが動いている裏で発行されている生のSQL文を取得し、それが「インデックスの貼られた検索」になっているかを確認する方法を紹介します。

NestJS+PostgreSQL+PrismaアプリをDockerで起動し、DBeaverというDB管理ツールを用いて、SQL文を実行していきます。

1. デモアプリの作成

firstName, middleName, lastNameを持つuserを用意して、3つのnameで複合インデックスを貼ります。

また、lastNameのみでfindするメソッドと、3つのnameをすべて使用してfindするメソッドを用意しておきます。

具体的には、以下のようなファイルを用意して、デモアプリを起動しておきます。

prisma/prisma.schema
prisma/prisma.schema
datasource db {
  provider = "postgresql"
}
generator client {
  provider = "prisma-client-js"
}
model User {
  id          Int      @id @default(autoincrement())
  firstName   String
  middleName  String
  lastName    String
  // firstName + middleName + lastName の複合インデックス
  @@index([firstName, middleName, lastName], name: "User_name_idx")
}
src/user/user.controller.ts
src/user/user.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { UserService } from './user.service';
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) { }
  // ① lastName だけで検索するエンドポイント
  //    → 複合インデックスの一部だけを使うパターン(Seq Scan を見たい用)
  @Get('by-last-name')
  findByLastName(@Query('lastName') lastName: string) {
    return this.userService.findByLastNameOnly(lastName);
  }
  // ② full name(first + middle + last)で検索するエンドポイント
  //    → 複合インデックス全体を使うパターン(Index Scan を見たい用)
  @Get('by-full-name')
  findByFullName(
    @Query('firstName') firstName: string,
    @Query('middleName') middleName: string,
    @Query('lastName') lastName: string,
  ) {
    return this.userService.findByFullName(firstName, middleName, lastName);
  }
}
src/user/user.service.ts
src/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UserService {
  constructor(private readonly prisma: PrismaService) { }
  // ① 複合キー(firstName, middleName, lastName)の一部だけで検索
  //    → lastName のみ
  async findByLastNameOnly(lastName: string) {
    return this.prisma.user.findMany({
      where: {
        lastName,
      },
    });
  }
  // ② 複合キーすべて(firstName + middleName + lastName)で検索
  async findByFullName(
    firstName: string,
    middleName: string,
    lastName: string,
  ) {
    return this.prisma.user.findMany({
      where: {
        firstName,
        middleName,
        lastName,
      },
    });
  }
}
src/user/user.module.ts
src/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { PrismaService } from '../prisma/prisma.service';
@Module({
  controllers: [UserController],
  providers: [UserService, PrismaService],
})
export class UserModule { }

2. DBeaverの使い方

2.1. インストール方法

インストール方法については省略します。

Macの方は、homebrewを使用してインストールできるので、そちらがおすすめです!

2.2. DBeaverからDockerへ接続する

まず、DBeaverを起動してください。すると、以下のような画面が表示されます。

左上が接続したDBの一覧が表示されるエリアで、右側の大きな領域がDBやSQLのscriptを表示するエリアになっています。

DBeaver1.png

次に一番左上のコンセント+マークから、PostgreSQLを選択してください。

DBeaver2.png

すると、DBに接続するための情報を記入する欄が表示されます。

compose.ymlを参照しながら、以下の情報を入力します。

  • POSTGRE_DB
  • USER
  • PASSWORD
  • Port (例えば、5100:5432となっている場合は5100です)

すべて入力し終えたらfinishを押します。

DBeaver3.png

うまくいくと、左上のDB一覧の画面にDB名が表示され、緑色のチェックマークが付いています。

ここで失敗すると、赤いバツマークがつくので、その場合は

  • Dockerコンテナが起動しているか
  • 入力した内容が間違っていないか
  • 同じポート番号でローカル環境にDBを直接作成していないか

などをを確認してみてください。

2.3. DBeaverからSQLを実行する

ここまでうまくいったら、あとはSQLを実行できるようにするだけです。

DBの名前の上で右クリック > SQL Editor > Open SQL script を選択してください。

DBeaver4.png

これでめでたくSQLを実行する準備が整いました。

あとは右側エリアのエディタにSQL文を書き込んで、左上の黄色い三角ボタンを押して実行すれば、Dockerで起動したNestJSアプリにSQL文を直接実行することができます。(こちらは後ほど行います)

DBeaver5.png

では、デモアプリを立ち上げて、SQLを実行する準備をしてきましょう。

3. DockerでNestJSアプリを起動する

アプリが起動できたら、まずはmigrateを行います。

npx prisma migrate dev
npx prisma generate

それから以下のコマンドでprisma studioを立ち上げて、適当にfirstName, middleName, lastNameに値を入力します。

npx prisma studio

prisma-studio1.png

4. NestJSアプリで実際に発行されたSQL文を確認する

SQLをログとして出力するために、prisma/prisma.serviceに以下のような記述を追加します。

  constructor() {
    super({
    // 中略
    // ここで query イベントをログ出力
    this.$on('query', (e: Prisma.QueryEvent) => {
      console.log('--- Prisma Query ---');
      console.log(e.query);               // 発行された SQL
      console.log('Params:', e.params);   // バインドパラメータ
      console.log('Duration:', e.duration + 'ms'); // 実行時間
      console.log('--------------------');
    });
  }

デモアプリでは、複合キーの一部のみを使ったfind(by-last-name)と、すべてを使ったfind(by-full-name)の2種類を定義してあります。これらがインデックスを使った検索になっているかをみていきます。

まずは、by-last-nameメソッドを使用して、lastNameを使ったfindを呼び出してみましょう。

次にDockerDesktopのappコンテナのログを見にいくと、以下のようなSQL文が出力されています。

--- Prisma Query ---
SELECT "public"."User"."id", "public"."User"."firstName", "public"."User"."middleName", "public"."User"."lastName" FROM "public"."User" WHERE "public"."User"."lastName" = $1 OFFSET $2
Params: ["明治","0"]
Duration: 21.921249999999418ms
--------------------

5. DBeaverを使用して、SQL文にExplainをつけて実行してみる

先ほど出力されたSELECT以降のものがSQL文なので、そちらをコピーして、頭にEXPLAINをつければ全件検索のSequential Scanなのか、インデックスを使った検索のIndex Scanなのかがわかります。

ただ、$1のような変数を実際の値に置き換える必要があるため、先ほどのSQL文をコピーして、AIに「変数を実際の値に置き換えて、EXPLAINをつけて」というと楽です。

すると、以下のようなSQL文を出力してくれます。

EXPLAIN ANALYZE
SELECT
  "public"."User"."id",
  "public"."User"."firstName",
  "public"."User"."middleName",
  "public"."User"."lastName"
FROM "public"."User"
WHERE "public"."User"."lastName" = '明治'
OFFSET 0;

こちらを先ほどのDBeverで実行すると、以下のような結果が得られます。

Seq Scan on "User"  (cost=0.00..17.88 rows=3 width=100) (actual time=0.029..0.031 rows=1 loops=1)
  Filter: ("lastName" = '明治'::text)
Planning Time: 0.138 ms
Execution Time: 0.051 ms

Seq Scanということは、全件検索されているということです。複合キーの一部だけ使ったときは、インデックスの貼られた検索が行われていないという結果が得られました!(こちら最後に補足があります)

6. 複合キーすべてを指定したfindを試す

では、複合キーすべてを指定したfindも試してみましょう。工程自体はほぼ同じなので、サクッとまとめます。まずはby-full-nameメソッドを使用して、3つのnameを使ったfindを呼び出しましょう。

そして、以下のようなSQL文が取得できます。

--- Prisma Query ---
SELECT "public"."User"."id", "public"."User"."firstName", "public"."User"."middleName", "public"."User"."lastName" FROM "public"."User" WHERE ("public"."User"."firstName" = $1 AND "public"."User"."middleName" = $2 AND "public"."User"."lastName" = $3) OFFSET $4
Params: ["スーパーカップ","エッセル","明治","0"]
Duration: 17.355417000013404ms
--------------------

これを実際の値に置き換えて、頭にEXPLAINをつけます。

EXPLAIN ANALYZE
SELECT
  "public"."User"."id",
  "public"."User"."firstName",
  "public"."User"."middleName",
  "public"."User"."lastName"
FROM "public"."User"
WHERE
  "public"."User"."firstName" = 'スーパーカップ'
  AND "public"."User"."middleName" = 'エッセル.'
  AND "public"."User"."lastName" = '明治'
OFFSET 0;

最後に実行すると、ちゃんとIndex Scanになっていることが確認できました!

複合キーで指定したすべての引数を使用してfindを行うと、効率的な検索をしてくれるということです。

Index Scan using "User_name_idx" on "User"  (cost=0.15..8.17 rows=1 width=100) (actual time=0.203..0.203 rows=0 loops=1)
  Index Cond: (("firstName" = 'スーパーカップ'::text) AND ("middleName" = 'エッセル.'::text) AND ("lastName" = '明治'::text))
Planning Time: 0.285 ms
Execution Time: 0.241 ms

おわりに

と、このように書くと複合キーの一部だけだとIndex Scanがなされないような感じがすると思うのですが、実は複合キーに設定された中の左詰めの変数を使うと、ちゃんとIndex Scanになってくれます。

今回の例だと

  • firstName
  • firstName, middleName
  • firstName, middleName, lastName

の中のいずれかを指定してあげればIndex Scanになります。

本当かな?と思ったら、ぜひご自身の手元でお試しください!


株式会社シンシア

株式会社シンシアでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら

弊社には年間100人程度の実務未経験の方に応募いただき、技術面接を実施しております。
この記事が少しでも学びになったという方は、ぜひ wantedly のストーリーもご覧いただけるととても嬉しいです!

17
4
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
17
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?