データベースのコネクションプールサイズを待ち行列理論(M/M/c)でモデル化する

コネクションプールサイズの決め方をM/M/c待ち行列モデルとリトルの法則で定式化し、Pythonの自作離散事象シミュレーションで検証。理論値(Erlang C公式)との誤差1%未満の一致、およびロック競合下でスループットが適度なプールサイズでピークを迎える現象を実測しました。

1. コネクションプールサイズという運用上の問題

アプリケーションサーバーとデータベースの間には、たいてい「コネクションプール」が挟まっている。TCP接続の確立やDB側の認証は決して軽い処理ではないため、リクエストのたびに接続を張り直すのではなく、あらかじめ一定数のコネクションを確立しておき、リクエストがそれを使い回す。このプールのサイズ c をいくつにするかは、地味だが実務でよく揉める設定値である。

  • c が小さすぎる場合: 同時に処理できるリクエスト数がプールサイズで頭打ちになる。空きコネクションがなければリクエストは「待ち行列」に並ぶことになり、負荷が上がるにつれて待ち時間が指数関数的に悪化する。
  • c が大きすぎる場合: DBサーバー側のコネクション数が増えすぎると、コンテキストスイッチやロック競合(複数のバックエンドプロセス/スレッドが同じ行やインデックスページを奪い合う)のオーバーヘッドが増え、スループットがかえって落ちることがある。PostgreSQLやMySQLの運用でも「max_connections を闇雲に増やすとむしろ遅くなる」という話はよく聞く。

つまり最適な c は「小さすぎても大きすぎてもダメ」という、どこかに最適点がある問題であり、感覚ではなく数理モデルとシミュレーションで検証するのに向いている。本記事では、この問題を M/M/c待ち行列モデル として定式化し、Pythonで実際にシミュレーションを回して、待ち時間の増大と(条件付きで)スループットのピーク現象を定量的に確認する。

なお、この記事はSQLの基礎を扱ったhttps://yuhi-sa.github.io/posts/20211121_db/1/の延長線上にある内容で、「クエリそのものを速くする」話ではなく「同時にいくつのクエリを捌けるようにプールを設計するか」という、一段上のレイヤーの話になる。

2. M/M/c待ち行列モデルとリトルの法則

待ち行列理論では、次の3つのパラメータでシステムをモデル化する。

  • 到着率 \(\lambda\) : 単位時間あたりにリクエストが到着する平均回数(ポアソン過程を仮定。到着間隔は平均 \(1/\lambda\) の指数分布に従う)。
  • サービス率 \(\mu\) : 1本のコネクションが単位時間あたりに処理できるリクエスト数の平均(サービス時間は平均 \(1/\mu\) の指数分布に従う)。
  • サーバー数 \(c\) : コネクションプールのサイズ。

このとき、1サーバーあたりの平均負荷(利用率)は

\[ \rho = \frac{\lambda}{c\mu} \]

で定義され、システムが安定する(待ち行列が無限に伸びない)ための条件は \(\rho < 1\) 、すなわち \(c > \lambda/\mu\) である。\(\lambda/\mu\) は「オファード負荷(offered load)」と呼ばれ、単位はアーラン(Erlang)である。

平均待ち時間 \(W_q\) (キューで待つ時間。サービス時間そのものは含まない)の解析解は Erlang C公式 として知られている。到着があったときに全サーバーが埋まっていて待たされる確率を \(C(c, a)\) (\(a = \lambda/\mu\) )とすると、

\[ C(c, a) = \frac{\dfrac{a^c}{c!}\cdot\dfrac{1}{1-\rho}}{\displaystyle\sum_{k=0}^{c-1}\frac{a^k}{k!} + \dfrac{a^c}{c!}\cdot\dfrac{1}{1-\rho}} \] \[ W_q = \frac{C(c, a)}{c\mu - \lambda} \]

もう一つ、システムの規模に依存せず常に成り立つ関係として リトルの法則(Little’s Law) がある。

\[ L = \lambda W \]

ここで \(L\) はシステム内の平均客数(待ち行列中+サービス中の合計)、\(W\) はシステム内平均滞在時間(待ち時間 + サービス時間)である。これは分布の形に関係なく成り立つ非常に強力な恒等式で、後述のシミュレーションでも直接検証する。

3. 離散事象シミュレーション(DES)の実装

理論式だけでなく、実際にクライアントがプールに接続を要求し、空きがなければ待たされる様子を Python でシミュレーションする。イベント駆動シミュレーションのライブラリとして simpy があるが、実行環境で確認したところインストールされていなかった(pip show simpy はコマンド自体が見つからず、python3 -c "import simpy" でも ModuleNotFoundError: No module named 'simpy')。そのため標準ライブラリの heapq だけで、イベント(到着・退出)を時刻順に処理する離散事象シミュレーションを自作した。

