T-CREATOR

Prisma でトランザクション処理をスマートに実装

Prisma でトランザクション処理をスマートに実装

データベースの整合性を保つためのトランザクション処理は、現代の Web 開発において避けて通れない重要な技術です。特に Prisma を使用する際、適切なトランザクション処理の実装は、データの安全性と信頼性を大きく左右します。

本記事では、Prisma でトランザクション処理を効果的に実装する方法を、基本概念から実践例まで丁寧に解説いたします。あなたのアプリケーションがより堅牢で信頼性の高いものになることを願っています。

トランザクション処理とは?基本概念の理解

トランザクション処理とは、複数のデータベース操作を一つの単位として扱い、全ての操作が成功した場合のみ変更を確定する仕組みです。もし途中で一つでも失敗した場合、すべての変更がロールバック(取り消し)されます。

これは銀行の振込処理を想像していただくとわかりやすいでしょう。送金者の口座から金額を引き落とし、受取人の口座に同じ金額を入金する。この 2 つの操作は必ず両方とも成功する必要がありますよね。

ACID 特性とは

トランザクション処理には、以下の 4 つの重要な特性があります:

特性英語名説明
1Atomicity(原子性)全ての操作が成功するか、全て失敗するか
2Consistency(一貫性)データベースの整合性制約が維持される
3Isolation(独立性)同時実行されるトランザクション間で干渉しない
4Durability(永続性)確定したデータは永続的に保存される

これらの特性により、データベースの信頼性が担保されるのです。

Prisma におけるトランザクションの必要性

Prisma を使用する際、なぜトランザクション処理が必要なのでしょうか?具体的な課題から見てみましょう。

データ整合性の問題

以下のような処理を考えてみてください:

typescript// 危険な例:トランザクションを使わない場合
async function transferMoney(
  fromUserId: string,
  toUserId: string,
  amount: number
) {
  // 送金者の残高を減らす
  await prisma.user.update({
    where: { id: fromUserId },
    data: { balance: { decrement: amount } },
  });

  // この時点でエラーが発生したら?
  throw new Error('Network error occurred');

  // 受取人の残高を増やす(実行されない)
  await prisma.user.update({
    where: { id: toUserId },
    data: { balance: { increment: amount } },
  });
}

このコードでは、最初の操作(送金者の残高減少)は成功しても、2 番目の操作(受取人の残高増加)でエラーが発生する可能性があります。結果として、お金が宙に浮いてしまうという深刻な問題が発生します。

同時実行による競合状態

複数のユーザーが同時にシステムを使用する際、競合状態(race condition)が発生する可能性があります:

typescript// 問題のあるコード例
async function purchaseItem(
  userId: string,
  itemId: string
) {
  const item = await prisma.item.findUnique({
    where: { id: itemId },
  });

  if (item.stock > 0) {
    // この間に他のユーザーが同じ商品を購入する可能性
    await prisma.item.update({
      where: { id: itemId },
      data: { stock: { decrement: 1 } },
    });

    await prisma.purchase.create({
      data: { userId, itemId },
    });
  }
}

このような問題を解決するために、Prisma のトランザクション機能が必要になるのです。

Prisma トランザクションの 3 つの実装方法

Prisma では、トランザクション処理を実装する 3 つの主要な方法があります。それぞれの特徴と適用場面を詳しく見てみましょう。

インタラクティブトランザクション

インタラクティブトランザクションは、最も柔軟性が高く、複雑な処理ロジックを含むトランザクションに適しています。

