Commit 3e62c5f0 by luoqi

docs(plan): 更正事故注释 —— 昨晚那条 OOM 日志是 6 天前的,叙事全错

09-05 用 journalctl -b -1 复盘,推翻了 09-04 当晚写进代码的三条事实:

① **当晚内核一条 OOM 都没打。** VNC 上那条
   `Killed process 945388 (node) anon-rss:6321668kB` 是 **2026-08-29 07:26:17** 的 ——
   pid/anon-rss/pgtables 与内核日志逐字段相同,内核 cmdline 带 console=tty0,
   它在 framebuffer 上留了 6 天没被刷掉。/var/log/messages 独立日志槽交叉验证同样只此 1 条。
② **这台机没有 swap**(Swap: 0B / vm.swappiness=0 / /proc/swaps 空),「swap 抖死」不成立。
③ **没有崩溃循环**:内核 `eth0: renamed from` 按天计数 Sep 03=0 / Sep 04=0,
   当晚一次容器启动都没有;journalctl -u docker 事故时段无条目。

真正形态是**无 swap 下的 page-cache refault 活锁**:匿名页吃满后只能回收文件页,
程序正文被刷掉、下一条指令又缺页读回(监控上 286MB/s 满速读、写≈0、CPU~15%)。
因为始终有文件页可回收,out_of_memory() 从未被调用 —— 没人被杀,也就没有任何自愈,
一路挂到人工硬重启,共约 11.8 小时(不是原注释写的 2.5 小时)。
对照 08-29:同机同水位同样吃到 6GB,那次走 OOM kill、10 分钟量级自愈。
**失败形态比内存本身更决定后果。**

死点(生产库写入痕迹重建,应用日志已随部署 --force-recreate 丢失):
  场景+预取 4,110s → 写入段仅 43s(550,011 行落库)→ 第 3 步 stale-close 最后一次提交
  20:05:49(全库最后一次写)→ 收尾 backfillMissing 一行没写、阶段耗时从未打出。

代码行为不改(分批本身经测试机实测有效:45 批 heapUsed 单调下降 462→107MB、零累积;
属性测试证明结果与批大小无关)。改的是**它的定位**:
从「本次事故的直接对策」改成「降低峰值 → 降低再次触发活锁的概率」,
并明确写出**不治**的三项:hits 交接的双份拷贝(≈2.1GB,现存最大未处理项)、
第 3 步整池 findMany、以及真正的兜底缺口(容器 MemLimit=0 + 无 swap ⇒
cgroup OOM 与内核 OOM 两道兜底全被绕过)。

