Featured image of post 起動時に全件を先読みし、起動に42秒、メモリは1.3GB超。必要になってから読む【Lazy Loading】

起動時に全件を先読みし、起動に42秒、メモリは1.3GB超。必要になってから読む【Lazy Loading】

コード探偵ロックの事件簿:デプロイのたびにワーカーが全テナントのデータを先読みし、起動に42秒、メモリは 1.3GB を超えていた。実際に触るのはその一部だけ。重いデータは必要になるまで読まず、一覧の経路だけは明示的に先読みする(Lazy Loading)。

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

金曜の午後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;
}

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

 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 自体は軽い。持っているのは store と tenant_id だけで、実データは get が呼ばれたときに初めて読み込まれる。

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

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

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

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

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

predicate と clearer が入っているのも重要だった。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',
    },
);

呼び出し側は TenantProfileProxy を TenantProfile のように扱う。display_name や report_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_b と tenant_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 へ退避し、lazy と builder で必要時だけ読む
  3. predicate と clearer を付けて、読んだかどうかと無効化の責任を明示する
  4. 呼び出し側の API を変えたくない場所は Virtual Proxy で委譲する
  5. identity を先に見せたいドメインオブジェクトは Ghost 化し、詳細だけを後で hydrate する
  6. 一覧系では lazy access を並べず、明示的 prefetch を別経路で用意する
  7. 遅延した失敗がどこで表面化するかを把握し、監視やヘルスチェックへつなぐ

ロックより

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

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

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource へ
Hugo で構築されています。
テーマ Stack は Jimmy によって設計されています。