0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

リトライ設計と指数バックオフ入門 ― ちいかわのサウナ回「冷たいのにあったかい」で学ぶ

0
Posted at

この記事は、個人ブログ kinolab に掲載した記事の転載です。
初出: リトライ設計と指数バックオフ入門 ― ちいかわのサウナ回「冷たいのにあったかい」で学ぶ

こんにちは。ちいかわが好きで、サウナも好きなエンジニアです。

今日もサウナに行ってきました。バレルサウナがありました。あの樽です。木の樽を横倒しにして中に人が入るやつです。丸いので熱がきれいに回るとされていて、実際よく回っていました。木の匂いも良く、ずっと入っていたくなる感じでした(ずっとは入りませんでした。だいじ)。

で、ととのいながらぼんやり考えていたんですが、ちいかわに水風呂の回があるんですよ。

ざっくりおさらいすると、こんな話です(1コマの画像と、論評に必要なセリフのみを引用します。全体はぜひちいかわ公式Xでご覧ください)。

  1. サウナの中で再挑戦に誘われ、ちいかわが尻込みする
  2. 「もう少しガマンしないとダメみたい」と気づく
  3. 意を決して水風呂へ入り、必死にこらえる
  4. そして——「冷たいのにあったかい…」
  5. 外気浴で「白目剥いちゃいそう」
  6. 水の中のくりまんじゅうが、黙ってうなずく

かわいい。かわいいのですが、リトライ、閾値、体感と実測の乖離、クールダウン、そして先達によるレビューが一連のコマに全部入っています。ナガノ先生が意図したはずはないのですが、だからこそ恐ろしい。

というわけで、この回を題材に IT の設計の話をします。最後にサウナを Python のクラスで書きます。


1. 「もっかい挑戦してみよ」= リトライ

まず1コマ目です。「もっかい挑戦してみよ」。

つまり前回は失敗しているわけです。一度水風呂に入ろうとして、無理だった。それでもう一度やる。

これ、IT の世界では リトライ(retry) と呼ばれます。

リトライとは

処理が失敗したとき、もう一度やり直すこと。

ネットワーク越しの通信は、そこそこの頻度で失敗します。相手のサーバが一瞬混んでいた、回線が瞬断した、たまたま同時アクセスが集中した。こういう一時的な失敗は、少し待ってもう一度投げると普通に成功します。

だから現代のシステムは、失敗したら即座に諦めません。まず、もっかい挑戦してみます。

ただし、すぐ再挑戦してはいけない

ここが重要です。失敗した直後に全力で再突入すると、だいたい同じ理由で失敗します。相手が混んでいるのが原因なら、間髪入れずに叩き直すのは追い打ちです。

なので、待ち時間を置いてから再試行します。しかも回を追うごとに間隔を伸ばす。これを 指数バックオフ(exponential backoff) と言います。

1回目の失敗 → 1秒待って再試行
2回目の失敗 → 2秒待って再試行
3回目の失敗 → 4秒待って再試行
4回目の失敗 → 8秒待って再試行

サウナで言えば、水風呂に失敗したあと、サウナ室に戻ってしっかり温まってからもう一度行くやつです。冷えたまま即リトライしても、絶対に入れません。体を温める時間がバックオフです。

ちいかわたちも、ちゃんとサウナで温まってから「もっかい挑戦してみよ」と言っています。設計として正しい。

さらに、待ち時間にはブレを足す

指数バックオフだけだと、まだ足りません。同時に失敗した全員が「1秒後」「2秒後」「4秒後」に揃って再突入するからです。避けたかったはずの追い打ちが、きれいに再現されます。

なので待ち時間にランダムなブレを混ぜます。これを ジッター(jitter) と言います。サウナで言えば、全員が同じタイミングで水風呂に殺到しないよう、各自バラバラに向かうということです。実際そうなっていないと水風呂が渋滞します。

そして、リトライしてはいけない処理がある

もうひとつ大事な注意です。やり直すと二重に効いてしまう処理は、気軽にリトライできません。

