これはなに?
Pythonの公式Redisクライアントredis-pyには、コネクションプールを作成・管理する機能があります。
これについての日本語記事を見ると、「redis.Redisの作成時にコネクションプールオブジェクトを注入すると、動作が早くなる」といった記述が散見されます。
import redis
connection_pool = redis.ConnectionPool()
# こうすると早いという記述が、けっこう散見される
client = redis.Redis(connection_pool=connection_pool, port=...)
これはある意味では正解ですが、取り方によっては大きな誤解を招き、間違った運用を誘発しかねない曖昧な記述です。
この記事は、redis-pyに限らないコネクションプールとはなんぞやという話と、redis-pyにおけるコネクションプールの実装を紐解き、検証を交えた解説を以って、正しく実装してもらうための指針を得てもらうことを目指したものです。
想定読者
- Pythonで
redis-pyを使ったことがある方 - コネクションプールという言葉は聞いたことがあるが、仕組みをちゃんと理解したい方
- Redisクライアントの設計で迷っている方
コネクションプールとはなんぞや
んなこたぁ知ってるよという方は次の「Redis-Pyでは誰がコネクションを管理するのか?」までスキップしていただいて構いません。
コネクションプールとは、「一度確立したTCP接続を保持・再利用することで、接続確立のオーバーヘッドを減らす仕組み」のことです。
コネクションは接続、プールは保持。言葉のままですね。
WebアプリケーションはRedisやDB、外部APIなど多くの外部リソースとTCP/IPで通信します。
TCP/IPでは、本番の通信を始める前にハンドシェイクという接続確立の手順が必要です。
電話でイメージするとわかりやすいと思います。
一言伝えるたびに電話を切り、毎回「もしもし」からやり直しているようなものです。明らかに非効率ですよね。
特に、外部サーバーとの通信はコンピューターの仕事の中でも非常に遅い部類のものです。毎回ハンドシェイクを挟めば、処理時間が数倍に膨れ上がります。さらに、OSレベルでソケットの作成・破棄にもオーバーヘッドが発生します。
コネクションプーリングは、この「電話を切らずに受話器を開きっぱなしにしておく」仕組みです。
空いている受話器(コネクション)があればそれを使い、なければ新しく電話をかける。これがコネクションプールの役割です。
Redis-Pyでは誰がコネクションを管理するのか?
redis.ConnectionPoolインスタンスがコネクションを管理します。
リクエスト時に空きコネクションがなければ新規作成し、クライアントに貸し出し、リクエストが終われば回収して再利用可能な状態で保持します。
では、redis.Redisにコネクションプールを渡すときと渡さないときで何が違うのでしょうか?
実は、どちらの場合もConnectionPoolがコネクションを管理しています。違うのはライフサイクルとスコープです。
パターン1: コネクションプールを渡さない場合
検証コードの事前準備 (クリックで展開)
RedisHost = "localhost"
RedisPort = 6379
RedisDB = 0
def get_connected_clients() -> int:
"""redis-cliを使用して接続中クライアント数を取得する"""
result = subprocess.run(
[
f"redis-cli -h {RedisHost} -p {RedisPort} -n {RedisDB} INFO clients | grep connected_clients"
],
shell=True,
capture_output=True,
text=True,
)
connections = result.stdout.strip().split(":")[1]
return int(connections) - 1 # redis-cli分を引く
client = redis.Redis(host=RedisHost, port=RedisPort, db=RedisDB)
connection_poolが渡されなかったとき、redis.Redisは内部で自動的にConnectionPoolインスタンスを生成します。これは他のどのクライアントとも共有されず、clientをcloseすればこの内部プールもcloseされます。
client_1 = redis.Redis(host=RedisHost, port=RedisPort, db=RedisDB)
client_2 = redis.Redis(host=RedisHost, port=RedisPort, db=RedisDB)
# client_1とclient_2のconnection_poolは同じか?
print(id(client_1.connection_pool) == id(client_2.connection_pool))
# => False
print(get_connected_clients())
# => 0
client_1.ping()
print(get_connected_clients())
# => 1
for _ in range(100):
client_1.ping()
print(get_connected_clients())
# => 1 同じクライアント内ではコネクションが再利用されるので増えない
client_2.ping()
print(get_connected_clients())
# => 2 別のクライアントなので、別のコネクションが作られる
client_1.close()
print(get_connected_clients())
# => 1 client_1の内部プールごとコネクションが閉じられる
client_2.close()
print(get_connected_clients())
# => 0
同じクライアントを使い回す分にはコネクションプールの恩恵を得られます。しかし、あちこちで別々のクライアントを生成していると、コネクションが共有されずパフォーマンスが落ちます。
パターン2: コネクションプールを渡す場合
CONNECTION_POOL = redis.ConnectionPool(host=RedisHost, port=RedisPort, db=RedisDB)
client = redis.Redis(connection_pool=CONNECTION_POOL)
client内部ではプールが作成されず、渡されたものが使われます。複数のクライアントで共有可能です。
また、clientをcloseしてもコネクションプールのコネクションは閉じられません。
CONNECTION_POOL = redis.ConnectionPool(host=RedisHost, port=RedisPort, db=RedisDB)
client_1 = redis.Redis(connection_pool=CONNECTION_POOL)
client_2 = redis.Redis(connection_pool=CONNECTION_POOL)
print(id(client_1.connection_pool) == id(client_2.connection_pool))
# => True コネクションプールはクライアント間で共通
client_1.ping()
print(get_connected_clients())
# => 1
for _ in range(100):
client_1.ping()
print(get_connected_clients())
# => 1 コネクションが再利用される
client_2.ping()
print(get_connected_clients())
# => 1 同じプールなので、client_2を使ってもコネクションが再利用される
client_1.close()
print(get_connected_clients())
# => 1 クライアントはプールの所有権を持たないので、コネクションは閉じられない
client_2.close()
print(get_connected_clients())
# => 1 同上
CONNECTION_POOL.disconnect()
print(get_connected_clients())
# => 0 プール自体をdisconnectして初めてコネクションが閉じられる
まとめ
| ケース | コネクションの共有スコープ | ConnectionPoolのライフサイクル |
|---|---|---|
| clientにconnection_poolを渡さない場合 | 同じclientを使う通信 | clientと同期(clientが閉じるとconnectionも閉じる) |
| clientにconnection_poolを渡す場合 | 同じconnection_poolインスタンスを受け取ったclientを使う通信 | clientと独立(clientが閉じてもconnectionはcloseされない) |
ちなみに、ConnectionPoolはredis.Redisと同じkwargsを受け取れるので、接続設定のシングルトンとしても機能します。
ベンチマーク: コネクションプールの恩恵はどれくらい?
検証する要素は2つです。
- コネクション再利用の有無によるオーバーヘッド
- クライアントインスタンス生成のオーバーヘッド
以下の4パターンで比較します。
検証コード(クリックで展開)
import time
import redis
RedisHost = "localhost"
RedisPort = 6379
RedisDB = 0
CONNECTION_POOL = redis.ConnectionPool(host=RedisHost, port=RedisPort, db=RedisDB)
# 1. クライアント使い回し × プール注入なし
start_time = time.time()
single_client_without_pool = redis.Redis(host=RedisHost, port=RedisPort, db=RedisDB)
for i in range(5000):
single_client_without_pool.set(f"key:{i}", "value")
single_client_without_pool.delete(f"key:{i}")
duration_1 = time.time() - start_time
# 2. クライアント使い回し × プール注入あり
start_time = time.time()
single_client_with_pool = redis.Redis(connection_pool=CONNECTION_POOL)
for i in range(5000):
single_client_with_pool.set(f"key:{i}", "value")
single_client_with_pool.delete(f"key:{i}")
duration_2 = time.time() - start_time
CONNECTION_POOL.disconnect()
# 3. 毎回新規作成 × プール注入なし
start_time = time.time()
for i in range(5000):
client = redis.Redis(host=RedisHost, port=RedisPort, db=RedisDB)
client.set(f"key:{i}", "value")
client.delete(f"key:{i}")
duration_3 = time.time() - start_time
# 4. 毎回新規作成 × プール注入あり
start_time = time.time()
for i in range(5000):
client = redis.Redis(connection_pool=CONNECTION_POOL)
client.set(f"key:{i}", "value")
client.delete(f"key:{i}")
duration_4 = time.time() - start_time
結果
| # | パターン | 所要時間 | 備考 |
|---|---|---|---|
| 1 | クライアント使い回し × プール注入なし | 0.8265秒 | 内部プールでコネクション再利用 |
| 2 | クライアント使い回し × プール注入あり | 0.8217秒 | 1とほぼ同じ |
| 3 | 毎回新規作成 × プール注入なし | 3.2154秒 | 毎回ハンドシェイク発生 |
| 4 | 毎回新規作成 × プール注入あり | 1.0371秒 | コネクション再利用で大幅改善 |
1 vs 2: クライアントを使い回す場合、プール注入の有無はほぼ関係ありません。どちらも内部的にConnectionPoolがコネクションを再利用しているためです。
3 vs 4: 毎回クライアントを新規作成する場合、差は歴然です。プール注入なし(3)は毎回ハンドシェイクが発生するため約3.2秒。プール注入あり(4)はコネクションが再利用されるため約1.0秒。4が2より遅いのはインスタンス生成のオーバーヘッド分です。
ローカル環境でこの差ですから、ネットワークレイテンシの大きい本番環境ではさらに顕著になります。
まとめ
冒頭の「ConnectionPoolを注入すると速くなる」をより正確に言い直すとこうなります。
プロセス内で複数のクライアントインスタンスを使う場合、共有されたConnectionPoolを注入することで、クライアント間でコネクションが再利用され、TCPハンドシェイクのオーバーヘッドを回避できる。
逆に言えば、単一のクライアントを使い回すだけならConnectionPoolの明示的な注入は不要です。
おすすめの運用パターン
プロダクトでredis-pyを扱う場合、グローバルスコープのredis.ConnectionPoolを設定オブジェクト兼コネクション管理として使い、シングルトンやファクトリに組み込むのが良いでしょう。
import os
import redis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
GLOBAL_CONNECTION_POOL = redis.ConnectionPool.from_url(
url=REDIS_URL,
max_connections=30,
decode_responses=True,
)
# シングルトンインスタンス
singleton_redis_client = redis.Redis(connection_pool=GLOBAL_CONNECTION_POOL)
# もしくはプールを共有したファクトリパターン
def redis_client_factory() -> redis.Redis:
return redis.Redis(connection_pool=GLOBAL_CONNECTION_POOL)
注意点
ConnectionPoolの所有権について
redis.Redisの初期化にはもう一つfrom_poolというファクトリがあります。
client = redis.Redis.from_pool(connection_pool=GLOBAL_CONNECTION_POOL)
一見redis.Redis(connection_pool=...)と同じに見えますが、コネクションプールの所有権が異なります。
from_poolで作られたクライアントはプールの所有権を持つため、クライアントがGCで回収されたりcloseされたりすると、渡したConnectionPoolごと閉じてしまいます。グローバルプールと組み合わせると事故の元なので、お気をつけください。
ライブラリごとに共有すべき層が異なる
ここまでの話はredis-pyに限ったパターンです。redis-pyのクライアントはステートレスな設計なので、ConnectionPoolのみの共有で問題ありません。
一方たとえばhttpxの場合、コネクションプールはクライアント自体が保持します。コネクション再利用の恩恵を得るには、クライアントインスタンスそのものを共有する必要があります。
どのインスタンスがどの層を抽象化し、どんな状態を持っているのか。ライブラリごとに設計は異なるので、常に意識しておきましょう。
マルチプロセス環境での注意
コネクションプールが保持するコネクションとは、OSレベルではソケットに紐づくファイルディスクリプタを参照します。これはシステム内でコネクションと1:1であるべきものです。
マルチスレッド環境ではメモリ空間が共有されるため、グローバルなプール共有で問題ありません。しかし、マルチプロセス環境では注意が必要です。Linuxにおけるデフォルトのマルチスレッド戦略であるforkでは、既存プロセスのメモリ空間がまるごとコピーされるため、1:1であるべきファイルディスクリプタへの参照が複数のプロセスに複製されてしまいます。
redis-pyはこの問題をライブラリ側で解決しています。コネクションにプロセス番号を記録しておき、使用時にプロセスの変更を検知したらコネクションを破棄・再作成するという設計です。
ただし、他のライブラリが同様にプロセスセーフであるとは限りません。グローバル共有パターンをマルチプロセス環境で使う場合は、必ずそのライブラリのプロセスセーフ性を確認しましょう。
以上!