Prisma 読み書き分離設計:読み取りレプリカ/プロキシ/整合性モデルを整理
データベースのスケーラビリティを高める上で、読み書き分離は重要な設計パターンです。特に読み取り処理が多いアプリケーションでは、プライマリデータベースへの負荷を軽減し、パフォーマンスを大幅に向上させることができます。
Prisma は ORM として優れた機能を持ちながら、読み書き分離の実装方法については公式ドキュメントでも限定的な情報しか提供されていません。本記事では、Prisma を使った読み書き分離設計の具体的なアプローチと、レプリカ構成、プロキシ活用、データ整合性の保ち方について、実践的な視点から詳しく解説いたします。
背景
データベースの読み書き分離とは
読み書き分離(Read-Write Splitting)は、データベースへの書き込み操作と読み取り操作を異なるデータベースインスタンスに振り分ける設計パターンです。この手法により、負荷分散とパフォーマンス向上を実現できます。
一般的な Web アプリケーションでは、読み取り操作(SELECT クエリ)が書き込み操作(INSERT、UPDATE、DELETE)の 5〜10 倍以上発生すると言われています。このような状況では、すべてのクエリを単一のデータベースで処理すると、書き込み処理のパフォーマンスも低下してしまうのです。
mermaidflowchart TB
app["アプリケーション"]
primary[("プライマリ DB<br/>書き込み+読み取り")]
replica1[("レプリカ DB 1<br/>読み取りのみ")]
replica2[("レプリカ DB 2<br/>読み取りのみ")]
app -->|書き込み| primary
app -->|読み取り| replica1
app -->|読み取り| replica2
primary -.->|レプリケーション| replica1
primary -.->|レプリケーション| replica2
上記の図は、プライマリデータベースとレプリカデータベースの基本的な関係性を示しています。プライマリへの書き込みは自動的にレプリカへ複製され、読み取りリクエストは複数のレプリカに分散されます。
Prisma における読み書き分離の必要性
Prisma はデフォルトでは単一のデータベース接続を想定しています。しかし、本番環境でのスケーラビリティを考慮すると、以下のような理由から読み書き分離が必要になるでしょう。
- プライマリデータベースへの負荷集中を避ける
- 読み取り処理のレスポンスタイムを改善する
- データベースのスケーラビリティを水平方向に拡張する
- 高可用性を実現する(レプリカがフェイルオーバー先となる)
Prisma でこれらを実現するには、複数のクライアントインスタンスを管理するか、接続プロキシを活用する必要があります。
課題
Prisma の標準構成における制約
Prisma の標準的な使い方では、PrismaClient インスタンスは単一のデータベース接続文字列を持ちます。この制約により、以下のような課題が発生します。
課題 1:接続先の固定化
schema.prisma ファイルで定義されたデータソースは 1 つのみです。複数のデータベースエンドポイントを指定する標準的な方法が提供されていません。
typescript// schema.prisma の標準的な構成
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
課題 2:動的な接続先の切り替え
クエリごとに接続先を変更することは、Prisma の設計思想には含まれていません。そのため、開発者は独自の仕組みを構築する必要があります。
課題 3:レプリケーション遅延の考慮
プライマリからレプリカへのデータ複製には、通常数ミリ秒から数秒の遅延が発生します。この遅延を考慮せずに実装すると、以下のような問題が起きてしまいます。
- 書き込み直後の読み取りで古いデータが返される
- ユーザーが更新した内容が画面に反映されない
- トランザクションの一貫性が保証されない
mermaidsequenceDiagram
participant client as クライアント
participant app as アプリケーション
participant primary as プライマリ DB
participant replica as レプリカ DB
client->>app: データ更新リクエスト
app->>primary: INSERT/UPDATE
primary-->>app: 成功
app->>replica: 即座に読み取り
Note over replica: まだレプリケーション<br/>されていない
replica-->>app: 古いデータ
app-->>client: 更新前のデータ表示
Note over client: ユーザー混乱
上記のシーケンス図は、レプリケーション遅延による問題を示しています。書き込み直後に読み取りレプリカへアクセスすると、ユーザー体験を損なう可能性があるのです。
パフォーマンスとデータ整合性のトレードオフ
読み書き分離を導入する際、パフォーマンスとデータ整合性の間にはトレードオフが存在します。このバランスをどう取るかが、設計の重要なポイントになるでしょう。
| # | 項目 | 説明 | 影響 |
|---|---|---|---|
| 1 | 強い整合性 | 常に最新データを保証 | パフォーマンス低下 |
| 2 | 結果整合性 | 最終的に整合性を保証 | パフォーマンス向上 |
| 3 | セッション整合性 | 同一セッション内で整合性保証 | バランス型 |
多くのアプリケーションでは、全てのクエリで強い整合性を求める必要はありません。適切な整合性モデルを選択することが、実用的な読み書き分離設計の鍵となります。
解決策
複数の PrismaClient インスタンスによる実装
最もシンプルな解決策は、書き込み用と読み取り用に別々の PrismaClient インスタンスを作成する方法です。この方式では、接続先を明示的に制御できます。
インスタンスの作成
まず、プライマリデータベースとレプリカデータベースへの接続を持つ 2 つのクライアントを準備しましょう。
typescript// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
環境変数から接続文字列を取得し、それぞれのクライアントを初期化します。
typescript// プライマリデータベース用クライアント(書き込み+読み取り)
const prismaWrite = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_PRIMARY_URL,
},
},
log: ['query', 'error', 'warn'],
});
続いて、読み取り専用のレプリカ用クライアントを作成します。
typescript// レプリカデータベース用クライアント(読み取りのみ)
const prismaRead = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_REPLICA_URL,
},
},
log: ['query', 'error', 'warn'],
});
最後に、両方のクライアントをエクスポートして、アプリケーション全体で利用できるようにしましょう。
typescript// エクスポート
export { prismaWrite, prismaRead };
この実装により、書き込みと読み取りで異なるデータベースエンドポイントへ接続できるようになります。
環境変数の設定
.env ファイルに、プライマリとレプリカの接続文字列を定義します。
bash# .env
# プライマリデータベース(書き込み+読み取り)
DATABASE_PRIMARY_URL="postgresql://user:password@primary-db.example.com:5432/mydb"
レプリカデータベースの接続文字列も追加しましょう。
bash# レプリカデータベース(読み取り専用)
DATABASE_REPLICA_URL="postgresql://user:password@replica-db.example.com:5432/mydb"
環境変数を分けることで、デプロイ環境ごとに異なる接続先を柔軟に設定できます。
使用例
実際のアプリケーションコードでは、操作の種類に応じて適切なクライアントを選択します。
typescript// pages/api/users/[id].ts
import { prismaWrite, prismaRead } from '@/lib/prisma';
読み取り操作には、レプリカへ接続するクライアントを使用しましょう。
typescript// 読み取り操作はレプリカから
export async function getUser(userId: string) {
const user = await prismaRead.user.findUnique({
where: { id: userId },
include: { posts: true },
});
return user;
}
一方、書き込み操作はプライマリデータベースへ直接送信します。
typescript// 書き込み操作はプライマリへ
export async function updateUser(
userId: string,
data: any
) {
const updated = await prismaWrite.user.update({
where: { id: userId },
data: data,
});
return updated;
}
この方式により、クエリの種類に応じて適切なデータベースへルーティングできますね。
接続プールとレプリカの負荷分散
複数のレプリカがある場合、ラウンドロビンやランダム選択で負荷を分散させることが重要です。
複数レプリカの管理
レプリカが 3 台ある環境を想定し、それぞれへの接続を作成しましょう。
typescript// lib/prisma-replicas.ts
import { PrismaClient } from '@prisma/client';
各レプリカ用のクライアントインスタンスを配列で管理します。
typescript// レプリカクライアントの配列
const prismaReplicas = [
new PrismaClient({
datasources: {
db: { url: process.env.DATABASE_REPLICA_1_URL },
},
}),
new PrismaClient({
datasources: {
db: { url: process.env.DATABASE_REPLICA_2_URL },
},
}),
new PrismaClient({
datasources: {
db: { url: process.env.DATABASE_REPLICA_3_URL },
},
}),
];
次に、ランダムにレプリカを選択する関数を実装します。
typescript// ランダムにレプリカを選択
export function getReadClient(): PrismaClient {
const randomIndex = Math.floor(
Math.random() * prismaReplicas.length
);
return prismaReplicas[randomIndex];
}
この仕組みにより、読み取り負荷が複数のレプリカに均等に分散されるでしょう。
ラウンドロビン方式の実装
より均等な分散を実現するには、ラウンドロビン方式が有効です。
typescript// lib/prisma-round-robin.ts
import { PrismaClient } from '@prisma/client';
// レプリカクライアントの配列
const prismaReplicas: PrismaClient[] = [
new PrismaClient({
datasources: {
db: { url: process.env.DATABASE_REPLICA_1_URL },
},
}),
new PrismaClient({
datasources: {
db: { url: process.env.DATABASE_REPLICA_2_URL },
},
}),
new PrismaClient({
datasources: {
db: { url: process.env.DATABASE_REPLICA_3_URL },
},
}),
];
現在のインデックスを保持する変数を用意しましょう。
typescript// 現在のインデックス
let currentIndex = 0;
ラウンドロビンでレプリカを順番に選択する関数を実装します。
typescript// ラウンドロビンでレプリカを選択
export function getReadClient(): PrismaClient {
const client = prismaReplicas[currentIndex];
currentIndex = (currentIndex + 1) % prismaReplicas.length;
return client;
}
この方式では、各レプリカが順番にリクエストを処理するため、負荷が均等に分散されます。
データ整合性モデルの実装
レプリケーション遅延を考慮した、実用的な整合性モデルを実装しましょう。
セッション整合性の実装
同一ユーザーのセッション内では、書き込み後の読み取りは必ずプライマリから行うように制御します。
typescript// lib/prisma-session.ts
import { prismaWrite, prismaRead } from '@/lib/prisma';
セッションごとに「最近書き込みを行ったか」を追跡するマップを作成します。
typescript// セッションごとの最終書き込み時刻を記録
const sessionWriteMap = new Map<string, number>();
// セッション整合性を保つための待機時間(ミリ秒)
const REPLICATION_LAG_MS = 100;
書き込み操作を実行し、セッション ID を記録する関数を実装しましょう。
typescript// 書き込み操作
export async function writeData(
sessionId: string,
operation: any
) {
const result = await prismaWrite[operation.model][
operation.method
](operation.params);
// セッションの最終書き込み時刻を記録
sessionWriteMap.set(sessionId, Date.now());
return result;
}
読み取り操作では、最近書き込みがあったかを判定し、適切なクライアントを選択します。
typescript// 読み取り操作
export async function readData(
sessionId: string,
operation: any
) {
const lastWriteTime = sessionWriteMap.get(sessionId);
// 最近書き込みがあった場合はプライマリから読む
if (
lastWriteTime &&
Date.now() - lastWriteTime < REPLICATION_LAG_MS
) {
return await prismaWrite[operation.model][
operation.method
](operation.params);
}
// それ以外はレプリカから読む
return await prismaRead[operation.model][
operation.method
](operation.params);
}
この実装により、ユーザーが更新した直後でも、最新のデータを確実に取得できますね。
mermaidstateDiagram-v2
[*] --> checking_write: 読み取りリクエスト
checking_write --> use_primary: 最近書き込みあり<br/>(100ms 以内)
checking_write --> use_replica: 書き込みなし
use_primary --> [*]: プライマリから<br/>最新データ取得
use_replica --> [*]: レプリカから<br/>負荷分散読み取り
上記の状態遷移図は、セッション整合性を保つための判定フローを示しています。最近の書き込み有無によって、動的に接続先が切り替わります。
クリティカルパスの判定
アプリケーション内で、強い整合性が必要な箇所とそうでない箇所を明確に分けましょう。
typescript// lib/prisma-critical.ts
import { prismaWrite, prismaRead } from '@/lib/prisma';
// クエリオプションの型定義
interface QueryOptions {
critical?: boolean; // 強い整合性が必要か
}
クエリオプションに基づいて、適切なクライアントを選択するヘルパー関数を作成します。
typescript// クエリ実行のヘルパー関数
export async function executeQuery<T>(
operation: () => Promise<T>,
options: QueryOptions = {}
): Promise<T> {
// クリティカルな操作は必ずプライマリから
if (options.critical) {
return operation.call(prismaWrite);
}
// 通常の読み取りはレプリカから
return operation.call(prismaRead);
}
実際の使用例では、操作の重要度に応じてオプションを指定します。
typescript// 使用例
export async function getUserProfile(
userId: string,
critical = false
) {
return executeQuery(
async () => {
return await prismaRead.user.findUnique({
where: { id: userId },
include: { profile: true },
});
},
{ critical }
);
}
この設計により、パフォーマンスとデータ整合性の最適なバランスを実現できるでしょう。
ProxySQL を活用した透過的な読み書き分離
ProxySQL は、MySQL/PostgreSQL の前段に配置するデータベースプロキシです。Prisma のコードを変更せずに、クエリレベルで読み書き分離を実現できます。
ProxySQL の設定
Docker Compose で ProxySQL を含む環境を構築しましょう。
yaml# docker-compose.yml
version: '3.8'
services:
proxysql:
image: proxysql/proxysql:latest
ports:
- '6033:6033'
- '6032:6032'
volumes:
- ./proxysql.cnf:/etc/proxysql.cnf
environment:
- PROXYSQL_ADMIN_USER=admin
- PROXYSQL_ADMIN_PASSWORD=admin
プライマリデータベースとレプリカデータベースのコンテナを定義します。
yaml primary-db:
image: postgres:15
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydb
volumes:
- primary_data:/var/lib/postgresql/data
replica-db:
image: postgres:15
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydb
volumes:
- replica_data:/var/lib/postgresql/data
volumes:
primary_data:
replica_data:
この構成により、ProxySQL が自動的にクエリを適切なデータベースへルーティングします。
ProxySQL のルーティングルール
ProxySQL の設定ファイルで、クエリパターンに基づくルーティングルールを定義しましょう。
sql-- proxysql-config.sql
-- プライマリサーバーの登録
INSERT INTO mysql_servers(hostgroup_id, hostname, port)
VALUES (0, 'primary-db', 5432);
レプリカサーバーを読み取り専用のホストグループに登録します。
sql-- レプリカサーバーの登録
INSERT INTO mysql_servers(hostgroup_id, hostname, port)
VALUES (1, 'replica-db', 5432);
クエリルールを定義し、SELECT は読み取りグループへ、それ以外は書き込みグループへルーティングします。
sql-- クエリルールの設定
-- SELECT クエリは読み取りグループ(hostgroup 1)へ
INSERT INTO mysql_query_rules(rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES (1, 1, '^SELECT.*FOR UPDATE', 0, 1);
INSERT INTO mysql_query_rules(rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES (2, 1, '^SELECT', 1, 1);
書き込みクエリのルールも追加しましょう。
sql-- INSERT/UPDATE/DELETE は書き込みグループ(hostgroup 0)へ
INSERT INTO mysql_query_rules(rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES (3, 1, '^(INSERT|UPDATE|DELETE)', 0, 1);
設定を反映させるコマンドを実行します。
sql-- 設定の反映
LOAD MYSQL SERVERS TO RUNTIME;
SAVE MYSQL SERVERS TO DISK;
LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;
これらの設定により、Prisma からは単一のエンドポイントに接続するだけで、自動的に読み書きが分離されるのです。
Prisma からの接続
ProxySQL を経由する場合、Prisma の接続文字列は ProxySQL のエンドポイントを指定するだけで済みます。
typescript// lib/prisma-proxy.ts
import { PrismaClient } from '@prisma/client';
// ProxySQL 経由で接続(読み書き分離は自動)
const prisma = new PrismaClient({
datasources: {
db: {
url: 'postgresql://myuser:mypassword@localhost:6033/mydb',
},
},
});
エクスポートして、通常の Prisma クライアントと同様に使用できます。
typescriptexport default prisma;
この方式の最大のメリットは、アプリケーションコードを変更せずに読み書き分離を導入できる点です。
具体例
Next.js API Routes での実装例
実際の Next.js アプリケーションで、読み書き分離を実装した API エンドポイントを作成しましょう。
ユーザー取得 API(読み取り)
まず、読み取り専用のエンドポイントを実装します。
typescript// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { prismaRead } from '@/lib/prisma';
ユーザー情報をレプリカから取得する処理を実装しましょう。
typescript// GET リクエスト:ユーザー情報の取得(レプリカから)
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'GET') {
const { id } = req.query;
try {
// レプリカデータベースから読み取り
const user = await prismaRead.user.findUnique({
where: { id: String(id) },
include: {
posts: {
orderBy: { createdAt: 'desc' },
take: 10,
},
profile: true,
},
});
if (!user) {
return res
.status(404)
.json({ error: 'User not found' });
}
return res.status(200).json(user);
} catch (error) {
console.error('Error fetching user:', error);
return res
.status(500)
.json({ error: 'Internal server error' });
}
}
return res
.status(405)
.json({ error: 'Method not allowed' });
}
この実装では、負荷の高い読み取り処理をレプリカへオフロードしています。
ユーザー更新 API(書き込み)
次に、書き込み操作を含むエンドポイントを実装しましょう。
typescript// pages/api/users/update.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { prismaWrite, prismaRead } from '@/lib/prisma';
セッション情報を取得し、書き込み後の読み取り制御を行います。
typescript// PUT リクエスト:ユーザー情報の更新
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'PUT') {
const { userId, data } = req.body;
try {
// プライマリデータベースへ書き込み
const updatedUser = await prismaWrite.user.update({
where: { id: userId },
data: {
name: data.name,
email: data.email,
updatedAt: new Date(),
},
});
// 書き込み直後はプライマリから読み取り(整合性保証)
const freshUser = await prismaWrite.user.findUnique({
where: { id: userId },
include: { profile: true },
});
return res.status(200).json(freshUser);
} catch (error) {
console.error('Error updating user:', error);
return res
.status(500)
.json({ error: 'Failed to update user' });
}
}
return res
.status(405)
.json({ error: 'Method not allowed' });
}
書き込み直後の読み取りをプライマリから行うことで、ユーザーに最新データを確実に返せます。
トランザクション処理での注意点
複数の書き込み操作を含むトランザクションでは、すべての操作をプライマリデータベースで実行する必要があります。
注文処理のトランザクション例
EC サイトの注文処理を例に、トランザクション内での適切な処理を実装しましょう。
typescript// lib/order-service.ts
import { prismaWrite } from '@/lib/prisma';
注文作成と在庫更新を、単一のトランザクション内で実行します。
typescript// 注文作成処理(トランザクション)
export async function createOrder(
userId: string,
items: any[]
) {
// トランザクション内の全操作はプライマリで実行
const order = await prismaWrite.$transaction(
async (tx) => {
// 注文レコードの作成
const newOrder = await tx.order.create({
data: {
userId: userId,
status: 'pending',
totalAmount: 0,
},
});
let totalAmount = 0;
// 各商品の注文明細を作成し、在庫を減らす
for (const item of items) {
// 在庫チェック
const product = await tx.product.findUnique({
where: { id: item.productId },
});
if (!product || product.stock < item.quantity) {
throw new Error(
`Product ${item.productId} out of stock`
);
}
// 注文明細の作成
await tx.orderItem.create({
data: {
orderId: newOrder.id,
productId: item.productId,
quantity: item.quantity,
price: product.price,
},
});
// 在庫の減少
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } },
});
totalAmount += product.price * item.quantity;
}
// 注文の合計金額を更新
return await tx.order.update({
where: { id: newOrder.id },
data: { totalAmount },
include: {
items: {
include: { product: true },
},
},
});
}
);
return order;
}
この実装では、$transaction メソッド内の全操作が自動的にプライマリデータベースで実行され、データの整合性が保証されます。
トランザクション終了後、注文詳細の表示などはレプリカから行っても問題ありません。
typescript// 注文詳細の取得(レプリカから)
import { prismaRead } from '@/lib/prisma';
export async function getOrderDetail(orderId: string) {
// トランザクション完了後の読み取りはレプリカで OK
return await prismaRead.order.findUnique({
where: { id: orderId },
include: {
items: {
include: { product: true },
},
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
mermaidflowchart TD
start["注文リクエスト"] --> tx_start["トランザクション開始<br/>(プライマリ DB)"]
tx_start --> create_order["注文レコード作成"]
create_order --> loop_items["商品ループ処理"]
loop_items --> check_stock["在庫チェック"]
check_stock --> stock_ok{在庫あり?}
stock_ok -->|Yes| create_item["注文明細作成"]
stock_ok -->|No| rollback["ロールバック"]
create_item --> update_stock["在庫減少"]
update_stock --> next_item{次の商品?}
next_item -->|Yes| loop_items
next_item -->|No| update_total["合計金額更新"]
update_total --> commit["コミット"]
commit --> read_order["注文詳細取得<br/>(レプリカ DB)"]
read_order --> finish["完了"]
rollback --> error["エラー返却"]
上記のフローチャートは、トランザクション処理の流れを示しています。書き込みはプライマリで完結し、完了後の読み取りはレプリカへ分散できるのです。
キャッシュ戦略との組み合わせ
読み書き分離とキャッシュを組み合わせることで、さらなるパフォーマンス向上を実現できます。
Redis キャッシュレイヤーの追加
頻繁にアクセスされるデータは、Redis にキャッシュしましょう。
typescript// lib/cache-service.ts
import Redis from 'ioredis';
import { prismaRead } from '@/lib/prisma';
// Redis クライアントの初期化
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD,
});
キャッシュから取得し、存在しない場合はレプリカから読み取る処理を実装します。
typescript// キャッシュを使った読み取り処理
export async function getUserWithCache(userId: string) {
const cacheKey = `user:${userId}`;
// まず Redis キャッシュをチェック
const cached = await redis.get(cacheKey);
if (cached) {
console.log('Cache hit');
return JSON.parse(cached);
}
// キャッシュになければレプリカから読み取り
console.log('Cache miss, reading from replica');
const user = await prismaRead.user.findUnique({
where: { id: userId },
include: { profile: true },
});
// 取得したデータを 5 分間キャッシュ
if (user) {
await redis.setex(cacheKey, 300, JSON.stringify(user));
}
return user;
}
書き込み時には、キャッシュを無効化することで整合性を保ちます。
typescriptimport { prismaWrite } from '@/lib/prisma';
// 書き込み時はキャッシュを無効化
export async function updateUserWithCache(
userId: string,
data: any
) {
// プライマリへ書き込み
const updated = await prismaWrite.user.update({
where: { id: userId },
data: data,
});
// キャッシュを削除
const cacheKey = `user:${userId}`;
await redis.del(cacheKey);
return updated;
}
この多層キャッシュ戦略により、データベースへの負荷を最小限に抑えられますね。
mermaidflowchart LR
app["アプリケーション"]
cache[("Redis<br/>キャッシュ")]
replica[("レプリカ DB<br/>読み取り")]
primary[("プライマリ DB<br/>書き込み")]
app -->|1. キャッシュ確認| cache
cache -->|Hit: 即座に返却| app
cache -.->|Miss: DB へ| replica
replica -->|2. データ取得| app
app -->|3. キャッシュ保存| cache
app -->|書き込み| primary
app -->|キャッシュ無効化| cache
上記の図は、キャッシュレイヤーを含む読み書き分離の全体像を示しています。3 層構造により、高速かつスケーラブルなデータアクセスが実現できるでしょう。
モニタリングとヘルスチェック
読み書き分離を運用する上で、各データベースの状態監視は欠かせません。
レプリケーション遅延の監視
レプリカの遅延状況を定期的にチェックする仕組みを実装しましょう。
typescript// lib/replication-monitor.ts
import { prismaWrite, prismaRead } from '@/lib/prisma';
プライマリとレプリカの時刻差を計測する関数を作成します。
typescript// レプリケーション遅延のチェック
export async function checkReplicationLag(): Promise<number> {
try {
// プライマリに一意のタイムスタンプを書き込み
const testId = `replication_test_${Date.now()}`;
const writtenAt = new Date();
await prismaWrite.$executeRaw`
INSERT INTO replication_test (id, created_at)
VALUES (${testId}, ${writtenAt})
`;
// レプリカから読み取るまで最大 5 秒待機
let lag = 0;
const maxWait = 5000;
const checkInterval = 100;
for (let i = 0; i < maxWait / checkInterval; i++) {
const result = await prismaRead.$queryRaw<any[]>`
SELECT created_at FROM replication_test WHERE id = ${testId}
`;
if (result.length > 0) {
lag = Date.now() - writtenAt.getTime();
break;
}
await new Promise((resolve) =>
setTimeout(resolve, checkInterval)
);
}
// テストデータの削除
await prismaWrite.$executeRaw`
DELETE FROM replication_test WHERE id = ${testId}
`;
return lag;
} catch (error) {
console.error('Replication lag check failed:', error);
return -1;
}
}
この監視データを Prometheus や Datadog などのメトリクスシステムへ送信することで、異常を早期に検知できます。
ヘルスチェックエンドポイント
API として公開し、ロードバランサーやオーケストレーションツールから利用できるようにしましょう。
typescript// pages/api/health.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { prismaWrite, prismaRead } from '@/lib/prisma';
import { checkReplicationLag } from '@/lib/replication-monitor';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
// プライマリの接続確認
await prismaWrite.$queryRaw`SELECT 1`;
// レプリカの接続確認
await prismaRead.$queryRaw`SELECT 1`;
// レプリケーション遅延の確認
const lag = await checkReplicationLag();
const status = {
status: 'healthy',
timestamp: new Date().toISOString(),
database: {
primary: 'connected',
replica: 'connected',
replicationLag: lag,
},
};
// 遅延が 1 秒以上の場合は警告
if (lag > 1000) {
status.status = 'degraded';
}
return res.status(200).json(status);
} catch (error) {
return res.status(503).json({
status: 'unhealthy',
error: String(error),
});
}
}
このエンドポイントにより、システムの健全性をリアルタイムで把握できますね。
まとめ
Prisma での読み書き分離設計について、レプリカ構成、プロキシ活用、整合性モデルの観点から詳しく解説いたしました。ここで重要なポイントを整理しておきましょう。
実装方式の選択
複数の PrismaClient インスタンスを使う方式は、最もシンプルで制御しやすい方法です。一方、ProxySQL などのプロキシを活用すれば、アプリケーションコードを変更せずに読み書き分離を導入できますね。プロジェクトの規模や運用体制に応じて、適切な方式を選択してください。
データ整合性の保証
レプリケーション遅延を考慮した設計が、実用的なシステムの鍵となります。セッション整合性を実装することで、ユーザー体験を損なわずにパフォーマンスを向上できるでしょう。全てのクエリで強い整合性を求めず、クリティカルパスを明確にすることが重要です。
運用とモニタリング
読み書き分離を導入したら、レプリケーション遅延の監視は欠かせません。ヘルスチェックエンドポイントを実装し、異常を早期に検知できる体制を整えましょう。また、キャッシュレイヤーと組み合わせることで、さらなるパフォーマンス向上が期待できます。
本記事で紹介した実装パターンを参考に、皆さんのアプリケーションに最適な読み書き分離設計を実現してください。スケーラビリティとパフォーマンスの両立が、大規模システムの成功につながりますね。
関連リンク
articlePrisma 読み書き分離設計:読み取りレプリカ/プロキシ/整合性モデルを整理
articlePrisma スキーマ定義チートシート:model/enum/@id/@unique/@index の最短リファレンス
articlePrisma Driver Adapters 導入手順:libSQL/Turso・Neon の最短セットアップ
articlePrisma vs Drizzle vs Kysely:DX・型安全性・最適化余地を実測比較
articlePrisma トラブルシュート大全:P1000/P1001/P1008 ほか接続系エラーの即解決ガイド
articlePrisma アーキテクチャ超図解:Engines/Client/Generator の役割を一枚で理解
articlePrisma 読み書き分離設計:読み取りレプリカ/プロキシ/整合性モデルを整理
articleMermaid で日本語が潰れる問題を解決:フォント・エンコード・SVG 設定の勘所
articlePinia 2025 アップデート総まとめ:非互換ポイントと安全な移行チェックリスト
articleMCP サーバー 実装比較:Node.js/Python/Rust の速度・DX・コストをベンチマーク検証
articleLodash のツリーシェイクが効かない問題を解決:import 形態とバンドラ設定
articleOllama のインストール完全ガイド:macOS/Linux/Windows(WSL)対応手順
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来