決済や投稿がそれです。タイムアウトで打ち切ったものの、実は相手側では成功していた——という場合、リトライすると二重注文になります。だから「同じ処理を何度実行しても結果が変わらない」性質、冪等性(べきとうせい) を確保したうえでリトライします。具体的には、リクエストに一意のIDを付けて、同じIDの依頼は1回しか処理しないようにする、といったやり方です。

水風呂は何度入っても水風呂なので冪等です。安心してリトライできます。


2. 「もう少しガマンしないとダメみたい」= 閾値とタイムアウト

次のコマです。

もう少し「ガマン」しないとダメみたい

エンジニアはこの一言に震えるべきです。これは 閾値(しきい値) の話であり、タイムアウト設計 の話だからです。

タイムアウトとは

「ここまで待って応答がなければ、失敗とみなして打ち切る」という制限時間のこと。

無限に待つわけにはいかないので、どんな通信にも必ず期限を設けます。問題は、その値をいくつにするかです。

  • 短すぎる: 本当は成功していた処理を、途中で殺してしまう。しかもリトライが走るので、サーバには二重に負荷がかかる
  • 長すぎる: 死んだ相手をいつまでも待ち続け、その間リソースを握ったままになる

水風呂とまったく同じです。

  • 早く出すぎる: 「冷たい」で終わる。ととのわない
  • 長く入りすぎる: 普通に危ない

「もう少しガマンしないとダメみたい」というのは、閾値が手前に寄りすぎていたことに気づいた瞬間です。前回は短すぎるタイムアウトで打ち切っていた。だから今回はもう少し伸ばす。

適正値は経験からしか出てこない

タイムアウトの適正値は、理屈だけでは決まりません。実測して調整するしかない。「p99 のレイテンシが 800ms だから、タイムアウトは 3 秒にしよう」というふうに、実際のデータから決めます。

サウナ界にも同じ経験則があります。水風呂は1〜2分、心拍が落ち着いて体の表面に薄い膜(羽衣)ができるまで、というやつです。誰かが計測して積み上げた値です。

ただしこれはあくまで一般的な目安で、適正値は体調・年齢・持病によって大きく変わります。システムでも、あるサービスのタイムアウト値をそのまま別のサービスに持ってくると事故ります。少しでも異変を感じたら、閾値を待たずにすぐ出てください。

ちいかわたちは自力でこの閾値を発見しました。えらい。


3. 「冷たいのにあったかい」= 体感と実測は違う

そしてクライマックスです。

アレ…
冷たいのにあったかい…

これは物理的には矛盾しています。水温は当然ながら冷たい。測れば冷たい。それなのに、あたたかく感じる。

血管が収縮したあと血流が戻ることで起きる現象と言われていますが、ここで言いたいのはそこではありません。エンジニア的に重要なのは、

メトリクス(実測値)とユーザー体験は、一致しないことがある

という一点です。

よくある光景

サーバの監視画面は全部緑。CPU も正常、エラー率も 0%、平均レスポンスタイムも良好。なのに問い合わせが来る。「サイトが重いんですけど」。

こういうとき、だいたい見ている指標が間違っています。

  • 平均を見ていた: 平均 200ms でも、10人に1人が 5 秒待たされていれば、その人にとっては「重いサイト」です。だから p95 / p99(遅いほうから数えて 5% / 1% の値)を見ます
  • サーバ側しか見ていなかった: サーバが 50ms で返しても、画面が表示されるまでに 4 秒かかっていることがあります。だからブラウザ側で計測します

逆もあります。数値上は明らかに遅いのに、ユーザーは快適だと言う。何も表示されないまま待たされるより、進んでいるとわかるほうが短く感じられる傾向がある、というのは UI 設計でよく言われる話です(もっとも、作り込みすぎた進捗表示がかえって長く感じさせた事例も報告されているので、万能ではありません)。

水風呂は、この逆パターンの極みです。測れば冷たいのに、体験としてはあたたかい。 ちいかわは身をもって「指標と体感は別」を証明しました。

