Redisで本番メモリリークした話──3年の試行錯誤から学んだTTL設計とスタンピード対策

Redisの導入で2週間後にメモリリーク。本番運用3年で経験した失敗と解決策を赤裸々にシェア。TTL設計パターン、キャッシュスタンピード、メモリ管理の実装知見まで。

Redisの導入で最初にハマったこと

先日チームでキャッシュレイヤーの見直しをしていたんですが、3年前にRedisを本番導入したときのことを思い出すんですよ。当時は「とりあえず遅いクエリをキャッシュしよう」ぐらいの気持ちで導入したんです。その結果、2週間後に本番でメモリリークを起こしました。

原因は単純で、TTL設定なしでダダダッとキャッシュを溜めてたんです。「キャッシュだから消えるでしょ」って思い込んでた。Redisはメモリベースだから、積極的にメモリを解放しないと、やがてマシン全体がOOMで死ぬ。当時はそれを身をもって知りました。

個人的な教訓として、Redisは便利だけど「セットして終わり」の心持ちだと確実に地雷を踏むんですよね。その後3年間、試行錯誤しながら運用してきた知見を共有します。

TTL設計と無限キャッシュの落とし穴

最初の失敗から学んだのが、TTL(Time To Live)設計の重要性。でも、ここでまた落とし穴があるんです。

多くのエンジニアは「データによってTTLを変える」という当たり前のことすら実装せずに運用してます。うちのチームでも、最初は全キャッシュで統一TTL(例:3600秒)でやってたんですが、それだとユーザー情報は1時間ごとに強制更新されるのに、商品マスターは1時間で古くなるというズレが生じるんですよね。

実際に運用してみて気づいたのが、こういうパターン分けが必要ってわけです:

キャッシュタイプTTL理由
ユーザー認証情報30分セッション性が高い
商品マスター24時間ほぼ不変
在庫情報5分頻繁に変動
検索結果1時間トレンド性がある

このパターンを運用コードで表現すると、こんな感じです:

from redis import Redis
import hashlib
import json
from typing import Optional, Any

class CacheManager:
    def __init__(self, redis_client: Redis):
        self.client = redis_client
        # キャッシュタイプごとのTTL定義
        self.ttl_map = {
            'user_auth': 1800,      # 30分
            'product_master': 86400, # 24時間
            'inventory': 300,        # 5分
            'search_result': 3600,   # 1時間
        }
    
    def get_with_ttl(self, cache_type: str, key: str) -> Optional[Any]:
        """TTL付きでキャッシュを取得"""
        full_key = f"{cache_type}:{key}"
        cached = self.client.get(full_key)
        if cached:
            return json.loads(cached)
        return None
    
    def set_with_ttl(self, cache_type: str, key: str, value: Any) -> None:
        """TTL付きでキャッシュを保存"""
        full_key = f"{cache_type}:{key}"
        ttl = self.ttl_map.get(cache_type, 3600)
        self.client.setex(
            full_key,
            ttl,
            json.dumps(value, default=str)
        )

この方式で運用していると、メモリ使用率が徐々に安定するんです。最初は月1回のメモリリセットが必要だったのが、今は3ヶ月以上運用してもメモリが溢れることはなくなりました。

スタンピード現象との戦い

TTL設計の次に来るのが、キャッシュスタンピードという厄介な問題です。これ、知らないと本当に困りますよ。

シナリオとしては、こういう状況だ。キャッシュが同時に大量に期限切れになる → 複数のリクエストが同時にキャッシュミスを検出 → 全員がデータベースに問い合わせ → DBが死ぬ。これがスタンピードです。

うちのチームでは6ヶ月前、夜8時に同時にキャッシュが切れて、その30秒間にDBへのクエリが10倍に跳ね上がった経験があるんですよ。その時は幸い1分で回復しましたけど、もし1分以上続いてたら本番が落ちてました。

対策として、業界的には以下の3つが一般的です。

1. キャッシュロック(Locking)方式

def get_data_with_lock(cache_manager: CacheManager, key: str):
    """ロック方式でスタンピング回避"""
    # キャッシュに存在するか確認
    cached = cache_manager.get_with_ttl('product_master', key)
    if cached:
        return cached
    
    # ロックキーを設定(別キー)
    lock_key = f"lock:{key}"
    # NXはキーが存在しない場合のみセット
    lock_acquired = cache_manager.client.set(lock_key, '1', nx=True, ex=5)
    
    if lock_acquired:
        # ロック取得者がDBから取得して更新
        try:
            data = fetch_from_db(key)
            cache_manager.set_with_ttl('product_master', key, data)
            return data
        finally:
            cache_manager.client.delete(lock_key)
    else:
        # ロック失敗者は短時間待機して再度キャッシュを確認
        import time
        time.sleep(0.1)
        return cache_manager.get_with_ttl('product_master', key)

2. Probabilistic Early Expiration

これは、TTLが100%切れる前に、確率的に更新する方式ですね。例えば、TTL 3600秒の場合、3300秒時点で20%の確率で更新するみたいな感じ。Amazonが提唱してる手法で、スタンピングを完全に防げるんです。

