perf(public): 公开读路径进程内分域缓存——meta/goods/matrix 版本域 + TTL 兜底 + in-flight 合并
- 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 例(读命中/写后立即可见 端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user