Skip to content

締め切りを渡さないと、リトライが掛け算になる

実装: resilience/deadline/ / 実行: go test ./resilience/deadline/

呼び出しが3段つながっていて、各段が3回まで再送すると、いちばん下は9回呼ばれる。4段なら27回で、段を1つ足すたびに3倍になる。段ごとに同じ長さの締め切りを置くと、入口が諦めたあとも下は働き続けた。入口の締め切りをそのまま下へ渡すと、末端の呼び出しは段数によらず予算ぶんで頭打ちになる。ただし予算が短いと、あと1回で通ったものを落とす。

この章で作るもの

リトライとバックオフで、一時的な失敗は少し待って再送すれば通ることが多い、と書いた。あの章が見ていたのは1段ぶんの再送になる。呼び出す側と、呼ばれる側。その2者だけだ。

実物の呼び出しは、たいてい2者では終わらない。入口が別のサービスを呼び、それがさらに別のサービスを呼ぶ。そしてそれぞれの段が、独立にリトライの設定を持っている

ここで何が起きるかを測る。

  入口              2 段目            末端
   ●───────────▶ ○ ○ ○  ───────▶ ●●● ●●● ●●●
   │  1 回目        3 回試す          9 回

   │  2 回目 ────▶ ○ ○ ○  ───────▶ ●●● ●●● ●●●

   │  3 回目 ────▶ ○ ○ ○  ───────▶ ●●● ●●● ●●●

                     入口から見れば 3 回のつもりでも、末端は 27 回

  段を 1 つ足すたびに、末端の呼び出しは 3 倍になる
3 段の呼び出しで、各段が 3 回まで再送する。上の段の 1 回が、下の段の 3 回になる

順に見ていく。

  1. リトライは段ごとに掛け算になる: 段を1つ足すたびに、末端への呼び出しが試行回数ぶん倍になる
  2. 段ごとに締め切りを持つと、誰も待っていない仕事が続く: 下の段は自分が呼ばれた時刻から測り直すため
  3. 締め切りを下へ渡すと、掛け算が予算で止まる: 段数によらず頭打ちになる
  4. ただし予算が短すぎると、通ったはずのものを落とす: 締め切りは無料ではない

① リトライは段ごとに掛け算になる

連なりの形をこう置いた。

go
type Chain struct {
	Hops   int    // 段数。入口を 1 段目とし、いちばん下が末端になる
	Tries  int    // 1 段が下へ試す回数
	Cost   int    // 末端が 1 回の呼び出しに使う時間
	Budget int    // 締め切りまでの時間
	Fails  int    // 末端が最初の何回を失敗するか
	Policy Policy // 締め切りの渡し方
}

末端は壊れたままにする。何回呼んでも失敗する相手だ。実物でこれが起きるのは、下流が落ちているときや、過負荷で応答できないときになる。

段数を変えて、末端が何回呼ばれるかを測った。各段は3回まで試す。

段数末端への呼び出し全段あわせて経過した時間
23630
392190
42766270
581201810

段を1つ足すたびに、末端への呼び出しが3倍になる。試行回数を変えて確かめると、2回なら2倍、4回なら4倍だった。倍率は試行回数そのものになる。

読み方が2つある。

入口から見た回数と、末端から見た回数が違う。 入口の設定には「3回まで」と書いてある。運用の人がその設定を読んでも、末端が27回呼ばれているとは思わない。各段の設定はどれも小さく、どれも妥当に見える。掛け算は設定のどこにも書かれていない。

そして末端がいちばん弱い。 下流が落ちているから失敗しているのに、落ちている相手にいちばん多く投げることになる。リトライとバックオフで見たリトライ嵐が、1台のクライアントの中だけで起きている。

② 段ごとに締め切りを持つと、誰も待っていない仕事が続く

素直な対処は、締め切りを置くことだ。ただし置き方が2通りある。

  • 段ごとに持つ: 各段が「自分は45まで待つ」という設定を独立に持つ
  • 下へ渡す: 入口が決めた時刻を、そのまま下へ伝える

違いはここに出る。

