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:
yeuimu
2026-09-03 03:40:20 +08:00
parent ca38476047
commit ab79325ab0
33 changed files with 1777 additions and 927 deletions
+171
View File
@@ -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)}`);
}
}
}
}