Quartz 站点构建的内存悬崖
症状
往知识库 a (${KNOWLEDGE_ROOT}) 里加了 14 篇 md(awesome/github-stars/,约 330KB 文本),
重新构建站点时 node 崩了:
Parsed 1082 Markdown files in 17s
Filtered out 0 files in 865μs
Emitting files
FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory
fatal error: all goroutines are asleep - deadlock! ← esbuild 子进程跟着挂
数字:加这批文件前堆峰值 < 1G 能过;加了之后默认 1G 直接 OOM,抬到
--max-old-space-size=3072 仍在 2975/3088 MB 处 OOM。也就是说这不是”线性多吃一点”,
是某个环节被这批内容放大了一个数量级(根因见下方”根因”节:唯一 tag 数的 O(N²))。
更严重的连带伤害:崩在 emitting 阶段会清空 public/
./deploy/build.sh 是先用 -o public/ 覆盖输出目录的。崩在 Emitting files 阶段时,
public/ 已经被清掉而新文件没写完 → 站点直接 404(quartz-vault.service 本身
active (running),Caddy 正常,只是没内容可服务)。实测 / /ops /awesome 全 404。
排查时先看 public/ 还在不在,别去查 Caddy 或 unit:
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: ${LANIP}:100' http://${LANIP}:100/
find ${HOME}/github/quartz/public -type f | wc -l # 0 = 构建崩了,不是服务挂了恢复方式就是让它成功跑完一次完整构建(约 3 分钟)。
处置
把这批文件加进 ~/github/quartz/quartz.config.yaml 的 ignorePatterns:
- awesome/github-stars记录留在库里给 agent 读,不进站点。加完重建通过:Parsed 1068 / Emitted 4557 files,
站点 200 恢复。
根因(2026-10-06 当日定位)
Quartz 的 tag 机制是 O(N²):每个唯一 tag 生成一个 tag 页面,而它渲染的每个页面都内嵌
全站 tag 列表(.tags 组件)+ 整棵文件树。 于是唯一 tag 数 N 决定了两个乘数——页面数
≈N,每页大小 ∝N。
那批 star 记录把 1444 个仓库的 GitHub topics 原样写成了 #topic:3353 个唯一 topic
→ 1755 个新增唯一 tag,站点从约 2200 个 tag 页推到近 4000 个,每页还从 ~40KB 涨到 124KB
(实测单个 tag 页内嵌 655 个 tag-link、1324 个链接)。乘起来就是那次 OOM。
定位过程(可复跑的方法,不依赖猜):
- 隔离复现:整库复制一份、quartz 项目
cp -a --reflink=auto复制一份(btrfs 秒级), 构建输出改到临时目录 —— 之后所有失败构建都不再影响线上站点(这一步是必需的,见上文 “崩在 emitting 会清空 public/”)。 - 整库二分:14 篇里先测两个半区,7 篇过 / 7 篇崩,递归到单篇 →
by-language/python.md(580 条 / 130KB)单独就能触发。 - 压缩试验环境:31 篇的迷你库 + python.md 依然 OOM,单次构建从 3 分钟降到 10 秒, 后面的探索才跑得动。
- 文件内前缀二分:249 条过、250 条崩 → 但那一”第 250 条”单独跑却过。 说明不是脏数据,是贴天花板(同输入重复跑 3 次,峰值 RSS 波动 1374/1764/1711MB), 任何小改动都能翻转结果 —— 所以对照实验里”去掉反引号也过了”是假信号。
- 定向对照:在必崩规模上逐项去掉一个特征。去掉行内
#topics→ 过;把 topics 收敛成单个 tag → 迷你库产出从 1334 个 tag 页降到 128 个文件,默认堆 5 秒过。 唯一 tag 数就是那个变量。
修法:fetch.sh 里 topics 改成纯文本(· topics: a · b),信息保留、不产生 tag。
完整库(1083 篇,含全部 14 篇)默认堆复测:Emitted 4618 / 3 分钟 / 无错误;
产出 4614 个文件,对比不含 stars 的 4557 只多 57 个。
现状与边界
- 站点当前内容规模 1083 篇 md / 4614 个产出文件,构建峰值约 1.2GB RSS,默认堆够用。
- 真正要盯的指标不是篇数,是唯一 tag 数。本库目前 ~2200 个 tag 页就已经是这个构建 的主要成本;再加成千上万个唯一 tag 就会重现这次的 O(N²) 爆炸。批量导入外部数据 (topics、分类、标签云)时,先数一下会新增多少唯一 tag。
- 想发布大清单,依旧要做拆分与限长(本批已把单条 description 限到 300 字符,
不然
others.md会被一个 59K 字符的垃圾 description 撑到 190KB)。 - 换 heap 上限不是解法:3072 也没救回来,且在 available 只有 4.6G 的机器上开 4G 堆 会把构建推进 swap。
相关
- 构建脚本
~/github/quartz/deploy/build.sh(其中--concurrency 1与”用 node 不用 bun” 是既有坑,别动) - 服务
quartz-vault.service→ Caddy:100(局域网 + Tailscale) - 记录本体:
awesome/github-stars/(README 索引 + by-language/13 篇 + JSON 快照 + fetch.sh)
2026-10-09 Codex 审阅说明
3 GB 堆失败是该版本、该内容集和该次构建的观测,不是所有 Quartz 站点的固定下限。O(N²) 结论须限定为测到的具体处理路径;reflink 是否可用取决于源/目标文件系统支持,不能在所有分区套用。MemoryHigh 与 MemoryMax 分别是回收节流边界和硬限制。
核验依据:Linux cgroup v2。