監視画面が全部緑なのに苦情が来たら、思い出してください。冷たいのに、あったかいことがあるのです。


4. 外気浴 = クールダウンを工程に含める

その次のコマです。

外気浴の椅子に並んで座るちいかわとハチワレ。ふきだしは「白目剥いちゃいそう」。手前にはくりまんじゅうがいる

出典: ナガノ『ちいかわ』2022年7月25日 ちいかわ公式X(@ngnchiikawa)投稿
https://x.com/ngnchiikawa/status/1551538161213272064 より引用

このコマ、何もしていないんです。サウナにも水風呂にも入っていない。ただ椅子に座っているだけです。

画面の作りを見てください。まず、2人が横並びで、まったく同じ姿勢を取っています。それまでのコマでは「誘う側」と「尻込みする側」、「先に入る側」と「後から続く側」で、常に姿勢や表情に差がついていました。ここで初めて2人の描き分けが消えます。同じ工程を通過した者同士が、同じ状態に収束している。

次に、画面の下半分を占めているのは、後ろ向きのくりまんじゅうです。手前にこれだけ大きく配置されていながら顔はあまり見えないので、読者の視線は自然と奥の2人へ抜けていきます。

そして背景がほとんど描かれていません。線があるのは床のタイルくらいです。この余白そのものが「何もしていない時間」を画面上で表現しています。動きの線を引いてしまったら、この時間は成立しません。

それでいて、この回でいちばん幸福度が高いのがこのコマです。負荷が終わったあとの、何もしていない時間にピークが来ている。ここが工程として設計されているのがすごいところです。

サウナは「サウナ室 → 水風呂 → 外気浴」で1セットです。多くの人が誤解しますが、いわゆる「ととのう」は外気浴で起きます。熱いところと冷たいところを往復するのが目的ではなく、その後の休憩でようやく完成する。

負荷をかけっぱなしで終わらない。必ず休ませる。

IT でも同じ設計をします。

  • クールダウン期間: サーバ台数を減らしたあと、すぐに増やしたりしないよう一定時間の待機を挟む
  • デプロイ後の監視時間: リリースしたら即解散ではなく、しばらくメトリクスを見る
  • グレースフルシャットダウン: サーバを止めるとき、いきなり電源を落とさず、処理中のリクエストを終わらせてから閉じる(これは「休ませる」というより「流し切る」ほうですが、いきなり断ち切らないという点では同じ思想です)

どれも「終わったあとに何もしない時間」を、工程として明示的に確保しています。省くと事故ります。外気浴を飛ばして脱衣所に直行すると整わないのと同じです。

サウナが上手い人は、休憩が上手い。運用が上手いチームも、だいたい休憩が上手い。


5. くりまんじゅうの「うんうん」= 承認

先ほどのコマの手前にも写っていましたが、最後のコマでは水の中にいるくりまんじゅうが「うんうん」とうなずいています。セリフはこれだけです。

しかしこれ、レビューの承認です。

先にその道を通った者が、後から来た者のやり方を見て、口を出さずにうなずく。指摘がないということは「よし」ということです。GitHub で言えば Approve です。コメント欄に何も書かず、緑のチェックだけ付けて去っていく先輩。

しかも、くりまんじゅうは先に水に入っている。つまり本番環境で先に動いている実績があります。これほど重みのある承認はありません。

余談ですが、良いレビューは「間違いを見つけること」だけが仕事ではありません。うまくいっているものに、うまくいっていると言うことも同じくらい価値があります。うんうん、は偉大です。


6. サウナを Python で書いてみる

さて本題です。ここまでの話を踏まえて、サウナをクラスにします。

設計で悩むところ

サウナをモデリングしようとすると、最初に壁にぶつかります。「サウナ」と一口に言っても中身が全然違うのです。

  • ドライサウナ: 温度が高く、湿度が低い。日本の銭湯でおなじみ
  • フィンランド式: 温度は控えめ、湿度は高め。ロウリュができる
  • バレルサウナ: 樽。今日入ったやつ。丸いので熱の対流が良い
  • テントサウナ: 川辺などに設営するやつ
  • スチームサウナ: 蒸気。温度は低いが湿度がほぼ 100%
  • スモークサウナ: 煙で温める伝統的なやつ。準備に半日かかる

