-
perf(plan): 写入段改端到端分批 —— 取数早就分块了,产物却全程不释放 · 31ed4846
2026-09-04 生产 OOM 宕机 2.5 小时。内核日志: Killed process (node) anon-rss:6,321,668kB(6.03GB) total-vm:16.7GB 机器 14GB。进程被杀后整机 swap 抖死,sshd 与 Web 全部无响应,靠人工重启才能恢复。 docker-compose.prod.yml 那段注释 2026-08-26 就写过「这是止血不是根治:池子还在涨, 8G 迟早也会到顶」—— 那天到了。 ## 病根不是"没分块",是"不释放" 取数早就是 CHUNK=2000 一块(prefetchForBatch 内),但**产物累积**在跨全量存活的 Map 里; runPool 的闭包又把 latest/persona/visit/logRows 全部 context-allocate, V8 要到 runAllForHost 整个 frame 结束才可能回收 → 第 3 步扫全池时它们全还在,那一下就是峰值。 堆账(550,049 命中患者 / 约 96 万条 hit,按 V8 对象布局建模): latestByPatient 1.93GB + hitsByPatient 1.06GB + persona/visit 0.14GB + activePlans 0.09GB + logRows 0.07GB ≈ 活跃保留 3.3GB,实测 RSS 6.03GB(≈1.8×) ## 改动 · prefetchForBatch 拆成两半: - prefetchSnoozeAnchors(scope, now) —— **整轮一次**。它的代价跟"终态且冷静期未到期的 计划数"走、与患者数无关(生产实测全库 106 行)。拆出来是让这条纪律在类型上也成立: 它拿不到 patientIds,想塞进批循环也塞不了。 - prefetchOneBatch(scope, ids) —— 每批一次,删掉内部的 CHUNK 循环,切批权交给调用方。 · 写入段改成端到端批循环:每批 预取 → runPool 写库 → createMany 日志 → 释放。 hit 的所有权交给本批局部数组后立刻 hitsByPatient.delete(pid), 批末连同 latest/persona/visit/logRows 一起出作用域回收。 · 新增 resolvePlanBatchSize():默认 2000,夹在 [1, 20000](上限守 PG 32767 bind —— latest/persona 走 patientId in ids),**显式传 0 = single-shot 回退开关** (照 cold-import 的 resolveCohortBatchSize 同一形状)。 · 每 5 批 + 末批打一行 rss= / heapUsed= / heapTotal=(rss 沿用 cold-import 字样便于并排 grep; 判 8G 堆余量只能看 heapUsed,rss 含 Prisma Rust 引擎与碎片)。 ## 唯一的硬全局依赖,以及它的坑 第 3 步 stale-close 拿本轮命中集对整个召回池取补集(`!hitsByPatient.has(pid)` → supersede)。 分批后 hitsByPatient 被逐批清空 ⇒ 改用跨批累加的 `hitPatientIds` Set(只存 id,约 54MB)。⛔ 第 3 步**必须等所有批跑完再跑一次**。搬进循环里 = 每批把其他批的患者判成"信号消失" → 整池 supersede + 每条认领单一条 auto_release,**而且不报错**, 那行「本轮关闭 N 条无信号 plan」的日志反而会解释成「这不是 bug」。⛔ 循环体里**不加 try/catch**:今天的语义是"selectHits 抛错发生在任何写之前 → 整轮中止、 第 3 步不跑";分批后前 N 批已落库,吞错"把剩下的批跑完"会让第 3 步拿着残缺命中集去关池子。 ## 测试 新增 describe.each 覆盖 PAC_PLAN_BATCH_SIZE ∈ {0,1,2,3,2000},同一 fixture 各跑一遍, 断言引擎返回八个字段 + plan 终态 + 日志行**逐字段一致**,直接编码「结果与批大小无关」。 **为什么必须新加**:既有 20+ 条用例每个只有 1~4 个患者、默认批 2000,永远只有一批 —— 分批写错它们全绿。batch=1 是最凶的一档,同时压测跨批不误关 / touchedPlanIds 按批等价 / snooze 外提后仍被每批查到。fixture 里特意放了一个本轮 0 命中的 p9 压 stale-close。 有牙验证:把第 3 步判据改回 hitsByPatient.has(分批后已清空)→ plansClosed 0→3、 active 与 assigned 双双变 superseded,多条用例立刻红。 ## 预计效果与未做的部分 峰值从"latest+hits+logRows 同时在堆"的堆叠拆成单批驻留,预计 3.3GB → 约 2.1GB。⚠ ️ 这一级**不碰任何 SQL、不碰 selectHits**,08-30 那根 ×2.31 的并发杠杆与 work_mem 收益 一个字节不动。但 hitsByPatient 仍在第 1 步满载(1.06GB),峰值仍与命中总数线性相关。 要真正解耦需要二级(患者域分区 selectHits),那要改 SQL 且必须先在生产标定 11×P 次子场景查询的墙钟 —— 生产当前宕机,标不了,另开。⚠ ️ 第 3 步的 activePlans findMany 仍是无分页全池扫(约 94MB,占比 3%)。改键集分页有真实风险: plan-engine-stale-close.spec 的 findMany mock 忽略 take/orderBy/id.gt,分页写错在 CI 里全绿。 单独一件事做,别和本次混。⚠ ️ PAC_PLAN_BATCH_CONCURRENCY 从来没被 withCohortDerivedPool 算进连接池(2026-09-02 已因 漏算一次导致健康日报连续三天超时)。本次不改,记在这。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
| Name |
Last commit
|
Last update |
|---|---|---|
| .. | ||
| asr-sensevoice | Loading commit data... | |
| pac-docs | Loading commit data... | |
| pac-service | Loading commit data... | |
| pac-web | Loading commit data... |