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日6 分钟阅读1.3k 字数|

属于我们的Performance & Scalability系列

阅读完整指南

Web 应用程序的 Redis 缓存模式

数据库查询是 Web 应用程序中最常见的性能瓶颈。一个索引不良的查询需要 200 毫秒,在 1,000 个并发用户的每个页面加载时调用,每秒会产生 200 秒的数据库 CPU — 死亡螺旋。 Redis 是解药:一种亚毫秒级的内存存储,可以吸收读取负载,否则这些负载会重复访问数据库以获取相同的数据。

但缓存不仅仅是“把东西放到Redis里”。错误的模式会导致过时的数据错误、惊群灾难或无限制的内存增长。本指南涵盖了四种规范的缓存模式、缓存失效策略、踩踏预防和生产 NestJS 实现,以便您从一开始就正确缓存。

要点

  • Cache-aside(延迟加载)是最安全的默认设置 - 只缓存实际请求的内容
  • 直写使缓存和数据库保持同步,但增加了写入延迟 - 用于频繁读取、不频繁写入的数据
  • 当流行密钥过期时,缓存踩踏(惊群)会破坏数据库 - 使用概率提前过期或互斥锁
  • 必须在每个键上设置 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:Cache-Aside(延迟加载)

最常见的模式。从缓存中读取;如果未命中,则从数据库读取并填充缓存。

// 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:缓存踩踏预防

当一个流行的缓存键过期时,数百个并发请求都会同时错过缓存并全部命中数据库——“惊群”。数据库在突然的负载下崩溃了。

互斥锁模式

只有一次请求重建缓存;其他人等待并重试:

// 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 — 由最大内存策略逐出的键(对于缓存侧应该接近 0)
  • connected_clients — 连接池利用率
  • instantaneous_ops_per_sec — 当前吞吐量

常见问题

我应该为缓存使用什么最大内存策略?

使用 allkeys-lru (从所有键中删除最近最少使用的键)或 volatile-lru (删除设置了 TTL 的 LRU 键)。对于纯缓存工作负载,allkeys-lru 是标准的 - 当内存已满时,Redis 会自动驱逐冷键。切勿使用 noeviction 进行缓存 - 当内存已满时它会返回错误而不是逐出旧数据。

如何避免在 Redis 中存储敏感数据?

切勿缓存原始密码、私钥、支付卡数据或 SSN。对于身份验证会话,缓存最少的数据:userId、role、organizationId、tokenVersion — 不是完整的 JWT 有效负载或 API 凭据。在生产中启用 Redis AUTH 和 TLS。如果您的 Redis 实例受到威胁,当您遵循此规则时,只有元数据会被暴露。

模式删除时SCAN和KEYS有什么区别?

KEYS pattern 是一个阻塞 O(n) 操作,它会在扫描时暂停所有 Redis 命令 - 它可能会导致大型密钥空间出现数秒的停机时间。 SCAN 是非阻塞的,使用游标以小块进行迭代。始终使用 SCAN 进行生产模式删除。权衡是,如果在扫描期间添加或删除了所有键,SCAN 可能不会返回所有键 - 对于缓存失效来说是可以接受的。

我应该使用单独的 Redis 实例进行缓存、速率限制还是会话?

对于大多数应用程序来说,一个具有不同键前缀的 Redis 实例就足够了。在以下情况下,单独的实例有意义:缓存需要与速率限制存储不同的 maxmemory-policy(缓存:allkeys-lru,速率限制:noeviction),或者当您需要独立扩展和故障转移时。大规模时,将 Redis 集群与每个工作负载类型的单独集群一起使用。

如何处理分页列表查询的缓存失效?

分页列表缓存很棘手 - 在第 1 页上添加联系人会移动第 2 页以上的所有联系人。务实的解决方案:使用短 TTL(2-5 分钟),并使用模式无效 (contacts:{orgId}:*) 在任何写入时使组织的所有页面无效。对于写入量大的大型组织,完全跳过缓存分页并依赖数据库级优化(适当的索引、覆盖索引)。

如何在 Vitest 中测试缓存行为?

在单元测试中模拟 CacheService,使用 vi.fn() 返回命中/未命中场景的适当值。对于集成测试,请使用真实的 Redis 实例(Docker)并使用 beforeEach 中的 redis.flushdb() 来启动 clean。显式测试缓存命中路径(无数据库调用)和缓存未命中路径(数据库调用 + 缓存填充)。


后续步骤

Redis 缓存是 Web 应用程序中投资回报率最高的性能投资之一。 200 毫秒的数据库查询和 2 毫秒的 Redis 命中之间的差异是 100 倍——从规模上看,这直接转化为基础设施成本的节省和用户体验的改善。

ECOSIRE 在每个 NestJS 项目上实现了 Redis 缓存,包括缓存旁路、直写失效、踩踏预防和缓存命中率监控。 探索我们的后端工程服务 了解我们如何设计高性能、可扩展的 API。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天