Redis Caching Patterns for Web Applications

Master Redis caching patterns for Node.js and NestJS: cache-aside, write-through, cache stampede prevention, TTL strategies, invalidation, and production monitoring.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.4k 語数|

Performance & Scalabilityシリーズの一部

完全ガイドを読む

Web アプリケーションの Redis キャッシュ パターン

データベース クエリは、Web アプリケーションで最も一般的なパフォーマンスのボトルネックです。インデックスが不十分なクエリは 200 ミリ秒かかり、同時ユーザー 1,000 人のページ読み込みごとに呼び出され、データベースの CPU 使用率が 1 秒あたり 200 秒に達します。これは死のスパイラルです。 Redis はその解毒剤です。Redis は、同じデータに対してデータベースに繰り返し負荷をかける読み取り負荷を吸収する、ミリ秒未満のメモリ内ストアです。

ただし、キャッシュは単に「Redis に内容を入れる」だけではありません。間違ったパターンは、古いデータのバグ、激しい集団災害、または際限のないメモリの増大を引き起こします。このガイドでは、最初から正しくキャッシュできるように、4 つの正規のキャッシュ パターン、キャッシュ無効化戦略、スタンピード防止、本番環境の NestJS 実装について説明します。

重要なポイント

  • キャッシュアサイド (遅延読み込み) が最も安全なデフォルトです - 実際に要求されたもののみをキャッシュします
  • ライトスルーはキャッシュとデータベースの同期を維持しますが、書き込み遅延が増加します - 頻繁に読み取られ、あまり書き込まれないデータに使用します
  • 人気のあるキーの有効期限が切れると、キャッシュ スタンピード (雷の群れ) によりデータベースが破壊されます。確率的な早期有効期限またはミューテックス ロックを使用します。
  • TTL はすべてのキーに設定する必要があります — 無制限の Redis の増加は、今後起こるのを待っているインシデントです
  • キャッシュの無効化は困難です。完全に無効化しようとするよりも、短い TTL とイベント駆動型の無効化を好みます。
  • キャッシュ キーにはすべてのクエリ ディメンションが含まれている必要があります: contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}
  • Redis 名前空間とキー パターンを使用して一括無効化を有効にする
  • キャッシュ ヒット率を監視します。 80% 未満の場合は、TTL が短すぎるか、キーの構造が間違っていることを意味します

NestJS での Redis セットアップ

pnpm add ioredis @nestjs-modules/ioredis
// src/modules/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

@Injectable()
export class CacheService {
  private readonly logger = new Logger(CacheService.name);

  constructor(@InjectRedis() private readonly redis: Redis) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    if (!value) return null;
    return JSON.parse(value) as T;
  }

  async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
    await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
  }

  async del(key: string): Promise<void> {
    await this.redis.del(key);
  }

  // Use SCAN instead of KEYS to avoid blocking Redis on large key spaces
  async invalidatePattern(pattern: string): Promise<void> {
    let cursor = '0';
    do {
      const [nextCursor, keys] = await this.redis.scan(
        cursor,
        'MATCH', pattern,
        'COUNT', 100
      );
      cursor = nextCursor;
      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
    } while (cursor !== '0');
  }

  async remember<T>(
    key: string,
    ttlSeconds: number,
    factory: () => Promise<T>
  ): Promise<T> {
    const cached = await this.get<T>(key);
    if (cached !== null) return cached;

    const value = await factory();
    await this.set(key, value, ttlSeconds);
    return value;
  }
}
// src/modules/cache/cache.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';

@Global()
@Module({
  imports: [
    RedisModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'single',
        options: {
          host: config.get('REDIS_HOST', 'localhost'),
          port: config.get<number>('REDIS_PORT', 6379),
          password: config.get('REDIS_PASSWORD'),
          db: config.get<number>('REDIS_DB', 0),
          keyPrefix: 'ecosire:',
        },
      }),
    }),
  ],
  providers: [CacheService],
  exports: [CacheService],
})
export class AppCacheModule {}

パターン 1: キャッシュアサイド (遅延読み込み)

最も一般的なパターン。キャッシュから読み取ります。ミスの場合は、データベースから読み取り、キャッシュにデータを追加します。

