1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

キャッシュ戦略の徹底比較~Lazyキャッシュを中心に~

1
Posted at

Lazyキャッシュ(Cache-Aside)を軸に、他の戦略と何がどう違うのか を実例とコードで比較していきます。

1. なぜ比較が必要か

「とりあえずRedis入れてキャッシュした」では本番で必ず痛い目を見ます。各戦略には明確な得意領域と地雷ポイントがあり、ワークロードに合わない戦略を選ぶと、整合性崩壊・パフォーマンス劣化・データロストのいずれかを引き当てます。

2. Lazy vs Write-Through 〜整合性のトレードオフ〜

同じ「ユーザー更新」処理の比較

Lazyキャッシュ(Cache-Aside)の場合

public async Task UpdateUserAsync(int userId, UserData data)
{
    await _db.UpdateAsync(userId, data);              // 1. DBを更新
    await _cache.RemoveAsync($"user:{userId}");       // 2. キャッシュを削除(無効化)
    // → 次回読み込み時にDBから再取得してキャッシュに積む
}

Write-Throughの場合

public async Task UpdateUserAsync(int userId, UserData data)
{
    await _cache.SetAsync($"user:{userId}", data);    // 1. キャッシュを更新
    await _db.UpdateAsync(userId, data);              // 2. DBも同期更新
    // → キャッシュは常に最新状態
}

比較ポイント

観点 Lazy(Cache-Aside) Write-Through
更新直後の読み込み キャッシュミス→DB取得(遅い) 即座にキャッシュヒット(速い)
書き込みレイテンシ DB書き込みのみ(速い) キャッシュ+DB両方(遅い)
整合性リスク 削除と更新の間に古い値が読まれる可能性 キャッシュとDBが原子的に揃う
使われないデータ キャッシュに乗らない(効率的) 全部キャッシュされる(無駄あり)

どちらを選ぶか

  • Lazy: 読み込み主体・更新頻度が低い・多少の古さを許容できる(SNSのプロフィール、商品マスタ等)
  • Write-Through: 書き込み直後に即読み込みされる・整合性必須(在庫数、残高表示等)

3. Lazy vs Write-Behind 〜速度と耐障害性のトレードオフ〜

「いいね」カウント更新の例

Lazy(Cache-Aside)の場合

public async Task LikePostAsync(int postId)
{
    await _db.ExecuteAsync(
        "UPDATE posts SET likes = likes + 1 WHERE id = @id",
        new { id = postId });
    await _cache.RemoveAsync($"post:{postId}");
    // → 毎回DBに書き込みが走る
}

Write-Behindの場合

public async Task LikePostAsync(int postId)
{
    await _cache.IncrementAsync($"post:{postId}:likes");  // キャッシュ即座に+1
    _writeQueue.Enqueue(postId);                          // DBへの反映は後でまとめて
    // → ピーク時の負荷を平準化できる
}

// バックグラウンドワーカー(BackgroundService)
public class LikeFlushService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var batch = _writeQueue.DequeueBatch(size: 1000);
            await _db.BulkUpdateAsync(batch);              // 1000件まとめてUPDATE
            await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
        }
    }
}

比較ポイント

観点 Lazy Write-Behind
書き込み速度 DB速度に依存 キャッシュ速度(数十倍速い)
DB負荷 リクエスト数に比例 バッチ化で激減
障害時のデータロスト なし(DBがマスター) あり(キャッシュ障害で消える)
実装難易度 高(キュー管理・冪等性・順序保証)

どちらを選ぶか

  • Lazy: データロストが許されない(決済、注文)
  • Write-Behind: 大量書き込み・多少のロスト許容(アクセスログ、いいねカウント、メトリクス)

4. Lazy vs Read-Through 〜責務の境界線〜

両者は「読み込み時にキャッシュミスならDBから取得してキャッシュに積む」という挙動が全く同じですが、誰がその処理を書くかが違います。

Lazy(Cache-Aside)

// アプリが両方を制御する
public async Task<User> GetUserAsync(int userId)
{
    var cached = await _cache.GetAsync<User>($"user:{userId}");
    if (cached != null)
        return cached;

    var user = await _db.QueryAsync<User>(userId);            // ← アプリがDBを叩く
    await _cache.SetAsync($"user:{userId}", user);
    return user;
}

Read-Through

