ブログ一覧

DBが少し遅くなっただけで、なぜ全体が止まるのか

· 約9分で読めます

始まりは、インデックスの外れたクエリ1本だった。そこから決済APIが止まるまでを追うと、タイムアウトや再試行の設定がどの順番で効いてくるかが見えてくる。

舞台:決済ボタンからDBまで

決済の呼び出し経路。各段に、別々のタイムアウトと再試行の設定がある

クライアント → API → 在庫サービス → DB

平常時、APIには毎秒500件が届き、1件は約100msで返る。ある日、インデックスの外れたクエリがデプロイされ、DBへの問い合わせが数百ms遅くなった。

障害のきっかけとしては小さい。全体が止まるかどうかは、ここから先の設定の組み合わせで決まる。

遅くなる:タイムアウトは各段でばらばらに動く

最初に増えるのは、処理中のリクエストだ。同時に抱える件数は到着率 × 処理時間(リトルの法則)で決まるので、応答が1秒に延びると50件が500件になる。

毎秒500件のときに処理中の件数(プールの上限は100)
  • 平常:100ms: 50件
  • DBが遅い:1秒: 500件(上限の5倍)

次に、タイムアウトのずれが無駄を生む。APIが1秒で諦めても、在庫サービスのタイムアウトが5秒なら、誰も待っていない問い合わせを4秒間DBに投げ続ける。上流が諦めたことは、下流には伝わらない。

時間の予算は外側から内側へ配る。そのうえで、固定値ではなく締め切りの時刻を下流へ渡す。

段予算決め方
クライアント3秒利用者が待てる長さ(再試行を含む)
API2.5秒クライアントの1回の試行に収める
在庫サービス1秒下流の呼び出し+自分の処理
DB300msクエリのp99.9の計測値
Go:締め切りを受け取り、間に合わないなら呼ばない
// APIの入口で、リクエスト全体の締め切りを決める
ctx, cancel := context.WithTimeout(r.Context(), 2500*time.Millisecond)
defer cancel()

// 残り時間が足りなければ、下流を呼ばずに失敗を返す
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < 50*time.Millisecond {
	return errDeadlineTooClose
}

// gRPCはctxの締め切りを下流へ伝える。下流も同じ時刻で処理をやめられる
res, err := inventory.Reserve(ctx, req)

失敗し始める:再試行は掛け算で増える

タイムアウトが出始めると、各段の再試行が動き出す。クライアント・API・在庫サービスがそれぞれ最大3回試すと、DBに届く試行は最大で 3 × 3 × 3 = 27回になる。

DBが全面停止したとき、1回の操作でDBに届く試行(最大)
  • 3層がそれぞれ3回まで: 27回
  • クライアントだけが3回まで: 3回
  • クライアントだけ+再試行予算10%: 約1.1回

ふだん毎秒1,000件の操作が、障害中は毎秒27,000件の試行になる。回復しかけたDBは、これでまた倒れる。直し方は3つある。

  1. 再試行する層を1つに決める。 ほかの層は、失敗をそのまま上へ返す。
  2. 間隔を指数的に延ばし、ランダムな揺らぎ(ジッター)を足す。 全員が同じ間隔で再試行すると、負荷の山がそろう。
  3. 再試行の総量に上限を置く。 成功するたびにトークンを0.1枚足し、再試行のたびに1枚使う。尽きたら再試行しない。
Go:指数バックオフ+フルジッター
func backoff(attempt int) time.Duration {
	const base, limit = 100 * time.Millisecond, 2 * time.Second
	d := base << attempt // 100ms, 200ms, 400ms, …
	if d > limit {
		d = limit
	}
	// 0〜dの間で一様に選び、再試行の山をばらす
	return time.Duration(rand.Int63n(int64(d)))
}

3つ目の上限があると、再試行は成功の1割を超えない。全面障害では成功がないのでトークンが尽き、負荷はふだんの約1.1倍で止まる。

あふれる:一部を早く断るほうが、多く返せる

処理能力が毎秒1,000件のサーバーに、毎秒1,500件が届き続けるとする。上限のないキューは毎秒500件ずつ伸び、待ち時間は1秒ごとに0.5秒延びる。