// contacts.service.ts
async findAll(orgId: string, page: number, limit: number, search?: string) {
  const cacheKey = `contacts:${orgId}:${page}:${limit}:${search ?? 'all'}`;

  return this.cache.remember(cacheKey, 300, async () => {
    const results = await this.db.query.contacts.findMany({
      where: and(
        eq(contacts.organizationId, orgId),
        search
          ? or(
              ilike(contacts.name, `%${search}%`),
              ilike(contacts.email, `%${search}%`)
            )
          : undefined
      ),
      limit,
      offset: (page - 1) * limit,
      orderBy: desc(contacts.createdAt),
    });
    return results;
  });
}

長所: シンプル。キャッシュには実際に要求されたもののみが含まれます。 短所: 最初のリクエストは常に遅い (キャッシュミス)。データは TTL が期限切れになるまで古いままになる可能性があります。

無効にする場合: 作成/更新/削除後、contacts:{orgId}:* に一致するすべてのキーを削除します。


パターン 2: ライトスルー

書き込みのたびにデータベースと同時にキャッシュを更新します。書き込みレイテンシを犠牲にして読み取りの一貫性を確保します。

async update(orgId: string, contactId: string, dto: UpdateContactDto) {
  // 1. Update the database
  const [updated] = await this.db
    .update(contacts)
    .set({ ...dto, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, contactId),
        eq(contacts.organizationId, orgId)
      )
    )
    .returning();

  // 2. Update the individual contact cache immediately (write-through)
  const singleKey = `contact:${orgId}:${contactId}`;
  await this.cache.set(singleKey, updated, 3600);

  // 3. Invalidate list caches (they now contain stale order/counts)
  await this.cache.invalidatePattern(`contacts:${orgId}:*`);

  return updated;
}

使用する場合: 頻繁に読み取られるが、あまり書き込まれないレコード (ユーザー プロファイル、設定、製品カタログ)。


パターン 3: キャッシュ スタンピードの防止

一般的なキャッシュ キーの有効期限が切れると、何百もの同時リクエストがすべて同時にキャッシュをミスし、すべてがデータベースにヒットします (「雷の群れ」)。突然の負荷によりデータベースが崩壊します。

ミューテックスロックパターン

キャッシュを再構築するリクエストは 1 つだけです。他の人は待って再試行します。

// cache.service.ts — mutex-protected cache fetch
async getWithLock<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>
): Promise<T> {
  // Try cache first
  const cached = await this.get<T>(key);
  if (cached !== null) return cached;

  const lockKey = `lock:${key}`;
  const lockOwner = `${process.pid}-${Date.now()}`;

  // Try to acquire lock: NX = only set if not exists, EX = expire in 30s
  const acquired = await this.redis.set(lockKey, lockOwner, 'NX', 'EX', 30);

  if (acquired === 'OK') {
    try {
      const value = await factory();
      await this.set(key, value, ttlSeconds);
      return value;
    } finally {
      // Atomic check-and-delete: only release if we still own the lock
      // Uses Lua defined with defineCommand for atomicity
      const currentOwner = await this.redis.get(lockKey);
      if (currentOwner === lockOwner) {
        await this.redis.del(lockKey);
      }
    }
  } else {
    // Another process is rebuilding — wait 100ms and retry
    await new Promise<void>((resolve) => {
      const timer = setTimeout(resolve, 100);
      // Store timer reference so Node.js does not block on it
      timer.unref?.();
    });
    return this.getWithLock(key, ttlSeconds, factory);
  }
}

確率的早期有効期限 (XFetch)

キャッシュの有効期限が切れる前に確率的にキャッシュの再構築を開始し、再計算の負荷を分散します。

async getWithEarlyExpiration<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>,
  beta = 1 // Higher = more aggressive early recomputation
): Promise<T> {
  const metaKey = `${key}:meta`;
  const meta = await this.get<{ createdAt: number; ttl: number }>(metaKey);
  const value = await this.get<T>(key);

  if (value !== null && meta) {
    const elapsed = Date.now() / 1000 - meta.createdAt;
    const remaining = meta.ttl - elapsed;

    // XFetch: trigger early recomputation probabilistically
    const shouldRecompute = -Math.log(Math.random()) * beta > remaining;
    if (!shouldRecompute) return value;
  }

  const newValue = await factory();
  const now = Date.now() / 1000;
  await this.set(key, newValue, ttlSeconds);
  await this.set(metaKey, { createdAt: now, ttl: ttlSeconds }, ttlSeconds + 60);

  return newValue;
}