さらに設備の有無が施設ごとにバラバラです。

  • セルフロウリュ: 自分で石に水をかけてよい。できる施設とできない施設がある
  • オートロウリュ: 一定間隔で勝手に水が落ちてくる装置。あったりなかったりする

ここで大事なのは、「無い」を表現できる形にしておくことです。オートロウリュが無い施設に auto_loyly フィールドを空文字で埋めるような設計にすると、後で必ず事故ります。無いなら None にします。

熱波サービスは、なぜメソッドにしないのか

ここが今回いちばん語りたいところです。

熱波サービス(アウフグース)を Sauna クラスのメソッドとして書きたくなります。sauna.heat_wave() みたいに。でも、これはやめたほうがいいと思っています。

理由は、熱波サービスがサウナ室の性質ではないからです。

性質(属性・メソッド) 出来事(イベント)
例 温度、湿度、定員、セルフロウリュ可否 15時の熱波サービス
いつのもの 常にそう 特定の時刻に起きる
誰のもの サウナ室 施設のスケジュール
変わるか めったに変わらない 毎日変わる。中止もある

セルフロウリュの可否は「その部屋でできること」なので、部屋が持っていて構いません。でも熱波サービスは、特定の時刻に、特定の熱波師が、特定の回数だけ行う出来事です。しかも独自の属性を持ちます。開始時刻、担当者、所要時間、その回だけの定員(立ち見不可のことがある)、そして中止や時間変更。

これを Sauna のメソッドにすると、サウナ室のクラスがスケジュール管理まで抱え込みます。「今日の15時の回は誰が担当か」「雨で中止になった」といった情報が、温度や湿度と同じ場所に同居する。すぐに破綻します。

なので、熱波サービスは HeatWaveEvent として独立させ、EventSchedule が持ちます。サウナ室は自分の性質だけを知っていればよく、イベント表は施設側が持つ。両者は「どのサウナ室のイベントか」という名前だけで緩くつながります。EventSchedule に Sauna を渡さないのがポイントで、渡してしまうとせっかく分けた意味がなくなります。

中止の扱いも、イベント側に持たせたからこそ素直に書けます。表から消してしまうと「中止になった」という事実まで消えるので、cancelled というフラグで残します。

現実の施設もそうなっています。サウナ室の壁に温度計は貼ってありますが、熱波の時間割は脱衣所の掲示板に貼ってあります。別の場所にあるのには理由があるのです。

コード

Pydantic を使います。ただの入れ物ではなく、ありえない値を弾いてくれるのが理由です。湿度120%のサウナは存在しませんし、温度が 300℃ のサウナも存在してほしくありません。

from __future__ import annotations

from datetime import time
from enum import Enum

from pydantic import BaseModel, ConfigDict, Field, computed_field


class SaunaStyle(str, Enum):
    """サウナの様式。同じ「サウナ」でも、この違いで体験がまるで別物になる"""

    DRY = "ドライサウナ"
    FINNISH = "フィンランド式"
    BARREL = "バレルサウナ"
    TENT = "テントサウナ"
    STEAM = "スチームサウナ"
    SMOKE = "スモークサウナ"


class LoylyNotAvailableError(Exception):
    """ロウリュが許可されていないサウナ室で水をかけようとした"""


class AutoLoyly(BaseModel):
    """一定間隔で勝手に水が落ちてくる装置。あるサウナと無いサウナがある"""

    model_config = ConfigDict(frozen=True)

    interval_minutes: int = Field(gt=0, le=60)
    # 装置が動いた直後は体感温度が跳ね上がる
    temperature_spike: float = Field(default=10.0, ge=0)


