import { Injectable, Logger } from '@nestjs/common'; /** * public 读路径的进程内缓存(性能整改 P0-1,详见 * docs/references/performance-review-public-port.md 与 * plans/refactor/public-capacity-10k-refactor.md)。 * * 语义要点(全局约束 §2 的落地): * - 分域版本号失效:写路径在事务提交成功后 bump 依赖域,依赖该域的缓存 * 条目立即作废——「等 TTL 自然过期」只是内存回收兜底,不是失效手段; * - 条目记录写入时全部依赖域的版本快照,读时逐一比对,跨域依赖 * (列表同时依赖 goods+matrix+meta)天然联动失效; * - loader 完成后复核版本:bump 发生在加载期间 → 结果照常返回但不入缓存, * 杜绝「失效瞬间的并发读把旧值写回」竞态; * - in-flight 合并:并发 miss 只触发一次 loader,防瞬时洪峰击穿缓存; * - 单进程部署下进程内即全局缓存;扩容多副本时需换共享存储(计划 P1-3 阶梯 c)。 * * 环境开关:PUBLIC_CACHE_DISABLED=true 全直通(应急); * PUBLIC_CACHE_TTL_MS / PUBLIC_CACHE_MAX_ENTRIES 可调(默认 10 分钟 / 512 条)。 */ export const PUBLIC_CACHE_DOMAINS = ['meta', 'goods', 'matrix'] as const; export type PublicCacheDomain = (typeof PUBLIC_CACHE_DOMAINS)[number]; const DEFAULT_TTL_MS = 10 * 60 * 1000; const DEFAULT_MAX_ENTRIES = 512; interface CacheEntry { value: unknown; expiresAt: number; /** 写入时各依赖域的版本快照——任一域 bump 即作废 */ versions: ReadonlyMap; } @Injectable() export class PublicCacheService { private readonly logger = new Logger(PublicCacheService.name); private readonly versions = new Map( PUBLIC_CACHE_DOMAINS.map((domain) => [domain, 0]), ); private readonly entries = new Map(); private readonly pending = new Map>(); /** * 读穿透封装:命中(未过期且全部依赖域版本未变)直接返回缓存值, * 否则执行 loader 并缓存。domains 声明该值依赖的数据域—— * 写路径 bump 其中任一域都会让本条目作废。 */ async wrap( key: string, domains: readonly PublicCacheDomain[], loader: () => Promise, ): Promise { this.assertDomains(domains); if (this.disabled()) return loader(); const storeKey = `${[...domains].sort().join('+')}|${key}`; const hit = this.readHit(storeKey); if (hit !== undefined) return hit as T; const inflight = this.pending.get(storeKey); if (inflight) return inflight as Promise; const snapshot = this.snapshotVersions(domains); const run: Promise = (async () => { const value = await loader(); if (this.versionsMatch(snapshot)) { this.writeEntry(storeKey, value, snapshot); } else { this.logger.debug(`cache skip (version bumped during load): ${storeKey}`); } return value; })(); this.pending.set(storeKey, run as Promise); // 失败与成功都要摘掉 pending,异常不得残留在途槽位 void run.catch(() => undefined).finally(() => this.pending.delete(storeKey)); return run; } /** * 失效入口:写事务提交成功后调用。递增域版本并清除依赖该域的条目 * (版本号本身已保证正确性,清条目只为及时回收内存)。 */ bump(...domains: PublicCacheDomain[]): void { this.assertDomains(domains); for (const domain of domains) { this.versions.set(domain, (this.versions.get(domain) ?? 0) + 1); } if (this.entries.size === 0) return; for (const [key, entry] of this.entries) { if (domains.some((domain) => entry.versions.has(domain))) { this.entries.delete(key); } } } /** 当前域版本(诊断/测试用) */ version(domain: PublicCacheDomain): number { this.assertDomains([domain]); return this.versions.get(domain) ?? 0; } private readHit(storeKey: string): unknown { const entry = this.entries.get(storeKey); if (!entry) return undefined; if (Date.now() >= entry.expiresAt) { this.entries.delete(storeKey); return undefined; } if (!this.versionsMatch(entry.versions)) { this.entries.delete(storeKey); return undefined; } return entry.value; } private versionsMatch(snapshot: ReadonlyMap): boolean { for (const [domain, version] of snapshot) { if ((this.versions.get(domain) ?? 0) !== version) return false; } return true; } private snapshotVersions( domains: readonly PublicCacheDomain[], ): Map { return new Map(domains.map((domain) => [domain, this.versions.get(domain) ?? 0])); } private writeEntry( storeKey: string, value: unknown, versions: ReadonlyMap, ): void { const maxEntries = this.maxEntries(); if (this.entries.size >= maxEntries) { const now = Date.now(); for (const [key, entry] of this.entries) { if (entry.expiresAt <= now) this.entries.delete(key); } while (this.entries.size >= maxEntries) { const oldest = this.entries.keys().next().value; if (oldest === undefined) break; this.entries.delete(oldest); } } this.entries.set(storeKey, { value, expiresAt: Date.now() + this.ttlMs(), versions }); } private ttlMs(): number { const parsed = Number(process.env.PUBLIC_CACHE_TTL_MS); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_MS; } private maxEntries(): number { const parsed = Number(process.env.PUBLIC_CACHE_MAX_ENTRIES); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ENTRIES; } private disabled(): boolean { return process.env.PUBLIC_CACHE_DISABLED === 'true'; } private assertDomains(domains: readonly PublicCacheDomain[]): void { for (const domain of domains) { if (!this.versions.has(domain)) { throw new Error(`未知的 public 缓存域: ${String(domain)}`); } } } }