はじめに
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
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
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
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
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を表示するエリアになっています。
次に一番左上のコンセント+マークから、PostgreSQLを選択してください。
すると、DBに接続するための情報を記入する欄が表示されます。
compose.ymlを参照しながら、以下の情報を入力します。
- POSTGRE_DB
- USER
- PASSWORD
- Port (例えば、5100:5432となっている場合は5100です)
すべて入力し終えたらfinishを押します。
うまくいくと、左上のDB一覧の画面にDB名が表示され、緑色のチェックマークが付いています。
ここで失敗すると、赤いバツマークがつくので、その場合は
- Dockerコンテナが起動しているか
- 入力した内容が間違っていないか
- 同じポート番号でローカル環境にDBを直接作成していないか
などをを確認してみてください。
2.3. DBeaverからSQLを実行する
ここまでうまくいったら、あとはSQLを実行できるようにするだけです。
DBの名前の上で右クリック > SQL Editor > Open SQL script を選択してください。
これでめでたくSQLを実行する準備が整いました。
あとは右側エリアのエディタにSQL文を書き込んで、左上の黄色い三角ボタンを押して実行すれば、Dockerで起動したNestJSアプリにSQL文を直接実行することができます。(こちらは後ほど行います)
では、デモアプリを立ち上げて、SQLを実行する準備をしてきましょう。
3. DockerでNestJSアプリを起動する
アプリが起動できたら、まずはmigrateを行います。
npx prisma migrate dev
npx prisma generate
それから以下のコマンドでprisma studioを立ち上げて、適当にfirstName, middleName, lastNameに値を入力します。
npx prisma studio
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 のストーリーもご覧いただけるととても嬉しいです!