class Sauna(BaseModel):
    # 代入時にも検証する。あとから humidity = 200 とされても弾ける
    model_config = ConfigDict(validate_assignment=True)

    name: str
    style: SaunaStyle
    temperature: float = Field(ge=40, le=120)
    humidity: float = Field(ge=0, le=100)
    capacity: int = Field(gt=0)
    # セルフロウリュの可否は「その部屋でできること」なので属性で持つ
    self_loyly_allowed: bool = False
    # オートロウリュは付いている施設と付いていない施設がある。無い場合は None
    auto_loyly: AutoLoyly | None = None

    def pour_loyly(self, ladles: int = 1) -> float:
        """セルフロウリュ。許可されていない部屋でやると事故になる"""
        if not self.self_loyly_allowed:
            raise LoylyNotAvailableError(
                f"{self.name} はセルフロウリュ禁止です。ストーブに水をかけないでください"
            )
        self.humidity = min(100.0, self.humidity + 5.0 * ladles)
        return self.humidity

    @computed_field
    @property
    def feels_like(self) -> float:
        """体感温度。厳密な指標ではなく、遊びの目安の式"""
        base = self.temperature + self.humidity * 0.3
        # オートロウリュがある部屋は、装置が動いた直後の跳ね上がりを織り込む
        return base + (self.auto_loyly.temperature_spike if self.auto_loyly else 0.0)


class HeatWaveEvent(BaseModel):
    """熱波サービス(アウフグース)。
    サウナ室の性質ではなく、特定の時刻に人が起こす出来事なのでイベントとして扱う"""

    model_config = ConfigDict(frozen=True)

    sauna_name: str
    start_at: time
    performer: str
    duration_minutes: int = Field(default=15, gt=0)
    # 定員はサウナ室の capacity とは別。立ち見不可の回もある
    seats: int | None = Field(default=None, gt=0)
    # 中止はイベント固有の状態。消さずに残すことで「中止になった」履歴が保てる
    cancelled: bool = False


class EventSchedule(BaseModel):
    """施設全体のイベント表。サウナ室とは独立して増減する"""

    events: list[HeatWaveEvent] = Field(default_factory=list)

    def add(self, event: HeatWaveEvent) -> None:
        self.events.append(event)

    def cancel(self, sauna_name: str, start_at: time) -> None:
        self.events = [
            e.model_copy(update={"cancelled": True})
            if (e.sauna_name == sauna_name and e.start_at == start_at)
            else e
            for e in self.events
        ]

    def next_for(self, sauna_name: str, now: time) -> HeatWaveEvent | None:
        """サウナ室の「名前」しか受け取らない。Sauna 型に依存させないため"""
        upcoming = [
            e
            for e in self.events
            if e.sauna_name == sauna_name and e.start_at >= now and not e.cancelled
        ]
        return min(upcoming, key=lambda e: e.start_at, default=None)

日付をまたぐ深夜の回まで扱うなら time ではなく datetime を持つべきですが、ここでは省いています。

動かしてみる

barrel = Sauna(name="バレルサウナ", style=SaunaStyle.BARREL,
               temperature=90, humidity=20, capacity=6, self_loyly_allowed=True)
main = Sauna(name="メインサウナ", style=SaunaStyle.DRY,
             temperature=100, humidity=10, capacity=30,
             auto_loyly=AutoLoyly(interval_minutes=30))

print(f"{barrel.name}(オートロウリュ無し): 体感 {barrel.feels_like:.1f}℃")
print(f"{main.name}(オートロウリュ有り): 体感 {main.feels_like:.1f}℃")

barrel.pour_loyly(ladles=2)
print(f"バレルサウナでセルフロウリュ2杯 → 体感 {barrel.feels_like:.1f}℃")

main.pour_loyly()  # ← ここで例外

実行結果です。

バレルサウナ(オートロウリュ無し): 体感 96.0℃
メインサウナ(オートロウリュ有り): 体感 113.0℃
バレルサウナでセルフロウリュ2杯 → 体感 99.0℃
LoylyNotAvailableError: メインサウナ はセルフロウリュ禁止です。ストーブに水をかけないでください