go
func (s *sim) childUntil(until int) int {
	switch s.c.Policy {
	case Pass:
		return until // 入口の時刻をそのまま渡す
	case Each:
		return s.now + s.c.Budget // 自分が呼ばれた時刻から測り直す
	default:
		return 0 // 締め切り無し
	}
}

段ごとに持つ形では、下の段が自分が呼ばれた時刻から測り直す。入口が0の時点で45を締め切りにしても、2段目が30の時点で呼ばれたら、2段目の締め切りは75になる。入口が諦めたあとも、2段目は働き続ける。

3段・各段3回・予算45で測った。

末端への呼び出し経過した時間誰も待っていない仕事
段ごとに持つ66010
下へ渡す4400

入口の締め切りは45なのに、段ごとに持つ形は60まで延びた。そして60のうち10は、入口が既に諦めたあとに始まった仕事になる。返しても受け取る相手がいない

これが効くのは負荷のほうだ。誰も待っていない仕事でも、下流は資源を使う。接続を持ち、処理を回し、場合によっては書き込む。入口の側から見ると何も起きていないので、この負荷は上流の指標には出てこない

③ 締め切りを下へ渡すと、掛け算が予算で止まる

渡す形を実装するとこうなる。次の1回を始める前に、締め切りに間に合うかを見る。間に合わないなら、始めずに諦める。

go
func Run(c Chain) Result {
	if c.Hops < 1 || c.Tries < 1 {
		return Result{}
	}
	s := &sim{c: c, left: c.Fails}
	until := 0
	if c.Policy != None {
		until = c.Budget
	}
	s.res.OK = s.call(0, until)
	s.res.Elapsed = s.now
	return s.res
}

段数を変えて、①と同じ条件で比べた。予算は45で、末端4回ぶんになる。

段数締め切り無し下へ渡す
23 回 / 経過 303 回 / 経過 30
39 回 / 経過 904 回 / 経過 40
427 回 / 経過 2704 回 / 経過 40
581 回 / 経過 8104 回 / 経過 40

段数を増やしても、末端への呼び出しは4回のままだ。掛け算が消えている

理由は素直で、予算が時間で書かれているからになる。末端の1回が10かかるなら、45の予算では4回しか入らない。何段あろうと、何回試そうと、入る回数は変わらない。段の数ではなく、時間で上限が決まる

2段のときだけ両者が一致しているのに注意したい。2段では末端が3回しか呼ばれず、経過30で予算45に収まっている。締め切りが効いていない。効き始めるのは、掛け算が予算を超えたところからになる。だから2段で試して「効いている」と判断すると、段が増えたときに見落とす。

④ ただし予算が短すぎると、通ったはずのものを落とす

締め切りは無料ではない。末端が5回目で通る相手だとして、予算を変えて測った。

予算末端への呼び出し経過した時間成功したか
20220しない
40440しない
50550する
60550する
100550する

予算40ではあと1回で通ったものを落としている。50にすると通る。そして60や100にしても、経過は50のままで変わらない。通ったらそこで終わるからだ。

ここから、予算の決め方が出てくる。多く取っても、通る場合には損をしない。損をするのは、通らない場合に長く待つことだけになる。だから予算は「通るために必要な時間」から決めて、「待てる時間」で上から抑えることになる。

そして②の話がここに戻ってくる。予算を長く取るのが安全なのは、下へ渡している場合だけだ。段ごとに持つ形で全段の値を長くすると、入口が諦めたあとの働き続ける時間もそのぶん延びる。

動かす

下のデモは、段数・試行回数・予算・締め切りの渡し方を変えて連なりを走らせる。段数を増やすと末端への呼び出しがどう増えるかを追える。渡し方を切り替えると、同じ設定でも経過と無駄仕事が変わる。予算を動かすと、通るところと落とすところの境目が見える。

デモ締め切りの渡し方(3 段以上の呼び出し)末端 27 回
締め切り無し段ごとに持つ下へ渡す末端は壊れている5 回目で通る
4
3
45
末端への呼び出し27締め切り無しなら 27
経過した時間270締め切り無し
誰も待っていない仕事0入口から見て失敗
1 本が末端の 1 回。左から右へ時間が進む。 末端の呼び出し
4 段で各段 3 回なら、末端は 3^3 = 27 回呼ばれる。入口の設定に書いてあるのは「3 回まで」だけで、この掛け算はどの設定ファイルにも書かれていない

