【NestJS】在 nest.js 项目中,如何使用 Postgresql 来做缓存?
在 NestJS 项目中使用 PostgreSQL 作为缓存存储是一个可行的方案,尽管它通常不如 Redis 等专门的内存缓存系统高效。但是,如果你已经在使用 PostgreSQL 并且希望避免引入额外的服务(如 Redis),或者你的缓存需求量不大、对延迟不那么敏感,那么这确实是一个不错的选择。
本指南将详细介绍如何在 NestJS 中使用 TypeORM 和 PostgreSQL 来实现一个基本的缓存机制。
重要提示 (Disclaimer)
- 性能考量: PostgreSQL 是一个关系型数据库,主要设计用于持久化存储和复杂查询。与 Redis 这种内存数据库相比,它在处理大量高并发的读写操作时通常会慢得多,因为它涉及磁盘 I/O、事务管理等开销。
- 适用场景: 这种方案更适合:
- 对缓存性能要求不那么极致的场景。
- 缓存数据量相对较小或更新不频繁的场景。
- 希望简化部署,避免引入新服务的场景。
- 已经深度依赖 PostgreSQL 的项目。
- 功能限制: Redis 提供了更多高级的缓存功能,如原子操作、发布/订阅、各种数据结构(列表、集合、哈希等)。PostgreSQL 只能模拟简单的键值对缓存。
步骤概览
- 数据库Schema设计: 创建一个
cache表来存储键值对和过期时间。 - NestJS Entity定义: 为
cache表创建 TypeORM Entity。 - Cache Service实现: 创建一个服务来封装缓存的
get、set、del和reset逻辑。 - Cache Module整合: 将 Entity 和 Service 整合到 NestJS 模块中。
- AppModule集成: 在根模块中导入和配置缓存模块。
- 使用示例: 在应用中使用缓存服务。
详细步骤
前提条件
- 一个 NestJS 项目。
- 已安装并配置 TypeORM 和 PostgreSQL 驱动:
npm install @nestjs/typeorm typeorm pg # 或者 yarn add @nestjs/typeorm typeorm pg - 确保你的
AppModule中已经配置了 TypeORM 的数据库连接。
1. 数据库Schema设计
在你的 PostgreSQL 数据库中创建一个 application_cache 表(或你喜欢的任何名称):
CREATE TABLE IF NOT EXISTS application_cache (key VARCHAR(255) PRIMARY KEY, -- 缓存键,建议加上索引以提高查询速度value JSONB NOT NULL, -- 缓存值,使用 JSONB 存储任意类型的数据expires_at TIMESTAMP WITH TIME ZONE, -- 缓存过期时间created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);-- 为 expires_at 列添加索引,以便更高效地清理过期数据
CREATE INDEX IF NOT EXISTS idx_application_cache_expires_at ON application_cache (expires_at);
解释:
key: 缓存的唯一标识符,设为主键,确保快速查找。value: 使用JSONB类型可以存储任何 JSON 兼容的数据结构(对象、数组、字符串、数字等),这是最佳实践。expires_at: 存储缓存的过期时间。如果为NULL,表示永不过期。created_at,updated_at: 审计字段。
2. NestJS Entity定义
创建 src/cache/cache.entity.ts 文件:
// src/cache/cache.entity.ts
import { Entity, PrimaryColumn, Column } from 'typeorm';@Entity('application_cache') // 对应数据库表名
export class CacheEntity {@PrimaryColumn({ type: 'varchar', length: 255 })key: string;@Column({ type: 'jsonb', nullable: false })value: any; // 使用 any 来存储 JSONB 类型的数据,TypeORM 会自动处理@Column({ type: 'timestamp with time zone', name: 'expires_at', nullable: true })expiresAt: Date | null;@Column({ type: 'timestamp with time zone', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })createdAt: Date;@Column({ type: 'timestamp with time zone', name: 'updated_at', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' })updatedAt: Date;
}
3. Cache Service实现
创建 src/cache/cache.service.ts 文件:
// src/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, Repository, IsNull } from 'typeorm'; // 导入 LessThan 和 IsNull
import { CacheEntity } from './cache.entity';@Injectable()
export class CacheService {private readonly logger = new Logger(CacheService.name);constructor(@InjectRepository(CacheEntity)private cacheRepository: Repository<CacheEntity>,) {// 定期清理过期缓存,例如每小时运行一次setInterval(() => this.cleanupExpiredCache(), 60 * 60 * 1000);}/*** 获取缓存值* @param key 缓存键* @returns 缓存值或 null*/async get<T>(key: string): Promise<T | null> {const cacheEntry = await this.cacheRepository.findOne({ where: { key } });if (!cacheEntry) {return null;}// 检查缓存是否过期if (cacheEntry.expiresAt && cacheEntry.expiresAt < new Date()) {this.logger.debug(`Cache entry for key "${key}" expired. Deleting.`);await this.del(key); // 自动删除过期缓存return null;}return cacheEntry.value as T;}/*** 设置缓存值* @param key 缓存键* @param value 缓存值* @param ttlSeconds 缓存存活时间 (秒)。如果为 undefined 或 0,则永不过期。*/async set(key: string, value: any, ttlSeconds?: number): Promise<void> {const expiresAt = ttlSeconds? new Date(Date.now() + ttlSeconds * 1000): null;// 使用 upsert 确保原子性:如果键存在则更新,不存在则插入await this.cacheRepository.upsert({ key, value, expiresAt },['key'] // 冲突解决策略:如果 key 冲突,则更新);}/*** 删除缓存* @param key 缓存键*/async del(key: string): Promise<void> {await this.cacheRepository.delete({ key });}/*** 清空所有缓存 (谨慎使用!)*/async reset(): Promise<void> {this.logger.warn('Clearing ALL cache entries!');await this.cacheRepository.clear();}/*** 后台任务:清理所有已过期的缓存条目*/private async cleanupExpiredCache(): Promise<void> {this.logger.debug('Starting cleanup of expired cache entries...');try {// 删除 expiresAt 字段存在且小于当前时间的记录const deleteResult = await this.cacheRepository.delete({expiresAt: LessThan(new Date()),});this.logger.debug(`Cleaned up ${deleteResult.affected || 0} expired cache entries.`);} catch (error) {this.logger.error('Error during cache cleanup:', error.stack);}}
}
解释:
get<T>(key): 查询数据库获取缓存,并手动检查expiresAt。如果过期,则删除并返回null。set(key, value, ttlSeconds): 计算过期时间,然后使用 TypeORM 的upsert方法(如果 TypeORM 版本支持)或save+findBy逻辑来插入或更新缓存。upsert是更好的选择,因为它在数据库层面处理了冲突,保证了原子性。del(key): 根据键删除缓存。reset(): 清空整个缓存表。在生产环境中需谨慎使用。cleanupExpiredCache(): 一个私有方法,通过setInterval定期运行,负责从数据库中物理删除所有过期的缓存条目。这对于维护数据库大小和性能很重要。
4. Cache Module整合
创建 src/cache/cache.module.ts 文件:
// src/cache/cache.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CacheService } from './cache.service';
import { CacheEntity } from './cache.entity';@Module({imports: [TypeOrmModule.forFeature([CacheEntity])], // 注册 CacheEntityproviders: [CacheService],exports: [CacheService], // 导出 CacheService,以便其他模块可以使用
})
export class CacheModule {}
5. AppModule集成
在你的 src/app.module.ts 中导入并注册 CacheModule,并确保 TypeOrmModule.forRoot 中包含了 CacheEntity。
// src/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CacheModule } from './cache/cache.module';
import { CacheEntity } from './cache/cache.entity'; // 引入 CacheEntity@Module({imports: [TypeOrmModule.forRoot({type: 'postgres',host: 'localhost',port: 5432,username: 'your_user',password: 'your_password',database: 'your_database',entities: [CacheEntity], // *** 确保这里包含了 CacheEntity ***synchronize: true, // 开发环境使用,生产环境请使用 Migrationslogging: false,}),CacheModule, // 导入你的 CacheModule],controllers: [AppController],providers: [AppService],
})
export class AppModule {}
注意:
entities: [CacheEntity]是非常重要的,它告诉 TypeORM 管理这个实体。synchronize: true在开发环境下很方便,它会自动创建或更新数据库Schema。但在生产环境中,强烈建议使用 TypeORM 的 Migrations。
6. 使用示例
现在,你可以在你的任何服务或控制器中注入并使用 CacheService 了。
// src/app.service.ts
import { Injectable } from '@nestjs/common';
import { CacheService } from './cache/cache.service';@Injectable()
export class AppService {constructor(private readonly cacheService: CacheService) {}async getHello(): Promise<string> {const cacheKey = 'hello_greeting';let greeting = await this.cacheService.get<string>(cacheKey);if (greeting) {console.log('Fetching greeting from cache.');return `Hello from cache: ${greeting}`;}console.log('Fetching greeting from slow source...');// 模拟一个耗时操作await new Promise(resolve => setTimeout(resolve, 2000));greeting = 'World! (from slow source)';// 将结果缓存 60 秒await this.cacheService.set(cacheKey, greeting, 60);return `Hello freshly: ${greeting}`;}async getUserData(userId: number): Promise<any> {const cacheKey = `user_${userId}_data`;let userData = await this.cacheService.get<any>(cacheKey);if (userData) {console.log(`User ${userId} data from cache.`);return userData;}console.log(`Fetching user ${userId} data from DB...`);// 模拟从数据库获取用户数据await new Promise(resolve => setTimeout(resolve, 1500));userData = { id: userId, name: `User ${userId}`, email: `user${userId}@example.com` };// 缓存用户数据 5 分钟 (300秒)await this.cacheService.set(cacheKey, userData, 300);return userData;}
}
进一步的优化和考虑
- 索引: 确保
key列有主键或唯一索引,expires_at列有普通索引,以加速查找和过期清理。 - 错误处理: 在
CacheService中添加更健壮的try/catch块来处理数据库操作可能出现的错误。 - 连接池: TypeORM 会自动管理数据库连接池,但在高并发场景下,确保你的数据库和 TypeORM 配置有足够的连接数。
- 序列化/反序列化:
JSONB列在 TypeORM 中会自动处理 JSON 对象的序列化和反序列化。如果存储的是简单字符串或数字,它也会将其转换为 JSON 兼容格式。 - 性能监控: 监控 PostgreSQL 的 CPU、内存、I/O 使用情况,以及缓存表的读写延迟。如果缓存成为瓶颈,则需要重新考虑引入专门的缓存系统(如 Redis)。
- 高级缓存策略:
- “Cache-Aside”(旁路缓存)模式: 这是我们目前实现的模式,应用程序代码负责检查缓存,如果未命中则从原始数据源获取,然后更新缓存。
- “Write-Through”(直写)/ “Write-Back”(回写)模式: 通常需要更复杂的抽象层,可能不太适合直接在关系型数据库上实现。
- 分布式缓存: 如果你的 NestJS 应用是多实例部署的,那么所有实例都会使用同一个 PostgreSQL 缓存表,这天然支持了分布式缓存。而 Redis 也同样支持。
通过上述步骤,你就可以在 NestJS 项目中成功地使用 PostgreSQL 作为缓存存储了。再次强调,请根据你的实际需求和性能预算来选择最合适的缓存方案。