顺带删掉 treatment-initiation-recall.scenario.ts 里一句自 2026-08-30 起就失真的注释:
「场景查询是串行的,同一时刻只有一条在跑,峰值可控」—— 那天起生产设了 conc=4,
RDS 上同时有 4 个 SET LOCAL work_mem='256MB' 的事务,DB 侧峰值不再可控。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent ce2d605a
Pipeline #3676 failed in 0 seconds
......@@ -29,14 +29,43 @@ import { PlanLabelService } from '../plan-label.service';
/**
* plan 写入段的**端到端批大小**(按患者切)。
*
* 🔴 2026-09-04 生产 OOM 宕机 2.5 小时的直接对策。内核日志:
* `Killed process (node) anon-rss:6,321,668kB(6.03GB)`,机器 14GB,之后整机 swap 抖死、
* sshd 与 Web 全部无响应。docker-compose.prod.yml 那段注释 2026-08-26 就预言过
* 「这是止血不是根治:池子还在涨,8G 迟早也会到顶」—— 那天到了。
* 🔴 2026-09-04 生产挂死 ~11.8 小时(20:06 → 次日 07:52 硬重启)后加的。
*
* 堆账(550,049 命中患者 / 约 96 万条 hit,按 V8 对象布局建模):
* ⚠️ **本注释 09-05 全面更正过一次,更正掉的东西比留下的多,值得先读这段。**
* 初版写的是「内核 OOM killer 杀了 node(anon-rss 6.03GB)→ 整机 swap 抖死」。
* 事后用 `journalctl -b -1` 核查,三条全错:
* ① 当晚**内核一条 OOM 都没打**。VNC 上看到的那条 `Killed process 945388 (node)`
* 是 **2026-08-29 07:26:17** 的,pid/anon-rss/pgtables 与内核日志逐字段相同 ——
* 内核 cmdline 带 `console=tty0`,它在 framebuffer 上留了 6 天没被刷掉。
* ② **这台机没有 swap**(`Swap: 0B`、`vm.swappiness=0`),「swap 抖死」不成立。
* ③ 没有崩溃循环:内核 `eth0: renamed from` 按天计数 Sep 03=0 / Sep 04=0,
* 当晚一次容器启动都没有,dockerd 整晚无日志。
* 真正的形态是**无 swap 下的 page-cache refault 活锁**:匿名页吃满后内核只能回收
* 文件页,把程序正文/mmap 刷掉、下一条指令又缺页读回来(监控上那条 286MB/s 满速读、
* 写≈0、CPU~15%)。因为**始终有文件页可回收,`out_of_memory()` 从未被调用** ——
* 没人被杀,也就没有任何自愈动作,一路挂到人工硬重启。
* ⇒ 对照 08-29:同一台机、同一水位、同样吃到 6GB,那次走 OOM kill,机器 10 分钟量级自愈;
* 这次走活锁,挂了 11.8 小时。**失败形态比内存本身更决定后果。**
*
* 死点(用生产库写入痕迹重建,应用日志已随部署 --force-recreate 永久丢失):
* 18:50:42 开跑 → 场景+预取 4,110s → 写入段 **仅 43s**(550,011 行落库)
* → 第 3 步 stale-close 分片事务最后一次提交 **20:05:49**(全库最后一次写)
* → 收尾 backfillMissing 一行没写,`[plan] 阶段耗时` 从未打出。
* ⇒ 死在第 3 步刚提交完的那几秒。场景/预取/写入三段都跑完了。
*
* 堆账(550,011 命中患者 / 约 96 万条 hit,按 V8 对象布局建模,**未经 heap snapshot 实测**):
* latestByPatient 1.93GB + hitsByPatient 1.06GB + persona/visit 0.14GB
* + activePlans 0.09GB + logRows 0.07GB ≈ **活跃保留 3.3GB**,实测 RSS 6.03GB(≈1.8×)。
* + activePlans 0.09GB + logRows 0.07GB ≈ **活跃保留 3.3GB**。
*
* ⚠️ **本改动治什么、不治什么**(别把它当这次事故的直接对策):
* 治:把上面那堆「同时在堆」的产物拆成单批驻留 → 降低峰值 → 降低再次触发活锁的概率。
* 测试机实测 45 批 heapUsed 单调下降 462→107MB,零累积。
* ⛔ 不治:① selectHits → hitsByPatient 的交接仍同时持有**两份**完整 hit 载荷(≈2.1GB,
* 见下方批循环上游 `arr.push({...h})` 那处),这是现存最大的未处理项;
* ② 第 3 步仍一次拉整池(事故当轮 549,926 行);
* ③ 真正的兜底缺口不在这个文件:容器 `MemLimit=0` + 无 swap ⇒ cgroup OOM 与内核 OOM
* 两道兜底全被绕过,失败才会呈现为「活锁」而不是「杀掉重启」。
*
* 病根不是"取数没分块"(取数早就是 2000 一块),是**产物全程不释放**:
* runPool 的闭包(见下方批循环)把 latest/persona/visit/logRows 全部 context-allocate,
* V8 要到 runAllForHost 整个 frame 结束才可能回收 → 第 3 步扫全池时它们全还在,
......
......@@ -627,7 +627,15 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⛔ 必须用事务级 SET LOCAL,**不能全局调**:work_mem 是「每个排序/哈希节点」的上限,
// 不是每连接。生产 RDS 只有约 7GB 内存(shared_buffers 1.83GB 反推),全局设大值
// 遇上几十个并发连接同时排序会把内存吃穿。SET LOCAL 出了事务自动还原,
// Web API 的连接完全不受影响;且场景查询是串行的,同一时刻只有一条在跑,峰值可控。
// Web API 的连接完全不受影响。
//
// 🔴 **原文这里还有一句「且场景查询是串行的,同一时刻只有一条在跑,峰值可控」——
// 那句自 2026-08-30 起就是错的**,已删。生产从那天起设了
// PAC_RECALL_SUBSCENARIO_CONCURRENCY=4(见上方 conc 处注释),于是 RDS 上
// **同时有 4 个带 SET LOCAL work_mem='256MB' 的事务**。
// ⇒ DB 侧峰值内存 = 256MB × 并发数 × 该查询的排序/哈希节点数,不再"可控"。
// 生产 RDS 约 7GB,4 路已是能承受的上限;⛔ 谁要再调大 conc 或 work_mem,
// 必须先算这笔账,别只看场景段墙钟。(2026-09-05 事故复盘时发现这处注释失真。)
//
// ⛔ 别再去加「信号码部分索引」——(content->>'code') 上的部分索引在测试机和生产
// 都实测过,生产上 −5% 落在噪声里(同配置两次测量本身差 6%),不值得引入
......
......@@ -1160,7 +1160,11 @@ describe('归因继承的边界 — 判据是「客服碰过没」', () => {
/**
* 🔴 2026-09-04 端到端分批改造的**核心判据**:结果与批大小无关。
*
* 事故背景:生产 pac-service 涨到 6.03GB 常驻被内核 OOM killer 打掉,整机 swap 抖死 2.5 小时。
* 事故背景:生产 pac-service 挂死约 11.8 小时(2026-09-04 20:06 → 次日 07:52 硬重启)。
* ⚠️ 09-05 更正:当晚**没有** OOM kill(那条内核日志是 08-29 的陈旧 tty 回显),
* 机器也**没有 swap** —— 真正形态是无 swap 下的 page-cache refault 活锁,
* 因为始终有文件页可回收,out_of_memory() 从未被调用,所以没人被杀、也就没有自愈。
* 详见 plan-engine.service.ts 里 resolvePlanBatchSize 上方那段更正说明。
* 堆账里 latestByPatient(1.93GB)+ hitsByPatient(1.06GB)是大头,而它们原先**全程不释放**
* —— 取数早就是 2000 一块,但产物累积在跨全量存活的 Map 里,runPool 的闭包又把它们
* context-allocate 到整个 runAllForHost frame 结束。改造把取数分块升格成端到端分批。
......
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