☰
NestJS 缓存策略实战:在 Comp AI CRM 中基于 CacheModule 与 Redis 构建高性能数据层
2026/9/25 2:42:16 网站建设 项目流程
  • 后端
  • 前端
  • CRM
  • 人工智能
  • AI Agent

【免费下载链接】crm

Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.

项目地址:https://gitcode.com/gh_mirrors/crm48/crm
点击查看免费下载

导读

缓存是 NestJS 应用中降低数据库负载、缩短响应时间的核心手段,但"缓存一切"与"完全不缓存"同样是性能陷阱。本文以 Comp AI CRM(Agentic-first 开源 CRM)API 服务中的真实实现为蓝本,系统讲解如何使用 NestJSCacheModule(基于cache-manager与@keyv/redis)为昂贵查询、频繁访问数据和外部 API 调用建立分层缓存,并配合 TTL、主动失效、事件驱动失效与分布式锁等策略,实现既显著减负又保证数据一致性的缓存架构。读完本文,你将掌握模块级缓存配置、手动缓存读写、装饰器式自动缓存以及事件驱动失效的完整落地方法。

一、缓存原则:聚焦高影响区域,拒绝两个极端

NestJS 官方缓存文档与 Comp AI CRM 的开发规范(见 .agents/skills/nestjs-best-practices/rules/perf-use-caching.md)都强调同一核心原则:为昂贵操作、频繁访问的数据和外部 API 调用实现缓存,配合恰当的 TTL 与失效策略,而不是"缓存一切"。

两种典型的反面写法必须避免:

  1. 完全不缓存:每次请求都重复执行复杂的聚合查询(如多表leftJoin+groupBy+orderBy),即使结果在短时间内不会变化,也白白消耗数据库资源。
  2. 无脑缓存一切:给所有查询都加上CacheInterceptor并设置超长 TTL(如 3600 秒)。对变化频繁的数据(如用户列表)缓存 1 小时,会导致用户看到过期的数据,且失效路径缺失时缓存永远不会刷新。

正确的姿势是"战略性缓存":识别出读多写少、计算昂贵、对实时性要求不高的高影响区域,再为每个场景选择 TTL 与失效策略。下面结合 Comp AI CRM 的源码,逐一展开四种实战形态。

二、模块级配置:CacheModule + KeyvRedis 全局接入

NestJS 官方推荐的接入方式是在根模块通过CacheModule.registerAsync()注册缓存实例,使其对全应用可用。Comp AI CRM 在此基础上封装了一个全局缓存模块,位于 apps/api/src/cache/cache.module.ts,完整实现如下:

import KeyvRedis from "@keyv/redis"; import { CacheModule, type CacheOptions } from "@nestjs/cache-manager"; import { Logger, Module } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { EnvironmentVariables } from "../config/env.validation"; const DEFAULT_TTL_MS = 60_000; @Module({ imports: [ CacheModule.registerAsync({ isGlobal: true, inject: [ConfigService], useFactory: ( config: ConfigService<EnvironmentVariables, true>, ): CacheOptions => { const logger = new Logger("CacheModule"); const redisUrl = config.get("REDIS_URL", { infer: true }); const ttl = config.get("CACHE_TTL_MS", { infer: true }) ?? DEFAULT_TTL_MS; if (!redisUrl) { logger.warn({ message: "REDIS_URL is not set — falling back to a per-instance in-memory cache.", ttl, }); return { ttl }; } logger.log({ message: "Cache backed by Redis", ttl }); return { ttl, stores: [new KeyvRedis(redisUrl)] }; }, }), ], exports: [CacheModule], }) export class AppCacheModule {}

该实现的关键细节:

  • isGlobal: true:缓存模块全局注册,任何业务模块无需重复导入即可注入CACHE_MANAGER。在 apps/api/src/app.module.ts 中AppCacheModule被加入根模块imports,全 API 服务共享同一缓存实例。
  • 双后端自适应:通过REDIS_URL判断存储后端——配置了 Redis 则使用@keyv/redis的KeyvRedis存储;未配置则回退到进程内内存缓存并打出Logger.warn。这意味着本地开发无需 Redis 即可运行,生产环境再切换到分布式 Redis。
  • 默认 TTL 全局可调:CACHE_TTL_MS环境变量控制默认 TTL(默认 60 秒),各业务模块可在此基础上用更短的局部 TTL 覆盖。

对应的环境变量在 apps/api/src/config/env.validation.ts 中声明为可选:REDIS_URL?: string与CACHE_TTL_MS?: number(带@IsInt、@Min(0)校验),并在 .env.example 中给出示例:

