- PublicCacheService:分域版本号失效(bump 即作废,不等 TTL)、loader 期间 bump 的竞态防护(返回但不回写)、并发 miss 单飞、PUBLIC_CACHE_DISABLED /TTL_MS/MAX_ENTRIES 应急开关;@Global 模块 - PublicService 六端点接缓存:列表缓存全量物化(分页切片在缓存外按请求执行, 修复'所有页返回第一页'的切片缓存错误)、详情/首页/树/标签组/树序元数据/ 族最低价聚合各按依赖域缓存 - 全写路径挂钩 bump:admin CRUD(goods/categories/countries/tags/tag-groups/ positions/origin-goods 标签)、sync 三同步、族重算、整理全家桶—— 事务提交成功后失效对应域,product-families 经 recompute 天然覆盖 - 测试:PublicCacheService 单测 13 例 + 失效链路集成 8 例(读命中/写后立即可见 端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
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<PublicCacheDomain, number>;
|
|
}
|
|
|
|
@Injectable()
|
|
export class PublicCacheService {
|
|
private readonly logger = new Logger(PublicCacheService.name);
|
|
private readonly versions = new Map<PublicCacheDomain, number>(
|
|
PUBLIC_CACHE_DOMAINS.map((domain) => [domain, 0]),
|
|
);
|
|
private readonly entries = new Map<string, CacheEntry>();
|
|
private readonly pending = new Map<string, Promise<unknown>>();
|
|
|
|
/**
|
|
* 读穿透封装:命中(未过期且全部依赖域版本未变)直接返回缓存值,
|
|
* 否则执行 loader 并缓存。domains 声明该值依赖的数据域——
|
|
* 写路径 bump 其中任一域都会让本条目作废。
|
|
*/
|
|
async wrap<T>(
|
|
key: string,
|
|
domains: readonly PublicCacheDomain[],
|
|
loader: () => Promise<T>,
|
|
): Promise<T> {
|
|
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<T>;
|
|
|
|
const snapshot = this.snapshotVersions(domains);
|
|
const run: Promise<T> = (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<unknown>);
|
|
// 失败与成功都要摘掉 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<PublicCacheDomain, number>): boolean {
|
|
for (const [domain, version] of snapshot) {
|
|
if ((this.versions.get(domain) ?? 0) !== version) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private snapshotVersions(
|
|
domains: readonly PublicCacheDomain[],
|
|
): Map<PublicCacheDomain, number> {
|
|
return new Map(domains.map((domain) => [domain, this.versions.get(domain) ?? 0]));
|
|
}
|
|
|
|
private writeEntry(
|
|
storeKey: string,
|
|
value: unknown,
|
|
versions: ReadonlyMap<PublicCacheDomain, number>,
|
|
): 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)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|