パターン 4: タグベースのキャッシュの無効化

キャッシュ キーを論理タグごとにグループ化し、タグ全体を一度に無効にします。

// Tag-based invalidation using Redis sets
async setWithTags(
  key: string,
  value: unknown,
  ttlSeconds: number,
  tags: string[]
): Promise<void> {
  await this.set(key, value, ttlSeconds);

  // Add key to each tag's member set using a pipeline
  const pipeline = this.redis.pipeline();
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, key);
    pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
  }
  await pipeline.exec();
}

async invalidateByTag(tag: string): Promise<void> {
  const keys = await this.redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    const pipeline = this.redis.pipeline();
    for (const cacheKey of keys) {
      pipeline.del(cacheKey);
    }
    pipeline.del(`tag:${tag}`);
    await pipeline.exec();
  }
}

// Usage
await this.setWithTags(
  `contacts:${orgId}:page:1`,
  contacts,
  300,
  [`org:${orgId}`, 'contacts']
);

// After any contact mutation in org_123:
await this.invalidateByTag(`org:${orgId}`);

セッションと認証のキャッシュ

Redis は認証セッション ストレージに最適です。すべてのリクエストでのデータベース検索を排除します。

// Cache user session data (role, permissions, orgId) for JWT validation
async cacheUserSession(userId: string, session: UserSession): Promise<void> {
  const key = `session:${userId}`;
  await this.redis.hset(key,
    'id',             session.id,
    'email',          session.email,
    'role',           session.role,
    'organizationId', session.organizationId,
    'tokenVersion',   String(session.tokenVersion)
  );
  await this.redis.expire(key, 900); // 15 minutes — matches access token TTL
}

async getUserSession(userId: string): Promise<UserSession | null> {
  const data = await this.redis.hgetall(`session:${userId}`);
  if (!data || Object.keys(data).length === 0) return null;
  return {
    ...data,
    tokenVersion: parseInt(data.tokenVersion, 10),
  } as UserSession;
}

async invalidateUserSession(userId: string): Promise<void> {
  await this.redis.del(`session:${userId}`);
}

データ型別の TTL 戦略

データ型推奨TTL無効化戦略
ユーザープロフィール15分プロフィールの更新について
組織設定1時間設定変更時
製品カタログ24時間製品の作成/更新時
ブログ記事一覧10分公開後
API レート制限カウンターウィンドウごと (60 秒)自動有効期限
認証セッション15分ログアウト時またはトークンの取り消し
検索結果5分TTLのみ
集約されたメトリクス1分TTLのみ
ワンタイム コード (認証)60秒使用後または期限切れになった後
ページ分割されたリスト クエリ5分組織内の突然変異について

キャッシュパフォーマンスの監視

// Add hit/miss tracking to CacheService
async get<T>(key: string): Promise<T | null> {
  const value = await this.redis.get(key);
  if (value) {
    await this.redis.incr('metrics:cache_hits');
    return JSON.parse(value) as T;
  }
  await this.redis.incr('metrics:cache_misses');
  return null;
}

