Next.js環境でのPrismaClientシングルトン化による接続プール最適化
概要
Next.jsアプリケーションで各ページが個別にPrismaClientインスタンスを作成していたことで、開発環境でのホットリロード時に接続プールが枯渇する問題が発生していました。本記事では、適切なシングルトンパターンの実装とNeon Database向けの接続プール設定により、この問題を解決した手法を解説します。
技術スタック
- Next.js 14+ (App Router)
- Prisma ORM (v5.x)
- PostgreSQL (Neon Database)
- TypeScript (v5.x)
- シングルトンパターン
- Edge Runtime対応
背景・課題
問題の発生
開発環境で以下の現象が発生していました:
// 各ページで個別にインスタンスを作成していた(NG例)
// app/dashboard/page.tsx
export default async function DashboardPage() {
const prisma = new PrismaClient(); // ❌ 毎回新規作成
const customers = await prisma.customer.findMany();
// ...
}
// app/emails/[id]/page.tsx
export default async function EmailDetailPage() {
const prisma = new PrismaClient(); // ❌ 毎回新規作成
const email = await prisma.email.findUnique();
// ...
}
発生した問題:
- ホットリロードのたびに新しいPrismaClientインスタンスが作成される
- 古いインスタンスが適切に破棄されず、接続プールが枯渇
- 一定時間経過後のアクセスで接続が遅い
- 本番環境でも無駄な接続が増加
エラーメッセージ
Error: Can't reach database server at `xxx.xxx.xxx.xxx:5432`
Please make sure your database server is running at `xxx.xxx.xxx.xxx:5432`.
Prisma Client could not connect to the database.
パフォーマンス測定
接続時間の比較:
- シングルトン化前: 初回アクセス 500ms、2回目以降 300-400ms
- シングルトン化後: 初回アクセス 200ms、2回目以降 50-100ms
実装内容
1. シングルトンパターンの実装
packages/database/src/index.tsで適切なシングルトンパターンを実装:
// packages/database/src/index.ts
import { PrismaClient } from '@prisma/client';
// グローバル変数の型定義
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
// PrismaClientインスタンスの作成または取得
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
// development環境でのみグローバルに保存(ホットリロード対応)
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// 型を再エクスポート
export * from '@prisma/client';
重要なポイント:
-
globalThis の活用
- Next.jsのホットリロードで変数がリセットされても、globalThisは保持される
- 開発環境での無駄なインスタンス作成を防ぐ
-
環境別の処理
- production: 毎回新しいインスタンス(サーバーレス環境対応)
- development: グローバルに保存(ホットリロード対応)
-
ログレベルの設定
- development: クエリログを出力して開発効率向上
- production: エラーログのみで本番パフォーマンス優先
2. 接続プール設定の最適化
Neon Database向けの接続プール設定を.envに追加:
# .env.example
# Neon Database向け接続プール設定
DATABASE_URL="postgresql://user:password@host/dbname?connection_limit=10&pool_timeout=10&connect_timeout=10"
# パラメータ説明:
# - connection_limit: 最大接続数(Neonの無料プランは10まで)
# - pool_timeout: プールからの接続取得タイムアウト(秒)
# - connect_timeout: データベース接続タイムアウト(秒)
接続数の設計:
- Neon無料プラン: 最大10接続
- 推奨設定:
connection_limit=10 - 本番環境: アプリケーションインスタンス数を考慮して調整
3. 各ページでの使用方法統一
全7ファイルで共通インスタンスを使用するように変更:
// 改善前(NG)
import { PrismaClient } from '@prisma/client';
export default async function Page() {
const prisma = new PrismaClient(); // ❌
// ...
}
// 改善後(OK)
import { prisma } from '@myapp/database'; // ✅
export default async function Page() {
// 共通インスタンスを直接使用
const customers = await prisma.customer.findMany();
// ...
}
変更したファイル:
app/dashboard/page.tsxapp/customers/page.tsxapp/customers/[id]/page.tsxapp/emails/[id]/page.tsxapp/auth/signin/page.tsxapp/layout.tsxapp/api/attachments/[attachmentId]/download/route.ts
4. Next.jsキャッシュ戦略の明示化
データの一貫性を保証するため、動的レンダリングを明示:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // ✅ キャッシュ無効化
export default async function DashboardPage() {
const customers = await prisma.customer.findMany();
// 常に最新のデータを取得
}
キャッシュ戦略のオプション:
-
force-dynamic: 常に動的レンダリング(リアルタイム性重視) -
force-static: 静的生成(パフォーマンス重視) -
auto: Next.jsが自動判定(デフォルト)
パフォーマンス改善結果
接続時間の改善
| シナリオ | 改善前 | 改善後 | 改善率 |
|---|---|---|---|
| 初回アクセス | 500ms | 200ms | 60%改善 |
| 2回目以降 | 300-400ms | 50-100ms | 75%改善 |
| ホットリロード後 | 600ms | 150ms | 75%改善 |
接続プール状態
改善前:
Active connections: 15-20
Idle connections: 5-10
Total: 20-30(制限10を大幅超過)
→ エラー発生
改善後:
Active connections: 3-5
Idle connections: 5-7
Total: 8-12(制限内)
→ 安定稼働
メモリ使用量
- 改善前: 各ページで約10MBのPrismaClient
- 改善後: 共有インスタンスで約10MB(固定)
- メモリ削減: 約70-80MB(8ページ分)
Edge Runtimeでの動作
Edge環境での制約と対応
Next.jsのEdge RuntimeでPrismaを使用する際の考慮事項:
// Edge Runtime用のPrisma Adapterを使用
import { PrismaClient } from '@prisma/client/edge';
import { withAccelerate } from '@prisma/extension-accelerate';
// Edge環境用の設定
export const prismaEdge = new PrismaClient({
datasourceUrl: process.env.DATABASE_URL
}).$extends(withAccelerate());
// 使用例(Edge API Route)
export const runtime = 'edge';
export async function GET() {
const users = await prismaEdge.user.findMany({
cacheStrategy: { ttl: 60 } // Accelerateのキャッシュ活用
});
return Response.json(users);
}
Edge Runtime選択の判断基準:
- グローバル配信が必要 → Edge Runtime
- 複雑なデータベース操作 → Node.js Runtime
- リアルタイム性重視 → Node.js Runtime
- スケーラビリティ重視 → Edge Runtime
メモリリーク対策
$disconnect()の適切な呼び出し
// gracefulシャットダウンの実装
import { prisma } from '@myapp/database';
// プロセス終了時のクリーンアップ
async function gracefulShutdown() {
console.log('Gracefully shutting down...');
try {
await prisma.$disconnect();
console.log('Database connections closed');
} catch (error) {
console.error('Error during shutdown:', error);
}
process.exit(0);
}
// シグナルハンドリング
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
// Next.js API Routes内での適切な処理
export async function GET(request: Request) {
try {
const data = await prisma.user.findMany();
return Response.json(data);
} catch (error) {
// エラー時も接続を確実にクリーンアップ
if (error instanceof Error && error.message.includes('P2024')) {
// 接続プールがいっぱいの場合
await prisma.$disconnect();
await new Promise(resolve => setTimeout(resolve, 100));
// リトライまたはエラー返却
}
throw error;
}
}
メモリ使用量の監視
// メモリ監視とアラート
setInterval(async () => {
const memUsage = process.memoryUsage();
// メモリ使用量をMBで表示
const report = {
rss: Math.round(memUsage.rss / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
external: Math.round(memUsage.external / 1024 / 1024)
};
console.log('Memory usage:', report);
// 閾値を超えたら警告
if (report.heapUsed > 500) { // 500MB以上
console.warn('High memory usage detected!');
// 必要に応じて接続リセット
await prisma.$disconnect();
await prisma.$connect();
}
}, 60000); // 1分ごと
接続プール監視
Prisma.$metricsの活用
// packages/database/src/monitoring.ts
import { prisma } from './index';
export async function enableMetrics() {
// メトリクス有効化
await prisma.$metrics.json();
}
export async function getPoolMetrics() {
const metrics = await prisma.$metrics.json();
// 接続プール関連のメトリクス抽出
const poolMetrics = {
activeConnections: 0,
idleConnections: 0,
waitingClients: 0,
totalQueries: 0
};
metrics.counters.forEach(counter => {
switch (counter.key) {
case 'prisma_pool_connections_open':
poolMetrics.activeConnections = counter.value;
break;
case 'prisma_pool_connections_idle':
poolMetrics.idleConnections = counter.value;
break;
case 'prisma_pool_connections_busy':
poolMetrics.waitingClients = counter.value;
break;
case 'prisma_client_queries_total':
poolMetrics.totalQueries = counter.value;
break;
}
});
return poolMetrics;
}
// 定期的な監視
export function startPoolMonitoring() {
setInterval(async () => {
const metrics = await getPoolMetrics();
console.log('Connection Pool Status:', {
...metrics,
timestamp: new Date().toISOString()
});
// CloudWatchやDatadogへ送信
await sendToMonitoring(metrics);
// アラート条件チェック
if (metrics.activeConnections > 8) {
await sendAlert({
level: 'WARNING',
message: `High connection count: ${metrics.activeConnections}/10`,
metrics
});
}
}, 30000); // 30秒ごと
}
ダッシュボードの例
// app/api/admin/metrics/route.ts
import { NextResponse } from 'next/server';
import { getPoolMetrics } from '@myapp/database/monitoring';
export async function GET() {
try {
const metrics = await getPoolMetrics();
const health = {
status: metrics.activeConnections < 8 ? 'healthy' : 'warning',
database: {
connections: {
active: metrics.activeConnections,
idle: metrics.idleConnections,
total: metrics.activeConnections + metrics.idleConnections,
limit: 10
},
queries: {
total: metrics.totalQueries,
qps: calculateQPS(metrics.totalQueries)
}
},
timestamp: new Date().toISOString()
};
return NextResponse.json(health);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch metrics' },
{ status: 500 }
);
}
}
トランザクション管理の注意点
適切なトランザクション処理
// トランザクション内での接続管理
export async function transferCredits(
fromUserId: string,
toUserId: string,
amount: number
) {
// Interactive Transactionの使用
return await prisma.$transaction(async (tx) => {
// 1. 送信元の残高確認
const sender = await tx.user.findUnique({
where: { id: fromUserId },
select: { credits: true }
});
if (!sender || sender.credits < amount) {
throw new Error('Insufficient credits');
}
// 2. 送信元から減算
await tx.user.update({
where: { id: fromUserId },
data: { credits: { decrement: amount } }
});
// 3. 受信先に加算
await tx.user.update({
where: { id: toUserId },
data: { credits: { increment: amount } }
});
// 4. トランザクションログ記録
const log = await tx.transactionLog.create({
data: {
fromUserId,
toUserId,
amount,
timestamp: new Date()
}
});
return log;
}, {
maxWait: 5000, // トランザクション開始までの最大待機時間
timeout: 10000, // トランザクションのタイムアウト
isolationLevel: 'Serializable' // 分離レベル
});
}
// エラーハンドリング付き
export async function safeTransfer(
fromUserId: string,
toUserId: string,
amount: number
) {
let retries = 3;
while (retries > 0) {
try {
return await transferCredits(fromUserId, toUserId, amount);
} catch (error) {
if (error instanceof Error) {
// デッドロックやタイムアウトの場合はリトライ
if (error.message.includes('P2034') || error.message.includes('P2024')) {
retries--;
console.log(`Retrying transaction... (${retries} attempts left)`);
await new Promise(resolve => setTimeout(resolve, 1000));
} else {
throw error; // その他のエラーは即座に投げる
}
}
}
}
throw new Error('Transaction failed after maximum retries');
}
テスト環境での扱い
Jestでのモック方法
// __tests__/setup.ts
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
// Prisma Clientのモック作成
export const prismaMock = mockDeep<PrismaClient>() as unknown as DeepMockProxy<PrismaClient>;
// jest.setup.js
jest.mock('@myapp/database', () => ({
__esModule: true,
prisma: prismaMock
}));
// テストファイル例
import { prismaMock } from './setup';
import { getUserById } from '../services/user';
describe('User Service', () => {
beforeEach(() => {
mockReset(prismaMock);
});
it('should get user by id', async () => {
const mockUser = {
id: '1',
name: 'Test User',
email: 'test@example.com'
};
prismaMock.user.findUnique.mockResolvedValue(mockUser);
const user = await getUserById('1');
expect(user).toEqual(mockUser);
expect(prismaMock.user.findUnique).toHaveBeenCalledWith({
where: { id: '1' }
});
});
});
統合テストでのセットアップ
// __tests__/integration/setup.ts
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
// テスト用データベースURL
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5432/test';
// テスト用Prismaクライアント
export const testPrisma = new PrismaClient({
datasources: {
db: {
url: TEST_DATABASE_URL
}
}
});
// テスト前のセットアップ
export async function setupTestDatabase() {
// マイグレーション実行
execSync('npx prisma migrate deploy', {
env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL }
});
// シードデータ投入
await testPrisma.user.createMany({
data: [
{ name: 'Alice', email: 'alice@test.com' },
{ name: 'Bob', email: 'bob@test.com' }
]
});
}
// テスト後のクリーンアップ
export async function teardownTestDatabase() {
// データ削除(外部キー制約を考慮した順番)
await testPrisma.transactionLog.deleteMany();
await testPrisma.user.deleteMany();
// 接続クローズ
await testPrisma.$disconnect();
}
運用上の注意点
デプロイ時の考慮事項
# .github/workflows/deploy.yml
name: Deploy with Zero Downtime
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run migrations
run: |
# Blue-Green デプロイメント対応
npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Health check before deployment
run: |
# 接続プールの状態確認
curl https://api.example.com/api/admin/metrics
- name: Deploy to production
run: |
# ローリングデプロイメント
kubectl set image deployment/api api=myapp:${{ github.sha }}
kubectl rollout status deployment/api
- name: Post-deployment verification
run: |
# デプロイ後の接続プール確認
sleep 30
curl https://api.example.com/api/admin/metrics
監視とアラート設定
// monitoring/alerts.ts
export const alertRules = [
{
name: 'high_connection_count',
condition: 'connections.active > 8',
severity: 'warning',
action: 'notify_slack'
},
{
name: 'connection_pool_exhausted',
condition: 'connections.active >= 10',
severity: 'critical',
action: 'page_oncall'
},
{
name: 'slow_queries',
condition: 'query.duration > 1000',
severity: 'warning',
action: 'log_to_dashboard'
}
];
トラブルシューティング
よくある問題と解決策
1. "Too many connections" エラー
症状: データベース接続数が上限に達する
解決策:
// 接続リセットスクリプト
async function resetConnections() {
console.log('Resetting all database connections...');
// 既存の接続をすべて切断
await prisma.$disconnect();
// 少し待機
await new Promise(resolve => setTimeout(resolve, 1000));
// 新しい接続を確立
await prisma.$connect();
console.log('Connections reset successfully');
}
// エラーハンドラーに組み込み
app.use(async (error, req, res, next) => {
if (error.code === 'P2024') {
await resetConnections();
// リトライまたはエラーレスポンス
}
next(error);
});
2. ホットリロード時のメモリリーク
症状: 開発環境でメモリ使用量が増加し続ける
解決策:
// 開発環境用のクリーンアップ
if (process.env.NODE_ENV === 'development') {
// HMR (Hot Module Replacement) のクリーンアップ
if (module.hot) {
module.hot.dispose(async () => {
await prisma.$disconnect();
});
}
}
3. Serverless環境での接続エラー
症状: Lambda関数で断続的に接続エラーが発生
解決策:
// Serverless用の接続管理
let prisma: PrismaClient;
export function getPrismaClient() {
if (!prisma) {
prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL + '?connection_limit=1' // Serverlessは1接続
}
}
});
}
return prisma;
}
// Lambda handler
export const handler = async (event: any) => {
const client = getPrismaClient();
try {
// クエリ実行
const result = await client.user.findMany();
return { statusCode: 200, body: JSON.stringify(result) };
} finally {
// Lambdaのコールドスタート対策として接続は維持
// await client.$disconnect(); // しない
}
};
技術的な詳細
globalThisの仕組み
Next.jsの開発環境では、ファイル変更時にモジュールがリロードされます:
// ホットリロード時の挙動
// 1回目の実行
const prisma = new PrismaClient(); // インスタンスA作成
globalForPrisma.prisma = prisma; // グローバルに保存
// ファイル変更(ホットリロード)
// 2回目の実行
const prisma = globalForPrisma.prisma ?? new PrismaClient();
// → グローバルに保存されているインスタンスAを再利用
// → 新しいインスタンスは作成されない
production環境での挙動
本番環境では、サーバーレス関数の特性を考慮:
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// production環境ではグローバルに保存しない
// → 各Lambda/Edge関数が独自のインスタンスを持つ
理由:
- サーバーレス環境では関数ごとに独立したメモリ空間
- グローバル変数は共有されない
- 各関数が自身のPrismaClientを管理する方が効率的
接続プールのライフサイクル
// 接続プールの管理
const prisma = new PrismaClient(); // プール作成(10接続分)
// 1. クエリ実行時
await prisma.customer.findMany();
// → プールから接続を1つ取得
// → クエリ実行
// → 接続をプールに返却
// 2. アイドル時
// → 接続は維持されたまま次のクエリを待機
// → connect_timeout後、タイムアウト
// 3. アプリケーション終了時
await prisma.$disconnect();
// → すべての接続を適切にクローズ
学んだこと
意外だった落とし穴
-
Next.jsの環境別の挙動
- development: モジュールがホットリロードでリセット
- production: サーバーレス関数ごとに独立
- 両方に対応する設計が必要
-
PrismaClientの初期化コスト
- インスタンス作成に100-200ms
- スキーマの読み込みとバリデーション
- 接続プールの初期化
-
Neon Databaseの接続制限
- 無料プラン: 最大10接続
- 有料プラン: スケールアップ可能
- アプリケーション設計時に考慮が必要
今後使えそうな知見
-
シングルトンパターンのテンプレート
// 汎用シングルトンパターン const globalForX = globalThis as unknown as { x: SomeClass | undefined; }; export const x = globalForX.x ?? new SomeClass(config); if (process.env.NODE_ENV !== 'production') { globalForX.x = x; } -
接続プール設定のベストプラクティス
connection_limit = 総接続数 / アプリインスタンス数 pool_timeout = クエリタイムアウト + α connect_timeout = ネットワーク遅延 + α -
段階的な移行戦略
Phase 1: 共通インスタンスの作成 Phase 2: 1ファイルずつ移行してテスト Phase 3: 全ファイル移行後、パフォーマンス測定 Phase 4: 接続プール設定の最適化
もっと良い書き方の発見
改善前(各ページで作成):
// page1.tsx
const prisma = new PrismaClient();
// page2.tsx
const prisma = new PrismaClient();
// page3.tsx
const prisma = new PrismaClient();
// → 3つのインスタンス、30接続
改善後(共通インスタンス):
// database/index.ts
export const prisma = new PrismaClient();
// page1.tsx
import { prisma } from '@myapp/database';
// page2.tsx
import { prisma } from '@myapp/database';
// page3.tsx
import { prisma } from '@myapp/database';
// → 1つのインスタンス、10接続
終わりに
PrismaClientのシングルトン化は、Next.js環境でのデータベース接続最適化において非常に効果的な手法でした。今回の実装で学んだポイントは:
- 環境別の適切な実装: development/productionで異なる挙動を考慮
- globalThisの活用: ホットリロード対応のキーポイント
- 接続プール設定: データベースプランに応じた最適化
特に、Next.jsのApp Routerでは、Server Componentsが各リクエストで実行されるため、シングルトンパターンの実装が極めて重要です。これにより、パフォーマンス、メモリ使用量、接続数のすべてで大幅な改善が実現できました。
読者の皆さんも、Next.jsでPrismaを使用する際は、早めにシングルトンパターンを導入することをお勧めします。開発環境でのホットリロード時の問題や、本番環境での接続プール枯渇を未然に防ぐことができます。
参考リンク
- Prisma公式ドキュメント - Best practices for using Prisma with Next.js
- Next.js公式ドキュメント - Database
- Neon公式ドキュメント - Connection Pooling
この記事で紹介したコードは、実際のプロダクションコードを簡略化したものです。エラーハンドリングやセキュリティチェックなど、実際の実装では追加の考慮事項があります。
関連技術: Next.js, Prisma ORM, PostgreSQL, Neon Database, TypeScript, シングルトンパターン, 接続プール最適化, パフォーマンスチューニング
筆者: 91works開発チーム
Discussion