<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Redis on AI2CORE - AI技術ブログ</title>
    <link>https://www.ai2core.com/tags/redis/</link>
    <description>Recent content in Redis on AI2CORE - AI技術ブログ</description>
    <generator>Hugo -- 0.146.4</generator>
    <language>ja</language>
    <lastBuildDate>Fri, 06 Mar 2026 09:03:00 +0900</lastBuildDate>
    <atom:link href="https://www.ai2core.com/tags/redis/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>FastAPI &#43; Celery信頼性設計: 非同期ジョブを本番で壊さないための実装パターン</title>
      <link>https://www.ai2core.com/posts/2026-03-06-fastapi-celery-reliability-patterns/</link>
      <pubDate>Fri, 06 Mar 2026 09:03:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-06-fastapi-celery-reliability-patterns/</guid>
      <description>FastAPIとCeleryを使った非同期処理を本番運用するために、再実行安全性、監視、失敗復旧、デプロイ戦略を具体例付きで解説。</description>
      <content:encoded><![CDATA[<h1 id="fastapi--celery信頼性設計-非同期ジョブを本番で壊さないための実装パターン">FastAPI + Celery信頼性設計: 非同期ジョブを本番で壊さないための実装パターン</h1>
<p>FastAPIでAPIを作ると、重い処理はすぐに非同期ジョブへ逃がしたくなります。画像変換、レポート生成、外部API連携、メール配信など、Celeryは非常に便利です。ですが、本番で問題になるのは「動くかどうか」ではなく、<strong>失敗したときに壊れないか</strong> です。</p>
<ul>
<li>同じジョブが二重実行される</li>
<li>一時障害で永遠にリトライしてキューが詰まる</li>
<li>ワーカー再起動で中途半端な状態が残る</li>
<li>完了通知が先に飛んで実データがない</li>
</ul>
<p>本記事では FastAPI + Celery + Redis 構成を前提に、再実行安全性（idempotency）と運用信頼性を上げる実装手順をまとめます。</p>
<h2 id="1-まず守るべき設計原則">1. まず守るべき設計原則</h2>
<p>非同期基盤の事故は、ほぼ次の4原則で防げます。</p>
<ol>
<li><strong>At-least-once前提</strong>（同一タスク再実行は必ず起こる）</li>
<li><strong>副作用は冪等化</strong>（何回実行されても結果が壊れない）</li>
<li><strong>状態遷移を明示</strong>（PENDING/RUNNING/SUCCEEDED/FAILED）</li>
<li><strong>失敗を可観測化</strong>（リトライ回数・死活・滞留時間を計測）</li>
</ol>
<p>この原則を外すと、障害時に「何が完了して何が未完了か」が追えなくなります。</p>
<h2 id="2-参照アーキテクチャ">2. 参照アーキテクチャ</h2>
<ul>
<li>API: FastAPI</li>
<li>Queue Broker: Redis</li>
<li>Worker: Celery</li>
<li>Result Store: PostgreSQL（業務状態）</li>
<li>Monitoring: Flower + Prometheus + Sentry</li>
</ul>
<p>ポイントは、<strong>業務上重要な状態はRedis結果バックエンドに依存しない</strong> ことです。Redisは一時的に使い、真実の状態はRDBに持たせます。</p>
<h2 id="3-実装の土台-タスク受付api">3. 実装の土台: タスク受付API</h2>
<h3 id="31-受け付け時に-idempotency_key-を必須化">3.1 受け付け時に idempotency_key を必須化</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> fastapi <span style="color:#f92672">import</span> FastAPI, HTTPException
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pydantic <span style="color:#f92672">import</span> BaseModel
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sqlalchemy <span style="color:#f92672">import</span> select
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> FastAPI()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">JobRequest</span>(BaseModel):
</span></span><span style="display:flex;"><span>    idempotency_key: str
</span></span><span style="display:flex;"><span>    report_type: str
</span></span><span style="display:flex;"><span>    user_id: str
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.post</span>(<span style="color:#e6db74">&#34;/reports&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">create_report</span>(req: JobRequest):
</span></span><span style="display:flex;"><span>    existing <span style="color:#f92672">=</span> find_job_by_key(req<span style="color:#f92672">.</span>idempotency_key)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> existing:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;job_id&#34;</span>: existing<span style="color:#f92672">.</span>id, <span style="color:#e6db74">&#34;status&#34;</span>: existing<span style="color:#f92672">.</span>status}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    job <span style="color:#f92672">=</span> create_job_record(
</span></span><span style="display:flex;"><span>        idempotency_key<span style="color:#f92672">=</span>req<span style="color:#f92672">.</span>idempotency_key,
</span></span><span style="display:flex;"><span>        status<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;PENDING&#34;</span>,
</span></span><span style="display:flex;"><span>        report_type<span style="color:#f92672">=</span>req<span style="color:#f92672">.</span>report_type,
</span></span><span style="display:flex;"><span>        user_id<span style="color:#f92672">=</span>req<span style="color:#f92672">.</span>user_id,
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    generate_report<span style="color:#f92672">.</span>delay(job<span style="color:#f92672">.</span>id)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;job_id&#34;</span>: job<span style="color:#f92672">.</span>id, <span style="color:#e6db74">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;PENDING&#34;</span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>これでクライアント再送が来てもジョブ多重作成を防げます。</p>
<h3 id="32-db制約で最終防衛線を張る">3.2 DB制約で最終防衛線を張る</h3>
<p><code>idempotency_key</code> に UNIQUE 制約を入れ、アプリバグ時も二重作成を防ぎます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> async_jobs <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">CONSTRAINT</span> uq_async_jobs_idempotency <span style="color:#66d9ef">UNIQUE</span> (idempotency_key);
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="4-celeryタスクの実践設定">4. Celeryタスクの実践設定</h2>
<h3 id="41-推奨設定">4.1 推奨設定</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> celery <span style="color:#f92672">import</span> Celery
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>celery_app <span style="color:#f92672">=</span> Celery(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;worker&#34;</span>,
</span></span><span style="display:flex;"><span>    broker<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;redis://redis:6379/0&#34;</span>,
</span></span><span style="display:flex;"><span>    backend<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;redis://redis:6379/1&#34;</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>celery_app<span style="color:#f92672">.</span>conf<span style="color:#f92672">.</span>update(
</span></span><span style="display:flex;"><span>    task_acks_late<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    task_reject_on_worker_lost<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    worker_prefetch_multiplier<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>    task_time_limit<span style="color:#f92672">=</span><span style="color:#ae81ff">900</span>,
</span></span><span style="display:flex;"><span>    task_soft_time_limit<span style="color:#f92672">=</span><span style="color:#ae81ff">840</span>,
</span></span><span style="display:flex;"><span>    task_default_retry_delay<span style="color:#f92672">=</span><span style="color:#ae81ff">30</span>,
</span></span><span style="display:flex;"><span>    task_routes<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;tasks.generate_report&#34;</span>: {<span style="color:#e6db74">&#34;queue&#34;</span>: <span style="color:#e6db74">&#34;reports&#34;</span>}},
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></td></tr></table>
</div>
</div><ul>
<li><code>acks_late=True</code>: 実行完了後にACK。途中クラッシュ時は再配信</li>
<li><code>prefetch_multiplier=1</code>: 取り込み過多を防ぎ、偏りを減らす</li>
<li>time limit: ハング抑止</li>
</ul>
<h3 id="42-リトライは指数バックオフ--上限">4.2 リトライは指数バックオフ + 上限</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#a6e22e">@celery_app.task</span>(
</span></span><span style="display:flex;"><span>    bind<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    autoretry_for<span style="color:#f92672">=</span>(TemporaryExternalError,),
</span></span><span style="display:flex;"><span>    retry_backoff<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    retry_backoff_max<span style="color:#f92672">=</span><span style="color:#ae81ff">300</span>,
</span></span><span style="display:flex;"><span>    retry_jitter<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    max_retries<span style="color:#f92672">=</span><span style="color:#ae81ff">7</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">generate_report</span>(self, job_id: str):
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">...</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>無制限リトライは障害増幅装置です。必ず上限を設定します。</p>
<h2 id="5-冪等タスクの実装パターン">5. 冪等タスクの実装パターン</h2>
<h3 id="51-状態遷移をトランザクションで管理">5.1 状態遷移をトランザクションで管理</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">start_job</span>(job_id: str):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> session<span style="color:#f92672">.</span>begin():
</span></span><span style="display:flex;"><span>        job <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>get(Job, job_id, with_for_update<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> job<span style="color:#f92672">.</span>status <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;RUNNING&#34;</span>, <span style="color:#e6db74">&#34;SUCCEEDED&#34;</span>):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>        job<span style="color:#f92672">.</span>status <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;RUNNING&#34;</span>
</span></span><span style="display:flex;"><span>        job<span style="color:#f92672">.</span>started_at <span style="color:#f92672">=</span> utcnow()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">True</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>FOR UPDATE</code> を使い、同時実行で状態が競合しないようにします。</p>
<h3 id="52-副作用前に実行済みチェック">5.2 副作用前に“実行済みチェック”</h3>
<p>外部API呼び出しやファイル生成前に、既に成果物が存在するか確認します。</p>
<ul>
<li>既に同名レポートが生成済みならスキップ</li>
<li>外部通知は送信履歴テーブルで重複防止</li>
<li>決済や課金は必ず業務ID単位で一意化</li>
</ul>
<h3 id="53-完了処理はcompare-and-setで確定">5.3 完了処理はCompare-and-Setで確定</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">complete_job</span>(job_id: str, result_url: str):
</span></span><span style="display:flex;"><span>    updated <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>execute(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        UPDATE async_jobs
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        SET status=&#39;SUCCEEDED&#39;, result_url=:url, finished_at=now()
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        WHERE id=:id AND status=&#39;RUNNING&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &#34;&#34;&#34;</span>,
</span></span><span style="display:flex;"><span>        {<span style="color:#e6db74">&#34;id&#34;</span>: job_id, <span style="color:#e6db74">&#34;url&#34;</span>: result_url},
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> updated<span style="color:#f92672">.</span>rowcount <span style="color:#f92672">==</span> <span style="color:#ae81ff">1</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>これで二重完了更新を防げます。</p>
<h2 id="6-失敗時の設計dlq相当の運用">6. 失敗時の設計（DLQ相当の運用）</h2>
<p>Celeryに“標準DLQ”はありませんが、実運用では次の形で代替できます。</p>
<ul>
<li>リトライ上限超過時に <code>FAILED_PERMANENT</code> へ遷移</li>
<li>失敗理由とスタックトレースをDB保存</li>
<li>再実行API（手動リカバリ）を提供</li>
<li>重大失敗はSentry + Pagerで通知</li>
</ul>
<p>この構成で「黙って死ぬジョブ」をなくせます。</p>
<h2 id="7-監視設計最低限">7. 監視設計（最低限）</h2>
<h3 id="メトリクス">メトリクス</h3>
<ul>
<li>キュー滞留数（queue length）</li>
<li>oldest message age</li>
<li>タスク成功率 / 失敗率</li>
<li>p95 実行時間</li>
<li>リトライ回数分布</li>
</ul>
<h3 id="アラート例">アラート例</h3>
<ul>
<li>滞留数が通常の3倍を10分継続</li>
<li>失敗率 &gt; 5% が15分継続</li>
<li>oldest message age &gt; 20分</li>
<li>worker heartbeat消失</li>
</ul>
<p>「CPU高い」より「キューが古い」がユーザー影響に直結します。</p>
<h2 id="8-デプロイ時の落とし穴">8. デプロイ時の落とし穴</h2>
<h3 id="81-ローリング更新での重複実行">8.1 ローリング更新での重複実行</h3>
<ul>
<li><code>acks_late</code> + graceful shutdown を設定</li>
<li><code>TERM</code> 後にタスク完了待ち時間を確保</li>
<li>長時間ジョブは分割し、中断耐性を持たせる</li>
</ul>
<h3 id="82-スキーマ変更の順序">8.2 スキーマ変更の順序</h3>
<p>非同期基盤では、ワーカーとAPIが異なるバージョンで同居します。</p>
<p>安全な順序:</p>
<ol>
<li>先に後方互換なDB変更を適用</li>
<li>ワーカーを先に更新</li>
<li>APIを更新</li>
<li>非互換削除は次リリースで</li>
</ol>
<p>これを守らないと、古いタスクが新スキーマで失敗します。</p>
<h2 id="9-ローカルステージングでの検証手順">9. ローカル・ステージングでの検証手順</h2>
<ol>
<li>正常系: ジョブ作成→完了→結果取得</li>
<li>再送系: 同一 <code>idempotency_key</code> で2回POST</li>
<li>障害系: 外部APIタイムアウトを強制しリトライ確認</li>
<li>クラッシュ系: 実行中にworker再起動し再配信確認</li>
<li>負荷系: 1000ジョブ投入で滞留時間と失敗率確認</li>
</ol>
<p>この5ケースを自動テストに入れるだけで、運用品質は大幅に上がります。</p>
<h2 id="10-本番チェックリスト">10. 本番チェックリスト</h2>
<ul>
<li><input disabled="" type="checkbox"> idempotency_key のUNIQUE制約あり</li>
<li><input disabled="" type="checkbox"> 冪等な状態遷移実装（RUNNING/SUCCEEDED）</li>
<li><input disabled="" type="checkbox"> リトライ上限 + バックオフ設定済み</li>
<li><input disabled="" type="checkbox"> 手動再実行導線あり</li>
<li><input disabled="" type="checkbox"> 失敗通知（Sentry/Pager）有効</li>
<li><input disabled="" type="checkbox"> 滞留監視とアラート運用あり</li>
<li><input disabled="" type="checkbox"> デプロイ手順に互換性ルール明記</li>
</ul>
<h2 id="まとめ">まとめ</h2>
<p>FastAPI + Celeryの本質は、非同期化そのものではなく <strong>失敗しても壊れない設計</strong> にあります。</p>
<ul>
<li>At-least-once を前提に設計する</li>
<li>冪等性をDB制約と状態遷移で担保する</li>
<li>リトライと監視を“運用可能”な形で実装する</li>
<li>デプロイ時のバージョン混在を想定する</li>
</ul>
<p>ここまで作り込むと、ジョブ基盤は「たまに落ちるブラックボックス」から「予測可能に運用できるインフラ」へ変わります。まずは <code>idempotency_key</code> と状態遷移の明確化から始めるのがおすすめです。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>FastAPI</category>
      <category>Celery</category>
      <category>Redis</category>
      <category>Python</category>
      <category>Reliability</category>
    </item>
    <item>
      <title>Redisキャッシュスタンピード対策ガイド：高負荷時にDBを守る設計と実装</title>
      <link>https://www.ai2core.com/posts/2026-03-02-redis-cache-stampede-mitigation-guide/</link>
      <pubDate>Mon, 02 Mar 2026 09:16:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-02-redis-cache-stampede-mitigation-guide/</guid>
      <description>Redisで発生するキャッシュスタンピードを防ぐための実践ガイド。singleflight、TTLジッター、非同期再生成を具体コード付きで解説。</description>
      <content:encoded><![CDATA[<h1 id="redisキャッシュスタンピード対策ガイド高負荷時にdbを守る設計と実装">Redisキャッシュスタンピード対策ガイド：高負荷時にDBを守る設計と実装</h1>
<p>Redis を使っていても、ピークトラフィック時に DB が突然落ちることがあります。原因の多くはキャッシュスタンピードです。人気キーの TTL が同時に切れると、大量リクエストが一斉に DB へ流れ、接続プールが飽和します。</p>
<p>「Redis を入れたのに遅い」「ピーク時だけ 500 が増える」という現象は、このパターンで説明できることが非常に多いです。</p>
<p>本記事では、キャッシュスタンピードを実運用で防ぐために、<strong>設計原則・実装パターン・監視方法</strong>を順に解説します。</p>
<h2 id="1-キャッシュスタンピードとは何か">1. キャッシュスタンピードとは何か</h2>
<p>典型シナリオ:</p>
<ol>
<li>商品ランキング API が <code>ranking:daily</code> を Redis に 300 秒で保存</li>
<li>300 秒後、人気時間帯にキー期限切れ</li>
<li>同時に 1000 リクエストが miss</li>
<li>1000 回 DB 集計が走ってレイテンシ急増</li>
</ol>
<p>このとき Redis 自体は正常でも、背後の DB が壊れます。つまり、問題はキャッシュ障害ではなく「再生成の同時実行制御」です。</p>
<h2 id="2-防御の基本は三層構え">2. 防御の基本は三層構え</h2>
<p>スタンピード対策は単一施策では不十分です。次の三層を組み合わせると安定します。</p>
<ol>
<li><strong>同時再生成の抑制</strong>（singleflight / 分散ロック）</li>
<li><strong>期限切れの分散</strong>（TTL ジッター）</li>
<li><strong>期限切れ後の挙動制御</strong>（stale-while-revalidate）</li>
</ol>
<h2 id="3-パターン1-singleflight-で同時再生成を止める">3. パターン1: singleflight で同時再生成を止める</h2>
<p>同一キーの miss が同時発生しても、1 リクエストだけ再生成し、他は待つ設計です。</p>
<p>TypeScript 例:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">inflight</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>&lt;<span style="color:#f92672">string</span>, <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">string</span>&gt;&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getOrCompute</span>(<span style="color:#a6e22e">key</span>: <span style="color:#66d9ef">string</span>, <span style="color:#a6e22e">ttlSec</span>: <span style="color:#66d9ef">number</span>, <span style="color:#a6e22e">compute</span><span style="color:#f92672">:</span> () <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">Promise</span>&lt;<span style="color:#f92672">string</span>&gt;) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">cached</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">redis</span>.<span style="color:#66d9ef">get</span>(<span style="color:#a6e22e">key</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">cached</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">cached</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">inflight</span>.<span style="color:#a6e22e">has</span>(<span style="color:#a6e22e">key</span>)) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">p</span> <span style="color:#f92672">=</span> (<span style="color:#66d9ef">async</span> () <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">value</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">compute</span>();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">redis</span>.<span style="color:#66d9ef">set</span>(<span style="color:#a6e22e">key</span>, <span style="color:#a6e22e">value</span>, { <span style="color:#a6e22e">EX</span>: <span style="color:#66d9ef">ttlSec</span> });
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">value</span>;
</span></span><span style="display:flex;"><span>      } <span style="color:#66d9ef">finally</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">inflight</span>.<span style="color:#66d9ef">delete</span>(<span style="color:#a6e22e">key</span>);
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>    })();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">inflight</span>.<span style="color:#66d9ef">set</span>(<span style="color:#a6e22e">key</span>, <span style="color:#a6e22e">p</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">inflight</span>.<span style="color:#66d9ef">get</span>(<span style="color:#a6e22e">key</span>)<span style="color:#f92672">!</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>単一プロセスではこれで十分ですが、複数インスタンス構成では分散ロックも必要です。</p>
<h2 id="4-パターン2-分散ロックset-nx-ex">4. パターン2: 分散ロック（SET NX EX）</h2>
<p>複数 Pod で同時 miss が起きる場合、Redis ロックで再生成担当を 1 つに制限します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">lockKey</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`lock:</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">key</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">lock</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">redis</span>.<span style="color:#66d9ef">set</span>(<span style="color:#a6e22e">lockKey</span>, <span style="color:#a6e22e">instanceId</span>, { <span style="color:#a6e22e">NX</span>: <span style="color:#66d9ef">true</span>, <span style="color:#a6e22e">EX</span>: <span style="color:#66d9ef">10</span> });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">lock</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// ロック獲得: 再生成担当
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">value</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">compute</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">redis</span>.<span style="color:#66d9ef">set</span>(<span style="color:#a6e22e">key</span>, <span style="color:#a6e22e">value</span>, { <span style="color:#a6e22e">EX</span>: <span style="color:#66d9ef">ttlSec</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">redis</span>.<span style="color:#a6e22e">del</span>(<span style="color:#a6e22e">lockKey</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">value</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ロック未獲得: 少し待って再取得
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">await</span> <span style="color:#a6e22e">sleep</span>(<span style="color:#ae81ff">40</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">redis</span>.<span style="color:#66d9ef">get</span>(<span style="color:#a6e22e">key</span>);
</span></span></code></pre></td></tr></table>
</div>
</div><p>注意点は、ロック TTL が短すぎると再生成中に失効し、二重計算になることです。処理時間の p99 を見て余裕を持って設定してください。</p>
<h2 id="5-パターン3-ttl-ジッターで期限切れを分散">5. パターン3: TTL ジッターで期限切れを分散</h2>
<p>同系統キーが同時に切れると負荷波形が尖ります。TTL にランダム幅を持たせるだけでかなり改善します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">ttlWithJitter</span>(<span style="color:#a6e22e">baseSec</span>: <span style="color:#66d9ef">number</span>, <span style="color:#a6e22e">jitterSec</span>: <span style="color:#66d9ef">number</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">baseSec</span> <span style="color:#f92672">+</span> Math.<span style="color:#a6e22e">floor</span>(Math.<span style="color:#a6e22e">random</span>() <span style="color:#f92672">*</span> <span style="color:#a6e22e">jitterSec</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> <span style="color:#a6e22e">redis</span>.<span style="color:#66d9ef">set</span>(<span style="color:#a6e22e">key</span>, <span style="color:#a6e22e">value</span>, { <span style="color:#a6e22e">EX</span>: <span style="color:#66d9ef">ttlWithJitter</span>(<span style="color:#ae81ff">300</span>, <span style="color:#ae81ff">90</span>) });
</span></span></code></pre></td></tr></table>
</div>
</div><p>300 秒固定より、300〜390 秒の分布にすると、同時失効が目に見えて減ります。</p>
<h2 id="6-パターン4-stale-while-revalidate">6. パターン4: stale-while-revalidate</h2>
<p>高可用性が重要な API では、期限切れ直後でも古い値を短時間返しつつ、裏で更新する手法が有効です。</p>
<p>データ構造例:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;value&#34;</span>: {<span style="color:#f92672">&#34;items&#34;</span>: [<span style="color:#960050;background-color:#1e0010">...</span>]},
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;hardExpireAt&#34;</span>: <span style="color:#ae81ff">1700000000</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;softExpireAt&#34;</span>: <span style="color:#ae81ff">1699999700</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><ul>
<li><code>now &lt; softExpireAt</code>: 新鮮データ</li>
<li><code>softExpireAt &lt;= now &lt; hardExpireAt</code>: 古い値を返しつつ非同期更新</li>
<li><code>hardExpireAt &lt;= now</code>: 同期再生成</li>
</ul>
<p>この方式はピーク帯の体感性能を維持しやすく、DB 保護にも強いです。</p>
<h2 id="7-失敗時フォールバックを設計する">7. 失敗時フォールバックを設計する</h2>
<p>キャッシュ再生成が失敗したときの動作を明確にしておくと、障害時の揺れが減ります。</p>
<p>推奨順序:</p>
<ol>
<li>stale データがあれば返す</li>
<li>stale もなければ軽量な代替レスポンス</li>
<li>最後に明示エラー（再試行可能）</li>
</ol>
<p>「毎回 DB にフォールバック」は危険です。障害時に DB をさらに追い込むため、回路遮断（circuit breaker）とセットで設計すべきです。</p>
<h2 id="8-監視で見るべき指標">8. 監視で見るべき指標</h2>
<p>対策の効果はメトリクスで評価します。</p>
<ul>
<li>cache hit ratio</li>
<li>key 単位の miss burst（短時間 miss 件数）</li>
<li>再生成処理の同時実行数</li>
<li>DB クエリ QPS と接続待ち時間</li>
</ul>
<p>Prometheus を使う場合、以下のようなカウンタを実装すると分析しやすいです。</p>
<ul>
<li><code>cache_requests_total{key_group, result=&quot;hit|miss|stale&quot;}</code></li>
<li><code>cache_rebuild_total{key_group, result=&quot;ok|error&quot;}</code></li>
<li><code>cache_lock_contention_total{key_group}</code></li>
</ul>
<h2 id="9-導入手順既存システム向け">9. 導入手順（既存システム向け）</h2>
<h3 id="step-1-熱いキーを特定">Step 1: 熱いキーを特定</h3>
<p>アクセス上位 20 キーを抽出し、まずそこだけ対策します。</p>
<h3 id="step-2-singleflight--ジッター導入">Step 2: singleflight + ジッター導入</h3>
<p>実装コストが低く、効果が高い組み合わせです。</p>
<h3 id="step-3-分散ロック導入">Step 3: 分散ロック導入</h3>
<p>複数インスタンス環境で必須。ロック競合率も計測する。</p>
<h3 id="step-4-stale-while-revalidate-追加">Step 4: stale-while-revalidate 追加</h3>
<p>高トラフィック API へ段階適用。UX と DB 安定性が両立しやすい。</p>
<h2 id="10-よくある失敗例">10. よくある失敗例</h2>
<h3 id="失敗1-ttl-を長くしてごまかす">失敗1: TTL を長くしてごまかす</h3>
<p>データ鮮度要件を満たせず、別の問題が出ます。期限延長は応急処置に留める。</p>
<h3 id="失敗2-分散ロックだけで安心する">失敗2: 分散ロックだけで安心する</h3>
<p>ロックが取れない側の待機戦略がないと、スパイクは残ります。待機・再取得・stale 応答まで設計が必要です。</p>
<h3 id="失敗3-miss-率しか見ない">失敗3: miss 率しか見ない</h3>
<p>miss が少なくても、特定キーへの burst が強ければ障害は起きます。キーグループ単位で観測してください。</p>
<h2 id="11-実運用チェックリスト">11. 実運用チェックリスト</h2>
<ul>
<li><input disabled="" type="checkbox"> 熱いキーを定義し、キーグループで計測している</li>
<li><input disabled="" type="checkbox"> singleflight もしくは同等の同時実行制御がある</li>
<li><input disabled="" type="checkbox"> TTL にジッターを導入している</li>
<li><input disabled="" type="checkbox"> 分散ロックの TTL が処理時間 p99 を上回る</li>
<li><input disabled="" type="checkbox"> stale-while-revalidate の返却条件が明確</li>
<li><input disabled="" type="checkbox"> 障害時フォールバック（回路遮断含む）がある</li>
</ul>
<h2 id="まとめ">まとめ</h2>
<p>Redis キャッシュスタンピード対策は、キャッシュを置くことではなく「miss 後の世界を設計する」ことです。</p>
<ul>
<li>同時再生成を止める</li>
<li>失効タイミングを分散する</li>
<li>期限切れ直後の応答を制御する</li>
</ul>
<p>この3点を実装すれば、ピーク時の DB 崩壊リスクは大幅に下げられます。まずはアクセス上位キーから段階導入し、メトリクスで効果を確認してください。仕組みとして回り始めると、トラフィック増加に対する耐性が目に見えて改善します。</p>
<h2 id="12-キャッシュキー設計の実務ポイント">12. キャッシュキー設計の実務ポイント</h2>
<p>スタンピード対策では、アルゴリズム以前にキー設計が重要です。<code>user:123:timeline</code> のような粒度が粗すぎるキーは、人気ユーザーにアクセスが集中したとき一気にホットスポットになります。可能なら <code>page</code> や <code>segment</code> を分割し、巨大レスポンスを小さく分けると miss 時の再生成コストを抑えられます。</p>
<p>また、キーの命名規約をチームで統一しておくと観測しやすくなります。<code>service:domain:resource:variant</code> 形式で揃えれば、メトリクス集計時に <code>key_group</code> を自動分類しやすく、どの領域で burst が起きているかを短時間で判断できます。運用性を上げるキー設計は、性能改善と同じくらい価値があります。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>Redis</category>
      <category>Caching</category>
      <category>Backend</category>
      <category>Performance</category>
    </item>
  </channel>
</rss>