// Report hit rate every 5 minutes
@Cron('*/5 * * * *')
async reportCacheMetrics(): Promise<void> {
  const [hits, misses] = await this.redis.mget(
    'metrics:cache_hits',
    'metrics:cache_misses'
  );
  const h = parseInt(hits ?? '0', 10);
  const m = parseInt(misses ?? '0', 10);
  const total = h + m;
  const hitRate = total === 0 ? 1 : h / total;

  if (hitRate < 0.8) {
    this.logger.warn(`Low cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
  }

  // Reset counters
  await this.redis.set('metrics:cache_hits', '0');
  await this.redis.set('metrics:cache_misses', '0');
}

redis-cli INFO stats 経由で監視する主要な Redis メトリクス:

  • keyspace_hits / keyspace_misses — グローバル ヒット率 (目標 80% 以上)
  • used_memory — 際限のない増大に注意する
  • evicted_keys — maxmemory ポリシーによって削除されたキー (キャッシュアサイドの場合は 0 に近い値である必要があります)
  • connected_clients — 接続プールの使用率
  • instantaneous_ops_per_sec — 現在のスループット

よくある質問

キャッシュにはどの maxmemory ポリシーを使用する必要がありますか?

allkeys-lru (すべてのキーから最も最近使用されていないキーを削除) または volatile-lru (TTL が設定されている LRU キーを削除) を使用します。純粋なキャッシュ ワークロードの場合、allkeys-lru が標準です。メモリがいっぱいになると、Redis はコールド キーを自動的に削除します。 noeviction をキャッシュに使用しないでください。メモリがいっぱいになると、古いデータが削除されるのではなく、エラーが返されます。

機密データを Redis に保存しないようにするにはどうすればよいですか?

生のパスワード、秘密キー、支払いカード データ、または SSN は決してキャッシュしないでください。認証セッションの場合、完全な JWT ペイロードや API 認証情報ではなく、最小限のデータ (userId、role、organizationId、tokenVersion) をキャッシュします。本番環境で Redis AUTH と TLS を有効にします。 Redis インスタンスが侵害された場合、この規定に従うとメタデータのみが公開されます。

パターン削除における SCAN と KEYS の違いは何ですか?

KEYS pattern は、スキャン中にすべての Redis コマンドを一時停止するブロック O(n) 操作です。これにより、大きなキー スペースで数秒のダウンタイムが発生する可能性があります。 SCAN は非ブロッキングで、カーソルを使用して小さなチャンクで反復処理されます。本番パターンの削除には常に SCAN を使用してください。トレードオフとして、スキャン中にキーが追加または削除された場合、SCAN はすべてのキーを返さない可能性があり、キャッシュの無効化には許容されます。

キャッシュ、レート制限、セッションに別の Redis インスタンスを使用する必要がありますか?

ほとんどのアプリケーションでは、異なるキー プレフィックスを持つ 1 つの Redis インスタンスで問題ありません。個別のインスタンスは次の場合に意味があります: キャッシュにレート制限ストアとは異なる maxmemory-policy が必要な場合 (キャッシュ: allkeys-lru、レート制限: noeviction)、または独立したスケーリングとフェイルオーバーが必要な場合。大規模な場合は、ワークロード タイプごとに個別のクラスターを持つ Redis クラスターを使用します。

ページ分割されたリスト クエリのキャッシュ無効化はどのように処理すればよいですか?

ページ分割されたリストのキャッシュは扱いが難しく、ページ 1 に連絡先を追加すると、ページ 2 以降のすべての連絡先が移動します。実用的な解決策: 短い TTL (2 ~ 5 分) を使用し、パターンの無効化 (contacts:{orgId}:*) を使用して書き込み時に組織のすべてのページを無効にします。書き込み量が多い大規模な組織の場合は、ページネーションのキャッシュを完全にスキップし、代わりにデータベース レベルの最適化 (適切なインデックス、インデックスのカバー) に依存します。

Vitest でキャッシュの動作をテストするにはどうすればよいですか?

ヒット/ミス シナリオに適切な値を返す vi.fn() を使用して、単体テストで CacheService をモックします。統合テストの場合は、実際の Redis インスタンス (Docker) を使用し、beforeEachredis.flushdb() を使用してクリーンに開始します。キャッシュ ヒット パス (データベース呼び出しなし) とキャッシュ ミス パス (データベース呼び出し + キャッシュ データ入力) の両方を明示的にテストします。


次のステップ

Redis キャッシュは、Web アプリケーションにおけるパフォーマンス投資の中で最も高い ROI の 1 つです。 200 ミリ秒のデータベース クエリと 2 ミリ秒の Redis ヒットの差は 100 倍であり、大規模な場合、これはインフラストラクチャのコスト削減とユーザー エクスペリエンスの向上に直接つながります。

ECOSIRE は、キャッシュ アサイド、ライトスルー無効化、スタンピード防止、キャッシュ ヒット率監視を備えた Redis キャッシュをすべての NestJS プロジェクトに実装します。 バックエンド エンジニアリング サービスを探索 して、パフォーマンスとスケーラブルな API をどのように設計するかをご覧ください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット