1. Background and Design Considerations
In high-concurrency or frequently accessed scenarios, caching is crucial for improving performance and reducing database load. When designing a Redis cache service, consider the following:
- All caches in Redis: All data is stored in Redis, no in-memory first-level cache.
- Key organization strategy: Use regular keys for simple objects and hash tables for structured or grouped data.
- Field TTL: Hash table fields support automatic expiration by storing expiry information in the field value.
- Environment configuration: Flexible Redis settings for development, testing, and production environments.
- Unified interface: A common service interface for easy reuse across business modules.
- Extensibility: Support for Redis clusters, scheduled cleanup of expired fields, and other production-level features.
2. Implementing CacheService
1. Installing Dependencies
pnpm add @nestjs/cache-manager @keyv/redis
2. Configuring the CacheModule
// 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. Injecting the Cache Service Using CACHE_MANAGER
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. Regular Key Cache
For single objects or simple values, use regular Redis keys with TTL. The cache service should provide a unified interface:
- Read from Redis first.
- If missing, execute a callback to fetch the data and write it to Redis.
- Support TTL settings and optional empty value handling.
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, ttlSeconds * 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.defaultTtl): Promise<void> {
await this.cacheManager.set(key, value, ttl);
}
async get<T>(key: string): Promise<T | undefined> {
return this.cacheManager.get<T>(key);
}
...
}
5. Hash Table Cache
For structured or grouped data, use Redis hash tables:
- Hash tables help organize fields logically by business group.
- Each field can include an expiration time, checked on access.
- Provide unified methods like
hgetOrSet,hset, andhgetfor easy use.
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. Cleaning Expired Hash Fields
Since Redis hash fields do not natively support individual TTL, a cleanup routine is recommended in production:
- Periodically iterate all fields in a hash.
- Check expiration timestamps and remove expired fields.
- Ensure cache accuracy and reduce memory usage in 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. Summary
This approach allows building a Redis-only caching service in NestJS with the following characteristics:
- Unified Redis storage: All cache data in Redis, no first-level in-memory cache.
- Key organization strategy: Use regular keys plus hash tables for grouping and clarity.
- Field-level TTL: Supports automatic expiration for hash fields.
- Environment-specific configuration: Flexible for dev/test/prod.
- Unified service interface: Consistent API for business modules.
- Production-ready extensibility: Supports clusters and scheduled cleanup tasks.
This caching strategy is simple, maintainable, and suitable for managing large amounts of cache in real-world NestJS projects.