装置の有無が体感温度に効いていること、セルフロウリュ禁止の部屋で水をかけるときちんと例外になることがわかります。現実でもそうあってほしい。

ありえないサウナは、そもそも作れません。

Sauna(name="こわれたサウナ", style=SaunaStyle.DRY,
      temperature=100, humidity=120, capacity=10)
# → ValidationError: humidity
#    Input should be less than or equal to 100

湿度120%のサウナは、この世に存在してはいけません。validate_assignment=True を付けているので、あとから sauna.humidity = 200 と代入しようとしても同じように弾かれます。

熱波のほうは別管理です。

schedule = EventSchedule()
schedule.add(HeatWaveEvent(sauna_name="メインサウナ", start_at=time(15, 0),
                           performer="熱波師 A", seats=20))
schedule.add(HeatWaveEvent(sauna_name="メインサウナ", start_at=time(18, 0),
                           performer="熱波師 B"))

print("14時時点:", schedule.next_for("メインサウナ", time(14, 0)).performer)

schedule.cancel("メインサウナ", time(15, 0))  # 15時の回が中止に
print("中止後  :", schedule.next_for("メインサウナ", time(14, 0)).performer)
14時時点: 熱波師 A
中止後  : 熱波師 B

15時の回が中止になると、14時時点で案内される「次の熱波」が自動的に18時の回に切り替わります。そしてこの一連のやりとりに、Sauna オブジェクトは一度も登場していません。渡しているのはサウナ室の名前だけです。

サウナ室は自分が何度かを知っていればよく、今日誰が熱波を振るかも、それが中止になったかも知らなくていい。 これが分離できているということです。


まとめ

ちいかわの世界 IT用語 ひとことで言うと
もっかい挑戦してみよ リトライ / 指数バックオフ 失敗したら間を置いて再試行する。ただし冪等なものだけ
もう少しガマンしないとダメみたい タイムアウト / 閾値 短すぎても長すぎてもダメ
冷たいのにあったかい メトリクスと体感の乖離 数値が緑でも体験は別
外気浴 クールダウン 終わったあとの何もしない時間も工程
くりまんじゅうの「うんうん」 レビューの承認 何も言わないことが最大の評価
バレルサウナ / ドライサウナ 型とバリデーション 同じ「サウナ」でも中身が違う。ありえない値は弾く
熱波サービス イベント 性質ではなく出来事なので別管理

おわりに

サウナが好きな人は、たぶんシステム設計も好きになれると思っています。

温度と湿度と時間のバランスを取り、負荷をかけたあと必ず休ませ、経験から閾値を割り出し、数値ではなく体感で最終判断する。やっていることが、そのまま運用です。

そして「ととのう」は、負荷をかけただけでも、冷やしただけでも起きません。順番と、その後の休憩が揃って初めて成立します。デプロイして終わりではなく、そのあと監視する時間まで含めて1セットなのと同じです。

ちいかわとハチワレは、初挑戦でこれを完走しました。しかもリトライ付きで。えらい。

なお、抽象化やボトルネックの話はうさぎが冷凍うどんで動脈を冷やす回に、隔離とポストモーテムの話はハチワレがピーマンの肉詰めにされる回に書きました。ちいかわは IT の教材として優秀すぎます。

今日も明日も、水風呂で「もう少しガマン」していきましょう。ただし閾値は人によってまったく違います。血圧や心臓に不安がある方、お酒を飲んだあとは、ガマンせず素直に出てください。タイムアウトを伸ばしすぎるのが一番危ないのは、システムも人体も同じです。

(※本記事は非公式のファンコンテンツです。『ちいかわ』に関する権利は権利者に帰属し、当サイトは権利者・関係各社とは一切関係ありません。掲載した画像は、論評の対象として著作権法第32条に基づき1コマのみ引用したものであり、セリフの引用も論評に必要な範囲に限っています。作品の全体はぜひ公式Xや単行本でお楽しみください。)


この記事は個人ブログ kinolab からの転載です。ブログでは、身近な題材から
IT の考え方を解説するコラムを不定期で書いています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?