PostgreSQLトランザクション分離レベルの選び方|READ COMMITTED・REPEATABLE READ・SERIALIZABLE・ファントムリード・Node.js pg

(更新: 2026年6月22日 ) PostgreSQL トランザクション Node.js バックエンド データベース pg
結論
  • PostgreSQL のデフォルトは READ COMMITTED——多くの Web API はこのままで問題ない。
  • 同一トランザクション内で読み取り結果を固定したいなら REPEATABLE READ読み取りと書き込みの競合まで DB に直列化を任せたいなら SERIALIZABLE(SSI)を選ぶ。
  • Node.js pg では BEGIN 直後に SET TRANSACTION ISOLATION LEVEL を実行し、SERIALIZABLE では 40001 serialization failure のリトライを必ず実装する。
  • 分離レベルを上げるほど トランザクション時間が長くなり接続プール占有も増えるため、PostgreSQL 接続プール枯渇の対処 とセットで設計する。

トランザクション分離レベルとは

複数のクライアントが同時に PostgreSQL に接続し、読み書きを行うとき、あるトランザクションの途中経過を別トランザクションがどこまで見えるか——これが トランザクション分離レベル(Transaction Isolation Level) の問題です。

SQL 標準(ISO/IEC 9075)では 4 段階が定義されています。

分離レベルダーティリード非再現読取ファントムリード
READ UNCOMMITTED起きうる起きうる起きうる
READ COMMITTED防止起きうる起きうる
REPEATABLE READ防止防止起きうる(標準上)
SERIALIZABLE防止防止防止

PostgreSQL が実装しているのは READ COMMITTED・REPEATABLE READ・SERIALIZABLE の 3 つです。READ UNCOMMITTED を指定しても内部では READ COMMITTED として扱われます(ダーティリードは常に防止)。

本記事では、各レベルの挙動、ファントムリードが PostgreSQL ではどう扱われるか、node-postgrespg)での実装パターン、実務での選び方までを扱います。

異常現象の整理

分離レベルを選ぶ前に、3 つの代表的な異常現象を押さえます。

ダーティリード(Dirty Read)

他トランザクションがまだ COMMIT していない未確定データを読んでしまう現象です。相手が ROLLBACK すれば、存在しなかったデータを読んだことになります。

PostgreSQL は MVCC(Multi-Version Concurrency Control)により、未コミット行の xmin は他セッションから見えません。そのため READ COMMITTED 以上ではダーティリードは起きません。

非再現読取(Non-repeatable Read)

同一トランザクション内で同じ SELECT を 2 回実行したとき、結果が変わる現象です。典型例は、読み取りのあいだに別セッションが UPDATE して COMMIT するケースです。

-- セッション A                          -- セッション B
BEGIN;                                   
SELECT balance FROM accounts             
  WHERE id = 1;  -- 1000                  
                                          BEGIN;
                                          UPDATE accounts
                                            SET balance = 900
                                            WHERE id = 1;
                                          COMMIT;
SELECT balance FROM accounts             
  WHERE id = 1;  -- 900(READ COMMITTED では変わる)

READ COMMITTED では 各ステートメントの実行時点で見える最新コミット済み行が読まれるため、2 回目の SELECT で値が変わります。

ファントムリード(Phantom Read)

同一条件の SELECT を繰り返したとき、行数(または存在しない行の出現)が変わる現象です。UPDATE による値の変更ではなく、INSERT / DELETE による行集合の変化がポイントです。

-- セッション A                          -- セッション B
BEGIN;                                   
SELECT count(*) FROM orders              
  WHERE status = 'pending';  -- 3         
                                          BEGIN;
                                          INSERT INTO orders (status)
                                            VALUES ('pending');
                                          COMMIT;
SELECT count(*) FROM orders              
  WHERE status = 'pending';  -- 4(ファントム)