// アプリはキャッシュ層しか触らない
public async Task<User> GetUserAsync(int userId)
{
    return await _cacheLayer.GetAsync<User>(userId);          // ← 内部でDB取得は自動
}

比較ポイント

観点 Lazy Read-Through
アプリのコード キャッシュロジックが散らばる シンプル
キャッシュ層の依存 任意(Redis等で十分) 専用ライブラリが必要(NCache、Hazelcast等)
柔軟性 高い(カスタマイズ自由) 低い(ライブラリの仕様に従う)
ベンダーロックイン なし あり

どちらを選ぶか

  • Lazy: 一般的なWeb開発、Redisで十分なケース
  • Read-Through: マイクロサービス多数で同じロジックを使い回したい、専用キャッシュ製品で完結させたい

5. Lazy vs Write-Around 〜書き込み戦略の差〜

両者は読み込みは同じCache-Aside方式ですが、書き込み時の振る舞いが違います。

// Lazy: 書き込み時にキャッシュを「削除」
public async Task UpdateAsync(string key, object value)
{
    await _db.UpdateAsync(key, value);
    await _cache.RemoveAsync(key);         // 次回読まれた時にキャッシュ再生成
}

// Write-Around: 書き込み時にキャッシュは「触らない」
public async Task UpdateAsync(string key, object value)
{
    await _db.UpdateAsync(key, value);
    // キャッシュは古いまま放置(TTLで自然消滅を待つ)
}

一見同じだが決定的に違う点

Lazyは即座にキャッシュを消す → 次の読み込みで最新データを取得できる
Write-Aroundは消さない → TTLが切れるまで古いデータを返し続ける

使い分け

  • Lazy: 更新後すぐに正しい値を返したい(ほぼ全てのケース)
  • Write-Around: ログのような「書いたらしばらく読まれない」データ専用

6. 実戦:ユースケース別の最適解

ECサイトの商品詳細ページ

読み込み:書き込み = 1000:1、整合性は数秒の遅延OK
→ Lazy + TTL 60秒 が最適

銀行の残高表示

書き込み直後に必ず読まれる、整合性が絶対
→ Write-Through が最適(または DBから直接読む)

Twitterのいいねカウント

書き込み爆発、表示は概算でOK
→ Write-Behind + バッチ集計 が最適

ユーザーセッション管理

頻繁な読み書き、揮発OK
→ Redisにのみ保存(DBを介さない、キャッシュではなくセッションストア)

監査ログ

書き込み専用、めったに読まれない
→ Write-Around(書いた瞬間にキャッシュする意味がない)

7. 組み合わせる発想 〜単独で使わない〜

実際のシステムでは複数戦略を多段で組み合わせるのが定石です。

ブラウザキャッシュ(Cache-Control)
    ↓ ミス
CDN(CloudFront / Cloudflare)
    ↓ ミス
リバースプロキシ(Nginx / YARP)
    ↓ ミス
アプリ内メモリ(IMemoryCache)
    ↓ ミス
Redis(Lazy + TTL)
    ↓ ミス
DB(バッファプール)
    ↓
ディスク

各層で適切な戦略を選ぶことで、最下層(DB)まで到達するリクエストを劇的に減らせるのがキャッシュ設計の真髄です。

8. 比較サマリー

戦略 一言で言うと 主な利用シーン 最大の弱点
Lazy(Cache-Aside) 必要になったら積む 汎用・読み多め 初回アクセス遅延
Read-Through キャッシュ層が裏で取ってくる マイクロサービス ベンダーロックイン
Write-Through 書く時に両方更新 整合性最優先 書き込み遅い
Write-Behind 書く時はキャッシュだけ、後でDB 大量書き込み データロストリスク
Write-Around 書く時はDBだけ、キャッシュは無視 書いて読まないデータ 更新後の読み込みが遅い

まとめ

Lazyキャッシュは「迷ったらこれ」という万能戦略ですが、銀の弾丸ではありません。

  • 整合性が命なら → Write-Through
  • 書き込み爆発なら → Write-Behind
  • アプリをシンプルにしたいなら → Read-Through
  • 書いて読まないデータなら → Write-Around

そして実戦では単独で使わず多段構成にすること。ワークロードを観測し、各層で最適な戦略を組み合わせるのが、本物のキャッシュ設計です。

キャッシュ設計の鉄則:「どう積むか」より「いつ捨てるか」を先に決める。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?