Featured image of post コードドクター【Proxy】代謝バイパス欠損症〜最速エンジニアの重複クエリ地獄〜

コードドクター【Proxy】代謝バイパス欠損症〜最速エンジニアの重複クエリ地獄〜

往診

俺は速さに関しては一切妥協しない男だ。

矢嶋隼人、29歳。中堅SIerでバックエンド開発を6年やっている。社内ダッシュボードシステムのバックエンドを一人で担当していて、俺のコードは 社内最速 だと自負している。EXPLAIN ANALYZE は毎朝の日課。クエリのベンチマーク結果をSlackに貼るのが趣味みたいなものだ。

「1クエリ0.3ms」

そのスクリーンショットに今朝も「いいね」が3つついた。悪くない。

問題は——全体が遅いことだった。

当初10人程度で使っていたダッシュボードが経営層の目に留まり、全社200人に展開されることになった。途端にDBサーバのCPUが天井に張り付き始めた。俺はインフラチームに増強申請を出した。却下された。理由は「コードを見直してください」。

意味がわからない。俺のSQLは1本1本が最適化されている。ORMなんてオーバーヘッドは使わない、DBI経由の生SQL直書き。これ以上何を見直せっていうんだ。

金曜日の夕方、EXPLAIN ANALYZE の結果を睨んでいたときだった。フロアの入口に、見知らぬ二人が立っていた。

白衣の男と、ビジネスカジュアルの女性。

「……どちら様ですか?」

「大丈夫ですよ、ここはコード診療所です……あ、いえ、往診ですね」

女性——助手のナナコと名乗った——がそう言った。

「往診? いや、呼んでないけど……」

「失礼ですが、バックエンドをご担当の方ですよね? お名前を伺ってもよろしいですか?」

「……矢嶋だけど」

名乗ったのは警戒心からではない。ナナコさんの笑顔があまりに自然で、つい反射的に答えてしまっただけだ。

白衣の男はノーリアクションだった。というか、俺の言葉が聞こえていないかのように、隣のモニタを凝視していた。サーバ監視画面。CPU使用率のグラフが壁に張り付いている。

「遅い。」

男が言った。一言だけ。

血が逆流するかと思った。

「はあ? 0.3msのクエリのどこが遅いんだっつーの!」

男——ドクターとやらは、俺の怒声を完全に無視して、監視画面のグラフを指で弾いた。CPU 98%。メモリ使用量、右肩上がり。

ナナコさんがそっと割って入った。

「矢嶋さん、先生が指しているのはクエリ1本の速度ではなくて、サーバ全体の状態なんです。1本1本は速くても、合計の負荷が問題になることがあるんですよ」

「合計? 俺のクエリは余計なことしてないぞ。必要な分だけ必要なタイミングで取ってるだけだ」

ドクターは無言で俺の椅子に座り、ターミナルを開いた。

おい、勝手に座るな。

触診

ドクターがリポジトリを開いた。無言で、grep を叩く。

1
2
$ grep -rn '\$dbh->' lib/ | wc -l
47

「47回。」

ドクターが指でモニタを弾いた。

「47回DBに問い合わせてるってことだろ? そりゃダッシュボードのモジュール全体でそれくらいあるだろ。当然じゃないか」

ドクターは答えなかった。代わりに、1つのHTTPリクエストで何本のSQLが発行されるかをカウントするワンライナーを打ち込んだ。

結果が出た。

SELECT * FROM exchange_rates WHERE currency=? — 6回

俺は画面を二度見した。

「……は?」

ナナコさんが穏やかに言った。

「矢嶋さん、たとえるなら——毎食ごとにスーパーまで買い物に行っているような状態ですね。冷蔵庫がないので、朝・昼・晩、同じ牛乳を3回買いに行っている。1回の買い物は速いですけど、移動時間の合計が問題なんです」

「いや、それは……各モジュールが独立してデータを取得してるだけで……」

ドクターがもう一発 grep を叩いた。

