リレーショナルデータベース(RDB)を活用する上で、避けて通れないのが「トランザクション」です。ソフトウェア開発においてほぼ必須と言える機能ですが、その仕組みを真の意味で理解し、要件に合わせて使いこなすのは容易なことではありません。
そこで今回は、PostgreSQLのコミッターを務める、NTTデータの藤井雅雄さんにお話を伺いました。藤井さんは同社でPostgreSQLを中心とした技術コンサルティングやサポートを手掛ける傍ら、OSS活動ではレプリケーションやWAL(トランザクションログ)といった基幹部分の開発にも深く携わっています。
「なんとなく」使いがちなトランザクションやロックの仕組みについて、アプリ開発者が押さえておくべき必須知識を詳しく解説していただきました。
トランザクションの粒度は「最小化」が鉄則
― まずは、アプリ開発においてロングトランザクション(長い時間コミットやロールバックされずに滞留している処理)を防ぐための方法から伺いたいです。
前提として、PostgreSQLにおいてロングトランザクションがなぜ危険かというと、VACUUM(不要領域の回収処理)が効かなくなってしまう点が大きいです。
PostgreSQLはMVCC(多版型同時実行制御)という仕組みを採用しており、データの更新時に古いデータをすぐ消さず、別のバージョンとして残します。これらの一時的なゴミデータを掃除するのがVACUUMの役割ですが、古いトランザクションが一つでも残っていると、VACUUMはゴミを回収できなくなります。
ですので、大原則として「トランザクションの粒度は、本当に必要な処理だけに絞り込む」ことが大切です。よくあるアンチパターンは、トランザクションを開いたまま、アプリケーション側で重い計算処理を実行したり、外部APIのレスポンスを待ったりすることです。
そのため、「その処理は、本当にトランザクションを開いたまま行う必要があるのか?」を常に考え、アプリ設計の段階でトランザクションの範囲を最小化する工夫が求められます。
― とはいえ、大量データを扱う更新バッチなど、どうしても処理時間が長くなってしまうケースも実務では避けられません。そうした場合はどうすればよいでしょうか?
まずは、バッチを小さな単位に分割できないかを検討するのが定石です。ただ、ビジネスロジック上の制約で、どうしても一括で処理しなければならない場面もあるはずです。その場合は、管理されたロングトランザクションであれば許容される、というのが私の考えです。
要するに、開発者やDBAが「この処理にはこれくらいの時間がかかる」と正しく認識できている状態です。一時的にゴミデータが増えても、処理完了後にオートバキュームが動いて正常な状態に戻るというサイクルまで検証・合意できていれば問題ありません。
一番怖いのは、「ここでそんなに長いトランザクションが発生するはずがない」という想定外の事態です。意図しないロングトランザクションが、システムに最も大きな被害を与えます。
― トランザクションが長引いている状態を検知したり、未然に防いだりするための対策はありますか?
PostgreSQLには、安全弁となる設定がいくつか備わっています。特にアプリ開発者に知っておいてほしいのがidle_in_transaction_session_timeoutです。これは「トランザクションを開始したものの、次のクエリが来ないまま放置されている状態(アイドル状態)」が続いた際に、強制的にセッションを切断するパラメータです。
例えばこれを1時間などに設定しておけば、アプリケーション側のバグやコネクションプールの不具合でトランザクションがスタックしても、最悪の事態は防げます。
もちろん、こうした設定に頼るだけでなく、モニタリングも不可欠です。トランザクションの存続時間を可視化し、想定外の長時間セッションが発生した際にアラートが飛ぶ仕組みを整えておくことが、運用においては重要です。

適切なロックレベルの選択。意図しない停止を防ぐには?
― PostgreSQLには行レベルやテーブルレベルなど、多様なロックが存在します。ロックの粒度をどのようにコントロールすべきでしょうか?
基本となる考え方は、「必要な範囲で、適切なレベルのロックを、最小限の時間だけ保持する」ことです。行レベルのロックで済む処理であれば、テーブル全体をロックする必要はありません。
また、一口にロックといっても、SELECT FOR UPDATE(排他)やSELECT FOR SHARE(共有)など複数のモードが存在します。これら各モードの特性を理解し、アプリケーションの要件に合わせて選択することが、デッドロックやパフォーマンス低下を防ぐための第一歩となります。
― 逆に、テーブル単位の強いロックを意識しなければならないのは、どのような場面でしょうか?
通常のアプリが発行するクエリで、明示的に強いテーブルロックをかけるケースはそれほど多くありません。むしろ注意が必要なのは、DDL(データ定義言語)やメンテナンス操作によって、意図せず強いロックがかかってしまうケースです。
例えば、TRUNCATEは高速にデータを削除できる便利なコマンドですが、実行時にはACCESS EXCLUSIVEロックという最も強いロックを取得します。この間は、他からのSELECTすらブロックされてしまいます。また、ALTER TABLEによる構成変更も、操作内容によっては強いロックが必要です。
こうしたメンテナンス操作がオンライン処理と競合し、サービスを実質的に停止させてしまう事故は珍しくないため、実行タイミングや手法には注意を払ってください。
― 稼働中のサービスでDDLを実行する際、影響を最小限に抑える工夫はありますか。
最近のバージョンのPostgreSQLでは、ロックの影響を抑えて実行できる機能が拡充されています。
代表的なのはCONCURRENTLYオプションの活用です。CREATE INDEXやREINDEXの際にこのオプションを付与すれば、処理時間は通常より長くなりますが、テーブルへのロックを弱めて読み書きを阻害せずに実行できます。また、標準機能以外にも、ロックを極力取らずにテーブルメンテナンスを行うためのOSSツールなども存在します。これらを適材適所で活用するのが有効です。
― カラム追加などのスキーマ変更において、ロック時間を短縮するポイントはどこにありますか。
重要なのは「そのカラムの初期値(デフォルト値)をどう扱うか」です。
新しいカラムをNULL、あるいは定数のDEFAULT値付きで追加する場合、近年のバージョンであればカタログ情報の書き換えだけで済むため、処理は一瞬で完了します。
しかし、「既存の全レコードに対して、特定の計算に基づいた値を埋め込みたい」といった要件になると、実際にデータ本体を書き換える処理が走るため、データ量に比例してロック時間が延びてしまいます。
こうした場合は、最小限のロックでカラム追加だけを行い、値の更新はバックグラウンドで少しずつ進めるなど、二段階の移行戦略を検討するのが現実的です。
― ロックの問題を早期に検知するために、どのような施策を講じるべきでしょうか。
まずはログの設定です。log_lock_waitsパラメータを有効にしておくと、一定時間以上のロック待ちが発生した際にログへ記録されます。これらを監視システムで検知できるようにしておくのが基本です。
あわせて、統計情報としてデッドロックの発生回数なども可視化するのがおすすめです。「特定の時間帯にデッドロックが頻発している」といった傾向を掴むことで、修正が必要な箇所を早期に特定できるようになります。