# REDIS_URL="redis://localhost:6379" # CACHE_TTL_MS="60000"

启用 Redis 缓存只需设置REDIS_URL并重启 API 服务,启动日志会输出Cache backed by Redis,未设置时则会输出回退警告。

三、手动缓存:粒度控制 + 显式失效(cache-manager API)

装饰器自动缓存适合"读接口",但需要缓存命中后的自定义逻辑、异步写缓存、按需失效的场景,就必须直接注入CACHE_MANAGER手动操作。cache-manager提供三件套:cache.get(key)、cache.set(key, value, ttlMs)、cache.del(key)。

Comp AI CRM 的AuthService是手动缓存的典型范例(apps/api/src/auth/auth.service.ts):

const PROFILE_TTL_MS = 5 * 60_000; const profileKey = (userId: string) => `auth:profile:${userId}`; @Injectable() export class AuthService { constructor( @InjectDatabase() private readonly db: Db, @Inject(CACHE_MANAGER) private readonly cache: Cache, ) {} async getProfile(userId: string): Promise<UserProfile> { const key = profileKey(userId); const cached = await this.cache.get<UserProfile>(key); if (cached) { return cached; } this.logger.debug({ message: "Profile cache miss", userId }); const user = await this.db.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, emailVerified: true, image: true, createdAt: true, }, }); if (!user) { this.logger.warn({ message: "Session user no longer exists", userId }); throw new NotFoundException(`No user with id ${userId}.`); } const profile: UserProfile = { ...user, createdAt: user.createdAt.toISOString(), }; await this.cache.set(key, profile, PROFILE_TTL_MS); return profile; } async invalidateProfile(userId: string): Promise<void> { await this.cache.del(profileKey(userId)); this.logger.debug({ message: "Invalidated cached profile", userId }); } }

这里沉淀了三条可复用的经验:

  1. 命名规范:缓存键采用auth:profile:${userId}的命名空间冒号分隔格式,避免不同业务模块键冲突。
  2. TTL 按数据特性定:用户资料属于"读多写少但会变"的数据,5 分钟 TTL(PROFILE_TTL_MS)既能吸收大部分重复读,又保证资料变更最多延迟 5 分钟生效。
  3. 失效与写入对称:invalidateProfile()在资料更新路径上被调用,主动删除对应键,配合 TTL 形成"双保险"。

外部 API 调用是另一个高价值缓存场景。ModelCatalogService(apps/api/src/settings/model-catalog.service.ts)将模型目录这种第三方 HTTP 接口的结果缓存 30 分钟:

const CATALOG_TTL_MS = 30 * 60_000; const CATALOG_KEY = "settings:model-catalog"; const CATALOG_TIMEOUT_MS = 5_000; async models(): Promise<CatalogModel[] | null> { const cached = await this.cache.get<CatalogModel[]>(CATALOG_KEY); if (cached) return cached; const models = await this.fetchCatalog(); if (!models) return null; await this.cache.set(CATALOG_KEY, models, CATALOG_TTL_MS); return models; }

fetchCatalog()内部还使用了AbortSignal.timeout(CATALOG_TIMEOUT_MS)设置 5 秒超时,并在失败时返回null而不是抛错——外部依赖故障时降级、且不污染缓存,这是缓存外部调用的重要容错姿势。

四、装饰器缓存:CacheInterceptor + CacheKey + CacheTTL

对于纯粹的"读接口",NestJS 提供声明式缓存:在控制器或方法上挂CacheInterceptor自动缓存返回值,配合CacheKey自定义键、CacheTTL覆盖 TTL。

@Controller('categories') @UseInterceptors(CacheInterceptor) export class CategoriesController { @Get() @CacheTTL(30 * 60 * 1000) // 30 minutes - categories rarely change findAll(): Promise<Category[]> { return this.categoriesService.findAll(); } @Get(':id') @CacheTTL(60 * 1000) // 1 minute @CacheKey('category') findOne(@Param('id') id: string): Promise<Category> { return this.categoriesService.findOne(id); } }

使用要点:

  • TTL 与数据变化频率匹配:几乎不变的数据(如分类目录)可用 30 分钟甚至更长;变化稍快的数据(如单条详情)用 1 分钟。TTL 单位是毫秒。
  • @CacheKey是静态的:多个动态参数的方法共享同一键会互相覆盖,因此动态接口更适合在方法内部手动构造键(回到第三节的写法),或让CacheInterceptor默认基于路由自动生成键。
  • 只读接口优先装饰器,写路径必须配合失效:如果该控制器存在更新操作,务必在写方法中通过cache.del()清理对应键,否则缓存会长期过期。

五、事件驱动失效:OnEvent 批量清缓存

当同一类数据被多个写路径修改时,逐个调用cache.del()容易遗漏。NestJS 的事件系统可以把"失效"收敛到一处:

@Injectable() export class CacheInvalidationService { constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} @OnEvent('product.created') @OnEvent('product.updated') @OnEvent('product.deleted') async invalidateProductCaches(event: ProductEvent) { await Promise.all([ this.cache.del('products:popular'), this.cache.del(`product:${event.productId}`), ]); } }

