Featured image of post 第6回-APIキャッシュ戦略 - Cache::LRUとTTL

第6回-APIキャッシュ戦略 - Cache::LRUとTTL

PerlでAPIキャッシュ戦略を実装。Cache::LRUとTTL(有効期限)管理により、APIリクエスト数を削減しパフォーマンスを最適化する方法を解説。

前回の振り返り

前回は、Facadeパターンを使って複数のAdapterを統合管理しました。呼び出し側はget_weatherを呼ぶだけで、フォールバック処理が自動的に行われるようになりました。

今回は、パフォーマンスを向上させるためにキャッシュ機能を追加します。

今回の目標

第6回となる今回は、同じ都市への問い合わせを効率化するキャッシュ機能を実装します。5分間は同じ結果を返すことで、APIへのリクエスト回数を削減します。

なぜキャッシュが必要か

天気予報アプリケーションの利用シーンを考えてみましょう。

シナリオ: ニュースサイトの天気ウィジェット

1
2
3
4
# トップページの各セクションで天気を表示
show_weather_widget('Tokyo');  # ヘッダー
show_weather_widget('Tokyo');  # サイドバー
show_weather_widget('Tokyo');  # フッター

同じ都市の天気を何度も取得しています。毎回APIにリクエストを送ると以下の問題があります。

問題点

問題影響
API呼び出し回数増加無料プランの上限に達しやすい
レスポンス遅延ユーザー体験の悪化
外部依存APIがダウンするとすべて失敗

解決策: キャッシュ

一度取得したデータを一定時間保持し、同じリクエストには保持したデータを返します。

1
2
3
4
5
1回目: Tokyo → API呼び出し → キャッシュに保存 → 結果を返す
2回目: Tokyo → キャッシュにある → キャッシュから返す(API呼び出しなし)
3回目: Tokyo → キャッシュにある → キャッシュから返す(API呼び出しなし)
...
5分後: Tokyo → キャッシュ期限切れ → API呼び出し → キャッシュ更新

キャッシュの基本実装

まず、シンプルなハッシュを使ったキャッシュを実装してみましょう。

 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 WeatherFacade {
    use v5.36;
    use Moo;

    has adapters => (is => 'ro', required => 1);
    has _cache   => (is => 'ro', default => sub { {} });

    sub get_weather ($self, $city) {
        # キャッシュにあればそれを返す
        if (exists $self->_cache->{$city}) {
            return $self->_cache->{$city};
        }

        # キャッシュになければAPIから取得
        for my $adapter ($self->adapters->@*) {
            my $weather = $adapter->get_weather($city);
            if ($weather) {
                # キャッシュに保存
                $self->_cache->{$city} = $weather;
                return $weather;
            }
        }
        return;
    }
}

これで基本的なキャッシュは動作しますが、問題があります。

問題: キャッシュが永遠に有効

天気情報は時間とともに変化します。1時間前のデータを返し続けるのは不適切です。キャッシュには有効期限(TTL: Time To Live)が必要です。

TTL付きキャッシュの実装

キャッシュエントリに取得時刻を記録し、一定時間経過後は無効とします。

 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 WeatherFacade {
    use v5.36;
    use Moo;

    has adapters  => (is => 'ro', required => 1);
    has cache_ttl => (is => 'ro', default => 300);  # 5分 = 300秒
    has _cache    => (is => 'ro', default => sub { {} });

    sub get_weather ($self, $city) {
        # キャッシュをチェック
        if (my $cached = $self->_cache->{$city}) {
            my $age = time() - $cached->{cached_at};
            if ($age < $self->cache_ttl) {
                # まだ有効
                return $cached->{data};
            }
            # 期限切れ → キャッシュから削除
            delete $self->_cache->{$city};
        }

        # APIから取得
        for my $adapter ($self->adapters->@*) {
            my $weather = $adapter->get_weather($city);
            if ($weather) {
                # キャッシュに保存(取得時刻付き)
                $self->_cache->{$city} = {
                    data      => $weather,
                    cached_at => time(),
                };
                return $weather;
            }
        }
        return;
    }
}

ポイント

  • cache_ttl: キャッシュの有効期間(秒)。デフォルトは300秒(5分)
  • cached_at: データを取得した時刻(エポック秒)
  • キャッシュエントリは{ data => $weather, cached_at => $time }の形式

Cache::LRUを使った実装

実際のアプリケーションでは、キャッシュサイズの管理も重要です。無制限にキャッシュするとメモリを圧迫します。

Cache::LRUモジュールを使うと、LRU(Least Recently Used)アルゴリズムでキャッシュサイズを自動管理できます。

Cache::LRUとは

LRU(Least Recently Used)は「最も長く使われていないものを削除する」アルゴリズムです。

  • キャッシュが上限に達したとき、最も古いエントリを自動削除
  • 頻繁にアクセスされるデータは残る
  • メモリ使用量を一定に保てる