1
2
3
4
5
6
$ grep -rn 'get_exchange_rate' lib/
lib/Dashboard/Sales.pm:43:    my $rate = _fetch_exchange_rate('USD');
lib/Dashboard/Report.pm:28:   my $rate = _fetch_exchange_rate('USD');
lib/Dashboard/Export.pm:15:    my $rate = _fetch_exchange_rate('USD');
lib/Dashboard/Summary.pm:52:   my $rate = _fetch_exchange_rate('USD');
lib/Dashboard/Chart.pm:31:     my $rate = _fetch_exchange_rate('USD');

5つの異なるモジュールに、同じ関数が コピペ されていた。

俺は知っていた。知っていたが、見ないふりをしていた。「各モジュールが独立して高速に動く」——それが俺の設計思想だった。依存関係を減らすために、データ取得は各モジュールに閉じ込める。YAGNI。共通化なんて必要になったらやればいい。

「独立。問題はここだ。」

ドクターが短く言った。

ナナコさんが続けた。

「栄養——つまりデータを、消化器官を通さず直接血管に流し込んでいる状態です。消化器官がないので、同じ栄養を何度も取り込んでしまう。毒素……つまりエラーの濾過もできません」

「代謝バイパス欠損。」

ドクターが診断名を告げた。

grepの結果を見て愕然とする矢嶋と、冷静に指し示すドクター、穏やかに解説するナナコ

俺は、自分のベンチマーク結果を思い出した。「1クエリ0.3ms」。確かに速い。だがそれが6回走れば1.8ms。200人が同時にアクセスすれば……。

「……マイクロ秒を削って、マクロを見失ってたってことかよ」

誰に言うともなく呟いた。ドクターが一瞬だけこちらを見たが、何も言わなかった。

外科手術

ドクターがエディタに向き合った。俺は「勝手に触るな」と言いたかったが、47回の grep と6回の重複クエリが頭をよぎって、言葉が出なかった。

最初に書かれたのは、こんなコードだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package Dashboard::DataSource;
use v5.36;

sub new ($class, %args) {
    return bless \%args, $class;
}

sub fetch_sales_summary ($self, $year, $month) { ... }

sub fetch_exchange_rate ($self, $currency) { ... }

1;

「……インターフェースか?」

「はい。データの取得先の 窓口 を定義しているんです。実際のDBでも、テスト用の偽物でも、同じ窓口を通るようにするための土台ですね」

ナナコさんが説明した。俺は少しだけ納得した。だが、まだ腑に落ちていなかった。これだけならただの抽象化だ。俺が嫌いな「不要なレイヤー」にしか見えない。

次にドクターが書いたのは、本物のDB接続モジュールだった。

 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
27
28
29
30
31
package Dashboard::DataSource::Real;
use v5.36;
use parent 'Dashboard::DataSource';

use DBI;
use HTTP::Tiny;
use JSON::PP qw(decode_json);

sub fetch_sales_summary ($self, $year, $month) {
    my $dbh = DBI->connect($self->{dsn}, "", "", { RaiseError => 1 });
    my $rows = $dbh->selectall_arrayref(
        "SELECT product, SUM(amount) as total
         FROM sales WHERE year=? AND month=?
         GROUP BY product",
        { Slice => {} }, $year, $month
    );
    $dbh->disconnect;
    return $rows;
}

sub fetch_exchange_rate ($self, $currency) {
    my $http = HTTP::Tiny->new(timeout => 5);
    my $res  = $http->get(
        "https://api.exchange.example.com/latest?base=JPY&symbols=$currency"
    );
    die "API error: $res->{status}" unless $res->{success};
    my $data = decode_json($res->{content});
    return $data->{rates}{$currency};
}

1;

「これ、俺が書いてた処理と同じじゃねえか」

「その通りです。ここまでは矢嶋さんのコードと本質的に変わりません」

ナナコさんが頷いた。

「問題は次です」

ドクターの指が加速した。

 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
27
28
29
30
31
32
package Dashboard::DataSource::Cached;
use v5.36;
use parent 'Dashboard::DataSource';

sub new ($class, %args) {
    my $real = delete $args{real_source}
        // die "real_source is required";
    my $self = $class->SUPER::new(%args);
    $self->{real}  = $real;
    $self->{cache} = {};
    return $self;
}

