返回博客
NestJSRedisCacheService

NestJS 实用指南 — 缓存服务可以这样做

RaytonX
3 min read

1. 背景与设计考量

在高并发或频繁访问的数据场景下,缓存是提升系统性能、降低数据库压力的重要手段。设计 Redis 缓存服务时,我们通常关注以下几点:

  1. 缓存统一在 Redis:所有数据存储在 Redis 上,确保所有实例缓存一致。
  2. Key 组织策略:普通 key 用于简单对象,哈希表用于结构化或分组数据。
  3. 字段 TTL:哈希表字段支持自动失效,通过字段值记录过期时间。
  4. 环境变量配置:便于在开发、测试、生产环境中使用不同 Redis 配置。
  5. 统一接口:缓存服务提供统一方法,方便业务模块复用和维护。
  6. 易扩展:可增加集群支持、定时清理过期字段等生产级需求。

2. 实现

说明: 如果想查看 CacheService 的完整实现,包括所有辅助方法、Redis 哈希操作以及 TTL 处理,可以访问我们的 GitHub 仓库:RaytonX NestJS Setup。完整代码有助于更好地理解实现逻辑,并方便在项目中复用。

1. 安装依赖

pnpm add @nestjs/cache-manager @keyv/redis

2. 配置缓存模块

cache.module.ts 中配置缓存:

// cache.module.ts
import { CacheModule } from "@nestjs/cache-manager";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { Global, Module } from "@nestjs/common";
import KeyvRedis from "@keyv/redis";

@Module({
  imports: [
    CacheModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const redisUrl = configService.get<string>("REDIS_URL");
        if (!redisUrl) throw new Error("Missing REDIS_URL in env");
        return {
          stores: [new KeyvRedis(redisUrl)],
          ttl: 60 * 60 * 1000,
        };
      },
    }),
  ],
})
export class AppModule {}

3. 使用 CACHE_MANAGER 注入缓存服务 CacheService

import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable } from "@nestjs/common";
import { Cache } from "cache-manager";
import { RedisService } from "../redis/redis.service";

@Injectable()
export class CacheService {
  private readonly ttlSeconds = 60 * 60;
  private readonly emptyTtlSeconds = 60 * 60;

  constructor(
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
    private readonly redisService: RedisService
  ) {}
}

4. 普通 Key 缓存: 使用 CACHE_MANAGER

对于单个对象或简单值,直接使用 Redis key 存储,并支持 TTL。缓存服务提供统一接口:先从 Redis 读取,如果不存在则执行回调获取数据并写入缓存,同时支持空值处理和 TTL 设置。

export class CacheService {
  ...
  async getOrSet<T>(key: string, fn: () => Promise<T>, ttl = this.ttlSeconds): Promise<T> {
    const cached = await this.cacheManager.get<T>(key);
    if (cached) return cached;
    let result = await fn();
    if (result !== undefined && result !== null) {
      await this.cacheManager.set(key, result, ttl * 1000);
    } else {
      result = {} as Awaited<T>;
      await this.cacheManager.set(key, result, this.emptyTtlSeconds * 1000);
    }
    return result;
  }

  async set<T>(key: string, value: T, ttl = this.ttlSeconds): Promise<void> {
    await this.cacheManager.set(key, value, ttl);
  }

  async get<T>(key: string): Promise<T | undefined> {
    return this.cacheManager.get<T>(key);
  }
  ...
}

5. 哈希表缓存

对于结构化数据或需要分组管理的缓存,可以使用 Redis 哈希表(hash):

  • 哈希表用于组织字段,便于按照业务分组存储。
  • 每个字段可以包含过期时间,通过读取时判断是否失效。
  • 可以提供 hgetOrSethsethget 等统一接口,方便业务调用。
export class CacheService {
  ...
  async hgetOrSet<T>(
    hash: string,
    field: string,
    fn: () => Promise<T>,
    ttlSeconds?: number
  ): Promise<T> {
    const redis = this.redisService.getClient();
    const value = await redis.hget(hash, field);
    if (value) {
      return JSON.parse(value) as T;
    }

    const data = await fn();
    const cacheData = ttlSeconds
      ? { data, expireAt: Date.now() + ttlSeconds * 1000 }
      : { data };
    await redis.hset(hash, field, JSON.stringify(cacheData));
    return data;
  }

  async hset<T>(hash: string, field: string, data: T) {
    const redis = this.redisService.getClient();
    const cacheData = ttlSeconds
      ? { data, expireAt: Date.now() + ttlSeconds * 1000 }
      : { data };
    await redis.hset(hash, field, JSON.stringify(cacheData));
  }

  async hget<T>(hash: string, field: string): Promise<T | undefined> {
    const redis = this.redisService.getClient();

    const value = await redis.hget(hash, field);
    if (!value) return null;
    const parsed = JSON.parse(value);
    if (parsed.expireAt && Date.now() > parsed.expireAt) {
      await this.redisService.hdel(hash, field);
      return null;
    }
    return parsed.data;
  }
  ...
}

6. 清理过期哈希缓存

哈希表字段的 TTL 需要手动判断,生产中通常会配合定时任务清理过期字段:

  • 定期遍历哈希表所有字段
  • 检查过期时间,删除已过期的字段
  • 保证缓存数据准确性,同时减少 Redis 内存占用
async clearExpiredUsers() {
  const allFields = await this.redisService.hKeys('users');
  for (const userId of allFields) {
    const data = await this.redisService.getHash('users', userId);
    if (data?.expireAt && Date.now() > data.expireAt) {
      await this.redisService.hDel('users', userId);
    }
  }
}

3. 总结

通过以上设计,NestJS 项目可以实现一个全 Redis 缓存服务,特点包括:

  • 统一在 Redis:无需内存一级缓存,所有数据集中管理
  • Key 组织策略:普通 key + hash 分组,便于维护和规范化
  • 字段级 TTL:支持哈希表字段自动失效
  • 多环境可配置:支持开发/测试/生产独立配置
  • 统一接口:缓存服务方法统一,便于业务模块复用
  • 生产级扩展性:支持集群、定时清理等高可用需求

这种方案简单、清晰,同时便于在实际项目中管理大量缓存数据。