Claude Media
Claude Codeでキャッシュスタンピードを防ぐ — Redisのロックと期限ゆらぎ

Claude Codeでキャッシュスタンピードを防ぐ — Redisのロックと期限ゆらぎ

TTL切れの瞬間に100件が同じキーを再計算する状況を再現し、単一実行ロックで500回の再計算を5回に抑えました。期限ゆらぎの効果も数値で示します。

cache-asideは「キャッシュを見て、無ければDBから読んで、キャッシュに書く」だけの単純な型です。単純なぶん、人気のキーのTTLが切れた瞬間に弱くなります。その1ミリ秒後に届いた全リクエストが「無い」と判断し、全員が同じ重いクエリをDBへ投げます。これがキャッシュスタンピードです。

「Redisでキャッシュを足して」という依頼だけでは、期限切れの瞬間の振る舞いまでは指定されません。この記事では、スタンピードを手元で再現し、単一実行ロックと期限ゆらぎ(jitter)で収まったかを数値で確かめる流れを、Claude Codeに任せる前提でまとめます。設定全般はClaude Codeの総合ガイドにもまとまっています。

素朴なcache-asideは何が起きるのか

問題の核は、「GETで無いと分かる」と「SETで入れ直す」の間が、原子的でないことです。この隙間に入ったリクエストは全員が再計算に回ります。

症状

スタンピードの 2 つの形

  • 1 つのキーに集中する

    人気商品のページのように、単一のキーに多数のリクエストが集まる形です。期限が切れた瞬間に、並行数ぶんの再計算が走ります。

  • 多数のキーが同時に切れる

    起動直後やデプロイ後にまとめてキャッシュを温めると、同じTTLのキーが同じ時刻に切れます。キーごとの再計算は1回でも、DBへの総量が山になります。

前者にはロック、後者には期限のばらつきが効きます。2つは別の対策で、片方では片方を防げません。次の実験で、それぞれ単独の効果を分けて見ます。

手元のRedisで再現する

条件は次のとおりです。再計算は、DBアクセスを模して200ミリ秒スリープするだけの関数です。実際のDBは使っていないので、絶対値ではなく差を見てください。

  • Redis 6.0.9をローカルで起動する(ポートは6392)
  • クライアントはPythonの標準ライブラリだけで書いた最小のRESPクライアント
  • TTLは1000ミリ秒、ワーカーは100スレッド

まず、1つのキーに100ワーカーが5秒間アクセスし続けた結果です。5秒でTTLは5回切れます。

実装再計算の回数100ミリ秒あたりの最大
素朴なcache-aside再計算の回数500100ミリ秒あたりの最大100
単一実行ロック再計算の回数5100ミリ秒あたりの最大1

3回繰り返しても、結果は同じでした。素朴な実装は、TTL切れのたびに100ワーカー全員が再計算しています。ロックを入れると、TTL切れ1回につき1回しか再計算が走りません。

単一実行ロックの書き方

ロックはRedisのSETコマンドに NX と PX を付けた形で取ります。SETのページでは、NXが「キーが無いときだけ設定する」、PXが「有効期限をミリ秒で指定する」です。この組み合わせで「取る」と「期限を付ける」が1コマンドになり、取った直後にプロセスが落ちても、ロックが永久に残りません。

分散ロックのページには、単一インスタンスでの正しい手順が載っています。

SET resource_name my_random_value NX PX 30000

値の my_random_value は、全クライアントと全リクエストで一意にします。解放するときに「自分が取ったロックだけを消す」ためです。DELで消すと、自分のロックがTTLで切れたあとに他のクライアントが取ったロックを、うっかり消せてしまいます。Redis 8.4未満の解放は、公式ページのLuaスクリプトと同じ形で書きます。

実験で使った取得側は次の形です。

RELEASE = ('if redis.call("get",KEYS[1])==ARGV[1] '
           'then return redis.call("del",KEYS[1]) else return 0 end')
 
def get_with_lock(r, key, ttl_ms):
    while True:
        v = r.cmd("GET", key)
        if v is not None:
            return v
        tok = uuid.uuid4().hex
        if r.cmd("SET", "lock:" + key, tok, "NX", "PX", 5000) == "OK":
            try:
                v = r.cmd("GET", key)      # ロック取得後に再確認する
                if v is None:
                    v = db_load()
                    r.cmd("SET", key, v, "PX", ttl_ms)
                return v
            finally:
                r.cmd("EVAL", RELEASE, 1, "lock:" + key, tok)
        time.sleep(0.01)                  # 取れなかった側は待って読み直す

ロックを取ったあとのGETの再確認が要点です。この確認を抜くと、ロックを待っていた側が、先行者の書き込み後にもう一度再計算します。

再確認を抜くとどうなるか

再確認の効果も測りました。同じ条件(1キー、100ワーカー、5秒)で、ロック取得後のGETだけを外した版を3回走らせると、再計算は8回、6回、8回でした。再確認を入れた版は5回です。

増えた1〜3回は、ロックを取った側が、直前に別の側が書いたばかりの値に気づかず、もう一度DBへ行った分です。差は小さく見えますが、再計算が重いクエリであるほど、その1回が効きます。ロックだけを入れて「収まった」と判断せず、回数をTTL切れの回数と突き合わせる理由がここにあります。Claude Codeに検証させるときも、「再計算が減った」ではなく「TTL切れ5回に対して再計算が5回」という数え方で合否を渡します。

ロックの期限は再計算より長くする