各段のリトライ設定は、単体で見るとどれも小さい。掛け算になるのは段をまたいだときで、 その積はどの設定ファイルにも書かれていない。締め切りを時刻で下へ渡すと、 段数によらず時間で上限が決まる。長さで渡すと、下の段が自分の時刻から測り直して延びる。

設計の観点

  • 段ごとの設定はどれも妥当に見える: 掛け算は設定のどこにも書かれていないので、設定を読んでも気づけない。数えるなら末端の受けた回数を数える
  • 締め切りは時刻で渡す: 長さで渡すと、下の段が自分の時刻から測り直して延びる
  • 効いているかは、掛け算が予算を超える段数で確かめる: 2段で試すと、締め切りが効いていなくても通ってしまう
  • 予算は長めに取ってよい: 通る場合には損をしない。損をするのは通らない場合の待ち時間だけ
  • ただし長くしてよいのは渡している場合だけ: 段ごとに持つ形で長くすると、無駄仕事も延びる
  • 上流の指標には出てこない: 入口が諦めたあとの仕事は、入口から見ると存在しない。下流側で見るしかない
  • リトライの上限は回数と時間の両方で置く: 回数だけでは段をまたいだときに効かない

対照と実例

3段・各段3回・予算45で並べる。

末端への呼び出し経過した時間誰も待っていない仕事
締め切り無し9900
段ごとに持つ66010
下へ渡す4400

締め切り無しがいちばん長く、いちばん多く投げる。段ごとに持つ形はその中間で、代わりに無駄仕事が出る。下へ渡す形だけが、入口の締め切りを守っている。

同じ「上限を置く」話は、この本の別の場所にも出ている。リトライとバックオフは1段ぶんの待ち方で、この章はそれが積み重なったときの話になる。サーキットブレーカーは失敗が続く相手を切り離すので、掛け算が始まる前に止める側に立つ。配ると、いちばん遅い1台が全体を決めるの打ち切りは、横に広がったときの同じ考え方になる。

裏どり:

  • 数字はすべて手元の測定: 段数の表も、3つの方針の表も、予算の表も、この章の実装をテストで固定したもの。実物のサービス連なりを測ったものではない
  • 末端が壊れたままだとしている: ①から③は、末端が何回呼んでも失敗する場合を見ている。掛け算がいちばん大きく出るのはこの場合になる。たまに失敗する相手なら、途中で通るぶん回数は減る
  • 時間を末端だけが使うとしている: 実物では途中の段も時間を使う。ここでは掛け算の形を見たいので、末端に寄せた
  • 締め切りを渡す仕組みの名前: 呼び出しの向こう側へ締め切りを伝える仕組みは、gRPC では deadline、HTTP では期限を表すヘッダを使う形が知られている。この章では仕組みの名前ではなく、渡すか渡さないかの違いだけを扱っている
  • 段ごとに持つ形が現実に多いこと: 各サービスが自分の設定ファイルに独立してタイムアウトを書く形は、置き方として自然に発生する。どれくらい多いかの数字は持っていない

簡略化したこと

  • 待ち時間を入れていない: 実物のリトライはバックオフで間を空ける。そのぶん予算の消え方が変わる
  • 並行に呼んでいない: 1段が下を1つずつ順に呼ぶ形にしている。同時に複数を呼ぶ形は配ると、いちばん遅い1台が全体を決めるで扱った
  • 段ごとに設定が違う場合を扱っていない: 実物では段ごとに試行回数も締め切りも違う。ここでは全段を同じにした
  • 打ち切ったあとの後始末を扱っていない: 諦めた呼び出しを下流へ知らせて止めさせる形は実装していない
  • リトライしてよいかを見ていない: 恒久的な失敗を再送しない判断はリトライとバックオフの側にある
  • 時間を整数の刻みにしている: 実物のばらつきは連続で、刻みの中の差が見えない

参考資料