Featured image of post コード探偵ロックの事件簿【Lazy Loading】眠る証拠棚〜積みすぎた先読みの崩落〜

コード探偵ロックの事件簿【Lazy Loading】眠る証拠棚〜積みすぎた先読みの崩落〜

全テナントの設定を起動時に先読みして重くなったワーカーを、Lazy LoadingとProxy/Holder/Ghostで必要時だけ読む設計へ改めます

ロールアウト前夜 — 眠る棚の重さ

金曜の午後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 する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package TenantDirectory;
use v5.36;
use Moo;
use Types::Standard qw(ArrayRef HashRef Object);

use TenantProfile;

has store => (is => 'ro', isa => Object, required => 1);
has tenant_ids => (is => 'ro', isa => ArrayRef, required => 1);
has profiles_by_id => (is => 'rw', isa => HashRef, default => sub { {} });

sub BUILD ($self, $args) {
    my %profiles = map {
        my $payload = $self->store->fetch_profile($_);
        $_ => TenantProfile->new(%$payload);
    } $self->tenant_ids->@*;

    $self->profiles_by_id(\%profiles);
    return;
}

TenantDirectorynew した瞬間に、全テナントの 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 だった。重い本体ではなく、「あとで取りに行くための受け皿」を先に持つ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package TenantPayloadHolder;
use v5.36;
use Moo;
use Types::Standard qw(HashRef Object Str);

has store => (is => 'ro', isa => Object, required => 1);
has tenant_id => (is => 'ro', isa => Str, required => 1);

has _payload => (
    is        => 'rwp',
    isa       => HashRef,
    lazy      => 1,
    builder   => '_build__payload',
    init_arg  => undef,
    predicate => 'has_payload',
    clearer   => 'clear_payload',
);

sub _build__payload ($self) {
    return $self->store->fetch_profile($self->tenant_id);
}

sub get ($self) {
    return $self->_payload;
}

この構造で変わるのは、重い payload の責任時刻だ。TenantPayloadHolder 自体は軽い。持っているのは storetenant_id だけで、実データは get が呼ばれたときに初めて読み込まれる。

つまり、起動時には「どこにあるか」だけを持ち、「中身」は必要になるまで持たない。

「これで boot から重い処理が消えます」

「正確には、boot で背負う理由が消える」

ロックさんはそう言った。

その言い換えがよかった。ただ遅らせるのではない。boot に置く必然性がなくなるから、そこから外せる。

predicateclearer が入っているのも重要だった。Lazy Loading は「読む仕組み」だけでは足りない。「もう古い」と分かったときに、捨てて再読込できなければ、ただの stale cache になる。

Virtual Proxy: 呼び出し側から遅延を隠す

でも、Holder をそのまま使うと、呼び出し側が毎回 ->get を意識することになる。

「呼び出し側に鍵の存在を見せたくないなら?」

そう聞くと、ロックさんは保管箱の鍵だけを机に置いた。

「鍵を回す手順は裏側で済ませる。見えるのは証拠品だけでいい」

そこで Virtual Proxy を使う。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package TenantProfileProxy;
use v5.36;
use Moo;
use Types::Standard qw(InstanceOf);

has holder => (
    is       => 'ro',
    isa      => InstanceOf['TenantPayloadHolder'],
    required => 1,
    handles  => {
        display_name       => 'display_name',
        report_rule_count  => 'report_rule_count',
        can_access_console => 'can_access_console',
    },
);

呼び出し側は TenantProfileProxyTenantProfile のように扱う。display_namereport_rule_count を呼べばよく、その裏で holder が必要なら payload を取得する。

問題の性質がここでも変わる。Lazy Loading の都合が、呼び出し側の API まで汚染しなくなる。遅延の責任を Proxy の内側へ閉じ込められる。

「つまり、遅延の存在を知るべき場所を狭くできます」

「そうだ。indirection が広がるのではなく、隔離される」

Bulkhead 回の話と同じだった。今回の隔壁は、メモリでも接続数でもなく、責任境界に立つ。

Ghost: 同じオブジェクトのまま、中身だけ後で満たす

ただ、すべてを Proxy にすればよいわけでもなかった。

一覧画面では、テナントIDと表示名だけは最初から欲しい。そこまで隠すと、今度は軽い情報まで同じ重さで扱うことになる。

「ID と表示名は最初から持っていて、詳細だけ後で読みたい場合は?」

ロックさんは索引カードの見出しだけを上に向けた。

「名札は最初から首にかけておく。しかし分厚い調書は、呼ばれるまで開かない。それが Ghost だ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package GhostTenantProfile;
use v5.36;
use Moo;
use Types::Standard qw(HashRef Object Str);

has store => (is => 'ro', isa => Object, required => 1);
has tenant_id => (is => 'ro', isa => Str, required => 1);
has display_name => (is => 'ro', isa => Str, required => 1);

has _details => (
    is        => 'rwp',
    isa       => HashRef,
    lazy      => 1,
    builder   => '_build__details',
    init_arg  => undef,
    predicate => 'has_details',
    clearer   => 'clear_details',
);

sub _build__details ($self) {
    return $self->store->fetch_profile_details($self->tenant_id);
}

sub report_rule_count ($self) {
    return scalar $self->_details->{report_rules}->@*;
}