ロックのTTL(ここでは5000ミリ秒)が再計算の所要時間より短いと、再計算の途中でロックが切れ、2人目が入ってきます。ロックのTTLは、再計算の最悪時間に余裕を足した値にします。逆に長すぎると、再計算に失敗したときに待ち側が長く足止めされます。この2つのバランスは、Claude Codeに決めさせず、計測した最悪時間から人が決める値です。

TTLの前提になる再計算クエリの重さは、Redisプラグインで設計とクエリをレビューさせる手順で先に洗い出せます。

期限をゆらがせる(jitter)

別のキーが100個あるケースを見ます。100個のキーを100ワーカーが12秒間ランダムに読み、TTLは1000ミリ秒です。キーごとのロックは入れてあります。比べるのは、起動から6秒以降の「100ミリ秒あたりの再計算回数の最大」です。

TTL3回の最大値12秒間の再計算の総数
固定の1000ミリ秒3回の最大値39 / 33 / 3312秒間の再計算の総数1000
1000〜1500ミリ秒のランダム3回の最大値21 / 16 / 1912秒間の再計算の総数841〜850

ロックだけでは、キーごとの再計算を1回に抑えるだけで、全キーが同時に切れる山は消えません。TTLに乱数を足すと、最大値はおよそ半分になりました。総数が減ったのは、平均のTTLが長くなったぶん切れる回数が減ったためです。

実装は、TTLに乱数を足すだけです。

ttl_ms = base_ttl + random.randrange(jitter + 1)   # 例: 1000 + 0〜500

揺らぎの幅は、データの鮮度の許容範囲で決めます。TTLを最大1.5倍まで延ばしてよいデータなのか、業務側で決める項目です。

Claude Codeに任せるときの指示と検証

ここからがClaude Code側の話です。公式のベストプラクティスには「Claudeに、合否の返るチェックを渡す」という考え方があります。Claudeは作業が終わったように見えると止まるので、終わったかどうかを判定する手段が無ければ「見た目」しか根拠がありません。スタンピードは目で見てもコードレビューでも見落とします。再現スクリプトを合否の物差しにするのが近道です。

CLAUDE.mdに書く1節

リポジトリを読めば分かることは、書かなくてかまいません。書くのは、コードから読み取れない判断だけです。

## キャッシュ(Redis)
- cache-asideの再計算は、必ず単一実行ロック(SET NX PX)を通す
- ロックの解放は自分のトークンを比較するLuaで行う。DELで消さない
- TTLには必ず乱数を足す。幅は base_ttl の50%
- 検証コマンド: python3 exp/stampede.py lock 1 100 5 1000 0
  再計算が TTL切れ回数と同数であること

指示の例

次のように、何を再現し、何を合格とするかを先に渡します。

cache-asideの get_product() をスタンピード対策する。
手順: 1) exp/stampede.py naive で現状の再計算回数を取る
2) SET NX PX のロックと、TTLのjitterを入れる
3) exp/stampede.py lock で再計算がTTL切れ回数と同数になるまで直す
ロックのTTLは再計算の最悪時間の3倍にして、その根拠も書いて。

最後の一文は、前節で触れた「人が決める値」を、Claudeに根拠付きで出させるための指示です。出てきた根拠を、人が実測と見比べます。

Stop hookで検証を強制する

プロンプトの「合格するまで続けて」は、Claudeの判断に任せた約束です。公式のStop hookは、Claudeが終了しようとするたびにスクリプトを走らせ、終了コード2なら作業に戻します。標準エラー出力の内容が、戻る理由としてClaudeに返ります。型検査の例を、スタンピードの検証に置き換えると次の形です。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command",
            "command": "python3 exp/check_stampede.py 1>&2 || exit 2" }
        ]
      }
    ]
  }
}

例えば check_stampede.py は、stampede.py を呼んで「再計算回数がTTL切れ回数を超えたら異常終了する」だけの小さなスクリプトです。Stop hookの連続ブロックには上限があり、公式ページには、8回連続でブロックしてツール呼び出しが無ければ次のブロックを上書きして終えると書かれています。上限を変える環境変数もあります。無限ループにはなりませんが、検証が壊れていると8回空回りすることになります。

本番に入れる前の落とし穴

実験の数値は、スタンピードの構造を示すためのものです。実システムに持ち込むときは、次の点が残ります。

  • 待ち側のポーリング間隔(実験では10ミリ秒)は、待ち側のレイテンシと、Redisへの読み取り負荷の両方に効く
  • 単一のRedisのロックは、レプリカへ昇格したときに保証が崩れる。分散ロックのページ自身が、単一インスタンスでは「たまに競合が起きてよい用途向け」と書いている
  • 再計算が失敗したとき、ロックを手放すだけではキャッシュが空のまま。古い値を短時間返し続ける構成(stale-while-revalidate)は、別の設計になる
  • ロックのキー名に、本体のキーを必ず含める。固定名のロックにすると、無関係なキーまで直列化される
  • ロックはリクエスト数そのものを減らす仕組みではない。流入を絞る側はClaude CodeでRedisのレート制限を実装する記事で扱っている

まとめ

スタンピードは、ロックでキー単位の重複を消し、jitterで時間軸の山を崩す、2段の対策で収まります。実験では、1つのキーの再計算が500回から5回に、複数キーの山が最大39から21以下に下がりました。Claude Codeには、手元で再現できるスクリプトとStop hookを渡しておくと、ロックのTTLの根拠を含めて、結果を数値で受け取れます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. redis.io/docs/latest/commands/set
  3. redis.io/docs/latest/develop/clients/patterns/distributed-locks
この記事を共有:XはてブLinkedIn