staff-engineering-skills-thundering-herd

作成者: triggerdotdev

同期された需要の急増によるバックエンドの過負荷を防止します。TTL期限付きのキャッシュ実装、cronジョブのスケジューリング、再接続処理などを行う際に使用します。

npx skills add https://github.com/triggerdotdev/staff-engineering-skills --skill staff-engineering-skills-thundering-herd

Thundering Herd Trap

The cache expired. Ten thousand requests hit the database at once. Before writing any cache-miss handler, cron schedule, or reconnection logic, ask: what happens when every client does this at the same time?

The Four Triggers

TriggerWhat happensExample
Cache expiryPopular key expires, all concurrent requests miss and fetch simultaneouslyHomepage feed cached for 5 min; on expiry, 10,000 requests hit the DB at once
Service recoveryService comes back up, all queued retries hit it simultaneouslyDownstream API recovers from outage, backlogged clients all retry at once
Synchronized schedulingEvery instance fires at the same secondCron at 0 * * * * on 50 instances = 50 identical queries at :00
Connection stormAll clients reconnect simultaneously after a failureDatabase failover: 500 app servers all connect to the new primary at once

Detection: When You're Creating a Thundering Herd

Stop and fix if you see:

  1. "Check cache, miss, fetch, store" with no coalescing -- the textbook pattern. When 1,000 requests hit the miss simultaneously, 1,000 identical queries hit the database. Only one fetch is needed; the other 999 should wait for its result.

  2. A fixed TTL on a popular cache key -- cache.set(key, data, { ttl: 300 }). All keys set at similar times expire at similar times. Especially dangerous after a deployment or cache restart that repopulates everything at once.

  3. Cron at round times -- 0 * * * *, */30 * * * *, 0 0 * * *. Every instance fires at the same second. Every other system in the world also chose these times. Add jitter.

  4. cache.delete(key) or cache.clear() on a hot path -- invalidating a popular key causes an immediate stampede. Use write-through (set the new value) instead of delete, or use stale-while-revalidate.

  5. Reconnection logic without jitter -- on("error", () => connect()). Every client detects the failure at the same time and reconnects at the same time. The new server gets overwhelmed before it can serve anyone.

  6. Cache warming on startup with no stagger -- 50 pods restart during a deploy, each makes 50 warmup queries. That's 2,500 queries in seconds on top of normal load.

Patterns

Request coalescing (single-flight)

Only one request fetches on a miss. Everyone else waits for that result.

const inflight = new Map<string, Promise<any>>();

async function getWithCoalescing<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number): Promise<T> {
  const cached = await cache.get(key);
  if (cached) return JSON.parse(cached);

  // If someone is already fetching this key, wait for their result
  if (inflight.has(key)) return inflight.get(key) as Promise<T>;

  const promise = fetcher()
    .then(async (data) => {
      await cache.set(key, JSON.stringify(data), { EX: ttlSeconds + jitter(ttlSeconds) });
      inflight.delete(key);
      return data;
    })
    .catch((err) => {
      inflight.delete(key);
      throw err;
    });

  inflight.set(key, promise);
  return promise;
}

function jitter(baseTtl: number): number {
  return Math.floor(Math.random() * baseTtl * 0.2); // 0-20% jitter
}

This is in-process only -- works for single-instance services. For multi-instance, use a distributed lock (Redis SET NX PX) so only one instance across the fleet fetches.

Stale-while-revalidate

Serve stale data immediately, refresh in the background. The cache never has a true miss.

const refreshing = new Set<string>();

async function getWithSWR<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number): Promise<T> {
  const entry = await cache.getWithTTL(key); // Returns { data, remainingTtl }

  if (entry && entry.remainingTtl > 0) {
    return entry.data; // Fresh
  }

  if (entry) {
    // Stale but exists -- serve it, refresh in background
    if (!refreshing.has(key)) {
      refreshing.add(key);
      fetcher()
        .then((data) => cache.set(key, data, { EX: ttlSeconds + jitter(ttlSeconds) }))
        .finally(() => refreshing.delete(key));
    }
    return entry.data; // Return stale immediately
  }

  // No data at all -- must fetch synchronously (with coalescing)
  return fetchAndCache(key, fetcher, ttlSeconds);
}

The user gets a response in milliseconds (from stale cache) while the fresh data is fetched in the background. No stampede because there's never a moment where the cache is empty.

Tradeoff: Users may briefly see stale data. Fine for most content. Not acceptable for financial data or security-critical reads.

Jittered TTLs

Prevent mass expiry by spreading expirations across a time window.

function setWithJitter(key: string, data: any, baseTtlSeconds: number) {
  // Add 0-20% random jitter
  const ttl = baseTtlSeconds + Math.floor(Math.random() * baseTtlSeconds * 0.2);
  return cache.set(key, JSON.stringify(data), { EX: ttl });
}