import heapq
import math
import random
from collections import deque

def simulate_mmc(lam, mu, c, n_arrivals=300_000, warmup=20_000, seed=0, contention=None):
    """M/M/c待ち行列のFIFO離散事象シミュレーション。
    lam: 到着率, mu: 基本サービス率, c: プールサイズ
    contention: busy(使用中コネクション数)に応じてサービス率を変化させる関数(省略可)
    """
    rng = random.Random(seed)
    events = []  # (時刻, 種別, 連番): 種別 0=到着, 1=退出
    seq = 0
    heapq.heappush(events, (rng.expovariate(lam), 0, seq)); seq += 1

    busy = 0
    queue = deque()          # 待機中クライアントの到着時刻
    last_t = 0.0
    n_arrival_seen = 0
    wait_sum = wait_count = 0.0
    busy_area = 0.0           # 使用率計算用の面積
    n_in_system_area = 0.0    # リトルの法則(L)検証用の面積
    n_departures = 0
    window_start = None

    while events and n_arrival_seen < n_arrivals:
        time, etype, _ = heapq.heappop(events)
        dt = time - last_t
        if window_start is not None:
            busy_area += busy * dt
            n_in_system_area += (busy + len(queue)) * dt
        last_t = time

        if etype == 0:  # 到着
            n_arrival_seen += 1
            if n_arrival_seen == warmup:
                window_start = time
            if n_arrival_seen < n_arrivals:
                heapq.heappush(events, (time + rng.expovariate(lam), 0, seq)); seq += 1
            if busy < c:
                busy += 1
                mu_eff = mu if contention is None else contention(mu, busy)
                heapq.heappush(events, (time + rng.expovariate(mu_eff), 1, seq)); seq += 1
                if window_start is not None and n_arrival_seen > warmup:
                    wait_count += 1  # 待ち時間 0
            else:
                queue.append(time)  # 空きが出るまで待機

        else:  # 退出
            busy -= 1
            n_departures += 1
            if queue:
                arr_time = queue.popleft()
                busy += 1
                mu_eff = mu if contention is None else contention(mu, busy)
                heapq.heappush(events, (time + rng.expovariate(mu_eff), 1, seq)); seq += 1
                if window_start is not None and arr_time >= window_start:
                    wait_sum += time - arr_time
                    wait_count += 1

    duration = last_t - window_start
    return {
        "avg_wait": (wait_sum / wait_count) if wait_count else None,
        "utilization": busy_area / (c * duration),
        "throughput": n_departures / duration,
        "avg_n_in_system": n_in_system_area / duration,
    }

到着時にプールに空きがあれば即座にサービス開始(待ち時間0)、空きがなければ queue に積んでおき、どこかのコネクションが空いたタイミング(退出イベント)でFIFOに取り出してサービスを開始する。これは1本の待ち行列がc台の同一サーバーで捌かれるモデル(M/M/c)そのものの挙動になっている。ウォームアップ期間(先頭2万件)は過渡状態として統計から除外し、定常状態のみを集計している。

4. プールサイズ vs 平均待ち時間の実測

まずコンテンション(競合)なしのベースケースで、\(\lambda = 200\) req/s、\(\mu = 50\) req/s(平均サービス時間20ms、オファード負荷 \(a = \lambda/\mu = 4\) アーラン)としてプールサイズ c を 5〜30 まで振ってみる。安定条件は \(c > 4\) なので、c=5 が理論的な最小構成に近い。

lam, mu = 200.0, 50.0
for c in [5, 6, 7, 8, 9, 10, 12, 15, 20, 30]:
    r = simulate_mmc(lam, mu, c, n_arrivals=300_000, warmup=20_000, seed=42)
    print(f"c={c:3d}  rho={lam/(c*mu):.3f}  avg_wait={r['avg_wait']*1000:.3f} ms  util={r['utilization']:.3f}")

実測結果は次の通り。

プールサイズ c\(\rho = a/c\)平均待ち時間利用率
50.80011.225 ms0.801
60.6672.918 ms0.667
70.5710.929 ms0.572
80.5000.320 ms0.500
90.4440.099 ms0.445
100.4000.030 ms0.400
120.3330.003 ms0.333
150.267ほぼ0(測定分解能未満)0.267
200.200ほぼ00.200
300.133ほぼ00.133

M/M/cシミュレーション結果。プールサイズcを5〜30まで振ったときの平均待ち時間(ログスケール)。オファード負荷λ/μ=4に近いc=5では約11ms、cが増えるほど待ち時間は指数的に減少し、c=15以降はほぼゼロになる

c=5(\(\rho=0.8\) )では平均待ち時間が11ms程度あるのに対し、c=6(\(\rho=0.667\) )で2.9ms、c=8(\(\rho=0.5\) )で0.32msと、プールをわずかに増やすだけで待ち時間が桁単位で改善している。これは待ち行列理論でよく知られた「\(\rho \to 1\) に近づくほど待ち時間が発散する」現象そのもので、コネクションプールが逼迫している状況でプールサイズを少し増やすだけで劇的にレイテンシが改善する理由を裏付けている。逆に言えば、\(\rho\) が0.3を切るあたりまでプールを大きくしても待ち時間の改善効果はほぼ頭打ちになる。

5. Erlang C公式・リトルの法則との照合

シミュレーションが正しく実装できているかを検証するため、c=6(\(\rho = 0.667\) )のケースについて、解析的なErlang C公式と比較する。

def erlang_c_wait(lam, mu, c):
    a = lam / mu
    rho = a / c
    sum_terms = sum((a ** k) / math.factorial(k) for k in range(c))
    last_term = (a ** c) / (math.factorial(c) * (1 - rho))
    p_wait = last_term / (sum_terms + last_term)
    wq = p_wait / (c * mu - lam)
    return wq, p_wait

wq_analytic, p_wait = erlang_c_wait(200.0, 50.0, 6)
sim = simulate_mmc(200.0, 50.0, 6, n_arrivals=1_000_000, warmup=50_000, seed=7)

サンプル数を100万件に増やして精度を上げた結果は以下の通り。

lambda=200.0, mu=50.0, c=6, rho=0.6667
Analytic Erlang-C  Wq = 2.8476 ms   P(wait) = 0.2848
Simulated          Wq = 2.8731 ms
Relative error = 0.895 %

理論値2.8476msに対しシミュレーション値2.8731msと、相対誤差0.895%で一致した。乱数を使ったモンテカルロ的シミュレーションでこの精度が出ているのは、実装が理論通りに動いていることの強い裏付けになる。

同じシミュレーション結果を使って、リトルの法則 \(L = \lambda W\) (\(W\) = 待ち時間 + 平均サービス時間)も検証できる。

W (measured wait + mean service) = 22.8731 ms
L predicted = lambda * W          = 4.5746 customers
L measured  (time-avg in system)  = 4.5799 customers
Relative error = 0.114 %

システム内の平均客数の予測値4.5746に対し、時間平均で直接測定した値は4.5799と、相対誤差0.114%で一致している。異なる2つの検算(Erlang C公式とリトルの法則)がどちらも1%未満の誤差で理論と整合したことで、このDES実装が正しくM/M/cの挙動を再現できていると確認できた。

6. 「プールは多いほど良い」わけではない: ロック競合モデル

ここまでは1本のコネクションの処理能力(サービス率 \(\mu\) )が常に一定という前提だった。しかし実際のDBサーバーでは、同時に処理中のコネクション数が増えると、ロック待ち・コンテキストスイッチ・キャッシュラインの奪い合いなどのオーバーヘッドで1本あたりの実効サービス率が低下することがある。これを簡単なモデルで表現してみる。

同時使用中のコネクション数を busy として、ある閾値 \(k_0\) を超えた分だけ実効サービス率を指数的に減衰させる。

\[ \mu_{\text{eff}}(\text{busy}) = \mu_0 \cdot \exp\bigl(-\alpha \cdot \max(0, \text{busy} - k_0)\bigr) \]
def contention(mu0, busy, k0=8, alpha=0.15):
    excess = max(0, busy - k0)
    return mu0 * math.exp(-alpha * excess)

\(k_0=8\) 本までは競合なし(フル速度)、それを超えた分だけ1本増えるごとにサービス率がおよそ14%ずつ落ちていく設定である(\(e^{-0.15} \approx 0.86\) )。この効果を観測するには、プールを恒常的に埋め尽くすだけの負荷が必要なので、あえて \(\lambda = 600\) req/s という、どのプールサイズでも捌ききれない過負荷(オファード負荷 \(a=12\) アーランに対し c を最大60まで振っても常に \(\rho \approx 1\) で飽和)を与え、達成可能なスループットの天井だけを比較する。

lam_heavy, mu0 = 600.0, 50.0
for c in [4, 6, 8, 10, 12, 15, 18, 20, 25, 30, 40, 50, 60]:
    r = simulate_mmc(lam_heavy, mu0, c, n_arrivals=300_000, warmup=20_000, seed=123, contention=contention)
    print(f"c={c:3d}  util={r['utilization']:.3f}  throughput={r['throughput']:.2f} req/s")