Ghost は「別オブジェクトに隠す」のではなく、「同じオブジェクトの中で、まだ空の部分を残す」やり方だ。identity は最初からある。重い詳細だけが、あとから hydrate される。

だから、表示名だけを並べる一覧では軽いままでいられる。いっぽうで、詳細ルールに踏み込んだ瞬間だけコストを払う。

この違いは小さく見えて、かなり重要だった。Proxy は indirection を外側に立てる。Ghost は indirection を自分の内側に埋め込む。どちらも lazy だが、責任の置き場所が違う。

Lazy は万能ではない — 高件数経路では prefetch する

ここまで来ると、「では全部 Ghost か Proxy にすればよい」と言いたくなる。

でも、それはさっきの問いに戻るだけだった。一覧画面で 500 件を舐めれば、500 回の lazy load が走る。N+1 は、lazy そのものの罪ではない。高件数ループの中で、無自覚に lazy access したことが問題だ。

だから、一覧系だけは別経路で batch prefetch を用意する。

1
2
3
4
5
6
7
8
9
sub prefetch_profiles ($self, @tenant_ids) {
    my $payloads = $self->store->fetch_profiles(\@tenant_ids);

    for my $payload ($payloads->@*) {
        $self->profile_for($payload->{tenant_id})->holder->prime($payload);
    }

    return;
}

これで、単発アクセスでは遅延を使い、高件数経路では意図的にまとめて読む、という使い分けができる。

「結局、Eager と Lazy は対立ではないんですね」

「時間と件数の裁判だよ、ワトソン君。どちらが有罪かではない。どの経路に、どの責任を置くかだ」

そこまで言われて、ようやく今回の主犯がはっきりした。

主犯は Eager Loading そのものではない。起動時と実アクセス時を区別しなかったこと。単発参照と一覧表示を区別しなかったこと。つまり、経路ごとの責任境界を潰したことだった。

事件の終わり — 軽くなった起動、遅れて現れる故障

Phase 2 で組んだテストは、その違いをかなり素直に見せてくれた。

Before 側では、TenantDirectory->new(...) の時点で3件すべての fetch_profile が走る。要求されたのが tenant_a だけでも、tenant_btenant_c まで先に抱え込む。

After 側では、LazyTenantDirectory->new(...) の時点では重い payload を一件も読まない。tenant_a へ最初にアクセスした瞬間だけ1件読み、2回目はキャッシュを再利用する。clear_payload を呼べば、次回アクセス時にだけ再読込される。GhostTenantProfile も同じで、表示名だけなら軽いまま、詳細に触れた瞬間だけ hydrate される。

さらに、一覧向けには prefetch_profiles(...) を通すことで、個別 fetch を増やさずに済むことも確認した。

ホワイトボードの boot 42s を消して、boot 3s と書き直した。

それで終わりだと思いたかった。でも、ペンはそこで止まった。

遅い処理を起動時から追い出した結果、その失敗は初回アクセス時へ移った。つまり、「プロセスは立ち上がったが、最初の本物の要求を処理できるとは限らない」という新しい顔が出てきた。

「ロックさん。これで起動は軽くなります。でも……使えるかどうかは、最初のアクセスまで分からない場合がありますよね」

ロックさんは保管箱の蓋を閉めた。

「ようやく診断書の話ができる。故障を消したのではない。現れる時刻を変えただけなら、次は“健康だと名乗る根拠”を設計しなければならない」

それだけ言って、ロックさんは会議室を出ていった。

わたしはホワイトボードの右端に、小さく health? とだけ書いた。

起動は軽くなった。

でも、軽くなっただけではまだ足りない。どの瞬間に「使える」と判断するのか。その問いが、次の事件として残った。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
起動時の全件 preloadLazy Loading起動時間とメモリ消費を、実際に必要な範囲へ押し戻せる
payload を実体オブジェクトへ即時展開Value Holder重いデータを必要になるまで読まずに済む
遅延の都合が呼び出し側へ漏れる APIVirtual Proxy呼び出し側のインターフェースを保ったまま lazy 化できる
identity と詳細が密結合した重いオブジェクトGhost軽い識別情報だけ先に持ち、重い詳細だけ後で hydrate できる
すべての経路を一律 lazy にする設計明示的 prefetch一覧系の N+1 を避け、経路ごとに最適な読み方を選べる

推理のステップ

  1. 起動時に読んでいる処理を洗い出し、単発アクセスと高件数経路を分離する
  2. 単発アクセスの重い payload を Value Holder へ退避し、lazybuilder で必要時だけ読む
  3. predicateclearer を付けて、読んだかどうかと無効化の責任を明示する
  4. 呼び出し側の API を変えたくない場所は Virtual Proxy で委譲する
  5. identity を先に見せたいドメインオブジェクトは Ghost 化し、詳細だけを後で hydrate する
  6. 一覧系では lazy access を並べず、明示的 prefetch を別経路で用意する
  7. 遅延した失敗がどこで表面化するかを把握し、監視やヘルスチェックへつなぐ

ロックより

すべての証拠品を最初から机へ並べる探偵は、必要な瞬間を見失う。コードも同じだ。持てるから持つのではない。どの時刻に、どの責任で持つのかを決めたまえ。

ただし、隠したコストは消えない。別の時刻に現れるだけだ。起動を軽くしたなら、次は「いつ健康だと言うのか」を誤るな。そこを曖昧にすると、また別の事件が始まる。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。