ロールアウト前夜 — 眠る棚の重さ
金曜の午後6時前。小会議室のモニタに、canary deploy の進行状況とメモリ使用量のグラフを並べていた。
Object Pool を入れてから、注文APIの p99 は落ち着いていたはずだった。接続の使い捨てはやめた。Bulkhead で機能ごとに隔壁も切った。先週までは、それで十分だと思っていた。
ところが今度は、デプロイのたびにワーカーが重い。起動時間は42秒。RSS は 1.3GB を超える。しかも、そのワーカーが実際に触るテナントは30件前後しかない。数字だけ見ると単純だった。使っていないデータを抱え込みすぎている。
会議室のドアが開いた。
ロックさんは、細長い金属の保管箱と紙の索引カードを抱えて入ってきた。今回は帆船模型ではない。代わりに、押収品の管理係みたいな荷物だった。
「ワトソン君。今回は沈んだのではなく、積みすぎたのだね」
「まだ沈んではいません。でも、このまま次のロールアウトをやるのは危ないです」
ロックさんはモニタのグラフではなく、ホワイトボードの端に書いた数字を見た。
boot 42s / rss 1.3GB / touched tenants ~30
「最初の画面表示が遅いと言われて、善意で直したんです」
「善意で倉庫を空にしたのか」
言い方はいつも通りだった。でも、今回はその比喩が正確だった。
わたしはノートPCを開いた。
「はい。全テナントのレポート定義と権限設定を、起動時に全部読み込むようにしました」
現場検証 — 起動しただけで空になる書庫
Object Pool を入れたあと、社内からは別の苦情が出た。最初の画面表示だけが少し遅い、というものだ。
その瞬間のわたしの判断は、たぶん自然だった。どうせ後で読むのなら、最初から全部読んでおけばよい。そう考えた。
でも、その「全部」が重すぎた。
いまのコードはこうなっていた。
Beforeコード: BUILD で全件 preload する
| |
TenantDirectory を new した瞬間に、全テナントの fetch_profile が走る。必要になるかどうかは、その時点ではまだわからない。にもかかわらず、起動しただけで全部の payload を確保している。
ロックさんは保管箱を机に置いたまま、コードを見ていた。
「new した瞬間に、書庫の棚を全部空にしている。ワトソン君、起動しただけで12,000件すべてが必要になるのかね」
「なりません。実際に触るのは、そのうちの一部です」
「では、起動と必要を同じ時刻へ押し込んだのが犯人だ」
ホワイトボードに縦線を四本引いた。
boot / first access / list view / refresh
今回は構造図ではなく、時間の表にしたかった。どの時点で、何を持つべきか。その問いの方が核心に近かったからだ。
ロックさんは boot の欄を指先で叩いた。
「コストを消したのではない。boot に移しただけだ。しかもメモリのかたちで、ワーカー数ぶん増幅している」
そこが、今回のいちばん嫌なところだった。
初回アクセスの遅さは、一件ずつの不満だった。起動時の全件 preload は、ワーカーが増えるたびに同じ重みを複製する。問題の場所が変わっただけではなく、増え方まで悪くなっている。
推理披露 — 鍵を渡すな、索引を渡せ
「じゃあ、全部 lazy にすれば終わりですか」
自分でも、少し短絡的な問いだと思った。
ロックさんはすぐには答えず、索引カードを一枚抜いた。
「一覧画面で500テナントを並べたら?」
「1行ごとに lazy access すれば、500回取りに行きます。N+1 です」
「そうだ。Lazy Loading は免罪符ではない。必要になる瞬間を遅らせるだけで、件数の責任までは消さない」
そこで、ようやく問題が分かれた。
- 単発アクセスでは、必要になった瞬間にだけ読む
- 高件数一覧では、最初から複数件をまとめて読む
- 更新後は、古い値を抱え続けないように捨てる
Lazy と Eager は、どちらか一方を信仰する話ではない。どの経路で、どの件数を、どの時点で読むかを分ける設計の話だった。
Value Holder: 重い payload をまだ持たない受け皿
ロックさんは保管箱の上に索引カードだけを置いた。
「証拠品そのものではなく、棚番号だけ持つ。まずはこれだ」
それが Value Holder だった。重い本体ではなく、「あとで取りに行くための受け皿」を先に持つ。
| |
この構造で変わるのは、重い payload の責任時刻だ。TenantPayloadHolder 自体は軽い。持っているのは store と tenant_id だけで、実データは get が呼ばれたときに初めて読み込まれる。
つまり、起動時には「どこにあるか」だけを持ち、「中身」は必要になるまで持たない。
「これで boot から重い処理が消えます」
「正確には、boot で背負う理由が消える」
ロックさんはそう言った。
その言い換えがよかった。ただ遅らせるのではない。boot に置く必然性がなくなるから、そこから外せる。
predicate と clearer が入っているのも重要だった。Lazy Loading は「読む仕組み」だけでは足りない。「もう古い」と分かったときに、捨てて再読込できなければ、ただの stale cache になる。
Virtual Proxy: 呼び出し側から遅延を隠す
でも、Holder をそのまま使うと、呼び出し側が毎回 ->get を意識することになる。
「呼び出し側に鍵の存在を見せたくないなら?」
そう聞くと、ロックさんは保管箱の鍵だけを机に置いた。
「鍵を回す手順は裏側で済ませる。見えるのは証拠品だけでいい」
そこで Virtual Proxy を使う。
| |
呼び出し側は TenantProfileProxy を TenantProfile のように扱う。display_name や report_rule_count を呼べばよく、その裏で holder が必要なら payload を取得する。
問題の性質がここでも変わる。Lazy Loading の都合が、呼び出し側の API まで汚染しなくなる。遅延の責任を Proxy の内側へ閉じ込められる。
「つまり、遅延の存在を知るべき場所を狭くできます」
「そうだ。indirection が広がるのではなく、隔離される」
Bulkhead 回の話と同じだった。今回の隔壁は、メモリでも接続数でもなく、責任境界に立つ。
Ghost: 同じオブジェクトのまま、中身だけ後で満たす
ただ、すべてを Proxy にすればよいわけでもなかった。
一覧画面では、テナントIDと表示名だけは最初から欲しい。そこまで隠すと、今度は軽い情報まで同じ重さで扱うことになる。
「ID と表示名は最初から持っていて、詳細だけ後で読みたい場合は?」
ロックさんは索引カードの見出しだけを上に向けた。
「名札は最初から首にかけておく。しかし分厚い調書は、呼ばれるまで開かない。それが Ghost だ」
| |
Ghost は「別オブジェクトに隠す」のではなく、「同じオブジェクトの中で、まだ空の部分を残す」やり方だ。identity は最初からある。重い詳細だけが、あとから hydrate される。
だから、表示名だけを並べる一覧では軽いままでいられる。いっぽうで、詳細ルールに踏み込んだ瞬間だけコストを払う。
この違いは小さく見えて、かなり重要だった。Proxy は indirection を外側に立てる。Ghost は indirection を自分の内側に埋め込む。どちらも lazy だが、責任の置き場所が違う。
Lazy は万能ではない — 高件数経路では prefetch する
ここまで来ると、「では全部 Ghost か Proxy にすればよい」と言いたくなる。
でも、それはさっきの問いに戻るだけだった。一覧画面で 500 件を舐めれば、500 回の lazy load が走る。N+1 は、lazy そのものの罪ではない。高件数ループの中で、無自覚に lazy access したことが問題だ。
だから、一覧系だけは別経路で batch prefetch を用意する。
| |
これで、単発アクセスでは遅延を使い、高件数経路では意図的にまとめて読む、という使い分けができる。
「結局、Eager と Lazy は対立ではないんですね」
「時間と件数の裁判だよ、ワトソン君。どちらが有罪かではない。どの経路に、どの責任を置くかだ」
そこまで言われて、ようやく今回の主犯がはっきりした。
主犯は Eager Loading そのものではない。起動時と実アクセス時を区別しなかったこと。単発参照と一覧表示を区別しなかったこと。つまり、経路ごとの責任境界を潰したことだった。
事件の終わり — 軽くなった起動、遅れて現れる故障
Phase 2 で組んだテストは、その違いをかなり素直に見せてくれた。
Before 側では、TenantDirectory->new(...) の時点で3件すべての fetch_profile が走る。要求されたのが tenant_a だけでも、tenant_b と tenant_c まで先に抱え込む。
After 側では、LazyTenantDirectory->new(...) の時点では重い payload を一件も読まない。tenant_a へ最初にアクセスした瞬間だけ1件読み、2回目はキャッシュを再利用する。clear_payload を呼べば、次回アクセス時にだけ再読込される。GhostTenantProfile も同じで、表示名だけなら軽いまま、詳細に触れた瞬間だけ hydrate される。
さらに、一覧向けには prefetch_profiles(...) を通すことで、個別 fetch を増やさずに済むことも確認した。
ホワイトボードの boot 42s を消して、boot 3s と書き直した。
それで終わりだと思いたかった。でも、ペンはそこで止まった。
遅い処理を起動時から追い出した結果、その失敗は初回アクセス時へ移った。つまり、「プロセスは立ち上がったが、最初の本物の要求を処理できるとは限らない」という新しい顔が出てきた。
「ロックさん。これで起動は軽くなります。でも……使えるかどうかは、最初のアクセスまで分からない場合がありますよね」
ロックさんは保管箱の蓋を閉めた。
「ようやく診断書の話ができる。故障を消したのではない。現れる時刻を変えただけなら、次は“健康だと名乗る根拠”を設計しなければならない」
それだけ言って、ロックさんは会議室を出ていった。
わたしはホワイトボードの右端に、小さく health? とだけ書いた。
起動は軽くなった。
でも、軽くなっただけではまだ足りない。どの瞬間に「使える」と判断するのか。その問いが、次の事件として残った。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| 起動時の全件 preload | Lazy Loading | 起動時間とメモリ消費を、実際に必要な範囲へ押し戻せる |
| payload を実体オブジェクトへ即時展開 | Value Holder | 重いデータを必要になるまで読まずに済む |
| 遅延の都合が呼び出し側へ漏れる API | Virtual Proxy | 呼び出し側のインターフェースを保ったまま lazy 化できる |
| identity と詳細が密結合した重いオブジェクト | Ghost | 軽い識別情報だけ先に持ち、重い詳細だけ後で hydrate できる |
| すべての経路を一律 lazy にする設計 | 明示的 prefetch | 一覧系の N+1 を避け、経路ごとに最適な読み方を選べる |
推理のステップ
- 起動時に読んでいる処理を洗い出し、単発アクセスと高件数経路を分離する
- 単発アクセスの重い payload を Value Holder へ退避し、
lazyとbuilderで必要時だけ読む predicateとclearerを付けて、読んだかどうかと無効化の責任を明示する- 呼び出し側の API を変えたくない場所は Virtual Proxy で委譲する
- identity を先に見せたいドメインオブジェクトは Ghost 化し、詳細だけを後で hydrate する
- 一覧系では lazy access を並べず、明示的 prefetch を別経路で用意する
- 遅延した失敗がどこで表面化するかを把握し、監視やヘルスチェックへつなぐ
ロックより
すべての証拠品を最初から机へ並べる探偵は、必要な瞬間を見失う。コードも同じだ。持てるから持つのではない。どの時刻に、どの責任で持つのかを決めたまえ。
ただし、隠したコストは消えない。別の時刻に現れるだけだ。起動を軽くしたなら、次は「いつ健康だと言うのか」を誤るな。そこを曖昧にすると、また別の事件が始まる。
