T-CREATOR

Prisma 読み書き分離設計:読み取りレプリカ/プロキシ/整合性モデルを整理

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 などのプロキシを活用すれば、アプリケーションコードを変更せずに読み書き分離を導入できますね。プロジェクトの規模や運用体制に応じて、適切な方式を選択してください。

データ整合性の保証

レプリケーション遅延を考慮した設計が、実用的なシステムの鍵となります。セッション整合性を実装することで、ユーザー体験を損なわずにパフォーマンスを向上できるでしょう。全てのクエリで強い整合性を求めず、クリティカルパスを明確にすることが重要です。

運用とモニタリング

読み書き分離を導入したら、レプリケーション遅延の監視は欠かせません。ヘルスチェックエンドポイントを実装し、異常を早期に検知できる体制を整えましょう。また、キャッシュレイヤーと組み合わせることで、さらなるパフォーマンス向上が期待できます。

本記事で紹介した実装パターンを参考に、皆さんのアプリケーションに最適な読み書き分離設計を実現してください。スケーラビリティとパフォーマンスの両立が、大規模システムの成功につながりますね。

関連リンク