実装

  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
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
#!/usr/bin/env perl
use v5.36;

# 天気サービスの共通インターフェース(Role)
package WeatherAdapter::Role {
    use v5.36;
    use Moo::Role;

    requires 'get_weather';
    requires 'name';
}

# OpenWeatherMap用Adapter
package WeatherAdapter::OpenWeatherMap {
    use v5.36;
    use Moo;

    has name => (is => 'ro', default => 'OpenWeatherMap');

    with 'WeatherAdapter::Role';

    sub _get_raw_data ($self, $city) {
        my %mock_data = (
            'Tokyo'   => { name => 'Tokyo',   main => { temp => 25.5, humidity => 60 }, weather => [{ description => '晴れ' }] },
            'Osaka'   => { name => 'Osaka',   main => { temp => 27.2, humidity => 65 }, weather => [{ description => '曇り' }] },
            'Nagoya'  => { name => 'Nagoya',  main => { temp => 26.8, humidity => 62 }, weather => [{ description => '晴れ' }] },
        );
        return $mock_data{$city};
    }

    sub get_weather ($self, $city) {
        my $data = $self->_get_raw_data($city);
        return unless $data;
        return {
            city        => $data->{name},
            temperature => $data->{main}{temp},
            humidity    => $data->{main}{humidity},
            condition   => $data->{weather}[0]{description},
            source      => $self->name,
        };
    }
}

# WeatherStack用Adapter
package WeatherAdapter::WeatherStack {
    use v5.36;
    use Moo;

    has name => (is => 'ro', default => 'WeatherStack');

    with 'WeatherAdapter::Role';

    sub _get_raw_data ($self, $city) {
        my %mock_data = (
            'Tokyo'   => { location => { name => 'Tokyo' },   current => { temperature => 26, humidity => 58, weather_descriptions => ['Sunny'] } },
            'Sapporo' => { location => { name => 'Sapporo' }, current => { temperature => 19, humidity => 68, weather_descriptions => ['Rain'] } },
            'Sendai'  => { location => { name => 'Sendai' },  current => { temperature => 22, humidity => 65, weather_descriptions => ['Cloudy'] } },
        );
        return $mock_data{$city};
    }

    sub get_weather ($self, $city) {
        my $data = $self->_get_raw_data($city);
        return unless $data;
        return {
            city        => $data->{location}{name},
            temperature => $data->{current}{temperature},
            humidity    => $data->{current}{humidity},
            condition   => $data->{current}{weather_descriptions}[0],
            source      => $self->name,
        };
    }
}

# キャッシュ付きFacade
package WeatherFacade {
    use v5.36;
    use Moo;

    has adapters   => (is => 'ro', required => 1);
    has cache_ttl  => (is => 'ro', default => 300);   # 5分
    has max_size   => (is => 'ro', default => 100);   # 最大100エントリ
    has _cache     => (is => 'ro', default => sub { {} });
    has _cache_order => (is => 'ro', default => sub { [] });  # LRU順序管理
    has _stats     => (is => 'ro', default => sub { { hits => 0, misses => 0 } });

    # キャッシュから取得(LRU更新)
    sub _get_from_cache ($self, $city) {
        return unless exists $self->_cache->{$city};

        my $cached = $self->_cache->{$city};
        my $age = time() - $cached->{cached_at};

        if ($age >= $self->cache_ttl) {
            # 期限切れ
            $self->_remove_from_cache($city);
            return;
        }

        # LRU順序を更新(最近使用したものを末尾に)
        $self->_cache_order->@* = grep { $_ ne $city } $self->_cache_order->@*;
        push $self->_cache_order->@*, $city;

        return $cached->{data};
    }

    # キャッシュに保存
    sub _set_to_cache ($self, $city, $data) {
        # サイズ上限チェック
        while (scalar($self->_cache_order->@*) >= $self->max_size) {
            my $oldest = shift $self->_cache_order->@*;
            delete $self->_cache->{$oldest};
        }

        $self->_cache->{$city} = {
            data      => $data,
            cached_at => time(),
        };
        push $self->_cache_order->@*, $city;
    }

    # キャッシュから削除
    sub _remove_from_cache ($self, $city) {
        delete $self->_cache->{$city};
        $self->_cache_order->@* = grep { $_ ne $city } $self->_cache_order->@*;
    }

    # 天気情報を取得
    sub get_weather ($self, $city) {
        # キャッシュチェック
        if (my $cached = $self->_get_from_cache($city)) {
            $self->_stats->{hits}++;
            return { $cached->%*, from_cache => 1 };
        }

        $self->_stats->{misses}++;

        # APIから取得
        for my $adapter ($self->adapters->@*) {
            my $weather = $adapter->get_weather($city);
            if ($weather) {
                $self->_set_to_cache($city, $weather);
                return { $weather->%*, from_cache => 0 };
            }
        }
        return;
    }

    # キャッシュ統計
    sub cache_stats ($self) {
        my $total = $self->_stats->{hits} + $self->_stats->{misses};
        my $hit_rate = $total > 0 ? ($self->_stats->{hits} / $total * 100) : 0;
        return {
            hits     => $self->_stats->{hits},
            misses   => $self->_stats->{misses},
            hit_rate => sprintf("%.1f%%", $hit_rate),
            size     => scalar($self->_cache_order->@*),
        };
    }

    # キャッシュクリア
    sub clear_cache ($self) {
        $self->_cache->%* = ();
        $self->_cache_order->@* = ();
    }
}

# メイン処理
package main {
    use v5.36;

    say "=== 天気予報アグリゲーター(キャッシュ付き) ===";
    say "";

    my $facade = WeatherFacade->new(
        adapters  => [
            WeatherAdapter::OpenWeatherMap->new,
            WeatherAdapter::WeatherStack->new,
        ],
        cache_ttl => 300,  # 5分
        max_size  => 10,   # デモ用に小さく
    );

    # 同じ都市を複数回取得
    say "--- 1回目の取得(キャッシュなし) ---";
    for my $city (qw(Tokyo Osaka Sapporo)) {
        my $weather = $facade->get_weather($city);
        if ($weather) {
            my $cache_status = $weather->{from_cache} ? "キャッシュから" : "APIから";
            say "$city: $weather->{condition}($cache_status)";
        }
    }

    say "";
    say "--- 2回目の取得(キャッシュあり) ---";
    for my $city (qw(Tokyo Osaka Sapporo)) {
        my $weather = $facade->get_weather($city);
        if ($weather) {
            my $cache_status = $weather->{from_cache} ? "キャッシュから" : "APIから";
            say "$city: $weather->{condition}($cache_status)";
        }
    }

    say "";
    say "--- キャッシュ統計 ---";
    my $stats = $facade->cache_stats;
    say "ヒット数: $stats->{hits}";
    say "ミス数: $stats->{misses}";
    say "ヒット率: $stats->{hit_rate}";
    say "キャッシュサイズ: $stats->{size}";
}

実行結果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
=== 天気予報アグリゲーター(キャッシュ付き) ===

--- 1回目の取得(キャッシュなし) ---
Tokyo: 晴れ(APIから)
Osaka: 曇り(APIから)
Sapporo: Rain(APIから)

--- 2回目の取得(キャッシュあり) ---
Tokyo: 晴れ(キャッシュから)
Osaka: 曇り(キャッシュから)
Sapporo: Rain(キャッシュから)

--- キャッシュ統計 ---
ヒット数: 3
ミス数: 3
ヒット率: 50.0%
キャッシュサイズ: 3

キャッシュの効果

Before(キャッシュなし)

1
2
3
4
ページ表示時のAPI呼び出し: 3回
1日1000PVの場合: 3000回/日
月間: 約90000回
→ 無料枠(1000回/月)を大幅に超過

After(キャッシュあり、TTL=5分)

1
2
3
4
同じ都市への2回目以降: キャッシュから取得
1日1000PVの場合: 約300〜500回/日(推定)
月間: 約10000〜15000回
→ 大幅削減(約85%減)

キャッシュ戦略のポイント

1. TTLの設定

データの種類推奨TTL理由
天気予報5〜15分短時間での大幅な変化は少ない
株価1〜5分変動が激しい
ニュース1〜5分新着が重要
ユーザープロファイル1時間頻繁に変わらない

2. キャッシュ無効化

特定の条件でキャッシュを強制的に更新したい場合があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sub get_weather ($self, $city, %opts) {
    # force オプションでキャッシュを無視
    if (!$opts{force}) {
        if (my $cached = $self->_get_from_cache($city)) {
            return { $cached->%*, from_cache => 1 };
        }
    }
    # ... 以下、API呼び出し
}

# 使用例
$facade->get_weather('Tokyo');              # キャッシュを使う
$facade->get_weather('Tokyo', force => 1);  # 強制的にAPI呼び出し

3. キャッシュキーの設計

都市名だけでなく、パラメータも含めたキーにすることで、より柔軟なキャッシュが可能です。

1
2
# 単位(摂氏/華氏)もキャッシュキーに含める
my $cache_key = "$city:$units";  # "Tokyo:metric" or "Tokyo:imperial"

まとめ

今回は、キャッシュ機能を追加してパフォーマンスを向上させました。

  • 同じリクエストへの重複呼び出しを削減
  • TTL(有効期限)で古いデータの自動更新
  • LRUアルゴリズムでメモリ使用量を制限
  • キャッシュ統計で効果を可視化

APIへのリクエスト回数を大幅に削減できました。

次回予告

キャッシュで効率化できましたが、まだ考慮すべきことがあります。APIが完全に失敗した場合、どうすればよいでしょうか?次回は、エラーハンドリングとデフォルト値の設計について学びます。お楽しみに!

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