SQL 標準上、REPEATABLE READ まではファントムリードが起きうるとされていますが、PostgreSQL の REPEATABLE READ はスナップショット分離のため、同一トランザクション内の SELECT 結果集合は原則固定され、ファントムリードも防止されます(後述)。

PostgreSQL の 3 分離レベル

現在のセッション設定を確認する SQL です。

SHOW transaction_isolation;
-- 出力例: read committed

-- セッション全体のデフォルトを変更(スーパーユーザーまたは SET 権限)
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 次のトランザクションだけ変更
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- または
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
分離レベル PostgreSQL の挙動・向き不向き
READ COMMITTED(デフォルト) 各 SQL 文ごとに新しいスナップショット。最も競合が少なく、一般的な CRUD に最適。非再現読取・ファントムは起きうる
REPEATABLE READ トランザクション開始時のスナップショットを固定。非再現読取・ファントムを防止。更新競合は serialization failure(40001)で検出
SERIALIZABLE SSI(Serializable Snapshot Isolation)で読み取り依存も追跡。最も厳密だが 40001 とデッドロックが増えやすい。リトライ設計必須

READ COMMITTED:デフォルトの実務標準

READ COMMITTED は PostgreSQL のデフォルトです。BEGIN だけ開始したトランザクションは、明示指定がなければすべて READ COMMITTED として動作します。

ステートメント単位のスナップショット

READ COMMITTED では、各 SQL 文の実行開始時点でコミット済みデータのスナップショットが取得されます。トランザクションをまたいで一貫した「世界線」は保証されません。

CREATE TABLE IF NOT EXISTS inventory (
  sku text PRIMARY KEY,
  qty  integer NOT NULL CHECK (qty >= 0)
);

INSERT INTO inventory (sku, qty) VALUES ('WIDGET-A', 10)
ON CONFLICT (sku) DO NOTHING;

-- === デモ用:2 つの psql セッションで実行 ===

-- [セッション 1]
BEGIN;
SELECT qty FROM inventory WHERE sku = 'WIDGET-A';
-- qty = 10

-- [セッション 2] このあいだに COMMIT
BEGIN;
UPDATE inventory SET qty = 7 WHERE sku = 'WIDGET-A';
COMMIT;

-- [セッション 1] 続き
SELECT qty FROM inventory WHERE sku = 'WIDGET-A';
-- READ COMMITTED: qty = 7(非再現読取が発生)

COMMIT;

READ COMMITTED が向いている処理

  • 単一 INSERT / UPDATE / DELETE で完結する API
  • 都度最新値を読み直してよいダッシュボード・検索
  • SELECT ... FOR UPDATE で行ロックを取ったうえでの更新(ロック中は他者の COMMIT 後も再読込される点に注意)

FOR UPDATE との組み合わせ

在庫引当のように「読んでから減算」する処理を READ COMMITTED で行う場合、対象行を FOR UPDATE でロックするのが定石です。

BEGIN;

SELECT qty
FROM inventory
WHERE sku = 'WIDGET-A'
FOR UPDATE;

-- qty が足りなければ ROLLBACK
UPDATE inventory
SET qty = qty - 3
WHERE sku = 'WIDGET-A'
  AND qty >= 3;

-- UPDATE 0 行なら在庫不足
COMMIT;

FOR UPDATE により他セッションの競合 UPDATE はブロックまたはデッドロック候補になります。ただし 述語(WHERE 条件)に合致する未来の INSERT までは防げないため、厳密な直列化が必要なら REPEATABLE READ / SERIALIZABLE を検討します。

REPEATABLE READ:スナップショットの固定

REPEATABLE READ では、トランザクション内で最初のクエリが実行された時点(正確にはトランザクション開始時)のスナップショットが固定されます。同一トランザクション内の後続 SELECT は、他セッションが COMMIT した変更を見ません。

-- [セッション 1]
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT qty FROM inventory WHERE sku = 'WIDGET-A';
-- qty = 10