上限のないキューの待ち時間(クライアントのタイムアウトは2秒)
  • 1秒後: 0.5秒
  • 2秒後: 1秒
  • 4秒後: 2秒(タイムアウト)
  • 6秒後: 3秒

4秒後には待ち時間がタイムアウトを超え、サーバーは、クライアントが諦めた要求ばかりを処理するようになる。忙しく働いているのに、成功する応答はほぼゼロだ。

キューの上限は「許せる待ち時間 × 処理能力」で決める。0.5秒まで待たせてよいなら1,000 × 0.5 = 500件。超えた分には、待たせずに429や503を返す。

期限切れの判定には、最初に渡した締め切りがそのまま使える。タイムアウトの予算とロードシェディングは、同じ締め切りを共有している。

戻らない:きっかけが去っても負荷が下がらない

処理能力が毎秒10,000件のAPIに、ふだんは6,000件が届く。1分間だけ15,000件に跳ね、タイムアウトが増えて、クライアントが3回ずつ試すようになった。

届く負荷(毎秒)。処理能力は10,000件
  • 平常: 6,000件
  • 急増の1分間: 15,000件
  • 急増のあと(3回ずつ試行): 18,000件
  • 入口で絞り、再試行を止める: 5,000件

急増が終わって利用者の要求が6,000件に戻っても、ほぼ全部が3回ずつ試されるので、届く負荷は6,000 × 3 = 18,000件のまま下がらない。処理能力を超え続け、自然には戻らない。

負荷を保ち続ける循環。1周すると、きっかけがなくても負荷が戻ってくる

応答が遅れる → タイムアウトが増える → 再試行が増える → 届く負荷が増える → さらに遅れる

これがメタステーブル障害だ。急増やデプロイ、キャッシュの消失といったきっかけは防ぎきれない。設計で断つべきは、負荷を保ち続ける仕組みのほうで、この例では再試行がそれにあたる。

抜け出すには、入口で流量を毎秒5,000件に絞り、再試行を止める。応答が時間内に返り始めたら、数分ごとに1割ずつ戻していく。一気に戻すと、冷えたキャッシュに全量が当たって元の状態に戻りうる。この手順は障害の前に決め、訓練しておく。

そもそも再試行してよいか:冪等キー

読み取りは何度やり直しても害がない。支払いは違う。タイムアウトしたとき、クライアントには処理されなかったのか、応答だけが失われたのかが区別できない。

1日10万件の支払いで、処理後に応答だけがタイムアウトするものが0.1%あれば、毎日約100件が二重に請求される。

クライアントは操作ごとに冪等キーを1つ作り、再試行でも同じキーを送る。サーバーはキーと結果を支払いの記録と同じトランザクションで保存し、同じキーには保存した結果を返す。キーを作るのは再試行する層なので、再試行を1か所に寄せておくとキーの管理も1か所で済む。

見直すときのチェックリスト

  1. タイムアウトは外側ほど長いか。値は計測(p99.9など)から決めたか。
  2. 締め切りを下流へ渡し、間に合わない呼び出しをしていないか。
  3. 再試行する層は1つか。最下層への最大試行回数を掛け算で確かめたか。
  4. 再試行にジッター付きのバックオフと、総量の上限があるか。
  5. キューに上限があり、期限切れの要求を処理の前に捨てているか。
  6. 入口で流量を絞って少しずつ戻す手順を、訓練しているか。
  7. 書き込みの再試行に冪等キーがあるか。

面接で「依存先のDBが遅くなったら?」と聞かれたら

この記事の順番どおりに話すと、答えが1本につながる。

  1. 処理中の件数が増えてプールが詰まる(到着率 × 処理時間)
  2. 締め切りを下流へ渡し、無駄な処理を止める
  3. 再試行は1つの層に寄せ、総量に上限を置く
  4. 処理能力を超えた分は早く断る
  5. 悪い状態に入ったら、入口で絞って少しずつ戻す

「各層3回なら27倍」のように、数字を1つ添えるだけで説得力が大きく変わる。

この記事で深掘りしたTips