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
+97 -11
View File
@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from './public-cache.service';
import {
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
@@ -86,9 +87,28 @@ interface TreeOrderMeta {
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly cache: PublicCacheService,
) {}
/**
* 以下读端点统一走 PublicCacheService(性能整改 P0-1TTL 只是内存回收
* 兜底,数据新鲜度由写路径 bump 保证,见 public-cache.service.ts)。
* 依赖域声明:
* - meta:分类/国家/标签组等低熵元数据(含 DEFAULT 排序的树序元数据)
* - goodsgoods 行/关联展示数据(含 position)
* - matrix:族 price_matrix 物化 JSON(族最低价聚合)
* 列表/详情同时展示元数据名与族价格 → 三域并依赖,任一写路径 bump 即失效。
*/
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
return this.cache.wrap(`cat-tree:${countryId ?? 'all'}`, ['meta', 'goods'], () =>
this.loadCategoriesTree(countryId),
);
}
private async loadCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
const goodsWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
@@ -133,6 +153,10 @@ export class PublicService {
}
async getCountries(): Promise<PublicCountryDto[]> {
return this.cache.wrap('countries', ['meta', 'goods'], () => this.loadCountries());
}
private async loadCountries(): Promise<PublicCountryDto[]> {
const rows = await this.prisma.country.findMany({
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
@@ -141,6 +165,10 @@ export class PublicService {
}
async getTags(): Promise<PublicTagDto[]> {
return this.cache.wrap('tags', ['meta', 'goods'], () => this.loadTags());
}
private async loadTags(): Promise<PublicTagDto[]> {
const rows = await this.prisma.tag.findMany({
where: {
goodTags: {
@@ -158,6 +186,12 @@ export class PublicService {
}
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
return this.cache.wrap(`tag-groups:${countryId ?? 'all'}`, ['meta', 'goods'], () =>
this.loadTagGroups(countryId),
);
}
private async loadTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
const goodWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
@@ -189,7 +223,44 @@ export class PublicService {
}));
}
/**
* 缓存键 = 筛选参数(不含 page/pageSize):缓存的是已排序已分组的全量
* items,翻页在缓存命中后内存切片——所有页码共享同一份物化结果。
*/
private goodsListCacheKey(query: PublicQueryGoodDto): string {
return [
'goods-list',
query.countryId ?? '',
query.keyword ?? '',
query.categoryId ?? '',
query.minPrice ?? '',
query.maxPrice ?? '',
query.sort ?? 'DEFAULT',
JSON.stringify(query.tags ?? []),
].join('|');
}
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
// 缓存的是「已排序已分组的全量 items」;分页切片必须在缓存外按请求执行,
// 否则后续页会拿到第一页的切片(缓存值会被多个页码共享)
const materialized = await this.cache.wrap(
this.goodsListCacheKey(query),
['goods', 'matrix', 'meta'],
() => this.loadGoodsMaterialized(query),
);
const start = (query.page - 1) * query.pageSize;
return {
items: materialized.items.slice(start, start + query.pageSize),
total: materialized.total,
page: query.page,
pageSize: query.pageSize,
};
}
private async loadGoodsMaterialized(query: PublicQueryGoodDto): Promise<{
items: PublicGoodDto[];
total: number;
}> {
// 无族商品(自定义)不进公开列表:只认族
const where: Prisma.GoodWhereInput = {
familyId: { not: null },
@@ -270,21 +341,18 @@ export class PublicService {
query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price),
);
}
const total = items.length;
const start = (query.page - 1) * query.pageSize;
return {
items: items.slice(start, start + query.pageSize),
total,
page: query.page,
pageSize: query.pageSize,
};
return { items, total: items.length };
}
/**
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
* (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。
*/
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
private loadTreeOrderMeta(): Promise<TreeOrderMeta> {
return this.cache.wrap('tree-order-meta', ['meta'], () => this.queryTreeOrderMeta());
}
private async queryTreeOrderMeta(): Promise<TreeOrderMeta> {
const [countries, leaves] = await Promise.all([
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
this.prisma.$queryRaw<
@@ -331,7 +399,11 @@ export class PublicService {
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
* 每族一个数字。非数字/缺失 price 的行跳过,与旧内存版过滤语义一致。
*/
private async loadFamilyMinPrices(): Promise<Map<string, string>> {
private loadFamilyMinPrices(): Promise<Map<string, string>> {
return this.cache.wrap('family-min-prices', ['matrix'], () => this.queryFamilyMinPrices());
}
private async queryFamilyMinPrices(): Promise<Map<string, string>> {
const rows = await this.prisma.$queryRaw<
Array<{ family_id: bigint | string; min_price: Prisma.Decimal | null }>
>`
@@ -365,6 +437,12 @@ export class PublicService {
/** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */
private async getGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
return this.cache.wrap(`family-detail:${familyId.toString()}`, ['goods', 'matrix', 'meta'], () =>
this.loadGoodByFamilyId(familyId),
);
}
private async loadGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
const [family, goods] = await Promise.all([
this.prisma.productFamily.findUnique({ where: { id: familyId } }),
this.prisma.good.findMany({
@@ -405,6 +483,14 @@ export class PublicService {
}
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
return this.cache.wrap(
`home:${query.countryId ?? 'all'}:${query.limit}`,
['goods', 'matrix', 'meta'],
() => this.loadHomeGoods(query),
);
}
private async loadHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
const rows = await this.prisma.good.findMany({
where: {
positionId: { not: null },