バックエンド

ドレインが追いつかないほど速く満杯になるワーカーキューのためのバックプレッシャー

プロデューサーの生成速度がワーカーの処理速度を上回ると、無制限のキューは負荷を吸収するのではなく、クラッシュを先延ばしにするだけです。バックプレッシャーがシステムを稼働させ続ける仕組みは次のとおりです。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

生産者とワーカーの間のキューは、衝撃吸収材のようなものです。仕事はバースト的に到着し、キューがそれを保持し、ワーカーは自身のペースでそれを処理します。その直感は、平均到着率が平均処理率を下回っている間だけ成り立ちます。生産者がワーカーを十分に長い時間上回った瞬間、無制限のキューは何も吸収しなくなります。それは無制限に増大し、メモリやキューストアが枯渇し、システム全体が一度に停止します。解決策は、キューを大きくすることではありません。それはバックプレッシャーです。つまり、クラッシュするまで過負荷を隠すのではなく、生産者に押し戻す有界キューです。この記事では、なぜ無制限のキューが失敗するのか、Goにおけるバックプレッシャーがどのようなものか、そして「ノー」と言うことを学ぶことで生じるトレードオフについて説明します。

なぜ無制限キューはクラッシュを吸収するのではなく、先延ばしにするのか

キューを、水が注ぎ込まれ、排出されるバケツだと考えてみてください。平均して排出が流入と同じかそれ以上に速い場合、水位は一定の範囲に収まり、突発的な流入も平滑化されます。平均して流入が少しでも速い場合、水位は永遠に上昇し続けます。永続的な不均衡を解消できるバケツのサイズは存在せず、あふれるまでの待ち時間を変えるサイズがあるだけです。

無制限キューはあふれる点をなくします。これは機能のように聞こえますが、実際には障害の原因です。不均衡は依然として存在するため、バックログは利用可能なすべてのメモリを消費するか、キューストアがハードリミットに達するまで増え続けます。その前に2つのことが破綻しますが、どちらもクリーンなリジェクトよりも悪い状況です。

1つ目はレイテンシーです。先行するアイテムが100万個あるキューに入ったジョブは、100万個のアイテムが処理されるのを待ちます。ワーカーがそのジョブにたどり着く頃には、それを作成したリクエストはタイムアウトし、ユーザーは離脱し、結果は古くなっていることがよくあります。今や、あなたは乏しいワーカーのキャパシティを、誰も待っていない答えを計算するために費やしており、これがさらに排出レートを低下させ、バックログを深刻化させます。これは、それ自体で悪化していくフィードバックループです。

2つ目は連鎖的な障害です。キューストアが最終的にメモリを使い果たすと、それは丁寧には失敗しません。それは自身をホストするプロセスをダウンさせ、エンキューしようとするすべてのプロデューサーをブロックし、同じマシンや同じコネクションプールを共有していた無関係のサービスまで停止させる可能性があります。1つの遅いワーカーが、システム全体の停止に発展します。無制限キューは崩壊を防いだのではなく、それを遅らせ、より大きなものにしたのです。

バックプレッシャーとは、押し戻す機能を持つ有界キューである

バックプレッシャーとは、下流のステージが追いつけない場合に、圧力が中間で見えない形で積み上がるのではなく、上流のプロデューサーに伝わるという特性です。それを構築する最も簡単な方法は、固定の容量を持つキューを使用することです。キューがいっぱいになると、プロデューサーはエンキューできなくなり、その場で決定を下さなければなりません。その決定こそが、まさに重要な点です。そこは、負荷によってシステムが機能不全に陥るのではなく、システムが意図的に負荷を削減する方法を選択するポイントなのです。

Goでは、プリミティブはすでにバックプレッシャーを認識するようになっています。バッファ付きチャネルは有界キューであり、いっぱいになったチャネルへの送信は、ワーカーが空きを作るまで送信者をブロックします。そのブロックこそが、最も直接的な形のバックプレッシャーです。

// A buffered channel of capacity N is a bounded queue. When it holds N jobs,
// the next send blocks until a worker receives one. The block is the
// backpressure: the producer cannot outrun the workers.
jobs := make(chan Job, 256)

// Producer. This send parks the goroutine if the buffer is full, so the
// producer's own rate is capped by how fast workers drain.
jobs <- job

ブロッキング送信は、プロデューサーが速度を落とす余裕がある場合に正しい動作です。例えば、向こう側で待っているユーザーがいない内部バッチローダーなどです。ワーカーに合わせてプロデューサーの速度を落とすことはまさに望ましいことです。なぜなら、2つのレートが連動するようになり、バックログがバッファを超えて増大することがなくなるからです。

