Commit 5f063b0a by luoqi

perf(auth): 非 admin 首次登录仍撞 org-tree 冷派生 —— JIT 关闭 + 日志 + 同步后预热

上次修复(a3cbc827)只短路了空 orgScope(admin/集团级),非 admin(品牌/诊所级)
用户第一次撞冷缓存仍全表扫。线上实测(379万行事务表)单次冷派生约 4.9s,JIT
编译占近 20%。三处改:①派生查询关 JIT(SET LOCAL,不影响连接池其它请求)
②耗时打日志(>1s warn)方便下次卡顿时对时间戳 ③每日增量同步跑完主动
forceRefresh 预热,把冷派生代价转给后台 cron,真实登录请求稳态下不再承担。
TTL 6h→24h(配合每日预热,兜底而非决定性)。
parent a3cbc827
Pipeline #3334 failed in 0 seconds
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import {
buildOrgTree,
......@@ -20,15 +20,19 @@ import {
*/
@Injectable()
export class OrgTreeService {
private readonly logger = new Logger(OrgTreeService.name);
constructor(private readonly prisma: PrismaService) {}
private readonly cache = new Map<string, { tree: OrgTree; at: number }>();
/** 单飞:并发 miss 共享同一次派生,防惊群 —— 冷启动 bootstrap(loadSession + plans 列表)
* 多个请求同时 miss 时,原本各自全扫一遍 patient_transactions(数十万行)导致登录长时间卡。 */
private readonly inflight = new Map<string, Promise<OrgTree>>();
// org 结构随摄入数据日更(仅新诊所/品牌才变),不必频繁重派生;10min 太紧会让"空闲后再打开"每次重扫。
// TTL 内出现的新 ref 由 expandScope 的 forceRefresh 兜(见下),故拉长到 6h 安全。
private readonly ttlMs = 6 * 60 * 60 * 1000;
// org 结构随摄入数据日更(仅新诊所/品牌才变),不必频繁重派生。
// 现在 SyncIncrementalSchedulerService 每日同步完会主动 forceRefresh 预热(见该文件),
// 稳态下这个 TTL 基本不会真被撞到 —— 它只是兜底(cron 没配 / 新 host 还没跑过同步的安全网),
// 故拉到 24h(留 cron 延迟余量),比预热周期长即可,不必再纠结"空闲后重开多久算太久"。
private readonly ttlMs = 24 * 60 * 60 * 1000;
private readonly refreshMinMs = 30 * 1000; // 强制重建防抖:30s 内不重复查(挡 bogus ref 刷库)
/** 取 host org 树(缓存;miss/过期或 forceRefresh 时从摄入数据派生;并发单飞)。 */
......@@ -55,15 +59,30 @@ export class OrgTreeService {
/** 从摄入数据派生「品牌(source_unit)→ 诊所(clinic_id)」org 树(零硬编码)。 */
private async deriveTree(hostId: string, tenantId: string): Promise<OrgTree> {
const startedAt = Date.now();
// clinic_id 立柱在 patient_transactions,source_unit 在 patients → join patient_id。
// 显式 host+tenant 过滤(此处在 ALS 之前跑,纵深防御扩展不兜,自带条件)。
const rows = await this.prisma.$queryRaw<{ source_unit: string; clinic_id: string }[]>`
SELECT DISTINCT p.source_unit, pt.clinic_id
FROM patient_transactions pt
JOIN patients p ON p.id = pt.patient_id
WHERE pt.host_id = ${hostId}::uuid AND pt.tenant_id = ${tenantId}
AND p.source_unit <> '' AND pt.clinic_id <> ''
`;
//
// 结果恒个位数行,但 planner 按行数估算走 JIT——生产实测(379万行)占了近 20% 执行时间,
// 纯浪费。SET LOCAL 只在本事务生效,COMMIT 后自动复原,不污染连接池里其它请求。
const [, rows] = await this.prisma.$transaction([
this.prisma.$executeRaw`SET LOCAL jit = off`,
this.prisma.$queryRaw<{ source_unit: string; clinic_id: string }[]>`
SELECT DISTINCT p.source_unit, pt.clinic_id
FROM patient_transactions pt
JOIN patients p ON p.id = pt.patient_id
WHERE pt.host_id = ${hostId}::uuid AND pt.tenant_id = ${tenantId}
AND p.source_unit <> '' AND pt.clinic_id <> ''
`,
]);
const elapsed = Date.now() - startedAt;
// >1s 才 warn —— 冷派生本身预期慢(全表扫),日常预热命中不会看到这条;
// 留证据是为了"用户说卡"时能在日志里对上号,而不是靠猜。
if (elapsed > 1000) {
this.logger.warn(`org-tree 冷派生慢:host=${hostId} tenant=${tenantId} rows=${rows.length} elapsed=${elapsed}ms`);
} else {
this.logger.log(`org-tree 派生:host=${hostId} tenant=${tenantId} rows=${rows.length} elapsed=${elapsed}ms`);
}
const brandClinics: Record<string, string[]> = {};
for (const r of rows) {
(brandClinics[r.source_unit] ??= []).push(r.clinic_id);
......
......@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import type { AppConfig } from '../config/configuration';
import { AuthModule } from '../modules/auth/auth.module';
import { PersonaModule } from '../modules/persona/persona.module';
import { PlanModule } from '../modules/plan/plan.module';
import { SyncModule } from '../modules/sync/sync.module';
......@@ -57,6 +58,7 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro
PersonaModule,
PlanModule,
SyncModule, // 给 SyncIncrementalSchedulerService 注入 ColdImportService
AuthModule, // 给 SyncIncrementalSchedulerService 注入 OrgTreeService(同步后预热)
],
controllers: [DailyHealthReportController],
providers: [
......
......@@ -6,6 +6,7 @@ import {
ColdImportService,
SyncAlreadyRunningError,
} from '../modules/sync/cold-import/cold-import.service';
import { OrgTreeService } from '../modules/auth/org-tree';
import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
......@@ -36,6 +37,7 @@ export class SyncIncrementalSchedulerService {
constructor(
private readonly prisma: PrismaService,
private readonly coldImport: ColdImportService,
private readonly orgTree: OrgTreeService,
private readonly persona: PersonaService,
private readonly planEngine: PlanEngineService,
) {}
......@@ -131,12 +133,27 @@ export class SyncIncrementalSchedulerService {
}
}
// ─── Plan recompute(per tenant)──────
const tenants = await this.prisma.patient.findMany({
where: { hostId: r.hostId },
distinct: ['tenantId'],
select: { tenantId: true },
});
// ─── org-tree 主动预热(消除"当天第一个非 admin 登录用户撞冷派生"的尾延迟)──────
// 本次同步刚写完新数据,强制重派生一次(forceRefresh),后续 24h TTL 内任何用户登录
// 都直接命中缓存 —— 冷派生的代价(生产 379 万行,实测约 5s)由本 cron(后台)吞掉,
// 不再由真实用户请求承担。单个 tenant 预热失败不影响其余 tenant / 后续 sync 步骤。
for (const t of tenants) {
try {
await this.orgTree.getTree(r.hostId, t.tenantId, true);
} catch (err) {
this.logger.warn(
`org-tree 预热失败 host=${r.hostId} tenant=${t.tenantId}: ${(err as Error).message}`,
);
}
}
// ─── Plan recompute(per tenant)──────
let plansCreated = 0;
for (const t of tenants) {
const r2 = await this.planEngine.runAllForHost({
......
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