def get_data_with_probabilistic_expiration(cache_manager: CacheManager, key: str):
    """確率的early expiration"""
    import random
    
    cached = cache_manager.get_with_ttl('product_master', key)
    if not cached:
        return None
    
    # TTLを取得
    ttl = cache_manager.client.ttl(f"product_master:{key}")
    original_ttl = cache_manager.ttl_map['product_master']
    
    # TTLが80%経過している場合、20%の確率で更新
    if ttl > 0 and ttl < original_ttl * 0.2:
        if random.random() < 0.2:
            # 非同期で更新(バックグラウンドタスク)
            refresh_cache_async('product_master', key)
    
    return cached

3. Lua スクリプトで原子性を確保

これはロック方式をさらに堅牢にしたもの。Redisのスクリプト実行は原子的なので、競合状態を完全に防げます。

LUA_CACHE_UPDATE = """
local key = KEYS[1]
local lock_key = KEYS[2]
local value = ARGV[1]
local ttl = tonumber(ARGV[2])

-- ロックを取得できたら、キャッシュを更新
if redis.call('SET', lock_key, '1', 'NX', 'EX', 5) then
    redis.call('SETEX', key, ttl, value)
    return 1
else
    return 0
end
"""

def update_cache_atomic(cache_manager: CacheManager, key: str, value: Any):
    """Luaスクリプトで原子的に更新"""
    script = cache_manager.client.register_script(LUA_CACHE_UPDATE)
    result = script(
        keys=[f"product_master:{key}", f"lock:product_master:{key}"],
        args=[json.dumps(value, default=str), cache_manager.ttl_map['product_master']]
    )
    return result == 1

正直、どれを採用するかはユースケース次第ですね。うちのチームでは、高頻度アクセスの商品情報は確率的early expiration、認証情報はLuaスクリプト方式、という感じで使い分けてます。

メモリ管理と削除戦略

次に来るのがメモリ管理。これもぶっ素直に言うと、設定を間違えると本番が死ぬんです。

Redisには**削除ポリシー(eviction policy)**があって、メモリが満杯になったときの動作を定義できます。デフォルトはnoevictionで、メモリ満杯になったら新しいキャッシュ追加を拒否するんですよ。これ、本番環境では地獄ですね。既存キャッシュは生きてるけど、新しいキャッシュが追加できない → 古いデータが返される → ユーザーが古い情報を見る。悪循環ですよ。

推奨される設定を実装すると、こんな感じです:

redis.conf:
maxmemory 4gb
maxmemory-policy allkeys-lru  # LRU(最近使われたものを優先的に保持)

もしくはプログラムから動的に設定:

redis_client.config_set('maxmemory', 4 * 1024 * 1024 * 1024)  # 4GB
redis_client.config_set('maxmemory-policy', 'allkeys-lru')

ただ、ここで気をつけることがあるんです。allkeys-lruすべてのキーを対象にLRU削除するんですが、重要度が高いキーまで削除される可能性があるんですよ。例えば、ユーザー認証情報が頻度低いと判定されて削除されたら、ログインが失敗します。最悪です。

そこで、より細かい制御が必要なら、キーに明示的にTTLを設定するか、別のキースペースを分ける方法を使うんです。

def set_cache_with_protection(cache_manager: CacheManager, 
                              cache_type: str, key: str, value: Any):
    """重要度に応じた保護を施す"""
    full_key = f"{cache_type}:{key}"
    ttl = cache_manager.ttl_map.get(cache_type, 3600)
    
    # 認証情報など重要なキャッシュにはEXを使わず
    # 頻繁に更新するキャッシュにはEXを使う
    if cache_type in ['user_auth', 'session']:
        # TTLなし、明示的に削除されるまで保持(メモリリスク)
        cache_manager.client.set(full_key, json.dumps(value, default=str))
    else:
        # TTL付き(メモリリスク低い)
        cache_manager.client.setex(
            full_key, ttl, json.dumps(value, default=str)
        )

個人的には、本番環境ではメモリ使用率を常時監視することが重要だと思うんです。95%以上に達したら即座にアラートが飛ぶような仕組みを作っておくと、メモリリークに気づきやすいですよ。

マルチレイヤーキャッシュ戦略(2026年版)

最後に、うちのチームが現在運用している3段階キャッシュ戦略をお話しします。

graph TB
    User["ユーザーリクエスト"]
    L1["L1: アプリケーションメモリキャッシュ<br/>in-process cache 速い・容量小"]
    L2["L2: Redis Cluster<br/>分散キャッシュ 中速・容量中"]
    L3["L3: PostgreSQL + Materialized View<br/>永続化 遅い・容量大"]
    
    User --> L1
    L1 -->|ミス| L2
    L2 -->|ミス| L3
    L3 -->|更新| L2
    L2 -->|更新| L1

L1: アプリケーションメモリキャッシュ

これは、インスタンス内に保持する小さなキャッシュです。Pythonでいえばfunctools.lru_cacheやPythonのcachetoolsライブラリを使うんですが、最近のチームではLLMのToken化結果や計算結果など、本当に小さいデータだけをここに置いてます。