実測結果:

プールサイズ c実測スループット平均待ち時間
4213.92 req/s199.9 秒
6321.48 req/s140.6 秒
8428.24 req/s92.1 秒(ピーク)
10396.58 req/s105.9 秒
12352.51 req/s125.5 秒
15281.17 req/s162.1 秒
18215.43 req/s200.0 秒
20177.15 req/s224.8 秒
25104.34 req/s295.2 秒
3059.40 req/s390.6 秒
4018.03 req/s測定不能※
505.22 req/s測定不能※
601.58 req/s測定不能※

※ 過負荷とサービス劣化が同時に進行し、シミュレーション期間内に処理が完了するリクエストがほぼ無くなるため、平均待ち時間として意味のある値が測定できなかった(=事実上無限大に発散している状態)。

競合モデルのシミュレーション結果。プールサイズを4〜60まで振ったときの実測スループット。c=8で約430req/sのピークを迎え、それ以降はプールを増やすほどスループットが単調に低下し、c=60では1.58req/sまで落ち込む

c=8 で約430 req/sのピークを迎えたあと、プールサイズを増やすほどスループットは単調に悪化し、c=60 では1.58 req/sまで落ち込んだ。これは「同時に処理中のコネクションが増えるほど1本あたりの処理速度が落ちる」という設定のもとでは、闇雲にプールを大きくすることが逆効果になり得ることを、実際に動かして確認できたことを意味する。すべてのケースで利用率(util)はほぼ1.000、つまり常に飽和状態であることも確認できており、これは「プールが余っているのに詰まっている」のではなく「サービス能力そのものが競合で落ちている」ことを裏付けている。

もちろんこの \(\alpha=0.15\) 、\(k_0=8\) という数値自体はモデルのパラメータであり、実際のDBサーバーでどの値になるかはハードウェアやDBMSの実装に依存する。ただし「同時実行数の増加がサービス能力に対して非線形な劣化を及ぼしうる」という定性的な構造自体は、実務でよく言われる「max_connections を無闇に増やすと逆に遅くなる」という経験則と整合的であり、M/M/cモデルを拡張するだけでこの現象を定量的に再現できることは、待ち行列理論がDB運用の意思決定に実際に使えるツールであることを示している。

7. 実務への示唆

以上の実測を踏まえると、コネクションプールサイズの決め方について次のような指針が導ける。

  1. まず実測する: 本番相当の負荷での平均リクエスト到着率 \(\lambda\) と、DB側の平均クエリ実行時間(\(1/\mu\) )を計測する。多くのDBドライバやAPMツールがこれらのメトリクスを提供している。
  2. オファード負荷 \(a=\lambda/\mu\) を計算し、\(\rho\) を0.7〜0.8程度に収める c を選ぶ: 第4節の結果が示す通り、\(\rho\) をこの範囲に抑えれば待ち時間は実用上問題ないレベルまで小さくなる。\(\rho\) を極端に下げようとプールを闇雲に増やしても、待ち時間の改善効果は逓減する一方で、DB側のリソース消費は増え続ける。
  3. プールを増やす前にDB側の余力を確認する: 第6節で見たように、DBサーバー側の処理能力に余裕がない状態でプールだけを増やしても、競合によってスループットがかえって落ちるリスクがある。コネクション数を増やす施策は、DBサーバーのCPU使用率・ロック待ち・コンテキストスイッチ数などのメトリクスとセットで評価すべきである。
  4. 待ち時間そのものを監視する: プールの待ち行列で詰まっているのか、DB側のクエリ自体が遅いのかを切り分けるために、アプリケーション側でコネクション取得までの待ち時間を計測しておくと、c を増やすべきか、クエリやインデックスを見直すべきかの判断材料になる(インデックスの効果自体はhttps://yuhi-sa.github.io/posts/20211121_db/1/で実測している)。

「プールサイズは大きければ大きいほど安全」という直感は、少なくとも競合が発生しうる環境では成り立たない。M/M/c待ち行列モデルとリトルの法則という比較的シンプルな道具立てだけで、この非自明なトレードオフを定量的に扱えることが、今回のシミュレーションから確認できた。

関連記事

  • SQL基本構文:データベース操作の標準言語 - SQLのDML/DDL/DCLをPythonのsqlite3で実演し、CREATE INDEX の効果を100万行規模で実測しています。本記事のコネクションプールの話は、その先にある「同時に何本のクエリを捌けるようにするか」という運用設計の話にあたります。