Webエンジニア向けプログラミング解説動画をYouTubeで配信中!
▶ チャンネル登録はこちら

【ITニュース解説】Eliminating Race Conditions in Clustered Spring Boot with PostgreSQL Advisory Locks

2026年09月29日に「Dev.to」が公開したITニュース「Eliminating Race Conditions in Clustered Spring Boot with PostgreSQL Advisory Locks」について初心者にもわかりやすく解説しています。

作成日: 更新日:

ITニュース概要

複数Spring Bootアプリで起こる二重処理などのデータ不整合は、従来のロック手法では複雑な問題を引き起こす。PostgreSQLのAdvisory Locksは、既存DBを活用し、安全かつ運用負荷なく分散システム全体の競合を防ぐ。トランザクション連携により自動でロックが解除され信頼性が高い。

ITニュース解説

システムエンジニアを目指す初心者の皆さん、日々進化するITの世界では、複数のコンピューターやプログラムが連携して動作する「分散システム」が当たり前になっている。しかし、この分散システムには特有の難しい問題が潜んでいることがある。その一つが「レースコンディション」、日本語では「競合状態」と呼ばれるものだ。

例えば、ユーザーの銀行口座の残高を管理するシステムを想像してほしい。このシステムが複数のサーバー(「Pod」と呼ばれる独立したプログラムの実行単位)で動いているとする。あるユーザーの口座残高が100ドルで、2つのPodが同時に「100ドルの支払い処理」を依頼されたとする。Pod AとPod Bはほぼ同時に、口座残高が100ドルであることを確認し、それぞれ支払い処理を実行したとしよう。結果として、両方のPodが口座残高を0ドルに更新する前に支払い処理を行ってしまい、ユーザーは合計200ドルを受け取ってしまい、銀行は100ドルの損失を被る。これがレースコンディションの一例であり、システムが正しく動作しない大きな原因となる。

このような問題を解決するために、これまではいくつかの方法が試されてきたが、それぞれに限界があった。

まず、「JVM内ロック」という方法がある。これはJavaのsynchronizedキーワードやReentrantLockなどを使って、一つのプログラム内で複数の処理が同時に特定のデータにアクセスするのを防ぐ仕組みだ。しかし、システムが複数のPodで構成されている場合、Pod AとPod Bはそれぞれ独立したプログラムとして動いているため、Pod AのロックはPod Bには何の影響も与えない。したがって、複数のPodが関わるレースコンディションは解決できない。

次に、「悲観的行ロック」というデータベースの機能がある。これはSELECT ... FOR UPDATEといったSQL文を使うことで、データベースの特定の行をロックし、他の処理がその行を更新できないようにする方法だ。これは強力な解決策だが、いくつかの制約がある。一つは、ロックしたい行がデータベースに「すでに存在している」必要がある点だ。「もしユーザーの記録がなければ、新しく作成する」といったシナリオでは、ロック対象の行が存在しないため、この方法は使えない。また、特定の行をロックすると、データベース内部で様々なロック機構が働き、隣接するデータへのアクセスにも影響を与えたり、ビジネスロジックとデータベースの内部構造が密接に結びついてしまったりする可能性がある。

さらに、「Redis」のような外部システムを使った分散ロックも一般的だ。Redisは高速なキャッシュやデータストアとして知られ、Redissonのようなライブラリを使えば分散ロックを実現できる。しかし、この方法には新たな課題が伴う。Redisクラスターを運用するためには、追加のインフラ構築、冗長化(フェイルオーバー)、バックアップ、パッチ適用など、運用上の手間が大幅に増える。また、アプリケーションとRedis、そしてデータベースの三者が連携する中で、ネットワークの分断などが発生すると、ロックが適切に解放されずに宙に浮いたり、システム全体でデータが不整合になったりする「二重障害問題」のリスクも発生する。もしPostgreSQLを既に利用しているなら、その追加コストは不要なものとなるかもしれない。

ここで紹介したいのが、既存のPostgreSQLが持つ「アドバイザリーロック」という機能だ。これはPostgreSQLのデータベースサーバーのメモリ上で直接管理される、アプリケーションが任意に定義できるロックだ。アドバイザリーロックの最大の特徴は、データベースが特定のデータ(行、ページ、テーブルなど)に意味を持たせない点にある。単に、整数値のキーをメモリ上に保持し、他のデータベース接続がそのキーのロック状態を確認したり、ロックが解放されるまで待機したりできる、というシンプルな仕組みだ。これにより、データベースのデータ構造に縛られずに、任意のビジネスロジックに対して排他制御をかけることができる。

アドバイザリーロックには二つの種類があり、これを間違えると大きな問題につながる可能性がある。 一つは「セッションレベルロック(pg_advisory_lock)」だ。これは明示的にロックを解放するか、データベース接続が切れるまでロックを保持し続ける。Spring Bootのようなアプリケーションでは、データベースへの物理的な接続を再利用する「コネクションプール(HikariCPなど)」が使われる。もしセッションレベルロックを取得した後に、予期せぬエラーでロックの解放処理が実行されなかった場合、そのロックを保持したままの接続がコネクションプールに戻されてしまう。後で別の処理がその接続を再利用すると、ロックも引き継がれてしまい、他のプロセスがそのロックキーを要求しても永久に取得できないという、深刻なデッドロックやコネクションプール枯渇の原因となる。

もう一つは「トランザクションレベルロック(pg_advisory_xact_lock)」だ。こちらは、ロックを取得したトランザクションが完了(コミットまたはロールバック)すると、PostgreSQLが自動的にロックを解放してくれる。Spring Bootのトランザクション管理と組み合わせることで、アプリケーションが途中でクラッシュしても、データベース接続が切れると同時にトランザクションがアボートされ、ロックも即座に解放されるため、非常に安全に利用できる。Spring Bootアプリケーションでアドバイザリーロックを使う場合は、このトランザクションレベルロックがほぼ常に正しい選択となる。

具体的な実装には、いくつかの手順がある。 まず、アプリケーションのビジネスロジックで使う文字列のキー(例: "ユーザーID:123")を、PostgreSQLが認識できる64ビットの整数値(Javaのlong型)に変換する必要がある。これには、SHA-256のようなハッシュ関数を使って文字列からバイト配列を生成し、その先頭8バイトをlong型として解釈する方法が使われる。非常に広い64ビットの数値空間を持つため、異なるビジネスキーが偶然同じハッシュ値になる「ハッシュ衝突」は極めて稀であり、万が一発生しても一時的な待機で済むため、データが壊れることはない。

次に、この数値キーを使ってPostgreSQLのアドバイザリーロック関数を呼び出すためのリポジトリ層を作成する。Spring BootではJdbcTemplateを使うことで、現在のSpringトランザクションに紐付いたデータベース接続に対して、直接SQL文(SELECT pg_try_advisory_xact_lock(?)やSELECT pg_advisory_xact_lock(?))を実行できる。pg_try_advisory_xact_lockはロックをすぐに試みて、取得できたかどうかを真偽値で返すため、ロックが取得できない場合に即座に処理を中断する「フェイルファスト」な実装が可能となる。一方、pg_advisory_xact_lockはロックが取得できるまで処理を停止して待機する。

そして、これらのロック機能をより安全かつ使いやすくするために、汎用的な「アドバイザリーロックテンプレート」を作成する。このテンプレートは、Springの@Transactionalアノテーションと組み合わせて使用する。トランザクションの開始時にロックを取得し、トランザクションの終了(コミットまたはロールバック)時に自動でロックが解放されることを保証することで、レースコンディションを防ぎつつ、コードの簡潔さを保つことができる。例えば、ユーザーへの初回ウェルカムボーナス付与のシナリオでは、テンプレート内でユーザーIDに基づくロックキーを取得し、ロックが成功した場合のみボーナス付与処理を実行する。ロックが取得できなかった場合は、LockAcquisitionExceptionをスローして処理を中断し、ユーザーには「他の処理が進行中」などのメッセージを返すことができる。