-- [セッション 2]
BEGIN;
UPDATE inventory SET qty = 2 WHERE sku = 'WIDGET-A';
COMMIT;

-- [セッション 1]
SELECT qty FROM inventory WHERE sku = 'WIDGET-A';
-- REPEATABLE READ: qty = 10 のまま(非再現読取は防止)

COMMIT;

ファントムリードと PostgreSQL の REPEATABLE READ

標準 SQL では REPEATABLE READ でもファントムリードが起きうるとされますが、PostgreSQL は スナップショット分離(Snapshot Isolation) を実装しており、INSERT された新行もスナップショットに含まれないため、同一トランザクション内の COUNT も固定されます。

CREATE TABLE IF NOT EXISTS waitlist (
  id bigserial PRIMARY KEY,
  email text NOT NULL,
  event_id text NOT NULL
);

-- [セッション 1]
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM waitlist WHERE event_id = 'CONF-2026';
-- 例: 5

-- [セッション 2]
BEGIN;
INSERT INTO waitlist (email, event_id)
VALUES ('[email protected]', 'CONF-2026');
COMMIT;

-- [セッション 1]
SELECT count(*) FROM waitlist WHERE event_id = 'CONF-2026';
-- REPEATABLE READ: 5 のまま(ファントムも防止)

COMMIT;

更新競合と serialization failure

スナップショットは読み取りを固定しますが、COMMIT 時に書き込み競合があると直列化失敗として拒否されます。

-- [セッション 1]
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT qty FROM inventory WHERE sku = 'WIDGET-A';
-- 10

-- [セッション 2]
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT qty FROM inventory WHERE sku = 'WIDGET-A';
-- 10

-- [セッション 1]
UPDATE inventory SET qty = qty - 4 WHERE sku = 'WIDGET-A';
COMMIT;  -- 成功

-- [セッション 2]
UPDATE inventory SET qty = qty - 4 WHERE sku = 'WIDGET-A';
COMMIT;
-- ERROR: could not serialize access due to concurrent update
-- SQLSTATE: 40001

REPEATABLE READ でも 40001 が返るため、アプリ側リトライが必要です。SERIALIZABLE ほど述語ロックは厳しくありませんが、同一行を更新する競合は検出されます。

REPEATABLE READ が向いている処理

  • 1 リクエスト内で複数 SELECT し、結果の一貫性が必要なレポート
  • 「読み取りフェーズ → ビジネスロジック → 書き込み」の間に値が変わってほしくないワークフロー
  • 読み取りレプリカへ送る前の、プライマリ上での一貫読み取り

SERIALIZABLE:SSI による最も厳密な直列化

SERIALIZABLE は PostgreSQL 9.1 以降 SSI(Serializable Snapshot Isolation) で実装されています。REPEATABLE READ のスナップショットに加え、読み取った行・述語(範囲)に対する依存関係も追跡し、直列化可能でないスケジュールを COMMIT 前後に検出します。

述語ロックとファントムの防止

SERIALIZABLE では、あるトランザクションが WHERE status = 'pending' で読んだ場合、同じ条件への INSERT も競合として扱われ得ます。REPEATABLE READ では通過しうる「条件一致行の新規 INSERT」が、SERIALIZABLE では 40001 になる典型パターンです。

CREATE TABLE IF NOT EXISTS seat_reservations (
  id       bigserial PRIMARY KEY,
  seat_no  integer NOT NULL,
  show_id  text NOT NULL,
  user_id  text,
  UNIQUE (show_id, seat_no)
);

INSERT INTO seat_reservations (seat_no, show_id, user_id)
VALUES (12, 'SHOW-1', NULL)
ON CONFLICT DO NOTHING;

-- [セッション 1] 空席確認 → 予約
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT id FROM seat_reservations
WHERE show_id = 'SHOW-1' AND seat_no = 12 AND user_id IS NULL;
-- 1 行