しかし、ユーザーや、締め切りがあるアップストリームの呼び出し元がいる場合、ブロッキング送信は間違った動作です。ワーカーが遅れているという理由でHTTPハンドラーを無期限にブロックさせることは、キューでの無制限の待機をコネクションプールに移すだけであり、メモリの代わりにコネクションを使い果たしてしまいます。誰かが待っている場合、ブロックするのではなく、素早く拒否することが望ましいです。そうすれば、呼び出し元はシステムが飽和状態であることをすぐに知り、バックオフやフェイルオーバーができます。

// Non-blocking enqueue for request paths. If the buffer is full, do not wait.
// Reject now so the caller gets a fast, honest "try later" instead of a hang.
select {
case jobs <- job:
    // accepted
default:
    return errQueueFull // surface as HTTP 429 or 503 with Retry-After
}

プロデューサーをブロックするかジョブを拒否するかという2つの選択肢は、バックプレッシャーの2つの側面です。ブロックはレートを連動させます。拒否は過剰分を破棄します。プロデューサーごとにどちらを選択するかは、何かが待機しているかどうかによって決まり、実際のシステムでは通常、異なる場所で両方を使用します。

ワーカーがダウンストリームに過大な負荷をかけないように、コンカレンシーに上限を設ける

キューに上限を設けることで、プロデューサーから自身を保護します。ワーカーにも上限を設ける必要があります。なぜなら、ワーカーは自身の配下にあるもの、通常はデータベース、GPU、または別のサービスにとってのプロデューサーだからです。よくある間違いは、ジョブごとに goroutine を起動することです。この方法はきれいに見え、キューを完全に取り除きますが、それは単に無限の増加を別の場所に移すだけです。1万件の受信ジョブが1万の goroutine となり、それらすべてが一度にデータベースに過大な負荷をかけ、結果としてデータベースがダウンします。

固定ワーカープールがその上限となります。既知の数のワーカーを起動し、それぞれが有界キューからプルします。そして、その数が、配下のリソースに到達する最大のコンカレンシーとなります。プールのサイズは、どれだけのトラフィックが到着したかという偶然の結果ではなく、ダウンストリームのキャパシティから意図的に設定した制限となります。

// Fixed worker pool. poolSize is the hard ceiling on concurrent work hitting
// the downstream resource. It does not grow with load, which is the point.
func startPool(poolSize int, jobs <-chan Job) {
    var wg sync.WaitGroup
    for i := 0; i < poolSize; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                process(job) // the only place downstream load is generated
            }
        }()
    }
    wg.Wait()
}

これで、2つの境界が連携して機能します。バッファ付きチャネルは待機可能な作業量に上限を設け、プールは実行可能な作業量に上限を設けます。ダウンストリームへの総負荷は、最大でpoolSizeの並行オペレーションに、最大でバッファ容量分のキューを加えたものとなり、トラフィックのスパイクが発生してもどちらの数値も変動しません。スパイクはエンキューパスに到達し、そこで拒否されるか速度が低下させられ、洪水のようにデータベースに到達することはありません。プールサイズは、寛大に感じられるような切りの良い数字からではなく、ダウンストリームのキャパシティ、例えばデータベースのコネクションプールの上限や所有しているGPUの数などから選択してください。

古すぎて意味がなくなった作業は破棄する

キューとプールに上限を設けることでシステムは維持されますが、持続的な過負荷状態では、受け入れられたジョブであってもワーカーが処理する頃には古くなっている可能性があります。リクエストの予算が5秒で、ジョブが8秒間待機していた場合、それを実行するのは純粋な無駄です。呼び出し元がすでにあきらめた結果を生成するためにワーカーの時間を費やすことになり、それはまだチャンスのある新しいジョブからキャパシティを奪うことになります。

各ジョブにデッドラインを設けることで、この問題は解決します。ジョブに完了すべき時刻を刻印し、ワーカーには負荷の高い部分を開始する前にそのデッドラインを確認させます。デッドラインを過ぎていたら、ジョブを破棄して次に進みます。Goでは、慣用的な伝達手段はデッドライン付きのcontextであり、これによってすでに実行中の作業をキャンセルすることもできます。

type Job struct {
    Payload  []byte
    Deadline time.Time
}

func process(job Job) {
    // Skip work that can no longer be useful. Under overload this is what
    // keeps workers spending their time on jobs someone still wants.
    if time.Now().After(job.Deadline) {
        metrics.Expired.Inc()
        return
    }
    ctx, cancel := context.WithDeadline(context.Background(), job.Deadline)
    defer cancel()
    doExpensiveWork(ctx, job.Payload)
}

古くなった作業の破棄は、量ではなく時間を対象としたバックプレッシャーの一形態です。それは、一部のジョブがすでに失われていることを認め、それらが排出される際にリソースを消費することを拒否します。スパイク時には、これにより、飽和状態のシステムは死体のバックログを延々と処理する代わりに、まだ生きているリクエストを処理し続けることができます。

無制限キューとバックプレッシャーの並列比較

2つの設計の違いは、問題となるケース、つまり持続的な過負荷状態において最も明確に現れます。この状態では、到着率が意味のある期間にわたって排出率を上回り続けます。

持続的な過負荷状態での振る舞い 無制限キュー バックプレッシャー付き有界キュー
キューの深さ 無制限に増加 バッファサイズが上限
メモリまたはキューストア 枯渇するまで満たされる 一定に保たれる
受け付けられたジョブのレイテンシ 際限なく上昇 バッファとサービス時間の合計が上限
プロデューサーが知ること クラッシュするまで何もない ブロックまたは拒否によって即座に
障害の形態 一度に全体が崩壊 部分的、一部のジョブが拒否される
ダウンストリーム(データベース、GPU) goroutineのスポーンによりフラッディングが発生 プールサイズが上限
スパイク後の回復 まず巨大なバックログを処理する必要がある すでに定常状態に近い

無制限キューの列は、より多くの負荷を処理するシステムではありません。同じ負荷を処理し、障害が壊滅的になるまでそれを隠蔽するシステムです。有界キューの列は、より早く、より小さく、そして明確に失敗します。これは、障害に求めるべき特性です。

問題を増幅させない優先順位、分離、そして再試行

さらに2つの要素が、バックプレッシャーを単なる鈍いものではなく、実用的なものにします。第一に、すべての作業が同等というわけではありません。もしヘルスチェックと一括インポートが1つのキューを共有している場合、インポートが殺到するとヘルスチェックが枯渇し、まさに必要なときに監視が機能しなくなります。個別のプールを持つ個別のキューは、重要な作業を分離し、1つのレーンでの過負荷が他のレーンを圧倒するのを防ぎます。重要なジョブのための小さな専用プールは、一括処理レーンがすべてを拒否している間も、それらのジョブが流れ続けるようにします。

第二に、再試行です。拒否されたジョブを再試行するのは合理的ですが、単純な再試行ループは、バックプレッシャーが再試行の嵐に変わる原因となります。システムが飽和状態のためにジョブを拒否した場合、即座に再試行すると、すでに飽和しているシステムに負荷を追加することになります。そして、すべてのクライアントが同じことを行うと、元のスパイクが過ぎ去った後も、再試行だけでシステムがダウンし続ける可能性があります。再試行には、試行ごとに増加するバックオフと、試行回数の厳格な上限が必要です。これにより、拒否は即座の2回目のヒットではなく、より遅い次の試行につながります。バックプレッシャーとバックオフは、両端で適用される同じ考え方です。サーバーは拒否することで押し返し、クライアントはより強く叩くのではなく、速度を落とすことでその押し返しを尊重します。

可観測性が全体をまとめ上げます。キューの深さと待機時間は、早期警告となります。満杯に向かっているキュー、またはデッドラインに近づいている待機時間は、何かが拒否されるずっと前に不均衡が始まっていることを示します。これら2つの数値でアラートを設定すれば、障害によって知るのではなく、自身のスケジュールでワーカーを追加したり、プロデューサーの速度を落としたりすることができます。

トレードオフ:部分的な障害は稼働し続けるための代償

バックプレッシャーは無料ではなく、そのコストを明確にすることが誠実です。有界キューはジョブを拒否します。上限付きのプールは、より少ない同時リクエストを処理します。デッドラインは、生成に多大な労力を要した作業を破棄します。これらはすべて部分的な障害であり、誰かがそれを「通らなかったリクエスト」として経験します。何も拒否しないことを成功と見なすなら、バックプレッシャーはリグレッション(後退)のように見えます。

重要な比較対象は、バックプレッシャーと、すべてを受け入れる完璧なシステムとの比較ではありません。プロデューサーがワーカーを追い越せるようになると、そのようなシステムは存在し得ません。比較すべきは、部分的な障害と全体的な障害です。無制限キューは、どのジョブも受け入れられなくなる瞬間まで、すべてのジョブを受け入れ、その際にプロセスを道連れにしてダウンさせます。有界キューは、継続的にジョブの一部を拒否し、残りを処理し続けます。これがグレースフルデグラデーション(正常な機能低下)であり、負荷がかかると不安定になるシステムと、完全に停止してしまうシステムとを分ける特性です。

すべての作業を受け入れて後で整理するという本能は、負荷が低いときには問題ありません。そして、ほとんどのシステムはほとんどの時間、低負荷の状態で稼働しています。安定性は、負荷が高く、上昇している限界状況での振る舞いから生まれます。そして、そこでは拒否する方法を知っていることが役立つスキルとなります。「ノー」と言えるキューは、自身を保護しています。バックプレッシャーはシステムが障害を起こしているわけではありません。それは、大きな障害を決して起こさないように、どの小さな障害を受け入れるかをシステムが選択しているのです。