typescriptimport { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function interactiveTransaction() {
  try {
    const result = await prisma.$transaction(
      async (prisma) => {
        // 複数の操作を順次実行
        const user = await prisma.user.create({
          data: {
            name: 'John Doe',
            email: 'john@example.com',
          },
        });

        // 条件分岐も可能
        if (user.id) {
          await prisma.profile.create({
            data: {
              userId: user.id,
              bio: 'Welcome to our platform!',
            },
          });
        }

        return user;
      }
    );

    console.log('Transaction completed:', result);
  } catch (error) {
    console.error('Transaction failed:', error);
  }
}

このコードでは、ユーザー作成とプロフィール作成が一つのトランザクションとして実行されます。どちらかが失敗した場合、両方の操作がロールバックされます。

バッチトランザクション

バッチトランザクションは、複数の独立したクエリを効率的に実行する際に使用します。

typescriptasync function batchTransaction() {
  try {
    const result = await prisma.$transaction([
      prisma.user.create({
        data: {
          name: 'Alice',
          email: 'alice@example.com',
        },
      }),
      prisma.user.create({
        data: {
          name: 'Bob',
          email: 'bob@example.com',
        },
      }),
      prisma.category.create({
        data: {
          name: 'Electronics',
        },
      }),
    ]);

    console.log('Batch transaction completed:', result);
  } catch (error) {
    console.error('Batch transaction failed:', error);
  }
}

バッチトランザクションは、条件分岐を含まない単純な操作の集合に適しています。パフォーマンスが良く、コードもシンプルになります。

ネストクエリトランザクション

Prisma のネストクエリ機能を使用すると、関連するデータを一度に作成・更新できます。

typescriptasync function nestedTransaction() {
  try {
    const result = await prisma.user.create({
      data: {
        name: 'Carol',
        email: 'carol@example.com',
        profile: {
          create: {
            bio: 'Software developer',
            avatar: 'https://example.com/avatar.jpg',
          },
        },
        posts: {
          create: [
            {
              title: 'First Post',
              content: 'Hello World!',
            },
            {
              title: 'Second Post',
              content: 'Learning Prisma is fun!',
            },
          ],
        },
      },
    });

    console.log('Nested transaction completed:', result);
  } catch (error) {
    console.error('Nested transaction failed:', error);
  }
}

このアプローチは、親子関係のあるデータを同時に作成する際に非常に便利です。

実践例:EC サイトの注文処理でトランザクションを活用

実際の EC サイトの注文処理を例に、トランザクションの実装方法を詳しく見てみましょう。

要件の整理

EC サイトの注文処理では、以下の操作が必要です:

順序操作説明
1在庫確認商品の在庫が十分かチェック
2在庫減少商品の在庫を注文数分減らす
3注文作成注文レコードを作成
4注文明細作成注文した商品の明細を作成
5支払い処理外部決済サービスとの連携

これらの操作は、全て成功するか、全て失敗するかのどちらかでなければなりません。

実装コード

typescriptimport { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

interface OrderItem {
  productId: string;
  quantity: number;
  price: number;
}

interface CreateOrderData {
  userId: string;
  items: OrderItem[];
  paymentMethod: string;
}

async function createOrder(orderData: CreateOrderData) {
  try {
    const result = await prisma.$transaction(
      async (prisma) => {
        // 1. 在庫確認と予約
        const stockUpdates = [];

        for (const item of orderData.items) {
          const product = await prisma.product.findUnique({
            where: { id: item.productId },
          });

          if (!product) {
            throw new Error(
              `Product ${item.productId} not found`
            );
          }

          if (product.stock < item.quantity) {
            throw new Error(
              `Insufficient stock for product ${product.name}. Available: ${product.stock}, Required: ${item.quantity}`
            );
          }

          stockUpdates.push({
            id: item.productId,
            decrementBy: item.quantity,
          });
        }

        // 2. 在庫を減らす
        for (const update of stockUpdates) {
          await prisma.product.update({
            where: { id: update.id },
            data: {
              stock: { decrement: update.decrementBy },
            },
          });
        }

        return { stockUpdates };
      }
    );

    console.log('Stock reservation completed:', result);
  } catch (error) {
    console.error('Order creation failed:', error.message);
    throw error;
  }
}

上記のコードでは、まず在庫確認と在庫減少を一つのトランザクションで実行しています。続いて、注文作成の処理を見てみましょう。

typescript// 注文作成の続き
async function createOrder(orderData: CreateOrderData) {
  try {
    const result = await prisma.$transaction(
      async (prisma) => {
        // 前の処理(在庫確認と減少)
        // ... 省略 ...

        // 3. 注文作成
        const totalAmount = orderData.items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        );

        const order = await prisma.order.create({
          data: {
            userId: orderData.userId,
            totalAmount,
            status: 'PENDING',
            paymentMethod: orderData.paymentMethod,
          },
        });

        // 4. 注文明細作成
        const orderItems =
          await prisma.orderItem.createMany({
            data: orderData.items.map((item) => ({
              orderId: order.id,
              productId: item.productId,
              quantity: item.quantity,
              price: item.price,
            })),
          });

        return { order, orderItems };
      }
    );

    // 5. 支払い処理(外部API呼び出し)
    await processPayment(
      result.order.id,
      orderData.paymentMethod
    );

    // 支払い成功後、注文ステータスを更新
    await prisma.order.update({
      where: { id: result.order.id },
      data: { status: 'COMPLETED' },
    });

    return result.order;
  } catch (error) {
    console.error('Order creation failed:', error.message);
    throw error;
  }
}

支払い処理の実装

外部決済サービスとの連携では、エラーハンドリングが特に重要です。

typescriptasync function processPayment(
  orderId: string,
  paymentMethod: string
) {
  try {
    // 外部決済サービスのAPIを呼び出し
    const paymentResponse = await fetch(
      'https://payment-api.example.com/charge',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${process.env.PAYMENT_API_KEY}`,
        },
        body: JSON.stringify({
          orderId,
          paymentMethod,
        }),
      }
    );

    if (!paymentResponse.ok) {
      throw new Error(
        `Payment failed: ${paymentResponse.status}`
      );
    }

    const paymentData = await paymentResponse.json();

    // 支払い記録をデータベースに保存
    await prisma.payment.create({
      data: {
        orderId,
        externalId: paymentData.id,
        amount: paymentData.amount,
        status: 'SUCCESS',
      },
    });

    return paymentData;
  } catch (error) {
    // 支払い失敗時の処理
    await prisma.payment.create({
      data: {
        orderId,
        status: 'FAILED',
        errorMessage: error.message,
      },
    });

    throw error;
  }
}

エラーハンドリングとロールバック戦略

トランザクション処理では、適切なエラーハンドリングが成功の鍵を握ります。Prisma でよく発生するエラーとその対処法を見てみましょう。

よくあるエラーとその対処法

1. 一意制約違反エラー

typescript// エラー例:P2002 - Unique constraint failed
async function handleUniqueConstraintError() {
  try {
    await prisma.$transaction(async (prisma) => {
      await prisma.user.create({
        data: {
          email: 'existing@example.com', // 既に存在するメール
        },
      });
    });
  } catch (error) {
    if (error.code === 'P2002') {
      console.error(
        'Unique constraint violation:',
        error.meta.target
      );
      // 適切なエラーメッセージをユーザーに返す
      throw new Error(
        'このメールアドレスは既に使用されています'
      );
    }
    throw error;
  }
}

2. 外部キー制約違反エラー

typescript// エラー例:P2003 - Foreign key constraint failed
async function handleForeignKeyError() {
  try {
    await prisma.$transaction(async (prisma) => {
      await prisma.post.create({
        data: {
          title: 'New Post',
          authorId: 'non-existent-user-id',
        },
      });
    });
  } catch (error) {
    if (error.code === 'P2003') {
      console.error(
        'Foreign key constraint violation:',
        error.meta
      );
      throw new Error('指定されたユーザーが存在しません');
    }
    throw error;
  }
}

3. タイムアウトエラー

typescript// エラー例:P2024 - Timed out fetching a new connection
async function handleTimeoutError() {
  try {
    await prisma.$transaction(
      async (prisma) => {
        // 長時間実行される処理
        await heavyProcessing(prisma);
      },
      {
        timeout: 30000, // 30秒のタイムアウト
        maxWait: 5000, // 接続待機時間
      }
    );
  } catch (error) {
    if (error.code === 'P2024') {
      console.error('Transaction timeout:', error.message);
      throw new Error(
        '処理時間が長すぎます。もう一度お試しください'
      );
    }
    throw error;
  }
}

段階的ロールバック戦略

複雑な処理では、部分的な成功状態を管理する必要があります。

typescriptasync function complexOrderProcess(
  orderData: CreateOrderData
) {
  let rollbackActions = [];

  try {
    // 1. 在庫予約
    const stockReservation = await reserveStock(
      orderData.items
    );
    rollbackActions.push(() =>
      releaseStock(stockReservation)
    );

    // 2. 注文作成
    const order = await createOrderRecord(orderData);
    rollbackActions.push(() => cancelOrder(order.id));

    // 3. 支払い処理
    const payment = await processPayment(
      order.id,
      orderData.paymentMethod
    );
    rollbackActions.push(() => refundPayment(payment.id));

    // 4. 在庫確定
    await confirmStock(stockReservation);

    return order;
  } catch (error) {
    // エラー発生時、逆順でロールバック実行
    for (const rollback of rollbackActions.reverse()) {
      try {
        await rollback();
      } catch (rollbackError) {
        console.error('Rollback failed:', rollbackError);
      }
    }

    throw error;
  }
}

パフォーマンス最適化のポイント

トランザクション処理のパフォーマンスを向上させるための実践的なテクニックをご紹介します。

1. トランザクションの範囲を最小限に

typescript// 悪い例:不要な処理をトランザクション内で実行
async function inefficientTransaction() {
  await prisma.$transaction(async (prisma) => {
    // 重い処理(外部API呼び出しなど)
    const externalData = await fetchExternalData(); // 遅い!

    await prisma.user.create({
      data: { name: externalData.name },
    });
  });
}

// 良い例:事前に外部データを取得
async function efficientTransaction() {
  // トランザクション外で外部データを取得
  const externalData = await fetchExternalData();

  await prisma.$transaction(async (prisma) => {
    await prisma.user.create({
      data: { name: externalData.name },
    });
  });
}

2. バッチ処理の活用

typescript// 一つずつ処理する場合(遅い)
async function slowBatchInsert(users: User[]) {
  await prisma.$transaction(async (prisma) => {
    for (const user of users) {
      await prisma.user.create({ data: user });
    }
  });
}

// 一括処理(高速)
async function fastBatchInsert(users: User[]) {
  await prisma.$transaction(async (prisma) => {
    await prisma.user.createMany({
      data: users,
      skipDuplicates: true,
    });
  });
}

3. 接続プールの最適化

typescript// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// 接続プールの設定例
// DATABASE_URL="postgresql://user:password@localhost:5432/mydb?connection_limit=20&pool_timeout=20"

4. インデックスの活用

typescript// トランザクション内でよく使用されるクエリにインデックスを設定
model User {
  id    String @id @default(cuid())
  email String @unique
  name  String

  // 複合インデックス
  @@index([email, name])
}

model Order {
  id        String   @id @default(cuid())
  userId    String
  status    String
  createdAt DateTime @default(now())

  // ステータスと作成日時でのクエリが高速化
  @@index([status, createdAt])
}

パフォーマンス測定とモニタリング

typescriptasync function monitoredTransaction() {
  const startTime = Date.now();

  try {
    const result = await prisma.$transaction(
      async (prisma) => {
        // 処理内容
        return await performDatabaseOperations(prisma);
      }
    );

    const endTime = Date.now();
    const duration = endTime - startTime;

    // パフォーマンスログ
    console.log(`Transaction completed in ${duration}ms`);

    // 閾値を超えた場合のアラート
    if (duration > 5000) {
      console.warn(
        'Transaction took longer than expected:',
        duration
      );
    }

    return result;
  } catch (error) {
    console.error('Transaction failed:', error);
    throw error;
  }
}

まとめ

Prisma のトランザクション処理は、データベースの整合性を保つための強力な機能です。本記事で学んだ内容を振り返ってみましょう。

重要なポイント

  1. 適切な方法の選択: インタラクティブ、バッチ、ネストクエリの 3 つの方法から、用途に応じて最適なものを選択する
  2. エラーハンドリング: Prisma の具体的なエラーコードを理解し、適切な対処を行う
  3. パフォーマンス最適化: トランザクションの範囲を最小限に抑え、バッチ処理を活用する
  4. 実践的な応用: EC サイトの注文処理のような実際のユースケースで活用する

今後の学習の方向性

分野学習内容重要度
高度なエラーハンドリング分散トランザクションの実装★★★
パフォーマンス最適化大規模データでの最適化手法★★★★
セキュリティSQL インジェクション対策★★★★★
モニタリングトランザクションログの分析★★★

トランザクション処理は、最初は複雑に感じるかもしれませんが、一度理解すれば、あなたのアプリケーションの信頼性を大幅に向上させる強力な武器となります。

データの整合性を保つことは、ユーザーの信頼を得るために不可欠です。今日学んだ知識を活かして、より安全で信頼性の高いアプリケーションを作成してください。

プログラミングの世界では、完璧なコードというものは存在しません。しかし、適切なトランザクション処理を実装することで、予期しないエラーからユーザーを守り、データの整合性を保つことができます。これこそが、プロフェッショナルな開発者として最も重要なスキルの一つなのです。

関連リンク