Commit 628d253a by luoqi

chore(基准): 加初选矩阵基准脚本 —— 为「标签落库」改造留一把可复用的尺

优化之前先把现状量下来,否则改完只能凭感觉说"快了"。

脚本同时产出两样, 缺一不可:
  · **耗时**:EXPLAIN ANALYZE 的 Execution Time,每档取 N 轮**最快值**
     不取平均 —— 本机跑着别的东西,实测同一条件 1388~2130ms 都出现过,
      平均值被噪声主导,最快值才稳定可比。
  · **结果快照**:24 格逐格的数,拼成一行可逐字比对
    🔴 只比耗时是不够的 —— 把 join 改掉、把标签预计算,最容易的失败是
      **悄悄少算一批人**,而那正是产品最不能接受的
      (矩阵那段注释里写着「主管一对数就觉得系统在骗他」)。

本地基线(15,884 plan 的诊所,与测试服 15,770 几乎同规模):
  15884 →  958 ms  · 磁盘读 119,861 buffers
   2458 →  127 ms
     14 →   11 ms   ← 地板

 顺带印证了一件事:本地 `patient_facts` 只有 111 万行(测试服 1567 万,1/14),
  但同规模诊所耗时同一量级(958ms vs 1399ms)。⇒ **表大小不是主因,查询次数才是**
  —— 15,884 个 plan 摊出的三万多次随机查才是,这正是「标签落 plan_reasons」要消掉的东西。
parent 3f2c02fd
/**
* 初选矩阵基准 —— 「改之前 / 改之后」用**同一把尺**量。
*
* ⚠️ 只量 SQL 执行时间(`EXPLAIN ANALYZE` 的 Execution Time),⛔ 不含 HTTP / 序列化:
* 那两段在改动前后是一样的,混进来只会稀释信号。
* ⚠️ 每档跑 N 轮取**最快**,⛔ 不取平均 —— 这台机器上跑着别的东西,
* 平均值被噪声主导(实测同一条件下 1388~2130ms 都出现过)。最快值才稳定可比。
*
* 用法:
* npx ts-node -T scripts/bench-matrix.ts # 跑全部诊所档位
* npx ts-node -T scripts/bench-matrix.ts --rounds 5
*/
import { PrismaClient } from '@prisma/client';
import { poolBaseSql } from '../src/modules/plan/cohort-filter';
import { planLabelAnchorsSql } from '../src/modules/plan/reason-temperature.sql';
const prisma = new PrismaClient();
const ROUNDS = Number(process.argv[process.argv.indexOf('--rounds') + 1]) || 3;
/** 把 Prisma.Sql 的 `?` 占位换成字面量 —— EXPLAIN 不吃参数化语句。 */
function inline(q: { sql: string; values: unknown[] }): string {
let i = 0;
return q.sql.replace(/\?/g, () => {
const v = q.values[i++];
return typeof v === 'string' ? `'${v.replace(/'/g, "''")}'` : String(v);
});
}
function matrixSql(hostId: string, tenantId: string, clinicId: string): string {
const scope = { hostId, tenantId, clinicIds: [], sourceUnits: [] } as never;
const inner = inline(planLabelAnchorsSql(poolBaseSql(scope, clinicId)));
// 外层与 `CohortAttributesService.matrix` 同构(温度 CASE 简化成三档,量的是同一条 join 路径)
return `EXPLAIN (ANALYZE, BUFFERS)
WITH la AS (${inner}),
b AS (SELECT patient_id, label,
CASE WHEN NOW() <= hot_until THEN 'hot'
WHEN NOW() <= warm_until THEN 'warm' ELSE 'cold' END AS temp
FROM la)
SELECT label, temp, count(DISTINCT patient_id) AS n
FROM b GROUP BY GROUPING SETS ((label, temp), (temp));`;
}
async function run(sql: string) {
const rows = await prisma.$queryRawUnsafe<Array<Record<string, string>>>(sql);
const text = rows.map((r) => Object.values(r)[0]).join('\n');
const ms = Number(/Execution Time: ([\d.]+)/.exec(text)?.[1] ?? NaN);
const reads = [...text.matchAll(/read=(\d+)/g)].reduce((a, m) => a + Number(m[1]), 0);
// ⚠️ 并行查询里每个 worker 各报一段 JIT,这里取首段;⛔ 别拿它跟墙钟直接比大小
const jit = Number(/JIT[\s\S]*?Total (\d+\.?\d*) ms/.exec(text)?.[1] ?? 0);
return { ms, reads, jit };
}
/**
* 🔴 **结果快照** —— 性能改动必须同时证明「24 格一个数都没变」。
* ⛔ 只比耗时是不够的:把 join 改掉、把标签预计算,最容易的失败是**悄悄少算一批人**,
* 而那正是产品最不能接受的(「主管一对数就觉得系统在骗他」)。
*/
async function snapshot(sqlWithExplain: string) {
const plain = sqlWithExplain.replace(/^EXPLAIN \([^)]*\)\n/, '');
const rows = await prisma.$queryRawUnsafe<
Array<{ label: string | null; temp: string | null; n: bigint }>
>(plain);
return rows
.map((r) => `${r.label ?? '∑'}/${r.temp ?? '∑'}=${r.n}`)
.sort()
.join(' ');
}
(async () => {
const clinics = await prisma.$queryRaw<
Array<{ host_id: string; tenant_id: string; target_clinic_id: string; n: bigint }>
>`SELECT host_id, tenant_id, target_clinic_id, count(*) AS n
FROM followup_plans WHERE status='active' AND assignee_user_id IS NULL
GROUP BY 1,2,3 HAVING count(*) >= 10 ORDER BY 4 DESC LIMIT 5`;
console.log(`初选矩阵基准 · 每档取 ${ROUNDS} 轮最快值\n`);
console.log(' plan 数 最快耗时 磁盘读 JIT(首段)');
console.log(' ' + '─'.repeat(46));
const out: Array<Record<string, number>> = [];
const snaps: string[] = [];
for (const c of clinics) {
const sql = matrixSql(c.host_id, c.tenant_id, c.target_clinic_id);
let best = { ms: Infinity, reads: 0, jit: 0 };
for (let r = 0; r < ROUNDS; r++) {
const x = await run(sql);
if (x.ms < best.ms) best = x;
}
const n = Number(c.n);
console.log(
` ${String(n).padStart(7)} ${best.ms.toFixed(0).padStart(6)} ms ` +
`${String(best.reads).padStart(7)} ${best.jit ? best.jit.toFixed(0) + ' ms' : '—'}`,
);
out.push({ plans: n, ms: best.ms, reads: best.reads, jit: best.jit });
snaps.push(`${n}: ${await snapshot(sql)}`);
}
console.log('\nJSON ' + JSON.stringify(out));
console.log('\n结果快照(改动后必须逐字相同):');
for (const s of snaps) console.log(' ' + s);
await prisma.$disconnect();
})().catch((e) => {
console.error(e);
process.exit(1);
});
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