記事一覧と件数の一貫性を保つには、同じ node-postgres クライアント上で、両方の読み取りを 1 つの REPEATABLE READ READ ONLY トランザクション内で実行します。このチュートリアルでは、その方法と、Mac から実行できる 2 セッションでの確認手順を紹介します。

記事一覧と件数が食い違うのはなぜですか?

デスクトップのスクリプトが craft トピックの公開済み記事を取得し、次に該当する記事の件数を数えるとします。この 2 つの読み取りの間に、別のセッションが記事のトピックを変更すると、返される一覧には 2 件の記事が含まれているのに、件数は 1 件と表示されることがあります。

PostgreSQL のデフォルトの分離レベルである READ COMMITTED では、通常の各 SELECT 文の開始時点でスナップショットが取得されます。そのため、1 つのトランザクション内でも、連続する読み取りで異なるコミット済みデータが見えることがあります。PostgreSQL: トランザクション分離。

このタスクで必要なのは、記事行の一貫したビューです。後から行われた編集は、次回の実行時に反映されれば十分です。PostgreSQL の REPEATABLE READ では、トランザクション制御以外で最初に実行された文によって確立されたスナップショットが、その後の読み取りにも使われます。BEGIN を実行しただけでは、そのスナップショットは確立されません。PostgreSQL: トランザクション分離。

実装には 2 つの要件があります。読み取り前に分離レベルを指定することと、関連するすべてのクエリで同じクライアントを使うことです。node-postgres では、トランザクションに 1 つのクライアントを使うことが明示的に求められており、その各文に pool.query() を使わないよう注意されています。node-postgres: トランザクション。

Mac で簡単な記事データを用意する

起動中のローカル PostgreSQL サーバー、psql コマンド、サポート対象の Node.js と npm を用意してください。以下のコマンドは、既存のローカル接続で postgres データベースを利用できることを前提としています。使用中のデータベース名が異なる場合は、一貫して置き換えてください。PostgreSQL のインストールと接続設定は事前に済ませておく必要があります。

Terminal を開き、プロジェクトフォルダーを作成します。node-postgres のパッケージ名は pg です。node-postgres: はじめに。

mkdir article-snapshot-demo
cd article-snapshot-demo
npm init -y
npm install pg
export PGDATABASE=postgres
psql -d postgres

psql プロンプトで、次のセットアップを 1 回実行します。合成した記事行だけを含む専用テーブルが作成されます。明示的に識別子を指定しているため、後の演習も追いやすくなります。

CREATE TABLE public.article_snapshot_demo (
    id integer PRIMARY KEY,
    title text NOT NULL,
    topic text NOT NULL,
    published boolean NOT NULL
);

INSERT INTO public.article_snapshot_demo
    (id, title, topic, published)
VALUES
    (1, 'Paper Shapes', 'craft', true),
    (2, 'Color Studies', 'craft', true),
    (3, 'Desk Layouts', 'design', true);

テーブルがすでに存在する場合は、以前のデータを自動的に削除したり置き換えたりせず、作業を止めて中身を確認してください。以下の例では、開始時にこの 3 行だけがあるものとします。Node.js スクリプトを実行する前に、psql を \q の状態に戻してください。

1 つのチェックアウト済みクライアントで一覧と件数を読み取る

次の完全なプログラムを、プロジェクトフォルダー内の read-articles.mjs として保存します。new Pool() は、上でエクスポートした PGDATABASE の値を含む、node-postgres がサポートする PostgreSQL 接続環境変数を使います。node-postgres: 接続。

このプログラムは 1 つのクライアントを取得し、トランザクション特性をあらかじめ指定してトランザクションを開始します。次に両方の読み取りを実行し、組み立てた結果を返す前にコミットします。PostgreSQL では、これらの特性を BEGIN で直接指定でき、トランザクションは COMMIT または ROLLBACK で終了します。PostgreSQL: BEGIN。

import pg from 'pg';
const { Pool } = pg;

const pool = new Pool({ connectionTimeoutMillis: 5000 });
pool.on('error', (error) => {
  console.error('Idle database client error:', error.message);
  process.exitCode = 1;
});

