Commit 64e0325e by luoqi

fix(persona): getMonetaryQuantiles 加 single-flight 防并发惊群卡死

并发重算(--concurrency>1)时发现:缓存空/过期瞬间,N 个 recompute 同时调
getMonetaryQuantiles → 同时发 N 份全租户聚合 $queryRaw(370万 facts 实测单跑 4.1s)
→ 打爆 4 核 DB + 连接池 → 进程卡死(stat=S,0 吞吐)。串行版无事因缓存命中只跑 1 次;
且缓存 30min 过期,长批(~1.6h)半路会再次惊群。

修:single-flight —— 缓存 miss 时把在飞 Promise 存 map,并发调用复用同一个查询,
只跑 1 份。等待者 await JS promise(不占 DB 连接)。根治,也利好增量 cohort 路径。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent b5de1a0c
...@@ -34,6 +34,8 @@ export class PersonaService { ...@@ -34,6 +34,8 @@ export class PersonaService {
/// RFM 的 M 打分要群体分位(图 B.1.1),但 PAC 按患者重算 → 分位阈值缓存复用(分布慢变); /// RFM 的 M 打分要群体分位(图 B.1.1),但 PAC 按患者重算 → 分位阈值缓存复用(分布慢变);
/// 批量重算每轮首个患者算一次,后续命中缓存;TTL 兜底单刷场景。 /// 批量重算每轮首个患者算一次,后续命中缓存;TTL 兜底单刷场景。
private readonly mQuantileCache = new Map<string, { at: number; q: number[] }>(); private readonly mQuantileCache = new Map<string, { at: number; q: number[] }>();
/** single-flight:缓存空/过期时,并发调用共享同一个在飞查询(防惊群)。 */
private readonly mQuantileInflight = new Map<string, Promise<number[]>>();
private static readonly M_QUANTILE_TTL_MS = 30 * 60 * 1000; private static readonly M_QUANTILE_TTL_MS = 30 * 60 * 1000;
constructor( constructor(
...@@ -42,11 +44,20 @@ export class PersonaService { ...@@ -42,11 +44,20 @@ export class PersonaService {
private readonly gapSelector: PotentialTreatmentSelector, private readonly gapSelector: PotentialTreatmentSelector,
) {} ) {}
/** 租户内"累计净消费/患者"的 [p20,p40,p60,p80](cents)。缓存 30min;失败/无数据 → []。 */ /**
* 租户内"累计净消费/患者"的 [p20,p40,p60,p80](cents)。缓存 30min;失败/无数据 → []。
*
* 该查询全租户聚合(大库可达数秒)。**single-flight**:缓存空/过期时,并发调用(批量重算
* concurrency>1)共享同一个在飞查询 —— 否则 N 个重查询同时打 DB 会卡死(370万 facts 实测)。
*/
private async getMonetaryQuantiles(hostId: string, tenantId: string, nowMs: number): Promise<number[]> { private async getMonetaryQuantiles(hostId: string, tenantId: string, nowMs: number): Promise<number[]> {
const key = `${hostId}:${tenantId}`; const key = `${hostId}:${tenantId}`;
const hit = this.mQuantileCache.get(key); const hit = this.mQuantileCache.get(key);
if (hit && nowMs - hit.at < PersonaService.M_QUANTILE_TTL_MS) return hit.q; if (hit && nowMs - hit.at < PersonaService.M_QUANTILE_TTL_MS) return hit.q;
const inflight = this.mQuantileInflight.get(key);
if (inflight) return inflight; // 已有查询在跑 → 复用,不再发一份
const promise = (async () => {
try { try {
const rows = await this.prisma.$queryRaw<Array<{ q: number[] }>>` const rows = await this.prisma.$queryRaw<Array<{ q: number[] }>>`
WITH spend AS ( WITH spend AS (
...@@ -66,7 +77,12 @@ export class PersonaService { ...@@ -66,7 +77,12 @@ export class PersonaService {
} catch (err) { } catch (err) {
this.logger.warn(`getMonetaryQuantiles failed (${key}): ${err instanceof Error ? err.message : err}`); this.logger.warn(`getMonetaryQuantiles failed (${key}): ${err instanceof Error ? err.message : err}`);
return []; return [];
} finally {
this.mQuantileInflight.delete(key);
} }
})();
this.mQuantileInflight.set(key, promise);
return promise;
} }
/** /**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment