🎯 Kyselyが問題を「根本的に」解決する仕組み(詳細)
まず、Kyselyがなぜあなたの問題を解決できるのか、その核心的な理由を「2つの異なるメカニズム」から詳細に解説します。
1. スローリーク問題(原因1)の解決:原因部品の排除
-
Prismaの仕組み(復習): あなたのRemixアプリ(Node.js)が
prisma.findMany()を呼ぶと、それはNode.js内で完結せず、内部的にRust製の「クエリエンジン」バイナリ(query-engine-debian...)を呼び出します。 - 問題点: 第2回で監視したように、このクエリエンジン自体がメモリを少しずつ確保し、解放しない(リークする)というバグがコミュニティで報告されています。
-
Kyselyの仕組み(解決策): Kyselyは、このような外部のバイナリ(クエリエンジン)を一切使用しません。
- Kyselyの主な役割は、TypeScriptのコード(例:
kysely.selectFrom(...))を、ただの安全な「SQL文字列」に変換することです。 - 実際のデータベースとの通信は、
pgやmysql2といった、Node.jsの世界で長年使われている純粋なJavaScript製のデータベース・ドライバが直接行います。
- Kyselyの主な役割は、TypeScriptのコード(例:
- 結論: Kyselyを採用することは、スローリークの原因と疑われる**「Prismaクエリエンジン」という部品そのものをアプリケーションから取り外す**ことを意味します。これにより、リークの根本原因を排除できます。
2. メモリスパイク問題(原因2)の解決:処理方式の変更
-
Prismaの仕組み(復習):
await prisma.largeTable.findMany()は、データベースから取得した5万件のデータをすべてメモリ(Node.jsのヒープ)にロードし、巨大なJavaScriptオブジェクトの「配列」を一度に生成しようとします。 -
問題点: 5万件のデータがNode.jsのメモリ上限(例: 2GB)を超えると、プロセスがクラッシュします。これを「メモリスパイク」と呼びます。
-
Kyselyの仕組み(解決策): KyselyはDBドライバの低レベルな機能にアクセスできるため、「ストリーミング (Streaming)」処理が可能です。
-
ストリーミングの比喩:
-
Prisma
findMany: 倉庫(DB)から5万個の商品(データ)を一度に運び出し、作業場(メモリ)全体に広げてから処理を始める。作業場が狭ければ破綻する。 -
Kysely
stream: 倉庫(DB)から作業場(メモリ)へベルトコンベアを設置する。商品は100個ずつコンベアで運ばれ、作業場では常に100個分のスペースしか使わない。処理が終わった商品はすぐに次の工程(クライアントへのレスポンス)へ流す。
-
Prisma
-
ストリーミングの比喩:
-
結論: 5万件のデータを「配列(塊)」として扱うのではなく、「流れ(ストリーム)」として扱うことで、サーバーが一度に使用するメモリ量をごく僅か(例: 100件分)に抑えることができます。
🛠 ステップ1:環境構築(PrismaとKyselyの共存)
あなたの「一部だけKyselyを使いたい」というニーズに合わせ、Prismaの便利なマイグレーション機能は維持したまま、Kyselyを共存させます。
-
必要なライブラリのインストール:
ターミナルで以下を実行します。# Kysely本体と、DBドライバ (PostgreSQLの場合) pnpm add kysely pg # PrismaのスキーマからKysely用の型を生成するツール pnpm add -D prisma-kysely-
kysely: Kyselyのコアライブラリです。 -
pg: Node.js用のPostgreSQLドライバです。Kyselyはこれを介してDBと通信します。(MySQLの場合はmysql2を使います) -
prisma-kysely: 第1回(中間)で解説した「橋渡し」ツールです。
-
-
schema.prismaの編集:
prisma/schema.prismaファイルを開き、generatorブロックを1つ追加します。// prisma/schema.prisma generator client { provider = "prisma-client-js" } // ↓ これを追記する ↓ generator kysely { provider = "prisma-kysely" // 型定義ファイルの出力先を指定 (例: app/db/types.ts) output = "../app/db" fileName = "types.ts" } datasource db { // ... } // ... あなたのモデル定義 ... model LargeTable { id String @id @default(cuid()) name String // ... 他のカラム } -
型定義ファイルの生成:
ターミナルでprisma generateを実行します。pnpm prisma generate-
結果:
app/db/types.tsというファイルが自動生成されます。 -
中身(イメージ):
import type { ColumnType } from "kysely"; export type Generated<T> = ...; export interface LargeTable { id: string; name: string; } export interface Database { LargeTable: LargeTable; // ... 他のテーブルの型も自動で定義される } - これで、Kyselyがあなたのデータベース構造をTypeScriptの「型」として認識できるようになりました。
-
結果:
📦 ステップ2:Kyselyインスタンスのセットアップ(シングルトン)
第1回(中間)で解説した「VMとシングルトン」の概念に基づき、KyselyもPrismaと同様にシングルトン(アプリ全体で1つのインスタンス)として管理します。
app/db.server.ts (またはあなたがPrismaインスタンスを管理しているファイル)を以下のように修正します。
app/db.server.ts
import { PrismaClient } from '@prisma/client';
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg'; // 'pg' (node-postgres) から Pool をインポート
// KyselyにPrismaが生成したDBの型を教える
import type { Database } from './db/types';
// PrismaClientのシングルトン管理
let prisma: PrismaClient;
declare global {
var __prisma: PrismaClient | undefined;
}
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
if (!global.__prisma) {
global.__prisma = new PrismaClient();
}
prisma = global.__prisma;
}
// --- Kyselyのシングルトン管理を追加 ---
let kysely: Kysely<Database>;
declare global {
var __kysely: Kysely<Database> | undefined;
var __pool: Pool | undefined; // コネクションプールもグローバルで管理
}
if (process.env.NODE_ENV === "production") {
// 本番環境:新しいプールとKyselyインスタンスを作成
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // プールする最大接続数など、詳細設定が可能
});
const dialect = new PostgresDialect({ pool });
kysely = new Kysely<Database>({ dialect });
} else {
// 開発環境:ホットリロードでインスタンスが増えないようにする
if (!global.__pool) {
global.__pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
});
}
if (!global.__kysely) {
const dialect = new PostgresDialect({ pool: global.__pool });
global.__kysely = new Kysely<Database>({ dialect });
}
kysely = global.__kysely;
}
// PrismaとKyselyの両方をエクスポート
export { prisma, kysely };
-
Pool:pgのPoolは、DBへの接続(コネクション)を効率的に使い回すための「コネクションプール」です。Kyselyはこれを使ってDBと通信します。 -
Kysely<Database>: ジェネリクス<Database>に、先ほどprisma-kyselyが生成した型を渡すことで、Kyselyのすべての操作が型安全になります。
🌊 ステップ3:Kyselyでストリーミング処理を実装する
ここが最も重要な「解決」のステップです。5万件のデータを取得するRemixの loader 関数を、PrismaからKyselyのストリーミング処理に書き換えます。
Before: Prisma (メモリを圧迫するコード)
// app/routes/problem-page.tsx (書き換え前)
import { json } from '@remix-run/node';
import { prisma } from '~/db.server.ts';
export async function loader() {
// 5万件のデータをメモリに一括ロード -> メモリスパイク発生!
const largeData = await prisma.largeTable.findMany();
// 5万件の巨大な配列をJSONに変換 -> これもメモリを圧迫
return json(largeData);
}
After: Kysely (ストリーミングで解決するコード)
Kysely v0.27.0以降、.stream() メソッドが標準でサポートされ、非常に簡単にストリーミングが実装できます。
// app/routes/problem-page.tsx (書き換え後)
import { json } from '@remix-run/node';
import { kysely } from '~/db.server.ts';
export async function loader() {
// 1. Kyselyでクエリを構築する
const query = kysely
.selectFrom('LargeTable') // 型補完が効く!
.selectAll();
// 2. .stream() を呼び出し、ReadableStream (AsyncIterable) を取得
// chunkSize で一度にDBからフェッチする行数を指定 (例: 1000行)
// これがベルトコンベアの「1バッチ」のサイズ
const stream = query.stream(1000);
// 3. ストリームを処理する
// `for await...of`構文で、データが1000件ずつ流れてくる
// このループ内では、メモリは常に最大1000件分しか使われない!
const results = []; // 最終的な配列(※注意点あり)
for await (const chunk of stream) {
// chunk は `LargeTable` 型のオブジェクト (1000件の配列ではない)
// ※Kyselyの .stream() はデフォルトで行ごと(row-by-row)にデータを流します。
// (注:ドライバの実装による。pg-query-streamは行ごと)
// もしチャンク(配列)で受け取りたい場合は低レベルAPIを使いますが、
// 行ごとでもメモリ効率は最高です。
// (例:ここで何らかの加工処理を行う)
const processedData = { id: chunk.id, processedName: chunk.name.toUpperCase() };
results.push(processedData);
}
// 4. 結果を返す
// 注意! 結局 `results` に5万件pushするとメモリを圧迫します。
// 次のステップ4で、これをレスポンス自体もストリーミングする方法を解説します。
// ここでは一旦、処理方法のみを示します。
return json(results); // <- これはまだ不完全な解決策
}
-
.stream(1000): この1000という数字が「バッファサイズ」や「チャンクサイズ」に相当します。DBからNode.jsへデータを転送する際の効率とメモリ使用量のトレードオフを調整できます。
🚀 ステップ4:エンドツーエンド・ストリーミング(真の解決策)
ステップ3の最後で指摘した通り、DBからの取得をストリーミングしても、最終的に json(results) で巨大な配列を作ってしまっては意味がありません。
そこで、Remix(Web Fetch API)の Response オブジェクトがストリームを直接扱えることを利用し、DBからクライアント(ブラウザ)まで、一切メモリに溜め込まない「エンドツーエンド・ストリーミング」を実装します。
app/routes/problem-page.tsx (最終形態)
import type { LoaderFunctionArgs } from '@remix-run/node';
import { Response } from '@remix-run/node'; // json ヘルパーではなく Response を直接使う
import { kysely } from '~/db.server.ts';
export async function loader({ request }: LoaderFunctionArgs) {
// 1. Kyselyでストリームを取得
const stream = kysely
.selectFrom('LargeTable')
.selectAll()
.stream(1000); // 1000行ずつのチャンク
// 2. データをJSONL (JSON Lines) 形式に変換するストリームを作成
// (ブラウザで扱いやすいように、1行1JSONオブジェクトの形式にする)
const transformStream = new TransformStream({
start(controller) {
// レスポンスの開始(配列の始まり)
controller.enqueue('[\n');
},
transform(chunk, controller) {
// chunk (DBからの行データ) をJSON文字列に変換
// 2件目以降のために先頭にカンマを追加
controller.enqueue(JSON.stringify(chunk) + ',\n');
},
flush(controller) {
// レスポンスの終了(配列の終わり)
// (注: 最後のカンマを削除する処理が本当は必要だが、簡略化)
controller.enqueue('{}]'); // 最後のダミーオブジェクト
},
});
// 3. DBストリームを変換ストリームにパイプする
// .pipeThrough() で DBストリーム -> 変換ストリーム へと流す
// (注: Kyselyの stream は AsyncIterable のため、
// Web Stream API に変換するヘルパー関数が必要)
const bodyStream = iteratorToStream(stream, transformStream);
// 4. Remixからストリームを直接レスポンスとして返す
return new Response(bodyStream, {
status: 200,
headers: {
'Content-Type': 'application/json', // または 'application/jsonl'
'Transfer-Encoding': 'chunked', // ストリーミングを示す
},
});
}
// ヘルパー関数: AsyncIterable (Kysely) を ReadableStream (Web API) に変換
function iteratorToStream(iterator: AsyncIterable<any>, transform: TransformStream) {
const readable = new ReadableStream({
async pull(controller) {
const { value, done } = await iterator.next();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
},
});
// DBストリームを変換ストリームにパイプし、
// その結果(変換後のストリーム)を返す
return readable.pipeThrough(transform);
}
- 解説: このコードは高度ですが、やっていることは「ベルトコンベア(DBストリーム)」の先に「加工・包装機(TransformStream)」を設置し、それをそのまま「出荷トラック(Response)」に直結するイメージです。
- データはRemixサーバーのメモリを一瞬通過するだけで、どこにも溜まりません。これにより、5万件でも500万件でも、サーバーはほぼ一定のメモリ使用量で処理を完了できます。
✅ ステップ5:動作確認と再監視
Kyselyへの書き換えが完了したら、第2回で使った監視体制で効果を確認します。
-
アプリを再ビルド&起動:
docker buildとdocker runを再度実行し、Kysely版のコンテナを起動します。 -
htopで監視開始:
docker exec -it remix-test htopでコンテナ内部に入ります。 -
k6で負荷テスト実行:
k6 run script.jsを実行します。
観測すべき結果(解決の証拠)
-
【スローリークの解決】
-
htopのプロセス一覧に、query-engine-debian...がそもそも表示されません。(使っていないため) -
nodeプロセスやpgに関連するプロセスのRES(メモリ)が、k6の実行中、徐々に増加し続けることがなく、安定していることを確認します。
-
-
【メモリスパイクの解決】
-
k6がリクエストを送信した瞬間も、nodeプロセスのRES(メモリ)が急激に(数百MB単位で)跳ね上がることがないことを確認します。ストリーミングにより、常に低位で安定しているはずです。
-
これで、Prismaが抱えていた2種類のメモリ問題は根本的に解決されます。
💡 まとめ:PrismaとKyselyの賢い使い分け
- KyselyはORMではなく、型安全なクエリビルダです。
-
prisma-kyselyとの連携により、Prismaの強力なスキーマ管理(マイグレーション)機能の恩恵を受けつつ、Kyselyの型安全性を享受できます。 - すべてのクエリをKyselyにする必要はありません。
- 普段の単純なCRUD操作はPrismaのままでも問題ないことが多いです。
- 今回のように、大量データ処理や複雑なJOIN、パフォーマンスが最重要となる「ホットスポット」だけをKyselyで書き換え、ストリーミングのような低レベルな最適化を施す。