
2026/02/17 5:23
**同期バリアを使ったPostgreSQL のレースコンディション検証**
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
要約
この記事では、決定的同期バリア(deterministic synchronization barriers)が、同時実行データベース操作をテストする際に通常の逐次テストスイートでは検出できないレースコンディション・バグを露呈させる方法について説明しています。
createBarrier(count) ヘルパーを挿入すると、複数のゴルーチンがすべて期待されるリクエストが同じポイントに到達するまで停止し、制御されたインターレイ(interleaving)を強制します。
クレジットアカウント例では、初期
SELECT(読み取り)とその後の UPDATE(書き込み)の間にバリアを配置すると決定的な失敗が発生します。ロックなしで最終残高が $200 になるはずなのに、両方のゴルーチンが同じ古い値 ($100) を読み取り、$50 を上書きするため最終残高が $150 になってしまいます。PostgreSQL のデフォルトの READ COMMITTED 隔離レベルでは、単にトランザクションで操作を包むだけではこの問題は防げません。
SELECT … FOR UPDATE を使用すると行レベルロックが取得できますが、バリアを SELECT の 後 に配置した場合、両方のトランザクションが同時にバリアに到達するとデッドロックになる可能性があります。バリアを
BEGIN の直後(すなわち SELECT の前)に置くと、最初のトランザクションがロックを取得し、2 つ目は待機するため、最終残高は正しく $200 になります。
テストは実際の PostgreSQL インスタンス上で実行する必要があります。モックではロックや競合状態をシミュレートできないためです。また、ロックが無いと失敗し、ロックがあると通過することを明示的に検証すべきです。
本番コードでは、バリアはオプションのフック経由で注入されるため、通常動作時にはオーバーヘッドが発生しません。
これらのバリア駆動テストはリグレッション保護を提供します。将来のリファクタリングで必要なロックが削除された場合、テストがレースコンディションを検出して本番に入る前に問題を表面化させます。
この要約はキーポイント一覧からすべての主要点を取り込み、裏付けのない推論を追加せずに明確性を保っています。
本文
Mikael Lirbank — 2026年2月
レースコンディションのテストが無いと、システムに存在するすべてのレースコンディションは「リファクタリング一歩」で本番環境を壊す危険性があります。
同期バリアを使えば、安心してそのようなテストを書けます。
レースコンディションとは
ある関数がアカウントに入金処理をするとします。この関数は「現在の残高を読んで」→「金額を足し算」→「新しい残高を書き戻す」という流れです。
同じ時点で二つのリクエストが走ると、以下のようにタイミングが重なる可能性があります。
P1: SELECT balance → 100 P2: SELECT balance → 100 ── 両方とも100を読んだ状態で ── P1: UPDATE balance = 150 P2: UPDATE balance = 150
両方とも「100」を読み取り、「150」と計算し、最終的に「150」という残高になります。
期待していた「200」ではなく「50」が失われた状態です。エラーも起きず、トランザクションがロールバックされることもありません。データベースは指示通りに動作しただけです。
このような書き込みレースコンディションは、同じ古い値を読んだ二つの操作が、その後にそれぞれ書き込むことで発生し、第二の書き込みが最初を上書きするという形で現れます。金銭を扱うシステムでは、顧客の残高が誤ってしまい、ログにもエラーが記録されないままです。
テストにおける課題
テストスイートは通常「1つずつリクエスト」を実行します。
上記のインターレイ(交差)パターンは発生しません。そのため、コードが並列処理を正しく扱っていてもテストは通過してしまいます。
// 単純実装 — トランザクションなし、ロックなし const credit = async (accountId: number, amount: number) => { const [row] = await db.execute( sql`SELECT balance FROM accounts WHERE id = ${accountId}`, ); const newBalance = row.balance + amount; await db.execute( sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`, ); }; await Promise.all([credit(1, 50), credit(1, 50)]); expect(result.balance).toBe(200); // テストは通る — だが実際にはレースコンディションが存在する
sleep() を入れて重なりを強制しようとすると、テストが遅くなるだけでなく「たまに」しかバグを捕捉できません。何千回も実行してタイミングを合わせることを期待するのは現実的ではありません。
必要なのは、「どちらかを書き込む前に必ず両方が古い値を読んでいる」という状況を「確実に」作り出す方法です。
同期バリア
同期バリアとは、複数の並列操作が到着するまで待機し、最後の一つが来た瞬間に全員を同時解放する仕組みです。
function createBarrier(count: number) { let arrived = 0; const waiters: (() => void)[] = []; return async () => { arrived++; if (arrived === count) { waiters.forEach((resolve) => resolve()); } else { await new Promise<void>((resolve) => waiters.push(resolve)); } }; }
- counter:到着したタスク数を保持
- waiters:待機しているプロミスの解決関数
各呼び出し側は
arrived を増やし、最後まで来ていない場合は Promise で待ちます。最後の一人が来た瞬間に全員を解除します。
このバリアを「読み取り」と「書き込み」の間に挿入すれば、どちらも書き込む前に必ず同じ古い値を読んだ状態を作り出せます。レースコンディションを意図的に再現できます。
バリアの実演
1. 単純クエリ(トランザクションなし)
const barrier = createBarrier(2); const credit = async (accountId: number, amount: number) => { const [row] = await db.execute( sql`SELECT balance FROM accounts WHERE id = ${accountId}`, ); await barrier(); // 両方が読んだ後に書き込み開始 const newBalance = row.balance + amount; await db.execute( sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`, ); }; await Promise.all([credit(1, 50), credit(1, 50)]); const [result] = await db.execute( sql`SELECT balance FROM accounts WHERE id = 1`, ); expect(result.balance).toBe(200); // 失敗 — 残高は150
結果は次のようになります。
P1: SELECT balance → 100 P2: SELECT balance → 100 ── バリアで両方解放 ── P1: UPDATE balance = 150 P2: UPDATE balance = 150 Expected: 200 Received: 150 ✗
2. トランザクションを追加
const credit = async (accountId: number, amount: number) => { await db.transaction(async (tx) => { const [row] = await tx.execute( sql`SELECT balance FROM accounts WHERE id = ${accountId}`, ); await barrier(); const newBalance = row.balance + amount; await tx.execute( sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`, ); }); };
テストは同じく失敗します。
T1: BEGIN T1: SELECT balance → 100 T2: BEGIN T2: SELECT balance → 100 ── バリアで両方解放 ── T1: UPDATE balance = 150 T1: COMMIT T2: UPDATE balance = 150 T2: COMMIT Expected: 200 Received: 150 ✗
PostgreSQL のデフォルト隔離レベルは READ COMMITTED。各ステートメントが開始時点でコミット済みの状態を見ます。トランザクションは「書き込みロック」を付与するわけではなく、単に一貫したスナップショットを提供します。
3. 書き込みロック(SELECT … FOR UPDATE
)を追加
SELECT … FOR UPDATEconst credit = async (accountId: number, amount: number) => { await db.transaction(async (tx) => { const [row] = await tx.execute( sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`, ); await barrier(); const newBalance = row.balance + amount; await tx.execute( sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`, ); }); };
結果は次のようにデッドロックでハングします。
T1: BEGIN T1: SELECT balance FOR UPDATE → 100 (ロック取得) T2: BEGIN T2: SELECT balance FOR UPDATE → ☐ ブロック(T1 のロック待ち) 最初のタスクが `SELECT … FOR UPDATE` を実行してロックを取得。 二番目は同じクエリでブロックされ、バリアに到達できないためテストは停止します。
デッドロックは「ロックが存在する」ことを示していますが、CI ではハングしたままでは通れません。そのため実際には バリアを削除してテストをスキップ するか、他の手段で検証を行います。しかしこれではリファクタリング時にロックが失われても検出できません。
4. バリアを移動させる
SELECT … FOR UPDATE の前にバリアを置くとテストは通ります。
T1: BEGIN T2: BEGIN ── バリアで両方解放 ── T1: SELECT balance FOR UPDATE → 100 (ロック取得) T2: SELECT balance FOR UPDATE (T1 がコミットまで待機) T1: UPDATE balance = 150 T1: COMMIT (ロック解放) T2: SELECT balance FOR UPDATE → 150 (更新後の値を読取) T2: UPDATE balance = 200 T2: COMMIT Expected: 200 Received: 200 ✓
ここでテストが通る理由は、バリア位置が変わったからか、ロックが効いているからか分かりません。
FOR UPDATE を外して再実行すると:
T1: BEGIN T2: BEGIN ── バリアで両方解放 ── T1: SELECT balance → 100 T2: SELECT balance → 100 T1: UPDATE balance = 150 T1: COMMIT T2: UPDATE balance = 150 T2: COMMIT Expected: 200 Received: 150 ✗
ロックが無いと両方とも古い値を読んでしまうため失敗します。これにより「バリアの位置」ではなく「ロック」が実際に機能していることが証明されます。
実運用への導入
本番データベースでテストする
モックはロックやトランザクション、競合を再現できません。Neon Testing などの 実際の PostgreSQL インスタンス を使うことで、バリアと同時に本物の挙動を観測できます。
フックでバリアを注入
テスト用インフラとしてバリアは「プロダクションコードに存在してはいけない」ものです。そこで フック(hook) を利用します。
async function credit( accountId: number, amount: number, hooks?: { onTxBegin?: () => Promise<void> | void }, ) { await db.transaction(async (tx) => { if (hooks?.onTxBegin) { await hooks.onTxBegin(); } const [row] = await tx.execute( sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`, ); const newBalance = row.balance + amount; await tx.execute( sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`, ); }); }
は「トランザクション開始後、クエリ実行前」に呼ばれます。onTxBegin- 本番では
が未定義なので、何も起きません(オーバーヘッドはゼロ)。hooks - テスト時にバリアを渡せば、期待通りのインターレイが強制されます。
const barrier = createBarrier(2); await Promise.all([ credit(1, 50, { onTxBegin: barrier }), credit(1, 50, { onTxBegin: barrier }), ]); const [result] = await db.execute( sql`SELECT balance FROM accounts WHERE id = 1`, ); expect(result.balance).toBe(200);
プロダクションコードはそのまま:
await credit(1, 50); // フック無し、バリア無しで高速に動作
虚飾テストを避ける
数か月後にデータアクセス層がリファクタリングされるとします。
クエリを書き換えたり関数構造を変えることでロックが消えてしまう可能性があります。
バリア付きテストはその回帰を検出し、開発者のマシンで失敗させます。
ただし、テストが本当に回帰を捕捉していることを確認してください。
バリアやビジネスロジックを変更したら、ロックを無効化してテストが失敗するか再度確認します。両方ともパスすると「虚飾テスト」になってしまいます。
まとめ
レースコンディションのテストが無いと、システムに潜むすべての競合は「リファクタリング一歩」で本番へ飛び込む危険があります。
同期バリアを活用して「必ず古い値を読んだ状態」を再現し、ロックやトランザクションが正しく機能しているかを確実に検証できるようにしましょう。これでリファクタリング時の不具合も事前に発見できます。