多个@OnEvent装饰器可以叠加在同一方法上,批量清除受影响的键;用Promise.all并发删除,失效延迟降到最低。这种模式特别适合产品、公司、联系人这类多入口变更的领域对象。

Comp AI CRM 的TrackingConfigService(apps/api/src/tracking/tracking-config.service.ts)给出了事件失效的进阶变体——代数失效(generation-based invalidation):它维护一个generation计数器,任何配置变更(invalidate()、rotateSiteId())都会generation += 1并删除缓存键,写入新配置时只有"当前代数未被并发覆盖"才重新填充缓存,从而避免并发写场景下的读写竞态:

async invalidate(): Promise<void> { this.generation += 1; const written = this.generation; await this.cache.del(CONFIG_KEY); // ...重新读取配置、更新 hash... if (written !== this.generation) return; // 已被更新的写入抢占 if (!(await this.current(hash))) return; // hash 已过期 await this.cache.set(CONFIG_KEY, { config, hash }, CONFIG_TTL_MS); }

同时它用configHash与数据库中的trackingConfigHash比对,确保只有"当前仍是权威配置"时才回填缓存,从源头避免缓存写入过期数据。

六、进阶实践:用缓存实现分布式防重(Backfill 自动任务锁)

缓存不仅能提速,还能充当跨实例的短期互斥锁。BackfillService(apps/api/src/backfill/backfill.service.ts)在用户登录后触发自动回填,用一个 5 分钟 TTL 的缓存键防止多实例/多用户同时触发重复的回填任务:

const AUTO_KEY = "backfill:auto"; const AUTO_EVERY_MS = 5 * 60_000; async auto(): Promise<{ started: boolean }> { if (await this.cache.get(AUTO_KEY)) return { started: false }; await this.cache.set(AUTO_KEY, true, AUTO_EVERY_MS); void (async () => { try { await this.sweepWorkspace(); const companies = await this.runCompanies(false); const contacts = await this.runContacts(); // ... } catch (error) { this.logger.error(...); } })(); return { started: true }; }

这段代码展示了缓存超越"提速"的第二种价值:缓存即分布式锁。AUTO_KEY存在即表示"最近 5 分钟内已有一次回填在跑",后续调用直接短路返回。相比数据库锁,它零额外表、天然带过期(进程崩溃也不会死锁),代价是 5 分钟内最多触发一次——对回填这种幂等批处理任务完全够用。其注释还揭示了缓存与"重试频率"配合的设计思想:照片搜索等昂贵操作 30 天才重试一次(RECHECK_PHOTO_AFTER_MS),避免反复支付外部服务费用。

七、实战对照:把规则落进你的 NestJS 项目

综合 Comp AI CRM 的落地实践,可以提炼出一份可直接套用的决策清单:

场景推荐方案TTL 参考失效策略
重复执行的复杂聚合查询手动cache.get/set包裹查询5s ~ 60s写路径显式cache.del
用户资料等读多写少数据手动缓存 + 命名空间键5 分钟资料更新时失效
外部第三方 API 结果手动缓存 + 超时 + 失败降级10 ~ 30 分钟TTL 自然过期
纯只读列表/详情接口CacheInterceptor+CacheTTL按变化频率 1~30 分钟写接口配del
多写路径的领域对象事件驱动批量失效中等 TTL@OnEvent集中失效
批处理/定时任务防重缓存键即分布式锁等于任务执行窗口TTL 自动解锁

最后回到最初的规则(perf-use-caching.md):先度量再缓存,聚焦高影响区域。缓存不是银弹——键设计、TTL 选择、失效时机与并发安全共同决定成败。Comp AI CRM 给出的参考实现(全局 CacheModule、手动缓存、代数失效、缓存即锁)覆盖了从"读路径提速"到"写路径一致性"再到"分布式防重"的完整闭环,值得在引入缓存时逐一对标。

  • 后端
  • 前端
  • CRM
  • 人工智能
  • AI Agent

【免费下载链接】crm

Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.

项目地址:https://gitcode.com/gh_mirrors/crm48/crm
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询