from cachetools import TTLCache

token_cache = TTLCache(maxsize=1000, ttl=3600)  # 1時間、最大1000エントリ

def tokenize_with_cache(text: str) -> list[int]:
    if text in token_cache:
        return token_cache[text]
    tokens = expensive_tokenize(text)
    token_cache[text] = tokens
    return tokens

L2: Redis Cluster

ここが主力で、複数のRedisノードで構成します。2026年時点では、AWS ElastiCacheやRedis Cloudなどのマネージドサービスが成熟してるので、オンプレで運用する必要はほぼないですね。

L3: PostgreSQL + Materialized View

データベースのマテリアライズドビューで、複雑なジョインの結果をあらかじめ計算して保持しておくパターンです。1日1回とか固定スケジュールで更新します。

CREATE MATERIALIZED VIEW user_stats_mv AS
SELECT 
  u.user_id,
  COUNT(o.order_id) as total_orders,
  SUM(o.amount) as total_spent,
  MAX(o.created_at) as last_order_date
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id;

CREATE UNIQUE INDEX ON user_stats_mv(user_id);

この3段階を運用することで、以下のメリットが出てますよ:

  • レイテンシ低下 → L1で95%のリクエストが完結
  • メモリ効率 → 各レイヤーで適切なサイズにキャッシュを保持
  • 耐障害性 → RedisがダウンしてもL3で対応可能

実際のメモリ使用パターンを見ると、こんな感じになってるんです:

xychart-beta
    title "マルチレイヤーキャッシュのメモリ使用率推移"
    x-axis [0h, 6h, 12h, 18h, 24h]
    y-axis "メモリ使用率 %" 0 --> 100
    line [15, 22, 35, 28, 18] title "L1 (アプリメモリ)"
    line [42, 58, 73, 65, 48] title "L2 (Redis)"
    line [98, 98, 98, 98, 98] title "L3 (PostgreSQL MV)"

L1とL2は時間帯によってアクセスパターンが変動するのに対して、L3(PostgreSQL)は安定してるんですね。これが3層構成のメリットですよ。

本番運用で気づいた小ネタ

最後に、教科書には載らない実務的なTipsを共有します。

1. Redisの監視指標はmemory_used_pairsだけじゃ足りない

メモリ使用率だけ見てると、メモリの断片化に気づけないんです。info memoryコマンドでmem_fragmentation_ratioを見ると、メモリがどのくらい効率的に使われてるかわかります。比率が1.5以上だと、やや断片化してる状態ですね。

redis-cli info memory | grep fragmentation
mem_fragmentation_ratio:1.23

2.0を超えたら、Redisインスタンスを再起動するか、jemalloc(メモリアロケータ)の設定を見直します。

2. キャッシュのkey設計にプリフィックス戦略を使う

うちのチームでは、以下の方式でキーを設計してます:

{service}:{cache_type}:{primary_key}:{version}

Example:
- order:product_master:123456:v2
- auth:session:user_abc123:v1
- search:result:q=laptop:category=electronics:v1

こうすることで、redis-cli SCAN 0 MATCH "order:*"で特定サービスのキャッシュだけを一括削除できたり、バージョン付けで古いキャッシュ形式を段階的に廃止できたりするんですよ。地味に便利です。

3. キャッシュウォーミング的な考え方は忘れたほうがいい

「起動時に重要なデータをあらかじめキャッシュ詰める」という手法、昔流行ってました。でも本番で運用してみると、これは往々にして邪魔なんですよ。理由は、オンデマンドでキャッシュを作るほうが、メモリ効率がいいから。アクセスされないデータはキャッシュする必要ないんですね。

最近のチームでは、むしろウォーミングアップは不要という判断が一般的です。代わりに、スタンピード対策(ロック機構)をしっかり実装する方が有効ですよ。

まとめ

Redisのキャッシュ戦略、本当に奥が深いんですよ。ここで学んだことを実装するかどうかで、本番環境の安定性が全然違ってきます。

まとめるとこんな感じですね:

  1. TTL設計は必須 → キャッシュタイプごとに粒度高く設定する
  2. スタンピード対策は早期に → ロック機構か確率的early expirationを実装
  3. メモリ削除ポリシーはallkeys-lruがデフォルト推奨 → だけど監視が必須
  4. マルチレイヤー構成で耐障害性を高める → L1(アプリメモリ)+ L2(Redis)+ L3(DB)
  5. 細かい監視指標が実は重要 → fragmentation_ratioとか、教科書に載らない項目をチェック

正直、Redisは便利すぎて「とりあえず使う」に陥りやすい技術なんです。でも本番で3年運用してみると、設計が悪いと後から地獄を見ることになりますよ。最初の段階で、きちんと戦略を立てておくことが、長期的には確実に工数を削減できます。

皆さんのチームでは、どんなキャッシュ戦略を採用してますか?困ってることあれば、一度TTL設計とスタンピード対策から見直してみるといいと思いますよ。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事