-- [セッション 2] 同席を同時予約
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT id FROM seat_reservations
WHERE show_id = 'SHOW-1' AND seat_no = 12 AND user_id IS NULL;
-- 1 行

-- [セッション 1]
UPDATE seat_reservations
SET user_id = 'user-alice'
WHERE show_id = 'SHOW-1' AND seat_no = 12;
COMMIT;

-- [セッション 2]
UPDATE seat_reservations
SET user_id = 'user-bob'
WHERE show_id = 'SHOW-1' AND seat_no = 12;
COMMIT;
-- ERROR: could not serialize access due to read/write dependencies
-- SQLSTATE: 40001

SERIALIZABLE のコスト

SSI は メモリ上の述語ロックグラフ を維持します。ホットテーブル・広い範囲スキャン・長時間トランザクションほど、40001デッドロック(40P01) が増えます。

指標READ COMMITTEDREPEATABLE READSERIALIZABLE
読み取り一貫性文単位トランザクション単位トランザクション単位 + 述語
40001 の頻度低い中(更新競合時)高め(読み取り依存も検出)
デッドロックありありより増えやすい
実装の複雑さ中(リトライ)高(リトライ + 設計)

SERIALIZABLE は「アプリでロック順序を完全管理するのが難しい」「金融残高・座席・クーポン在庫など 直列化が要件」のときに検討します。まず REPEATABLE READ + 明確な行ロックで足りないかを試すのが現実的です。

分離レベルの選び方:実務フロー

1

[object Object]

2

[object Object]

3

[object Object]

4

[object Object]

5

[object Object]

ユースケース別の推奨

ユースケース推奨レベル理由
ユーザー名変更 APIREAD COMMITTED単一行 UPDATE。最新値で問題ない
注文サマリー + 明細の同時参照REPEATABLE READ集計中に明細が増えない一貫性
座席・クーポン・残高の同時奪い合いSERIALIZABLE または行ロック設計述語競合を DB で検出
バッチ集計(長時間)READ COMMITTED + スナップショットエクスポート長時間 RR/SER はロック・バージョン膨張のリスク
読み取りレプリカ参照READ COMMITTED(レプリカ側)プライマリで TX を張らない。遅延許容
長時間トランザクションは接続プールを圧迫する

分離レベルを REPEATABLE READ / SERIALIZABLE に上げると、「読み取り → 外部 API 呼び出し → 書き込み」のように トランザクションを開いたまま待機 しがちになります。pgpool.connect() で取得した client は COMMIT/ROLLBACK までプールに返却されず、接続プール枯渇 の直接原因になります。外部 I/O はトランザクション外に出し、DB TX は可能な限り数十 ms で閉じてください。

READ COMMITTED + 楽観ロック(代替パターン)

分離レベルを上げず、version 列による楽観的並行制御(OCC) も広く使われます。

ALTER TABLE inventory ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;

-- アプリ側の更新
UPDATE inventory
SET qty = qty - 3,
    version = version + 1
WHERE sku = 'WIDGET-A'
  AND version = $expected_version
  AND qty >= 3
RETURNING qty, version;

RETURNING が 0 行なら競合としてリトライまたは 409 Conflict を返します。SERIALIZABLE より 40001 は起きにくい一方、設計(version 列・リトライ)の責務はアプリに残ります。

Node.js pg での実装

node-postgrespg)では、プールから client を借り、トランザクション境界を明示するのが基本です。pool.query('BEGIN') だけでは同一接続が保証されないため、pool.connect() + client.query() を使います。

基本:READ COMMITTED トランザクション

import { Pool, PoolClient } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

export async function withTransaction<T>(
  fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

export async function deductInventory(sku: string, amount: number): Promise<number> {
  return withTransaction(async (client) => {
    const { rows: locked } = await client.query<{ qty: number }>(
      `SELECT qty FROM inventory WHERE sku = $1 FOR UPDATE`,
      [sku],
    );
    if (locked.length === 0) {
      throw new Error('SKU not found');
    }
    if (locked[0].qty < amount) {
      throw new Error('Insufficient inventory');
    }
    const { rows: updated } = await client.query<{ qty: number }>(
      `UPDATE inventory SET qty = qty - $2 WHERE sku = $1 RETURNING qty`,
      [sku, amount],
    );
    return updated[0].qty;
  });
}

finally { client.release() } は必須です。release 漏れは 接続プール枯渇 の典型原因です。

分離レベルを指定する

import { Pool, PoolClient, QueryResultRow } from 'pg';

type IsolationLevel =
  | 'READ COMMITTED'
  | 'REPEATABLE READ'
  | 'SERIALIZABLE';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

function isSerializationFailure(err: unknown): boolean {
  return (
    typeof err === 'object'
    && err !== null
    && 'code' in err
    && (err as { code: string }).code === '40001'
  );
}

export async function withIsolationTransaction<T>(
  isolation: IsolationLevel,
  fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query(`SET TRANSACTION ISOLATION LEVEL ${isolation}`);
    const result = await fn(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

export async function getPendingOrderSummary(eventId: string) {
  return withIsolationTransaction('REPEATABLE READ', async (client) => {
    const header = await client.query<{ total: string }>(
      `SELECT count(*)::text AS total FROM orders WHERE event_id = $1 AND status = 'pending'`,
      [eventId],
    );
    const details = await client.query<QueryResultRow>(
      `SELECT id, created_at FROM orders WHERE event_id = $1 AND status = 'pending' ORDER BY id`,
      [eventId],
    );
    return {
      total: Number(header.rows[0].total),
      orders: details.rows,
    };
  });
}

SET TRANSACTIONBEGIN の直後・最初のクエリより前 に実行する必要があります。BEGIN ISOLATION LEVEL REPEATABLE READ を 1 文で送る方法も同等です。

SERIALIZABLE + 指数バックオフリトライ

import { Pool, PoolClient } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function isRetryablePgError(err: unknown): boolean {
  if (typeof err !== 'object' || err === null || !('code' in err)) {
    return false;
  }
  const code = (err as { code: string }).code;
  return code === '40001' || code === '40P01';
}

export async function reserveSeat(
  showId: string,
  seatNo: number,
  userId: string,
  maxAttempts = 5,
): Promise<{ reservationId: number }> {
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      await client.query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');

      const available = await client.query<{ id: number }>(
        `SELECT id FROM seat_reservations
         WHERE show_id = $1 AND seat_no = $2 AND user_id IS NULL`,
        [showId, seatNo],
      );

      if (available.rows.length === 0) {
        await client.query('ROLLBACK');
        throw new Error('Seat not available');
      }

      const updated = await client.query<{ id: number }>(
        `UPDATE seat_reservations
         SET user_id = $3
         WHERE show_id = $1 AND seat_no = $2 AND user_id IS NULL
         RETURNING id`,
        [showId, seatNo, userId],
      );

      await client.query('COMMIT');
      return { reservationId: updated.rows[0].id };
    } catch (error) {
      await client.query('ROLLBACK');
      if (isRetryablePgError(error) && attempt < maxAttempts) {
        const backoffMs = Math.min(200 * 2 ** (attempt - 1), 2_000);
        const jitter = Math.floor(Math.random() * 50);
        await sleep(backoffMs + jitter);
        continue;
      }
      throw error;
    } finally {
      client.release();
    }
  }
  throw new Error('reserveSeat: exhausted retries');
}

40001(serialization failure)と 40P01(deadlock detected)はリトライ対象に含めるのが一般的です。リトライは 冪等性(同一 idempotency-key で二重予約しない)とセットで設計してください。API 冪等性設計 も参照してください。

Express ミドルウェアでのパターン

import express, { Request, Response, NextFunction } from 'express';
import { Pool, PoolClient } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

declare global {
  namespace Express {
    interface Request {
      dbClient?: PoolClient;
    }
  }
}

async function attachReadCommittedClient(
  req: Request,
  res: Response,
  next: NextFunction,
): Promise<void> {
  const client = await pool.connect();
  req.dbClient = client;
  try {
    await client.query('BEGIN');
    await client.query('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
    next();
  } catch (error) {
    client.release();
    next(error);
  }
}

async function commitAndRelease(
  req: Request,
  res: Response,
  next: NextFunction,
): Promise<void> {
  const client = req.dbClient;
  if (!client) {
    next();
    return;
  }
  try {
    if (res.statusCode >= 400) {
      await client.query('ROLLBACK');
    } else {
      await client.query('COMMIT');
    }
  } catch (error) {
    await client.query('ROLLBACK');
    next(error);
  } finally {
    client.release();
    delete req.dbClient;
  }
}

const app = express();

app.post(
  '/api/events/:eventId/register',
  attachReadCommittedClient,
  async (req, res, next) => {
    const client = req.dbClient!;
    const { eventId } = req.params;
    const userId = req.body.userId as string;
    await client.query(
      `INSERT INTO event_registrations (event_id, user_id) VALUES ($1, $2)`,
      [eventId, userId],
    );
    res.status(201).json({ ok: true });
    next();
  },
  commitAndRelease,
);

リクエスト全体を 1 トランザクションに束ねるパターンは、ハンドラ内で外部 HTTP を呼ばない場合に限り推奨します。外部 I/O があると接続占有時間が伸び、プール枯渇対処ガイド で述べた idle in transaction リスクにもつながります。

PgBouncer transaction pooling との注意

PgBouncer の transaction pooling モードでは、トランザクション終了後に接続が別クライアントに再利用されます。セッション単位の SETSET SESSION CHARACTERISTICS ...)は次のクライアントに漏れるため、必ず BEGIN 直後の SET TRANSACTION でトランザクションスコープに閉じてください。接続プールの全体像は PostgreSQL 接続プール枯渇の対処 を参照してください。

デッドロックと monitoring

分離レベルを上げると、デッドロック(40P01) も増えやすくなります。PostgreSQL はデッドロックを検出すると、犠牲トランザクションの 1 つを自動 ROLLBACK します。

SELECT
  datname,
  xact_commit,
  xact_rollback,
  deadlocks,
  conflicts
FROM pg_stat_database
WHERE datname = current_database();
カラム意味
xact_rollbackROLLBACK されたトランザクション数(40001 含む)
deadlocksデッドロック検出回数
conflictsレプリカ上のクエリキャンセル等(別文脈)

アプリログでは err.code を構造化ログに載せ、40001 / 40P01 の率をダッシュボード化します。SERIALIZABLE 導入後に 40001 が 1% を超えるなら、行ロック設計や楽観ロックへの切り替えを検討します。

SELECT
  pid,
  usename,
  state,
  wait_event_type,
  wait_event,
  query,
  xact_start,
  now() - xact_start AS tx_duration
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND state != 'idle'
ORDER BY xact_start NULLS LAST;

長時間 active / idle in transaction は、高い分離レベルと相まって スナップショット保持・接続占有 を悪化させます。

ORM を使う場合の注意

Prisma・TypeORM・Knex などでも、生 SQL または API で分離レベルを指定できます。

// Prisma 例: インタラクティブトランザクション
import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient();

export async function transferPoints(
  fromUserId: string,
  toUserId: string,
  points: number,
) {
  return prisma.$transaction(
    async (tx) => {
      await tx.$executeRaw`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`;

      const from = await tx.pointBalance.findUniqueOrThrow({
        where: { userId: fromUserId },
      });
      if (from.balance < points) {
        throw new Error('Insufficient points');
      }

      await tx.pointBalance.update({
        where: { userId: fromUserId },
        data: { balance: { decrement: points } },
      });
      await tx.pointBalance.update({
        where: { userId: toUserId },
        data: { balance: { increment: points } },
      });
    },
    {
      isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
      maxWait: 5_000,
      timeout: 10_000,
    },
  );
}

Prisma 5+ では isolationLevel オプションがそのまま使えます。ORM のトランザクションタイムアウトも、接続プールの connectionTimeoutMillis と整合させてください。

よくある FAQ(実装判断)

READ UNCOMMITTED を指定したらどうなる?

PostgreSQL では READ COMMITTED として動作します。未コミット行は読めません。ダーティリードを許容したいという要件は PostgreSQL では実現できません(そもそも通常は望みません)。

デフォルトを REPEATABLE READ に上げるべき?

全 API 一律では推奨しません。スナップショット保持による vacuum / バージョン膨張(table bloat) 圧力と、40001 リトライの実装コストが増えます。必要なエンドポイントだけ REPEATABLE READ / SERIALIZABLE に限定するのが安全です。

読み取り専用クエリも SERIALIZABLE にすべき?

読み取りだけなら READ COMMITTED または レプリカ で足りることが多いです。SERIALIZABLE の SSI は 読み取り同士の依存 も追跡するため、読み取りのみの TX でも書き込み TX と競合して 40001 になることがあります。

ファントムリードを完全に防ぐには?

PostgreSQL では REPEATABLE READ 以上でファントムは実質防止されます。ただし 更新の直列化保証 まで含めるなら SERIALIZABLE、または SELECT ... FOR UPDATE / ユニーク制約 / 楽観ロック などアプリ設計と組み合わせます。

テストで分離レベルを再現するには?

統合テストでは 2 つの client を同時に借り、Promise で同期 します。Jest では maxWorkers: 1 にし、テスト DB 専用プールで afterAll(() => pool.end()) を徹底してください。並列テストが接続を奪い合う問題は 接続プール枯渇ガイド のテスト節も参照してください。

チェックリスト(本番リリース前)

  • デフォルト READ COMMITTED で足りる API と、明示的にレベルを上げる API を文書化した
  • REPEATABLE READ / SERIALIZABLE 利用箇所でトランザクション内に外部 I/O がない
  • SERIALIZABLE / 競合 UPDATE で 4000140P01 のリトライと冪等性を実装した
  • pool.connect() すべてで finally { client.release() } がある
  • PgBouncer 利用時は SET SESSION ではなく SET TRANSACTION を使っている
  • pg_stat_database.deadlocks とアプリの 40001 率を監視している
  • 長時間 TX の上限(idle_in_transaction_session_timeout)を設定している

関連記事

この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
APIキャッシュ設計ガイドAPIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務
Web APIのエラーハンドリング設計Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け
API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較
APIのレート制限(Rate Limiting)実装ガイドAPIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング
APIレート制限と429対応APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装
WebHook設計のベストプラクティスWebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する
ZodでAPI境界のランタイム検証を設計するZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)
OpenAPI 3.1仕様のCIバリデーション完全ガイドOpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
GraphQL N+1 問題の解決GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド
Circuit Breaker + Retry パターン実装ガイドCircuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter
Sagaパターン完全ガイドSagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js)
Event SourcingとCQRSの基礎Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド

まとめ

PostgreSQL のトランザクション分離レベルは、READ COMMITTED(デフォルト)→ REPEATABLE READ(スナップショット固定)→ SERIALIZABLE(SSI による直列化) の順に厳しくなります。ファントムリードは PostgreSQL の REPEATABLE READ 以上で実質防止されますが、更新競合は 40001 としてアプリに返る点を忘れてはいけません。

Node.js pg では BEGIN 直後の SET TRANSACTION ISOLATION LEVEL、SERIALIZABLE 時の リトライ、そして 短いトランザクション が三要素です。分離レベルを上げるほど接続占有時間と競合コストが増えるため、PostgreSQL 接続プール枯渇の対処 と一体で設計・監視することが、本番で安定稼働する近道です。