0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

kyselyを使ってprismaのメモリリーク(スローリーク)を解消する手順

Posted at

🎯 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文字列」に変換することです。
    • 実際のデータベースとの通信は、pgmysql2 といった、Node.jsの世界で長年使われている純粋なJavaScript製のデータベース・ドライバが直接行います。
  • 結論: 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個分のスペースしか使わない。処理が終わった商品はすぐに次の工程(クライアントへのレスポンス)へ流す。
  • 結論: 5万件のデータを「配列(塊)」として扱うのではなく、「流れ(ストリーム)」として扱うことで、サーバーが一度に使用するメモリ量をごく僅か(例: 100件分)に抑えることができます。


🛠 ステップ1:環境構築(PrismaとKyselyの共存)

あなたの「一部だけKyselyを使いたい」というニーズに合わせ、Prismaの便利なマイグレーション機能は維持したまま、Kyselyを共存させます。

  1. 必要なライブラリのインストール:
    ターミナルで以下を実行します。

    # 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回(中間)で解説した「橋渡し」ツールです。
  2. 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
      // ... 他のカラム
    }
    
  3. 型定義ファイルの生成:
    ターミナルで 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: pgPool は、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回で使った監視体制で効果を確認します。

  1. アプリを再ビルド&起動:
    docker builddocker run を再度実行し、Kysely版のコンテナを起動します。
  2. htop で監視開始:
    docker exec -it remix-test htop でコンテナ内部に入ります。
  3. 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で書き換え、ストリーミングのような低レベルな最適化を施す。
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?