Caching patterns sử dụng Redis trong NestJS. Bao gồm cache interceptors, decorators, cache invalidation strategies, và distributed caching.
Skill này tập trung vào caching patterns sử dụng Redis trong NestJS cho performance optimization.
// cache.module.ts
import { CacheModule } from "@nestjs/cache-manager";
import { redisStore } from "cache-manager-redis-yet";
@Module({
imports: [
CacheModule.registerAsync({
useFactory: async () => ({
store: await redisStore({
socket: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
},
password: process.env.REDIS_PASSWORD,
ttl: 60 * 1000, // Default 60 seconds
}),
}),
isGlobal: true,
}),
],
exports: [CacheModule],
})
export class RedisCacheModule {}
@Injectable()
export class CacheService {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async get<T>(key: string): Promise<T | undefined> {
return this.cacheManager.get<T>(key);
}
async set(key: string, value: any, ttl?: number): Promise<void> {
await this.cacheManager.set(key, value, ttl);
}
async del(key: string): Promise<void> {
await this.cacheManager.del(key);
}
async reset(): Promise<void> {
await this.cacheManager.reset();
}
async getOrSet<T>(
key: string,
factory: () => Promise<T>,
ttl?: number,
): Promise<T> {
const cached = await this.get<T>(key);
if (cached) return cached;
const value = await factory();
await this.set(key, value, ttl);
return value;
}
}
// Format: entity:action:identifier[:sub-identifier]
const cacheKeys = {
// User cache
user: (id: string) => `user:${id}`,
userProfile: (id: string) => `user:${id}:profile`,
userPermissions: (id: string) => `user:${id}:permissions`,
// List cache with filters
usersList: (page: number, limit: number, filters: string) =>
`users:list:${page}:${limit}:${filters}`,
// API response cache
apiResponse: (endpoint: string, params: string) =>
`api:${endpoint}:${this.hashParams(params)}`,
};
// Helper to hash complex params
private hashParams(params: any): string {
return crypto
.createHash('md5')
.update(JSON.stringify(params))
.digest('hex')
.substring(0, 8);
}
@Injectable()
export class UserService {
constructor(
private userRepo: Repository<User>,
private cacheService: CacheService,
) {}
async findById(id: string): Promise<User> {
const cacheKey = `user:${id}`;
return this.cacheService.getOrSet(
cacheKey,
async () => {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException();
return user;
},
300, // 5 minutes
);
}
async findAll(page = 1, limit = 20): Promise<PaginatedResult<User>> {
const cacheKey = `users:list:${page}:${limit}`;
return this.cacheService.getOrSet(
cacheKey,
async () => {
const [data, total] = await this.userRepo.findAndCount({
skip: (page - 1) * limit,
take: limit,
});
return { data, meta: { total, page, limit } };
},
60, // 1 minute for lists
);
}
async update(id: string, dto: UpdateUserDto): Promise<User> {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException();
Object.assign(user, dto);
await this.userRepo.save(user);
// Invalidate related caches
await this.invalidateUserCaches(id);
return user;
}
async remove(id: string): Promise<void> {
await this.userRepo.softDelete(id);
await this.invalidateUserCaches(id);
}
private async invalidateUserCaches(userId: string): Promise<void> {
// Delete specific user cache
await this.cacheService.del(`user:${userId}`);
await this.cacheService.del(`user:${userId}:profile`);
// Delete list caches (pattern-based deletion if supported)
await this.cacheService.del("users:list:*"); // Or use Redis scan
}
}
// Cacheable decorator
export function Cacheable(options: {
key: string | ((...args: any[]) => string);
ttl?: number;
}) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
const cacheService = target.constructor.injectCacheService; // Assume injection
descriptor.value = async function (...args: any[]) {
const cacheKey =
typeof options.key === "function" ? options.key(...args) : options.key;
const cached = await cacheService.get(cacheKey);
if (cached) return cached;
const result = await originalMethod.apply(this, args);
await cacheService.set(cacheKey, result, options.ttl);
return result;
};
return descriptor;
};
}
// Usage
@Injectable()
export class ProductService {
@Cacheable({
key: (id: string) => `product:${id}`,
ttl: 600, // 10 minutes
})
async findById(id: string): Promise<Product> {
return this.productRepo.findOne({ where: { id } });
}
}
@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
trackBy(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest();
const { httpAdapter } = this.httpAdapterHost;
// Custom cache key: method + url + query string
const cacheKey = `${request.method}:${httpAdapter.getRequestUrl(request)}`;
return cacheKey;
}
}
// Controller usage
@Controller("products")
@UseInterceptors(HttpCacheInterceptor)
export class ProductsController {
@Get(":id")
@CacheTTL(300)
async findOne(@Param("id") id: string) {
return this.service.findOne(id);
}
}
@Injectable()
export class ArticleService {
constructor(
private articleRepo: Repository<Article>,
private cacheService: CacheService,
) {}
async findPublished(page = 1): Promise<Article[]> {
// Published articles rarely change - cache longer
return this.cacheService.getOrSet(
`articles:published:${page}`,
() =>
this.articleRepo.find({
where: { status: "published" },
order: { publishedAt: "DESC" },
}),
600, // 10 minutes
);
}
async findDrafts(userId: string): Promise<Article[]> {
// Drafts change frequently - shorter cache or no cache
return this.articleRepo.find({
where: { authorId: userId, status: "draft" },
});
// No caching for drafts
}
}
@Injectable()
export class CacheWarmingService {
constructor(
private cacheService: CacheService,
private productService: ProductService,
) {}
@Cron(CronExpression.EVERY_HOUR)
async warmPopularProductsCache(): Promise<void> {
const popularProductIds = await this.getPopularProductIds();
for (const id of popularProductIds) {
const product = await this.productService.findById(id);
await this.cacheService.set(
`product:${id}`,
product,
7200, // 2 hours
);
}
console.log(`Warmed cache for ${popularProductIds.length} products`);
}
private async getPopularProductIds(): Promise<string[]> {
// Query from analytics or views table
return ["prod-1", "prod-2", "prod-3"];
}
}
@Injectable()
export class CacheService {
async getOrSetWithLock<T>(
key: string,
factory: () => Promise<T>,
ttl: number,
): Promise<T> {
const lockKey = `${key}:lock`;
const lockValue = Date.now().toString();
// Try to acquire lock
const acquired = await this.redis.set(
lockKey,
lockValue,
"PX",
10000, // 10 seconds lock
"NX", // Only if not exists
);
if (!acquired) {
// Another process is computing, wait and retry
await new Promise((resolve) => setTimeout(resolve, 100));
return this.getOrSetWithLock(key, factory, ttl);
}
try {
const cached = await this.get<T>(key);
if (cached) return cached;
const value = await factory();
await this.set(key, value, ttl);
return value;
} finally {
// Release lock (only if we still own it)
const currentValue = await this.redis.get(lockKey);
if (currentValue === lockValue) {
await this.redis.del(lockKey);
}
}
}
}
// Cache aside pattern (recommended)
async getUser(userId: string): Promise<User> {
// 1. Try cache
let user = await this.cache.get<User>(`user:${userId}`);
if (!user) {
// 2. Cache miss - load from DB
user = await this.userRepo.findOne({ where: { id: userId } });
if (user) {
// 3. Populate cache
await this.cache.set(`user:${userId}`, user, 300);
}
}
return user;
}
// Write-through pattern
async createUser(dto: CreateUserDto): Promise<User> {
const user = this.userRepo.create(dto);
await this.userRepo.save(user);
// Update cache immediately
await this.cache.set(`user:${user.id}`, user, 300);
return user;
}
// Write-behind (async cache update)
async updateUser(id: string, dto: UpdateUserDto): Promise<User> {
const user = await this.userRepo.update(id, dto);
// Queue cache invalidation
this.eventEmitter.emit('cache.invalidate', { key: `user:${id}` });
return user;
}
// Cache metrics
@Injectable()
export class CacheMetricsService {
private hits = 0;
private misses = 0;
recordHit(): void {
this.hits++;
}
recordMiss(): void {
this.misses++;
}
getHitRate(): number {
const total = this.hits + this.misses;
return total > 0 ? (this.hits / total) * 100 : 0;
}
}