async function readArticles(topic) {
  const client = await pool.connect();
  let began = false;
  let discardClient = false;

  try {
    await client.query(
      'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'
    );
    began = true;

    const articles = await client.query(
      `SELECT id, title, topic
       FROM public.article_snapshot_demo
       WHERE topic = $1 AND published = true
       ORDER BY id`,
      [topic]
    );

    const count = await client.query(
      `SELECT count(*)::text AS total
       FROM public.article_snapshot_demo
       WHERE topic = $1 AND published = true`,
      [topic]
    );

    await client.query('COMMIT');
    began = false;

    return {
      topic,
      articles: articles.rows,
      total: count.rows[0].total
    };
  } catch (error) {
    if (began) {
      try {
        await client.query('ROLLBACK');
      } catch (rollbackError) {
        discardClient = true;
        throw new AggregateError(
          [error, rollbackError],
          'Article read failed; rollback also failed'
        );
      }
    } else {
      discardClient = true;
    }
    throw error;
  } finally {
    client.release(discardClient);
  }
}

try {
  const result = await readArticles('craft');
  console.log(JSON.stringify(result, null, 2));
} catch (error) {
  console.error(error);
  process.exitCode = 1;
} finally {
  await pool.end();
}

同じ Terminal セッションからプログラムを実行します。

node read-articles.mjs

データを変更していなければ、期待される結果には記事 1 と 2 が含まれ、total は文字列 "2" になります。件数は明示的にテキストとして返すため、この例では JavaScript の数値への変換は不要です。両方のクエリでトピックと公開状態のフィルターを一致させています。分離レベルを設定しても、述語の不一致は解消できません。

クリーンアップも、実用的な方法の一部です。読み取りに失敗したらロールバックし、ロールバックにも失敗したらクライアントを破棄します。node-postgres では、チェックアウト済みクライアントの破棄に client.release(true) を、スクリプトのプールの終了に pool.end() を使います。node-postgres: Pool API。

これは単独で実行するスクリプトなので、1 回の実行後にプールを終了します。既存のアプリケーションでは、そのアプリケーションのプールのライフサイクルを維持し、トランザクション関数をその中で使ってください。これらの読み取りに関わるすべてのヘルパーに、チェックアウト済みの client を渡す必要があります。ヘルパーが内部で pool.query() を呼び出すと、この境界が崩れます。node-postgres: トランザクション。

2 つの Terminal セッションでスナップショットを確認する

短時間で終わるスクリプトでは、別のセッションが何かを変更する前に処理が完了することがあります。関係するタイミングを明確にするため、次の手順では実行順序を意図的に指定します。ここで示すのは、作成済みのテーブルを使うセッションごとの SQL 操作であり、個別のセットアッププログラムではありません。期待されるデータベースの結果は、文書化された分離動作に基づくもので、PostgreSQL のテストを実行した結果として示しているものではありません。

Terminal ウィンドウを 2 つ開き、それぞれで psql -d postgres を実行します。読み取りを始める前に、セッション B で合成データのトピックを元に戻します。この文の実行完了を待ってください。

-- Session B: restore the fixture before beginning the exercise.
UPDATE public.article_snapshot_demo
SET topic = 'craft'
WHERE id = 2;

セッション A で読み取りトランザクションを開始し、一覧を取得します。クエリの実行が終わったら一時停止してください。

-- Session A: run these statements, then pause.
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;

SELECT id, title
FROM public.article_snapshot_demo
WHERE topic = 'craft' AND published = true
ORDER BY id;

一覧には Paper Shapes と Color Studies が含まれるはずです。次にセッション B に切り替え、単独の文でトピックを変更してコミットします。明示的なトランザクションブロックの外では、成功した文は PostgreSQL によって自動的にコミットされます。PostgreSQL: BEGIN。

-- Session B: run while session A remains open.
UPDATE public.article_snapshot_demo
SET topic = 'design'
WHERE id = 2;

セッション A に戻り、関連する読み取りを完了します。

-- Session A: finish the original reader transaction.
SELECT count(*)::text AS total
FROM public.article_snapshot_demo
WHERE topic = 'craft' AND published = true;

COMMIT;

期待される件数は 2 で、セッション A が先ほど取得した一覧と一致します。その後、Node.js スクリプトを新たに実行すると、Paper Shapes だけが返され、合計は "1" になります。以前の結果も新しい結果も、それぞれ一貫したビューを表しています。

条件を揃えて比較するには、セッション B で最初にトピックを元に戻す手順も含めて演習全体を繰り返します。ただし、セッション A では BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY; で開始してください。一時停止と更新の順序は同じにします。期待される結果は、最初の一覧には記事が 2 件含まれ、その後の件数は 1 になります。この比較では、トランザクション設定だけを変更条件として切り分けています。

関連する読み取りに適した境界を選ぶ

この例を応用する際は、次の判断材料を使ってください。まず、どの出力同士の一致が必要かを決め、次に、それらを提供する最小のデータベース操作を選びます。

読み取りの目的 適切な方法 維持すべき条件
通常の 1 回のクエリで記事一覧を 1 つ返す READ COMMITTED で実行できる 後続のクエリで同じスナップショットを共有する必要がないこと。
一覧と、別のクエリで取得する件数を返す 1 つの REPEATABLE READ READ ONLY トランザクション 1 つのクライアントを使い、フィルターを一致させること。
一貫した結果を得るために複数のバッチを取得する 読み取りを同じトランザクション内で行う 新しいトランザクションでは新しいビューが確立されること。
別々のデータベースセッションにまたがって読み取りを調整する スナップショットの明示的なエクスポートとインポート 独立したトランザクションでスナップショットが自動的に共有されるわけではないこと。

複数のセッションでは、PostgreSQL の pg_export_snapshot() と SET TRANSACTION SNAPSHOT を利用できます。エクスポート側は、他のトランザクションがそのスナップショットをインポートし終えるまで開いておく必要があります。インポートするトランザクションには REPEATABLE READ または SERIALIZABLE を設定し、最初のクエリまたはデータ変更文の前にインポートしなければなりません。Serializable でのインポートには、コマンドリファレンスに記載された追加の制約があります。PostgreSQL: スナップショット同期関数、PostgreSQL: SET TRANSACTION。

このデスクトップでの記事一覧と件数のタスクでは、1 つのクライアントを維持すれば、こうした追加の調整を避けられます。別の操作を人が選ぶのを待つ前に、データベースの読み取りを終えてトランザクションを閉じてください。

この分離レベルで解決しないことは何ですか?

PostgreSQL の REPEATABLE READ はファントムリードを防ぎますが、直列化異常は残ります。そのため、スナップショットが安定していても、同時実行されたワークフローが順番に 1 つずつ実行された場合と同じ結果になるとは限りません。また、シーケンス値は固定されません。シーケンスには特殊なトランザクション動作があります。PostgreSQL: トランザクション分離。

READ ONLY は、特定の一時テーブル操作など、文書化された例外を除き、通常のテーブルへの変更を禁止します。ただし、これ自体が安定したスナップショットを選ぶわけではありません。読み取り前に両方の特性を設定してください。トランザクションの最初のクエリまたはデータ変更文の後では、PostgreSQL は分離レベルの変更を許可しません。PostgreSQL: SET TRANSACTION。

レポートで、より強い Serializable の意味論が必要な場合、PostgreSQL では SERIALIZABLE READ ONLY DEFERRABLE を利用できます。スナップショットを取得する間に待機することがありますが、その後は直列化失敗によるキャンセルなしで実行されます。このトレードオフは、ここで説明した通常の記事読み取りの要件とは異なります。PostgreSQL: SET TRANSACTION。

記事一覧と件数では、実装の境界を明確にしてください。1 つのクライアントを取得し、必要な特性を指定して開始し、関連する結果をすべて読み取り、コミットしてからクライアントを解放します。同じフィルターを再利用し、トランザクションが完了してから組み立てた結果を公開してください。そうすれば、次のデスクトップ操作では、一貫した記事データ一式を使えます。

出典