This doesn't prevent a stampede on a single hot key (all requests still miss at the same moment). But it prevents mass expiry -- 10,000 keys populated at the same time won't all expire in the same second.

Jittered cron scheduling

// BAD: all instances fire at exactly :00
cron.schedule("0 * * * *", generateReport);

// GOOD: each instance starts at a random offset within the first minute
const jitterMs = Math.floor(Math.random() * 60_000);
setTimeout(() => {
  cron.schedule("0 * * * *", generateReport);
}, jitterMs);

Jittered reconnection

// BAD: reconnect immediately on failure
db.on("error", () => db.connect());

// GOOD: exponential backoff with jitter
db.on("error", () => {
  const baseDelay = 1000;
  const attempt = reconnectAttempts++;
  const delay = baseDelay * 2 ** Math.min(attempt, 6) + Math.random() * 1000;
  setTimeout(() => db.connect(), delay);
});

Without jitter, all clients reconnect at the same time after the same backoff duration. Jitter spreads them across a window so the server isn't overwhelmed.

Write-through instead of delete on invalidation

// BAD: delete causes a stampede on the next read
async function updateConfig(newConfig: Config) {
  await db.save(newConfig);
  await cache.delete("config"); // Every next request stampedes
}

// GOOD: write the new value directly -- no miss, no stampede
async function updateConfig(newConfig: Config) {
  await db.save(newConfig);
  await cache.set("config", JSON.stringify(newConfig), { EX: 300 });
  // Cache goes from old value → new value with no gap
}

Anti-Patterns

// Naive cache-aside: 1,000 concurrent misses = 1,000 identical DB queries
const cached = await cache.get(key);
if (!cached) { data = await db.query(key); await cache.set(key, data, { ttl: 300 }); }

// Fixed TTL, no jitter: all keys populated at deploy expire at the same time
cache.set(key, data, { ttl: 300 }); // Every key gets exactly 300s

// Cron at round time: 50 instances all fire at :00
cron.schedule("0 * * * *", expensiveJob);

// Cache clear in hot path: nuclear stampede
await cache.clear(); // Everything stampedes at once

// Reconnect without backoff: connection storm
db.on("error", () => db.connect());

Related Traps

  • Cache Invalidation -- cache stampede is the thundering herd problem applied to caching. Stale-while-revalidate and write-through invalidation prevent the gap where the cache is empty.
  • Retry Storms -- retries are a specific form of thundering herd. When a service is slow and clients retry, the retries themselves create a herd on the struggling service. Exponential backoff with jitter is the same fix in both contexts.
  • Hot Partitions -- a thundering herd on a partitioned system overwhelms whichever partition holds the hot key. The two traps compound.
  • Backpressure -- a cache stampede is a sudden loss of backpressure. The cache was absorbing load; when it expires, the full load hits the backend with no buffering.

triggerdotdevのその他のスキル

trigger-dev-tasks
triggerdotdev
このスキルは、Trigger.devのバックグラウンドタスクやワークフローの作成、設計、最適化に使用します。信頼性の高い非同期タスクの作成やAIの実装などが含まれます。
official
trigger-authoring-chat-agent
triggerdotdev
@trigger.dev/sdk/ai の chat.agent を使用して、永続的なAIチャットエージェントを作成・実行します:ターンごとの実行ループ、...chat.toStreamTextOptions() を必ずスプレッドする理由…
official
trigger-agents
triggerdotdev
Trigger.devを使用したAIエージェントパターン - オーケストレーション、並列化、ルーティング、評価者最適化、ヒューマンインザループ。LLMを活用したタスクを構築する際に使用します…
official
trigger-config
triggerdotdev
trigger.config.tsを使用してTrigger.devプロジェクトを設定します。Prisma、Playwright、FFmpeg、Pythonのビルド拡張を設定する場合や、デプロイをカスタマイズする場合に使用します…
official
trigger-cost-savings
triggerdotdev
Trigger.devのタスク、スケジュール、実行を分析し、コスト最適化の機会を見つけます。支出削減、コスト最適化、使用状況監査、適正化などを求められた際に使用します。
official
trigger-realtime
triggerdotdev
Trigger.devのタスク実行をフロントエンドとバックエンドからリアルタイムで購読します。進捗インジケーター、ライブダッシュボード、ストリーミングAI/LLM応答などを構築する際に使用します。
official
trigger-setup
triggerdotdev
プロジェクトにTrigger.devをセットアップします。初めてTrigger.devを追加するとき、trigger.config.tsを作成するとき、またはtriggerディレクトリを初期化するときに使用します。
official
trigger-tasks
triggerdotdev
Trigger.devを使用してAIエージェント、ワークフロー、耐久性のあるバックグラウンドタスクを構築します。タスクの作成、ジョブのトリガー、リトライ処理、cronジョブのスケジュール設定などに使用します。
official