sub fetch_sales_summary ($self, $year, $month) {
    my $key = "sales:$year:$month";
    return $self->{cache}{$key} if exists $self->{cache}{$key};

    my $result = $self->{real}->fetch_sales_summary($year, $month);
    $self->{cache}{$key} = $result;
    return $result;
}

sub fetch_exchange_rate ($self, $currency) {
    my $key = "rate:$currency";
    return $self->{cache}{$key} if exists $self->{cache}{$key};

    my $result = $self->{real}->fetch_exchange_rate($currency);
    $self->{cache}{$key} = $result;
    return $result;
}

1;

俺はコードを読んだ。二度読んだ。

「待て。これ……同じキーで2回呼んだら、2回目はDBに行かないってことか?」

ナナコさんが微笑んだ。

「そうですよ。冷蔵庫の完成です。一度買った牛乳は、次は冷蔵庫から取るだけです」

冷蔵庫。さっきの比喩が急に腹に落ちた。俺のコードには冷蔵庫がなかった。毎回スーパーまで走っていた。1回の買い物では最速でも、6回走ればトータルで遅い。

「しかも——」

ナナコさんが続けた。

「この CachedDataSource と同じインターフェースを持っています。呼び出し元を一切変えずに差し込めるんです」

「……Proxy か」

その言葉は自然に口から出た。本体と同じ顔をした代理人。呼び出し元からは区別がつかない。だがその裏側で、キャッシュが静かに仕事をしている。

ドクターが小さく頷いた。

「もう一つ」

ドクターがさらにもう1ファイル書いた。

 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
27
28
29
30
31
32
33
34
35
package Dashboard::DataSource::Mock;
use v5.36;
use parent 'Dashboard::DataSource';

sub new ($class, %args) {
    my $self = $class->SUPER::new(%args);
    $self->{sales_data} = delete $args{sales_data}  // [];
    $self->{rates}      = delete $args{rates}       // {};
    $self->{call_log}   = [];
    return $self;
}

sub fetch_sales_summary ($self, $year, $month) {
    push $self->{call_log}->@*, {
        method => 'fetch_sales_summary',
        args   => [$year, $month],
    };
    return $self->{sales_data};
}

sub fetch_exchange_rate ($self, $currency) {
    push $self->{call_log}->@*, {
        method => 'fetch_exchange_rate',
        args   => [$currency],
    };
    return $self->{rates}{$currency}
        // die "Unknown currency: $currency";
}

sub call_count ($self, $method) {
    return scalar grep { $_->{method} eq $method }
        $self->{call_log}->@*;
}

1;

「テスト用……?」

「はい。実際のDBやAPIに接続しなくても、同じインターフェースでテストが回せます。偽物の消化器官を差し込むイメージですね」

俺は思い出した。テストを書こうとするたびに「実DBがないと動かない」で諦めていたことを。CIでテストが走らないのは、俺のコードが直接DBをぶっ叩いていたからだ。

「Proxy を差し替えるだけで……テストが回る」

ドクターは何も言わなかった。ただキーボードを叩き続けていた。

ここで、ちょっとした出来事があった。

コーディング中、俺のデスクに缶コーヒーを2本置いてあった。午後の作業用に買っておいたやつだ。ドクターはコードを書く手を止め、ふと缶コーヒーに目をやると、1本を取り上げた。プシュッと開けて飲み始めた。

……いや、それ俺のなんだけど。

ナナコさんがすっと席を立ち、自販機に走って行った。戻ってきたとき、代わりの缶コーヒーを俺のデスクに置いた。

「すみません、いつものことなんです」

小声でそう言った。

ドクターはナナコさんが缶コーヒーを買ってきたのを見て、満足げに頷いた。どう見ても自分のために買ってきてくれたと思っている顔に見えた。

……何なんだこの空間。

術後経過

ダッシュボードを起動した。ドクターが組み替えた構造で、全モジュールが Cached Proxy 経由でデータにアクセスするようになっている。

1
2
3
4
5
6
# ダッシュボード起動時
my $real   = Dashboard::DataSource::Real->new(dsn => $DSN);
my $source = Dashboard::DataSource::Cached->new(real_source => $real);
my $app    = Dashboard->new(data_source => $source);

my $result = $app->render(2026, 2);

サーバ監視画面を見た。CPU使用率のグラフが——壁に張り付いていた線が、崖のように落ちている。

「……嘘だろ」

ナナコさんが数字を読み上げた。

「DBへの問い合わせ回数、ページ表示1回あたり6回から1回に減っています。キャッシュヒット率は83%です」

6回が1回。為替レートのAPIコールは4回が2回。合計10回のデータ取得が3回に。

「速い……いや、1クエリの速度は変わってないよな?」

「変えてない。」

ドクターが短く答えた。

「なのに全体が……」

「無駄を省いただけだ。」

ナナコさんが補足した。

「マラソンランナーが速くなったんじゃなくて、同じコースを6周走っていたのを1周にしたようなものですね」

俺はSlackに貼った自分のベンチマーク結果を思い出した。「1クエリ0.3ms」。あの数字は嘘じゃなかった。だが、あの数字に満足して、全体を見なかった。木を見て森を見ず——いや、SQL1本を見てシステム全体を見なかった。

ドクターが静かに鞄を持ち上げた。

「テスト。」

一言だけ残した。

「先生は金銭ではなく、コードの品質でお返しをいただくんです。今回でしたら、Caching Proxy が正しくキャッシュヒットしていること、Mock Proxy でCIが回ることを確認するテストを書いていただければ」

ナナコさんが微笑んで言った。

「……わかった。テストは書く。つーか書きたい。Proxy って言葉は知ってたけど、こうやって自分のコードに使うもんだとは思ってなかった。頭でっかちだったんだな、俺は」

俺は立ち上がって頭を下げた。

「ありがとうございました」

「感謝は、このコードに。」

ドクターはそれだけ言って、フロアを出て行った。ナナコさんが軽く会釈して続いた。

俺は自席に戻り、テストコードを書き始めた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
my $mock = Dashboard::DataSource::Mock->new(
    sales_data => [{ product => 'Widget', total => 1000 }],
    rates      => { USD => 0.0067 },
);

my $cached = Dashboard::DataSource::Cached->new(real_source => $mock);

$cached->fetch_sales_summary(2026, 1);
$cached->fetch_sales_summary(2026, 1);
$cached->fetch_sales_summary(2026, 1);

is($mock->call_count('fetch_sales_summary'), 1,
   'real source called only once despite 3 calls');

3回呼んでも、本体に到達するのは1回だけ。テストが緑色に光った。

速さは正義だ。その信念は変わらない。だが、速さの意味を俺は勘違いしていた。マイクロ秒を削ることじゃない。無駄を消すこと だ。

代理(Proxy)ってのは——俺のコードに足りなかった、消化器官だったんだ。


処方箋まとめ

症状適用すべき経過観察
同じデータを複数箇所から重複取得している
外部API/DBへの呼び出しが散乱してテスト不能
エラーハンドリングやリトライが各所にコピペされている
レートリミットやアクセス制御を一元管理したい
データ取得が1箇所からだけで重複がない
テスト時にモック差し替えが既にできている

治療のステップ

  1. Subject インターフェースの定義 — RealSubject と Proxy が共通で持つメソッドを決める
  2. RealSubject の実装 — 実際のDB/API接続を行う本体を1箇所にまとめる
  3. Caching Proxy の実装 — 同一引数の呼び出し結果をキャッシュし、2回目以降は本体に問い合わせない
  4. Mock Proxy の実装 — テスト用の偽データソースを作成し、呼び出し履歴を記録できるようにする
  5. クライアントの修正 — データソースをDI(依存性注入)で受け取るように変更し、Proxy を差し替え可能にする
  6. テストの追加 — キャッシュヒットの検証、Mock での呼び出し回数の検証、統合テストを実装する

助手より

矢嶋さん、お疲れさまでした。「速さは正義」という信念、間違いではないですよ。ただ、速さにはミクロとマクロの二つの視点があるんです。1本のクエリを0.3msに磨き上げる技術と、不要な呼び出しを消す設計——その両方が揃ったとき、本当の意味で「最速」になるんだと思います。

テストがCIで回るようになったら、きっとSlackに貼るベンチマーク結果も変わりますね。今度はシステム全体のレスポンスタイムを、ぜひ。

——ナナコ

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