Commit ec5eee72 by luoqi

merge: SQL dump 开关 + 耗时归因结论 → main

parents d46a6cc5 d4b5264f
Pipeline #3611 failed in 0 seconds
...@@ -374,7 +374,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -374,7 +374,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║ // ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║
// ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║ // ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║
// ╚═════════════════════════════════════════════════════════════════════╝ // ╚═════════════════════════════════════════════════════════════════════╝
const rows: HitRow[] = await this.prisma.$queryRaw` const scenarioSql = Prisma.sql`
SELECT SELECT
p.id AS patient_id, p.id AS patient_id,
p.external_id AS patient_external_id, p.external_id AS patient_external_id,
...@@ -466,6 +466,55 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -466,6 +466,55 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
) )
`; `;
// ══════════════════════════════════════════════════════════════════
// 📊 2026-08-29 全量重算耗时归因(测试服 585K 患者 / 87,661 命中,空闲机)
//
// 用下面这个 dump 开关 + pg_stat_activity 采样测出来的,结论与直觉相反,记在这里
// 免得后来人重走弯路:
//
// ① **11 个子场景里只有 2 个慢**,其余 9 个全部秒回:
// K05 牙周 9 分 13 秒
// K01 阻生牙 13 分钟+
// 且**与结果集大小无关** —— K02 龋齿理由数最多(49,382)却秒回。
//
// ② **11 条子场景 SQL 逐字节相同**,只有绑定参数(诊断码 / resolver 类目)不同。
// 所以慢是数据分布 + 执行计划的事,不是 SQL 写法的事。
//
// ③ 真实热点(EXPLAIN ANALYZE,K01 单条 301 秒):
// Append(resolvedTeeth 的 UNION) loops=117,256 × 1.71ms ≈ 201 秒
// → **68% 的时间花在「每候选行跑一次」的 gap 相关子查询上**
// 5.8 万个 (患者×信号) 候选,每行跑两个子查询。
//
// ⛔ 已实测**否定**的两个方案,别再试:
// · 子场景并发(PAC_RECALL_SUBSCENARIO_CONCURRENCY=3):24.1 分钟 vs 串行 23.3 —— 无效。
// 瓶颈是共享磁盘 I/O(全程 DataFileRead),并行只是抢同一批 page,总读取量不变。
// · 给信号码建部分索引(((content->>'code')) WHERE type IN (...) AND status='active'):
// EXPLAIN 估算成本 1,837,125 → 139,816(13×),信号扫描 122 万行 → 5,593 行,
// **但墙钟 24.5 分钟 vs 23.3 —— 无效**。教训:别拿 EXPLAIN 的估算成本当依据,
// 成本模型改善不等于实际耗时改善;要 EXPLAIN (ANALYZE) 看真实的 actual time。
//
// ✅ 真正能解决的方向(尚未做,是个独立项目):
// 把 buildGapCore 的 resolvedTeethSql 从**逐行相关子查询**改成**集合式**
// (一次算出全部患者的 resolved 牙位再做连接)。理论上能降一到两个数量级。
// ⚠️ 但它是召回与画像共用的单一真理源,重写必须配等价性验证
// (改写前后逐患者逐牙位比对),否则就是拿静默少召赌运气。
// ══════════════════════════════════════════════════════════════════
// ⚡ 调试开关:PAC_RECALL_DUMP_SQL=1 时把本子场景的完整 SQL 打出来。
// 为什么需要:pg_stat_activity.query 被 track_activity_query_size(默认 1024 字节)截断,
// 而本查询远超这个长度 —— 线上抓到"某条子场景查询跑了 6.7 分钟"却拿不到全文做 EXPLAIN,
// 2026-08-29 排查 plan 段耗时就卡在这一步。开关默认关,零开销。
// 用法:PAC_RECALL_DUMP_SQL=1 pnpm recompute-plans:prod -- --host=jvs-dw
// 然后把打出来的 SQL 喂给 EXPLAIN (ANALYZE, BUFFERS)。
if (process.env.PAC_RECALL_DUMP_SQL === '1') {
this.logger.log(
`[dump-sql] subKey=${subKey} primaryCode=${cfg.primaryCode} len=${scenarioSql.sql.length}\n` +
`${scenarioSql.sql}\n` +
`[dump-sql-params] ${JSON.stringify(scenarioSql.values)}`,
);
}
const rows: HitRow[] = await this.prisma.$queryRaw(scenarioSql);
// ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find) // ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find)
// 跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐 // 跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐
// 场景:王燕桦 K01 3 个 sig(38 / 48;38 / 18;28;38)牙位重叠 → 1 个临床 episode // 场景:王燕桦 K01 3 个 sig(38 / 48;38 / 18;28;38)牙位重叠 → 1 个临床 episode
......
...@@ -47,8 +47,18 @@ function flatten(frag: Prisma.Sql | SqlLike | unknown): string { ...@@ -47,8 +47,18 @@ function flatten(frag: Prisma.Sql | SqlLike | unknown): string {
/// 返回 [] 让调用方走空结果短路,不需要再 mock 后续查询。 /// 返回 [] 让调用方走空结果短路,不需要再 mock 后续查询。
function makeSqlCapturingPrisma(): { prisma: PrismaService; sql: () => string } { function makeSqlCapturingPrisma(): { prisma: PrismaService; sql: () => string } {
const captured: string[] = []; const captured: string[] = [];
const queryRaw = (strings: TemplateStringsArray, ...values: unknown[]) => { const queryRaw = (strings: TemplateStringsArray | { sql?: string }, ...values: unknown[]) => {
const parts = Array.isArray(strings?.raw) ? strings.raw : [String(strings)]; // 两种调用形态都要认:
// ① 标签模板 `$queryRaw`...`` → strings 是 TemplateStringsArray
// ② 传 Prisma.sql 对象 `$queryRaw(sql)` → strings 是 Sql,已摊平好的 .sql 直接用
// (2026-08-29 起主查询走 ② —— 先建 Prisma.sql 对象才能在 PAC_RECALL_DUMP_SQL=1 时
// 打出完整 SQL 做 EXPLAIN;pg_stat_activity 会把它截断在 1024 字节。)
if (typeof (strings as { sql?: string })?.sql === 'string') {
captured.push((strings as { sql: string }).sql);
return Promise.resolve([]);
}
const t = strings as TemplateStringsArray;
const parts = Array.isArray(t?.raw) ? t.raw : [String(strings)];
captured.push(parts.map((s, i) => s + (i < values.length ? flatten(values[i]) : '')).join('')); captured.push(parts.map((s, i) => s + (i < values.length ? flatten(values[i]) : '')).join(''));
return Promise.resolve([]); return Promise.resolve([]);
}; };
......
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