各種分離レベルの特徴と整合性の守り方
― PostgreSQLのデフォルトの分離レベルはRead Committedですが、他にもRepeatable ReadやSerializableといった選択肢があります。これらは実務でどのように使い分けるべきでしょうか?
- Read Committed: デフォルト設定。各クエリが開始された時点でコミット済みのデータのみを読み取れる。
- Repeatable Read: トランザクションが開始された時点でコミット済みのデータのみを読み取れる。
- Serializable: 最も厳格なレベル。複数のトランザクションを一つずつ順番に実行したのと同じ結果になることを保証する。
正直なところ、私が見てきたシステムのほとんどでは、PostgreSQLにおいてSerializableが活用されているケースはなく、デフォルトのRead Committedで運用されていました。ただし、Read Committed一択でよいわけではなく、特定のシーンではRepeatable Readが必要になります。
典型的なのは、複数のテーブルにまたがって一貫性のある状態でデータをエクスポートしたい場合です。Read Committedでは、一つのテーブルをダンプしている最中に別のテーブルが更新・コミットされると、全体としての整合性が崩れてしまいます。そうした「ある時点でのスナップショットとしての整合性」が求められる処理に限っては、Repeatable Readを選択するのが定石です。
― 分離レベルを選択する際、特に意識しておくべきことは何でしょうか?
Repeatable Read以上のレベル(Serializableを含む)を採用する場合、大事なのは「トランザクションが直列化エラーで失敗する可能性がある」という前提に立つことです。アプリケーション側で、必ずリトライ処理を実装しなければなりません。
一方で、デフォルトのRead Committedを使い続ける場合、トランザクションの途中であっても他者がコミットした変更が即座に見えてしまう点に注意が必要です。同じトランザクション内で同じクエリを2回実行した際、1回目と2回目で結果が異なる事象が起こり得ます。この挙動を許容できない箇所では、SELECT FOR UPDATEなどを用いて明示的に行ロックをかけるといった、整合性を担保するための設計が求められます。
どのレベルを選ぶにしても、「データベースがどのタイミングでどの情報を見せているのか」を正しく把握し、それをアプリケーション側のロジックと整合させることが欠かせません。
― 各種分離レベルの特性を理解した上での設計が不可欠なのですね。
補足として、運用面で知っておいていただきたい歴史的な経緯もあります。実はPostgreSQL 9.1より前のバージョンでは、Serializableを指定しても内部的な挙動はRepeatable Readと全く同じでした。9.1以降で本来のSerializableが実装され、両者は明確に異なる動きをするようになっています。
そのため、古いバージョンのPostgreSQLで構築して長期間塩漬けにしていたシステムをバージョンアップする際には注意が必要です。「設定上はSerializableだったが、実質的にはRepeatable Readの挙動に依存していた」というシステムを新しいバージョンへ移行すると、厳密な直列化チェックによってエラーが多発し、業務が止まるといったトラブルを招く恐れがあります。
「なぜ遅いのか」を解き明かす。スタック全体の俯瞰と仮説検証
― 実務において「なぜか処理が遅い」という事象に直面した際、統計情報のどこに注目してボトルネックを特定すべきでしょうか?
まずは特定の統計情報に飛びつくのではなく、全体像の把握から入ることが重要です。
クライアント、アプリケーション、DBドライバ、コネクションプール、ネットワーク。そしてPostgreSQL内部のプランナー、エグゼキュータ、I/O、さらにはその下のOS、ファイルシステム、物理ストレージ。これら一連のスタックを頭の中に描き、「どこにボトルネックがある可能性が高いか」という仮説を立ててから、調査範囲を絞り込んでいくプロセスを大切にしています。
― 調査の結果、特定のクエリが遅延の原因だと判明した場合は、次にどのようなアクションを取りますか?
そのクエリの実行計画が「いつ、なぜ変わったのか」を時系列で追います。昨日まで高速だったクエリが急に遅くなった場合、統計情報の変化によってPostgreSQLが選択する実行計画が変わってしまった可能性が高いからです。
例えば、これまでインデックススキャンを選択していたものが、データ量の増加や分布の変化によってシーケンシャルスキャンに切り替わっていないかを確認します。pg_stat_user_tablesなどのビューでスキャン回数の推移を確認したり、実際にEXPLAINを実行して現状のプランを分析したりする流れになります。
― 分析に役立つ、具体的なビューやツールについても教えてください。
クエリ単位の分析であれば、拡張モジュールのpg_stat_statementsが非常に強力です。各クエリの実行回数や合計実行時間、平均実行時間などの累積統計が記録されています。これを使うことで、「実行頻度は低いが極端に重いクエリ」や「一つひとつは軽量だが、回数が多すぎて負荷になっているクエリ」などを特定できます。
一方で、リアルタイムの状況把握にはpg_stat_activityビューを多用します。現在実行中のセッションが一覧で表示され、それぞれのプロセスがどのような待機イベント(Wait Event)の状態にあるかがわかります。
トラブルシューティングの現場では、このビューで「今、何が起きているか」を把握し、例えばロック競合が問題の場合は、必要に応じてpg_locksビューと組み合わせて詳細を深掘りしていくのが一般的な流れです。

― 非常に実践的な知見をありがとうございます。最後に、データベースの内部挙動を深く理解することの意義について、読者のエンジニアへメッセージをお願いします。
PostgreSQLの内部挙動、特にトランザクションやロックの仕組みを理解しておくことは、「一段上の設計」を行うための大きな武器になります。
適切な分離レベルやロックの粒度を選択できるようになれば、データの整合性を担保しつつ、システムのパフォーマンスを最大限に引き出すことができます。また、万が一トラブルが発生した際も、根拠に基づいた仮説を素早く立てられるため、原因究明までの時間を大幅に短縮できるはずです。ぜひ、データベースの内部で何が起きているのかに興味を持って、学んでいただければと思います。
撮影:山辺恵美子
.jpg&w=3840&q=75)
