高同時実行性においてアプリケーションレベルのコネクションプーリングが機能不全に陥る理由
Lukas Schneider
DevOps Engineer · Leapcell

はじめに
高パフォーマンスアプリケーションの世界では、データベース接続を効率的に管理することが不可欠です。各接続は貴重なサーバーリソースを消費し、管理の不十分な接続はすぐにボトルネックとなり、アプリケーションの応答性とスケーラビリティに深刻な影響を与えます。ほとんどの最新アプリケーションフレームワークは、データベース接続を再利用するための便利な方法を提供する、何らかの組み込みコネクションプーリング機能を提供していますが、運用がスケールアップしたときに、「アプリケーションレベルのコネクションプーリングは、高同時実行環境の要求に対して本当に十分なのか?」という重要な疑問が生じることがよくあります。この記事では、アプリケーションレベルのプーリングのみに依存することの限界を掘り下げ、PgBouncerやRDS Proxyのような専用コネクションプーラーを支持し、それらが堅牢でスケーラブルなデータベースアーキテクチャにとって不可欠である理由を説明します。
アプリケーションレベルのコネクションプーリングのボトルネック
アプリケーションレベルのプーリングが機能不全に陥る理由を理解するために、まず関連するコアコンセプトを定義しましょう。
- データベース接続: アプリケーションとデータベースサーバー間の開かれた通信チャネル。新しい接続の確立は、両端でのハンドシェイクプロトコル、認証、リソース割り当てを含む、比較的高価な操作です。
- コネクションプーリング(アプリケーションレベル): アプリケーションが開かれた再利用可能なデータベース接続のプールを維持する技術。各リクエストに対して新しい接続を開く代わりに、アプリケーションはプールから接続を借り、終了時に返却します。これにより、接続確立のオーバーヘッドとデータベースリソースの消費が削減されます。例としては、JavaのHikariCP、PythonのSQLAlchemyの
QueuePool、またはRuby on Railsの組み込みプーリングメカニズムが挙げられます。 - 専用コネクションプーラー(例:PgBouncer、RDS Proxy): アプリケーションとデータベースの間に配置される、軽量な独立したプロキシサービス。これは、データベースへのさらに大きな接続プールを管理し、複数のアプリケーション接続がより小さな実際のデータベース接続セットを共有できるようにします。これは、接続多重化、認証、アプリケーションを中断することなく安全なデータベース再起動などの高度な機能を提供します。
アプリケーションレベルのプーリングは中程度の負荷ではうまく機能しますが、その根本的な限界は、プロセス中心またはインスタンス中心の性質にあります。アプリケーションの各インスタンス(例:Webサーバープロセス、マイクロサービスコンテナ)は、独自の独立した接続プールを維持します。データベースにロードバランサーを介して展開された複数のインスタンスにアプリケーションが展開されているシナリオを考えてみましょう。
アプリケーションインスタンス1 --(プール1)--> データベース
アプリケーションインスタンス2 --(プール2)--> データベース
アプリケーションインスタンス3 --(プール3)--> データベース
このシナリオでは、アプリケーションレベルのプーリングがあっても、データベースは依然として複数の独立したプールからの接続を認識します。各アプリケーションインスタンスが、例えば20個の接続を維持し、10個のアプリケーションインスタンスがある場合、データベースは200個の同時接続を処理する可能性があります。これらの接続のそれぞれが、データベースサーバー上のメモリとCPUリソースを消費します。同時実行性が増加するにつれて、データベースサーバーは、クエリ実行によってではなく、管理しなければならないアクティブな接続の数自体によって圧倒される可能性があります。この現象は、しばしば高いメモリ使用量、増加したコンテキストスイッチ、およびリソース競合によるクエリ実行速度の低下によって特徴付けられます。
この問題は、接続スパイクまたは「セラード」シナリオでさらに悪化します。アプリケーションインスタンスが再起動またはスケールアップすると、すべてが同時に新しい接続を確立しようとし、データベースを接続要求でフラッディングする可能性があります。アプリケーションレベルのプールが健全な最小/最大サイズで構成されていても、接続の合計数は急速にクリティカルレベルに達し、データベースのダウンタイムにつながる可能性があります。
ここで、専用コネクションプーラーが不可欠になります。これらは抽象化と制御のレイヤーを導入します。
アプリケーションインスタンス1 --(アプリ接続)--> PgBouncer/RDS Proxy --(DB接続)--> データベース
アプリケーションインスタンス2 --(アプリ接続)--> PgBouncer/RDS Proxy --(DB接続)--> データベース
アプリケーションインスタンス3 --(アプリ接続)--> PgBouncer/RDS Proxy --(DB接続)--> データベース
このセットアップでは、各アプリケーションインスタンスはデータベースに直接ではなく、コネクションプーラーに接続します。プーラーは、データベースへのはるかに小さく最適化された実際の接続プールを維持します。たとえば、100個のアプリケーション接続を、プーラーによってわずか20個のデータベース接続に多重化できます。
概念的に、プールラーが介入する方法と対比して、簡単なPythonの例を使用してこれを説明しましょう。
アプリケーションレベルのプーリング(概念的なPython psycopg2例):
import psycopg2 from psycopg2 import pool import threading import time # 本番アプリでは、これはグローバルまたはマイクロサービスごとに構成されるでしょう # 各アプリインスタンスは独自のプールを持つことになります min_connections = 5 max_connections = 10 conn_pool = pool.SimpleConnectionPool(min_connections, max_connections, host="localhost", database="mydatabase", user="myuser", password="mypassword") def worker_thread(thread_id): connection = None try: connection = conn_pool.getconn() print(f"Thread {thread_id}: Acquired connection. Total active: {conn_pool.closed_and_idle_connections + conn_pool.used_connections}") cursor = connection.cursor() # いくつかのデータベース作業をシミュレート cursor.execute("SELECT pg_sleep(0.1)") cursor.close() print(f"Thread {thread_id}: Released connection.") except Exception as e: print(f"Thread {thread_id}: Error: {e}") finally: if connection: conn_pool.putconn(connection) # このアプリケーションインスタンスからの複数の同時リクエストをシミュレート threads = [] for i in range(20): # ONEアプリインスタンスからの20の同時リクエスト thread = threading.Thread(target=worker_thread, args=(i,)) threads.append(thread) thread.start() for thread in threads: thread.join() conn_pool.closeall() print("All connections closed.")
これをローカルで実行すると、この特定のPythonプロセス内で接続を管理するpsycopg2のSimpleConnectionPoolを確認できます。 max_connectionsに達すると、後続のgetconn()呼び出しは、接続が利用可能になるかタイムアウトが発生するまでブロックされます。10個のそのようなPythonプロセスが実行されている場合、データベースは最大で10 * max_connectionsの接続を認識します。
専用プーラー(PgBouncer/RDS Proxy)の役割:
代わりに、アプリケーションはPgBouncer/RDS Proxyに接続します。
アプリケーション -> PgBouncer/RDS Proxy -> データベース
PgBouncerはさまざまなモードで動作します。
- セッションプーリング(デフォルト): これは最も一般的なモードです。サーバー接続は、クライアントのセッション期間中、クライアントに割り当てられます。クライアントが切断すると、サーバー接続はプールに戻されます。これは、比較的長期間の接続を持つが、トランザクション間で永続的な状態を必要としないアプリケーションに適しています。
- トランザクションプーリング: サーバー接続は、トランザクションの期間中のみクライアントに割り当てられます。トランザクションが終了すると、サーバー接続はすぐにプールに戻されます。これは、多くの短いトランザクションを持つワークロードに対して非常に効率的です。これは、明示的な接続多重化が発生する場所です。
- ステートメントプーリング: Postgresのプロトコル制限のため、一般的に使用されていませんが、単一ステートメントに接続を割り当てます。
最も効率の高いトランザクションプーリングモードを考えてみましょう。
-- アプリケーションはPgBouncerに接続し、PgBouncerはクライアントIDを割り当てます BEGIN; SELECT * FROM users WHERE id = 1; UPDATE products SET stock = stock - 1 WHERE id = 10; COMMIT; -- アプリケーションの* PgBouncerへのクライアント接続*が開いたままでも、 -- PgBouncerはデータベース接続をすぐにその内部プールに戻します。 -- 別のアプリケーションリクエスト(または同じアプリクライアントでも、新しいトランザクション) -- 次の実行 BEGIN; INSERT INTO orders (user_id, product_id) VALUES (1, 10); COMMIT; -- PgBouncerは、この短いトランザクションのためにデータベース接続を再度再利用します。
ここでの主な利点は、PgBouncer(またはRDS Proxy)が、アプリケーション接続の数とデータベース接続の数を効果的に分離することです。データベースは、プーラーによって管理される接続のみを認識します。これにより、データベースのオーバーヘッドが大幅に削減され、高負荷時の安定性が向上します。
さらに、専用プーラーは以下を提供します。
- 集中化された接続管理: アプリケーションインスタンスのフリート全体にわたる接続制限の監視と構成が容易になります。
- スロットリングとキューイング: バックエンドデータベースが過負荷になった場合、プーラーは着信接続要求をキューイングし、連鎖的な障害を防ぐことができます。
- 安全なフェイルオーバー/再起動: データベースの再起動またはフェイルオーバーが必要な場合、プーラーはアプリケーション接続を保持し、新しいプライマリへの接続を透過的に再確立でき、アプリケーションのダウンタイムを最小限に抑えます。
- 認証と認可: クライアント認証を処理でき、データベースの負荷を軽減したり、追加のセキュリティレイヤーを提供したりできます。
たとえば、RDS ProxyはRDSインスタンスのフェイルオーバーを自動的に処理します。RDSインスタンスがフェイルオーバーすると、DNSが変更されます。RDS Proxyがない場合、アプリケーション接続は切断され、アプリケーションはそれらを再確立する必要があります。RDS Proxyを使用すると、フェイルオーバーを検出し、古いインスタンスへの接続を安全に閉じ、新しいプライマリへの接続を確立します。これらすべてを行いながら、クライアント接続を開いたままにします。このプロセスは、アプリケーションにとって、はるかに高速で透過的です。
結論
アプリケーションレベルのコネクションプーリングは、効率的なデータベースインタラクションのための基本的なベストプラクティスですが、複数のアプリケーションインスタンスにわたる分散的な性質により、本質的に限界があります。高同時実行環境、トラフィックバースト、または大規模なマイクロサービスフリートの場合、これらの内部プールのみに依存すると、データベースサーバー上のオープン接続が爆発的に増加し、パフォーマンスの低下と不安定性の原因となります。PgBouncerやAWS RDS Proxyのような専用コネクションプーラーは、接続管理を集中化し、接続を多重化し、データベースの回復力とスケーラビリティを大幅に向上させる高度な機能を提供する、重要な仲介者として機能します。要するに、真の高同時実行パフォーマンスのためには、アプリケーションレベルのコネクションプーリングは必要な最初のステップですが、専用コネクションプーラーは不可欠な次の飛躍です。

