λ、μ、そしてキューの深さ
一言でいうと
安定条件はλ < μの1つだけです。流入率が処理率を超えた瞬間、キューの深さは線形ではなく爆発的に増えます。
なぜ必要なのか
キューの深さのグラフを見ると、奇妙な点があります。負荷がゆっくり上がっているのに、キューの深さはしばらく0付近に張り付いたまま、ある地点を越えると、急に垂直に跳ね上がります。これはバグではなく、待ち行列の性質です。
流入率λが処理率μより小さければ、キューはたいてい空です。両者が近づくほど、瞬間的な変動を吸収するために平均の深さが大きくなり、λがμを超えると、キューは際限なく伸びます。利用率ρ = λ/μが0.7のときと0.95のときの待ち時間の差は、2倍ではなく、はるかに大きいです。
そのため、ワーカーの容量を流入のちょうど100%に合わせてはいけません。70–80%の利用率を目標にして、余裕を残すのが実務の慣行です。
どう動くのか
キューの深さを遅延に翻訳する道具が、リトルの法則です。L = λ x W。システム内の平均の項目数は、流入率かける平均の滞留時間です。逆にするとW = L / λです。キューの深さが3,000で、処理率が毎秒50件なら、いま入ってきたメッセージは60秒後に処理されます。この計算ができると、「キューが少し溜まっているな」が「いま受け付けているユーザーは、1分待つ」に変わります。
バックプレッシャーは、この状況でシステムが自分自身を守る方法です。3つの層があります。
1つ目、キューの上限。キューに最大の長さを置き、超過したらプロデューサーに429を返します。無限に受け取っておいてあとでできなくなるより、いまは受けられないと言うほうが誠実です。
2つ目、ロードシェディング(load shedding)。優先度の低いリクエストから捨てます。すべてのリクエストを抱え込んで遅く処理し、全員で一緒に死ぬより、一部を素早く拒否して残りを生かすほうが、全体の可用性に有利です。
3つ目、コンシューマーの並行性の調整。μを上げます。ただし、際限なく上げることはできません。ワーカーを増やすと、その後ろのDBや外部APIが新しいボトルネックになります。ボトルネックを移しただけなのか、なくしたのかを、毎回確認する必要があります。
現場での姿
429を返すときにRetry-Afterヘッダーも一緒に付けるとよいです。察しのよいクライアントは、このシグナルを尊重して、不要な早期リトライを控えます。これを付けないと、クライアントは自分なりのバックオフでリトライし、それが互いに重なります。
そして、キューの深さのアラートは、絶対値よりも傾向で設定するほうがよいです。深さ1,000が正常なシステムもあれば、10が異常なシステムもあります。「5分間増え続けている」のほうが、はるかに信頼できるシグナルです。
バックプレッシャーをどこにかけるのか
キューが伸び始めたとき、選択肢は3つしかなく、何も選ばなければ4つ目が 起きます。メモリを使い切って死にます。
| 方法 | 何を諦めるか | 向いている場所 |
|---|---|---|
| プロデューサーを止める(blocking) | 応答時間 | 内部パイプライン、バッチ |
| 新しいリクエストを拒否する(429) | 一部のリクエスト | 公開API |
| 古いものから捨てる | 古いデータ | メトリクス・ログ・リアルタイムの相場 |
3つ目が、意外によく正解になります。毎秒更新される相場を、5分後に処理することには、 何の価値もありません。古いものに価値がないデータなら、捨てることが正解です。 逆に、決済イベントは絶対に捨ててはいけないので、1行目か2行目を使います。
有界キューが基本
無制限のキューは、問題を遅らせるだけで、なくしません。上限を置けば、問題がキューではなく プロデューサーの側で表に出ます。 そのほうがはるかに早く見え、対応できます。
# ❌ 무제한 — 메모리가 다 찰 때까지 아무 신호가 없다
q = asyncio.Queue()
# ✅ 상한을 두면 put 이 기다리고, 그 대기가 곧 신호다
q = asyncio.Queue(maxsize=1000)
await asyncio.wait_for(q.put(item), timeout=0.5) # 못 넣으면 거절한다
上限値は消化時間で決めます。処理率が毎秒100件で、最大10秒まで 遅延を許容するなら、1,000が上限です。こう決めると、キューの深さがそのまま遅延バジェットに なります。
並行数の制限のほうが正確なとき
キューの深さよりも、進行中の作業数を制限するほうがよい場合が多くあります。特に、 下流が脆弱なときにそうです。
sem = asyncio.Semaphore(20) # 하류에 동시에 20개까지만
async def handle(item):
async with sem:
await downstream.call(item)
キューをどれだけ大きくしても、下流が同時20個しか耐えられないなら、それ以上を送ることは、 下流を崩す行為です。キューはバッファーで、セマフォは防護壁です。2つは 一緒に使います。
何をダッシュボードに載せるのか
큐 깊이 ← 지금 얼마나 밀렸나
소진 시간 = 깊이 ÷ 처리율 ← 이것이 경보 기준
생산율 λ 와 소비율 μ ← 둘의 차이가 추세를 만든다
거절·폐기 건수 ← 역압이 실제로 작동했는가
가장 오래된 항목의 나이 ← 지연의 최대치
最後の行が特に有用です。深さが同じでも、古いものが押されたままになっていないか (先入れ先出しが崩れていないか)を教えてくれます。
次のラボですること
負荷ジェネレーターでキューを埋めながら、深さを時系列で記録し、リトルの法則で待ち時間を推定し、キューの上限と429を付け、ワーカーの並行数を上げて処理率の改善を測ったあと、安定条件をドキュメントにまとめます。