初选矩阵此前每次开页面都从最原始的事实现推:
plan_reasons → 展开 evidence.factIds → 回查 patient_facts(1567 万行 / 18 GB)
最忙的诊所(15,884 条 plan)一次摊 34,459 次随机查、372 MB I/O ——
**而这一切的唯一产出就是一个标签字符串**。温度那根轴根本不碰 fact
(它来自 patient_profiles.last_visit_at)。
⇒ 加一列 `plan_reasons.potential_labels text[]`,矩阵改成
followup_plans ⋈ plan_reasons ⋈ patient_profiles + unnest。
■ 实测(本地,15,884 条 plan 的诊所,与测试服 15,770 同规模)
耗时 958 ms → 110 ms (8.7×)
磁盘读 119,861 → 13,500 (-89%)
JIT 触发 → 不触发
小诊所 11 ms → 0.4 ms (地板消失)
🔴 **五个诊所的 24 格逐字相同**(scripts/bench-matrix.ts 的结果快照 diff 零差异)——
性能改动最容易的失败是悄悄少算一批人,只比耗时看不出来。
■ 为什么是数组而不是单列
一条依据可挂多个 fact:93.7% 只有一个,但**3.7%(本地 1,391 条)会推出不止一个 code**。
⛔ 存单值会把这批人算少。
■ ⚠ ️ 年龄被冻结,所以必须刷
规则里三条带年龄(K08>18 / K07 3~12 / K07 13~40)⇒ 标签不是纯函数。
实测受影响 5,551 人中「明天跨档 0 人、30 天内 16 人」(≈0.5 人/天,占矩阵 1.4 万人的 0.003%)。
⇒ 每晚 03:30 全量重算(PlanLabelRefreshService);
⛔ 不刷就逐日漂,且不会有任何报错。
■ 三条写入路径共用同一份 SQL
🔴 ⛔ 绝不在写入侧用 TS 再算一遍:规则表是产品配置,第二份实现迟早和矩阵那份漂开,
而漂了之后矩阵的数会**静默**不一样。
· 生成完 plan 立刻 backfillMissing() —— ⛔ 不补的话新 plan 的列是 NULL,
`unnest` 直接跳过 ⇒ **今天生成的人在矩阵里看不见**,且不报错。失败不阻断生成。
· 每晚全量重算(年龄)
· 部分索引 `WHERE potential_labels IS NULL` —— 平时几乎是空的,补齐即 O(1),
否则每次生成完都要为找 NULL 扫一遍 31 万行。
■ 两个自己踩了又修的坑(都写进了注释与测试)
· 全量重算最初只写 `ORDER BY id LIMIT n` **没有游标** —— 每次取同一批,原地打转,
而且不报错只是每晚白跑。改成按 maxId 推进。
· 停止条件最初看"写了几行" —— 全量重算时绝大多数行算出来跟原值一样、不会被写,
written=0 ⛔ 不等于做完了。改成看 seen / maxId,并把两者分开报。
列**刻意可空**,三态有别:NULL=没算过 / '{}'=算过但推不出标签 / 非空=标签集。
⛔ 别加 NOT NULL DEFAULT '{}' —— 那样刷新任务再也找不到漏网的行。
验证:tsc 通过;jest 86 套 1342 例全过(新增 12);eslint 干净;
真 AppModule 起容器确认两个 service 都注入成功;
回填 37,300 条/8.4s;全量重算 37,300 条/2.7s 未卡死;人为挖 25 个洞→补齐→剩余 0。