さらに高度な使い方として、「ロックタイムアウト」機能も活用できる。pg_try_advisory_xact_lockのように即座に失敗させるのではなく、一定時間だけロックの取得を待機させたい場合がある。PostgreSQLには直接的なタイムアウト付きアドバイザリーロック関数はないが、SET LOCAL lock_timeout = 'XXXms'というSQL文をトランザクション内で実行することで、そのトランザクション内のすべてのロック要求(アドバイザリーロックを含む)にタイムアウトを設定できる。これにより、指定した時間内にロックが取得できなければ、PostgreSQLがエラーを発生させて処理を中断するため、アプリケーションが無限に待機するのを防ぐことができる。

このような分散システムの排他制御は、実際に複数スレッドや複数プロセスが同時に動作する環境でテストすることが非常に重要だ。Testcontainersというツールを使えば、テストコード内で一時的なPostgreSQLデータベースを起動し、複数スレッドを同時に実行して、アドバイザリーロックが期待通りに動作し、レースコンディションが確実に防止されることを検証できる。これにより、本番環境での予期せぬ問題発生を防ぐことができる。

もちろん、PostgreSQLアドバイザリーロックを使う上での注意点もある。 一つは「コネクション枯渇」のリスクだ。ロックされたクリティカルセクション内で、外部の決済API呼び出しなど、完了までに時間がかかる処理を実行してしまうと、その間ずっとデータベース接続がコネクションプールから占有され続けることになる。これにより、コネクションプール内の利用可能な接続がなくなり、他のデータベース処理がブロックされてしまう可能性がある。アドバイザリーロックを使うクリティカルセクション内の処理は、できるだけ短く、データベースへの書き込みや状態変更などの核となる部分に限定することが重要だ。

二つ目は、PgBouncerのようなデータベース接続プーラーを使用している場合だ。もしPgBouncerが「トランザクションプールモード」で動作している場合、セッションレベルロックは正しく機能しない可能性がある。しかし、トランザクションレベルロックはPgBouncerがトランザクションの期間中、クライアントを単一の物理データベース接続に固定するため、問題なく機能する。

三つ目は、PostgreSQLの「共有メモリ制限」だ。アドバイザリーロックはPostgreSQLの共有メモリに保存されるため、max_locks_per_transactionなどの設定によって同時にアクティブに保持できるロックの数に上限がある。しかし、通常の設定であれば数万個のアドバイザリーロックを同時に管理することは可能であり、ほとんどのアプリケーションでは問題にならない。アドバイザリーロックは、あくまで一時的な排他制御のためのツールであり、長期的な状態管理には向かないことを理解しておく必要がある。

このように、PostgreSQLアドバイザリーロックは、Javaのsynchronized、Redis分散ロック、データベースの行ロックといった他の排他制御の手法と比較して、クラスター環境への対応、非存在レコードの保護、既存のデータベース活用、自動クリーンアップといった点で優れた特性を持っている。

まとめると、分散システムで頻発するレースコンディションを解決するために、安易にRedisやZooKeeperといった追加の分散システムを導入する前に、まずは既存のPostgreSQLデータベースが持つ「アドバイザリーロック」に目を向けるべきだ。pg_try_advisory_xact_lockとSpringの@Transactionalを組み合わせることで、複数のPodにまたがる真のクラスター全体の排他制御を、シンプルな仕組みで実現できる。これにより、ロックの解放忘れによる問題も防ぎ、追加のインフラ運用コストも削減できる。複雑なシステム構成を避け、シンプルで堅牢な解決策を選ぶことは、システムの信頼性を高める上で非常に強力な武器となるだろう。

関連コンテンツ

関連IT用語

関連ITニュース