题面

把一个真实落地的项目折叠成一道架构题:

有一个 git 仓库,里面散落着 N 个 SKILL.md 文件(带 YAML frontmatter + 同目录的资源文件)。要做一个内部「Skill Hub」服务:

  • 浏览、搜索、分类、查看详情
  • 在线预览 markdown 与文件树
  • 一键下载某个 skill 的 zip 包
  • 源仓库 push 时 Hub 要自动跟上
  • 部署形态是单 Node 进程(容器平台只给一个端口)

怎么设计?

约束是分批给的。每听完一版方案,就追加一个新约束逼着方案迭代。下面 14 层按这个节奏走。每层先写最朴素的版本,再写暴露出来的问题,最后写改进,配最小可读的代码片段。

代码片段、字段名、目录名都是从一个真实落地的项目里抽出来的,业务和组织信息已经全部抹掉。


第 1 层:最朴素的版本

最直接的实现:

1
2
3
4
5
app.get("/api/skills", async () => {
await exec("git clone --depth 1 $REPO /tmp/work");
const skills = scan("/tmp/work");
return skills;
});

每次请求都 clone 一次仓库、扫描一次目录。

问题列出来就是清单:

  • 一次 clone 几百 MB,请求要等几十秒。
  • 并发请求会互相踩 /tmp/work
  • 几百次请求把对端 git 服务打爆。

git 是源,不是运行时存储。这一层的修正是引入本地 mirror 和内存索引。


第 2 层:本地 mirror + 启动同步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// services/gitMirror.ts
class GitMirror {
async ensureClone() {
if (!exists(this.dir)) {
await git("clone", "--mirror", this.repoUrl, this.dir);
}
}
async fetch() {
await git("-C", this.dir, "fetch", "origin");
}
async headSha() {
return git("-C", this.dir, "rev-parse", `refs/heads/${this.branch}`);
}
}

启动流程:

  1. 本地无 mirror 就 clone
  2. fetch origin
  3. headSha 未变就跳过重建索引
  4. headSha 变了就 checkout 加重新扫描 SKILL.md,再写 skills.json

第一个状态对象由此出现:

1
2
3
4
5
6
7
8
9
interface SyncState {
headSha: string;
previousHeadSha?: string;
status: "idle" | "syncing" | "failed";
lastSyncStartedAt?: string;
lastSyncFinishedAt?: string;
lastError?: string;
indexedSkillCount: number;
}

新的问题有两个:启动后只同步一次,后续仓库变了 Hub 不知道;业务期望源仓 push 完几秒内 Hub 就能搜到新 skill。


第 3 层:四种同步触发器并存

约束加一条:源仓刚 push,业务希望 30 秒内 Hub 上能搜到新 skill。

有三个可选触发器,三个都各有短板:

触发器 优点 缺点
启动同步 冷启动一致 仅一次
Webhook 实时 平台可能漏发、网络抖动
周期 poll 兜底 有延迟
手动同步 应急 需要鉴权

正确答案是四个都要,而不是「webhook 够用」。webhook 平台漏发、配错地址、网络抖动都会让索引悄悄落后几小时甚至几天,而周期 poll 是这类故障唯一能兜住的兜底。

四个触发器一起涌进来必然出问题:webhook 短时间连续触发(一次 push 一个 commit)、周期 poll 撞上 webhook、手动同步撞上启动同步。

第二个核心组件由此出现:串行同步队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// services/syncQueue.ts
class SyncQueue {
private running = false;
private pending: SyncTask | null = null;

enqueue(task: SyncTask) {
if (!this.pending || higherPriority(task, this.pending)) {
this.pending = task;
}
this.drain();
}

private async drain() {
if (this.running) return;
while (this.pending) {
this.running = true;
const task = this.pending;
this.pending = null;
try { await runSync(task); }
finally { this.running = false; }
}
}
}

三条不变量:

  1. 串行。同一时间只一个 sync 在跑。
  2. Debounce。2 到 5 秒窗口内的同源 enqueue 合并。
  3. 优先级。manual > webhook > startup > poll,pending 槽位被更高优先级替换。

第 4 层:索引存储的原子性

到这里最自然的写法是:

1
await fs.writeFile("skills.json", JSON.stringify(skills));

问题在掉电窗口。服务在 JSON.stringify 之后、write 完成之前掉电,skills.json 会被截断成半张表,下一次启动直接 JSON parse 报错,服务起不来。

原子写模板:

1
2
3
4
5
6
7
8
9
10
11
async function atomicWriteJson(target: string, data: unknown) {
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
const fh = await fs.open(tmp, "w");
try {
await fh.writeFile(JSON.stringify(data));
await fh.sync(); // 关键:fsync 到磁盘
} finally {
await fh.close();
}
await fs.rename(tmp, target); // rename 是 POSIX 原子操作
}

顺手再加两件事。

Schema version envelope:

1
2
3
4
5
{
"schemaVersion": 1,
"headSha": "ab12cd34",
"skills": [ ... ]
}

读取时校验 schemaVersion,未来格式迁移就有回退余地。低版本 server 读到高版本 index 时降级到空索引,比强解析崩溃强。

同步失败保留旧索引:

1
2
3
4
5
6
7
try {
const fresh = await indexSkills(headSha);
await atomicWriteJson("skills.json", fresh);
} catch (e) {
// 旧 skills.json 原样保留;写 sync-state.lastError
syncState.lastError = String(e);
}

只读索引型服务的可用性原则:新数据未就绪时旧数据继续服务。


第 5 层:sync 与 read 的并发竞争

这是最容易翻车的一层。

考虑这个时间线:

1
2
3
4
5
6
t0  用户 GET /api/skills/foo/file?path=README.md
t1 handler 拿到 skill.rootPath = /data/git/repo/foo
t2 sync 任务开始 checkout 到新 commit
t3 handler 走 fs.readFile(/data/git/repo/foo/README.md)
↑ 此时工作树正在被 git checkout 撕成两半
t4 返回给用户:截断 / 找不到 / 旧内容混新内容

zip 下载更危险。archiver 把目录树流式打包要持续几秒到十几秒,整个过程都在 checkout 风暴中。

朴素的读写锁不行。读多写少时这把锁会饿死 sync,而 zip 的流可能持续超过整个 sync 周期。

真正可用的方案是 per-commit snapshot 加原子 symlink swap。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// services/gitMirror.ts
async ensureSnapshot(commitSha: string): Promise<string> {
const dir = path.join(this.snapshotsDir, commitSha);
if (await exists(dir)) return dir;
await git("worktree", "add", "--detach", dir, commitSha);
return dir;
}

async swapServingTo(commitSha: string) {
const target = path.join(this.snapshotsDir, commitSha);
const link = this.servingDir; // symlink
const tmpLink = `${link}.tmp`;
await fs.symlink(target, tmpLink);
await fs.rename(tmpLink, link); // rename 原子替换 symlink
}

async pruneSnapshots(keep: number) {
// 保留最近 N 个 snapshot,长 zip 跨 sync 不被抽走
}

读路径只经过 servingDir,且 zip 构建前先 fs.realpath 把 inode 钉住:

1
2
3
const root = await fs.realpath(skill.rootPath);
// 哪怕之后 symlink 又被换走,这个 fd 继续指向旧 snapshot
const stream = archiver("zip").directory(root, false);

效果:sync 期 reader 看不到撕裂状态;旧 reader 永远看完旧 snapshot;保留 N 个 snapshot 让长 zip 跨多次 sync 也安全。


第 6 层:性能(I/O 与 fork 削减)

加一条约束:同步一次几百个 skill 要扫几百个 SKILL.md、几百次 git log、几百次 fs.stat。能不能快点?

第一刀砍串行 git log:

1
2
3
4
5
6
7
8
9
// 之前:for-of 串行
for (const skill of skills) {
skill.lastCommit = await gitLog1(skill.relativePath);
}

// 之后:Promise.allSettled 并行 + 单个失败不污染整体
const results = await Promise.allSettled(
skills.map(s => gitLog1(s.relativePath))
);

第二刀砍 N 次 fork:

1
2
3
4
5
6
// 之前:每个 skill fork 一次 git log
// 之后:一次 git log 拿到整张 (path → lastCommit) 表
async function batchLastCommits(paths: string[]): Promise<Map<string, Commit>> {
// git log --name-only --pretty=format:'%H|%ai|%an' -- <paths...>
// 然后客户端 reduce 出每个 path 的 first(=最新)
}

第三刀砍 metrics 实时写盘。浏览量、下载量本质是频繁的小增量,每次请求都 fs.writeFile 一次 metrics.json 等于把磁盘当寄存器用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MetricsStore {
private dirty = new Map<string, MetricsDelta>();
private timer: NodeJS.Timeout;

bump(id: string, kind: "views" | "downloads") {
this.dirty.get(id) ?? this.dirty.set(id, { views: 0, downloads: 0 });
this.dirty.get(id)![kind]++;
if (this.dirty.size >= 10) this.flush();
}

constructor() {
this.timer = setInterval(() => this.flush(), 5000);
}

async flush() {
if (this.dirty.size === 0) return;
const snapshot = this.dirty;
this.dirty = new Map();
await atomicWriteJson("metrics.json", merge(load(), snapshot));
}
}

阈值取 5 秒或 10 次累积任一触发。生产观测下来这条改动减少了约 95% 的 metrics I/O。

代价:进程崩溃最多丢 5 秒 / 10 次的计数。对浏览量统计来说可以接受,而且 graceful shutdown 会强制 flush(见第 13 层)。


第 7 层:性能(HTTP 缓存)

换个战场。前端 SPA 每次切页都拉一次 /api/skills,每次都重算列表。能让浏览器和服务器都少干点活吗?

三件套。

ETag 加 Cache-Control:

1
2
3
4
5
6
7
8
9
10
11
app.get("/api/skills/:id", async (req, reply) => {
const skill = repo.findById(req.params.id);
const etag = skill.commitSha; // 内容随 commit 变才算变
if (req.headers["if-none-match"] === etag) {
return reply.code(304).send();
}
reply
.header("etag", etag)
.header("cache-control", "private, max-age=10, stale-while-revalidate=60")
.send(skill);
});

ETag 粒度要按接口语义分级:

  • /api/skills/:id 用记录的 commitSha
  • /api/skills?q=...&page=...hash(headSha + queryString)
  • /api/home-feedheadSha,再加一层 memoize

接口合并。首页要展示「最近更新 / 下载热榜 / 工作流精选」三个 bucket。朴素做法是前端打三个请求。改成:

1
2
3
4
5
app.get("/api/home-feed", async () => ({
recent: repo.sortedByLatest.slice(0, 6),
hot: repo.sortedByDownloads.slice(0, 6),
picks: repo.byCategory("workflow").slice(0, 6),
}));

三次 TCP 握手降到一次。响应可以 memoize per headSha,命中率几乎 100%。

客户端 SWR:

1
2
3
import useSWR from "swr";
const { data } = useSWR("/api/skills", fetcher);
// 自动去重、stale-while-revalidate、focus revalidate

ETag 命中 304,SWR 拿旧数据立即渲染,网络几乎不传内容,用户感知是「秒开」。

数据结构层缓存。SkillsRepo 在加载 index 时一次性预计算好三个排序数组和两张倒排:

1
2
3
4
5
6
7
class SkillsRepo {
sortedByLatest: Skill[];
sortedByName: Skill[];
sortedByResourceSize: Skill[];
byCategory: Map<string, Skill[]>;
byTag: Map<string, Skill[]>;
}

list 接口从「每请求 clone 加 sort 全表」降到 O(pageSize) slice。再加一条小优化:metrics 只附加给可见页 slice,不附加给全表。

Tree 接口 LRU。/api/skills/:id/tree 是详情页打开时立刻要的。key 用 id@commitSha,size 200:

1
const treeCache = new LRU<string, TreeNode>({ max: 200 });

key 里带 commitSha 的好处是 commit 变了 key 自然失效,不需要主动 invalidation。


第 8 层:性能(带宽与序列化)

两个数字:列表接口响应是 57 KB JSON,每次请求都重新 JSON.stringify。brotli 在这个体积上能比 gzip 小多少?

编译态序列化。Fastify 支持 per-route response schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.get("/api/skills", {
schema: {
response: {
200: {
type: "object",
additionalProperties: false,
properties: {
total: { type: "integer" },
items: { type: "array", items: skillSummarySchema },
},
},
},
},
}, handler);

fast-json-stringify 把这个 schema 在 register 时编译成一个直接拼字符串的函数。热路径上比 JSON.stringify 快 2 到 10 倍。

additionalProperties: false 还顺手堵了字段泄漏。哪怕 handler 误返了 __internal 之类的字段,序列化层会丢掉。

压缩协议优先级:

1
2
3
4
await app.register(fastifyCompress, {
threshold: 1024,
encodings: ["br", "gzip", "deflate"], // brotli 优先
});

实测:57 KB JSON 经 brotli 约 1 KB,经 gzip 约 1.6 KB。内网应用 CPU 富余,brotli 的额外开销划算。

未来再让 nginx 用 brotli_static 配上构建期产物 *.br,CPU 直接归零,这一项放在文末「未做完的优化」里。


第 9 层:安全(Webhook)

webhook 接口最容易写成这样:

1
2
3
4
5
6
7
app.post("/api/webhooks/git", async (req) => {
if (req.headers["x-token"] !== process.env.WEBHOOK_SECRET) {
return { ok: false };
}
syncQueue.enqueue({ reason: "webhook" });
return { ok: true };
});

漏洞列表能写一篇:

  1. 明文 token 比较有时序侧信道,可以逐字符爆破。
  2. 没限制 ref,tag push 和 branch delete 全都触发同步。
  3. JSON body 先 parse 再校验,攻击者发 1 GB body 把进程 OOM。
  4. 未配置 secret 时 process.env.WEBHOOK_SECRET === undefined,等于完全不鉴权。
  5. HMAC 校验和共享 token 没有兜底。
  6. 错误信息回写「token mismatch」帮攻击者判断。

修复版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { timingSafeEqual, createHmac } from "crypto";

function tsEqual(a: string, b: string): boolean {
// 长度不等也要做一次假比较,防长度侧信道
const A = Buffer.from(a);
const B = Buffer.alloc(A.length);
const ok = Buffer.byteLength(b) === A.length;
if (ok) Buffer.from(b).copy(B);
return timingSafeEqual(A, B) && ok;
}

app.register((app, _, done) => {
// 关键:HMAC 校验在 JSON parse 之前,先拿到 raw body
installRawBodyCapture(app);

app.post("/api/webhooks/git", {
bodyLimit: 1024 * 1024, // 1MB 上限
}, async (req, reply) => {
const secret = process.env.WEBHOOK_SECRET;
if (!secret) return reply.code(503).send(); // fails closed

const sig = req.headers["x-hub-signature-256"];
const token = req.headers["x-platform-token"];
const rawBody = (req as any).rawBody as Buffer;

const expectedHmac = "sha256=" + createHmac("sha256", secret)
.update(rawBody).digest("hex");

const hmacOk = typeof sig === "string" && tsEqual(sig, expectedHmac);
const tokenOk = typeof token === "string" && tsEqual(token, secret);
if (!hmacOk && !tokenOk) return reply.code(401).send();

const body = JSON.parse(rawBody.toString("utf8"));
if (body.ref !== `refs/heads/${branch}`) {
return { ok: true, skipped: "non-target-ref" };
}

syncQueue.enqueue({ reason: "webhook" });
return { ok: true, accepted: true };
});
done();
});

六个修复点对应六个原始漏洞。


第 10 层:安全(其他攻击面)

webhook 只是入口之一。Hub 还有几个攻击面。

Admin endpoint:

1
2
3
4
5
// 仅在配置了 ADMIN_TOKEN 时才挂载路由
if (process.env.ADMIN_TOKEN) {
app.post("/api/admin/sync", { /* bearer + tsEqual */ }, handler);
}
// 未配置 → 路由根本不存在 → 直接 404,攻击面归零

「未配置时路由不挂」比「未配置时 401」安全。401 暴露了端点存在。

路径穿越(文件预览 + zip 下载):

1
2
3
4
5
6
7
8
// services/safePath.ts
export function resolveWithinRoot(root: string, requested: string): string {
const resolved = path.resolve(root, requested);
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
throw new Error("path-escape");
}
return resolved;
}

外加白名单加大小上限:

1
2
3
4
5
6
const TEXT_EXT = new Set([".md", ".ts", ".js", ".json", ".yaml", ".yml", ".sh", ".py", ".txt"]);
const MAX_FILE_BYTES = 256 * 1024;

if (!TEXT_EXT.has(ext)) return reply.code(415).send();
const st = await fs.stat(full);
if (st.size > MAX_FILE_BYTES) return reply.code(413).send();

Markdown XSS。SKILL.md 是用户内容,直接渲染会引入 <script>javascript: URL、内联事件。React 端接 rehype-sanitize

1
2
3
4
5
6
7
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{ a: ({href, children}) => (
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
)}}
>{md}</ReactMarkdown>

外链强制 target=_blank rel="noopener noreferrer",防 tab-nabbing。

凭证脱敏。git URL 经常是 https://oauth2:<token>@host/repo.git 形式。日志、错误信息、/api/sync-state 响应都要剥掉 token:

1
2
3
export function redactRepoUrl(url: string): string {
return url.replace(/\/\/[^@/]+@/, "//[redacted]@");
}

请求日志里 ?token=xxx&secret=yyy 这种 query 参数也要替换成 redacted


第 11 层:限流与反压

内网服务也会被爬虫、误调度、循环依赖打爆。两层保险。

限流:

1
2
3
4
5
6
7
8
9
10
11
12
await app.register(fastifyRateLimit, {
global: true,
max: 100, timeWindow: "1 minute", // 全局
});

app.post("/api/skills/:id/view",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
handler);

app.get("/api/skills/:id/download",
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
handler);

下载是大开销动作,单独抠到 10/min。

trustProxy 必须配白名单,否则攻击者伪造 X-Forwarded-For 绕开 IP 维度限流:

1
app: { trustProxy: "127.0.0.1,::1,10.0.0.0/8" }

反压:

1
2
3
4
5
6
7
await app.register(underPressure, {
maxEventLoopDelay: 1000,
maxHeapUsedBytes: 1024 * 1024 * 1024,
maxRssBytes: 1500 * 1024 * 1024,
retryAfter: 10,
message: "Server is under pressure",
});

事件循环延迟、heap、RSS 触顶时立刻 503 加 Retry-After: 10,告诉客户端「等 10 秒再来」。比让进程被 OOM Killer 杀掉强。


第 12 层:可观测性

排障场景里最痛苦的一类:webhook 触发了,但同步没跑,日志里完全没线索把这两件事连起来。

每行 log 自己拼上 reqId 不现实。AsyncLocalStorage 是这个问题的标准解法:

1
2
3
4
5
6
7
8
9
10
11
12
// services/requestContext.ts
import { AsyncLocalStorage } from "async_hooks";
const als = new AsyncLocalStorage<{ reqId: string }>();

export const requestContext = {
run<T>(reqId: string, fn: () => T) {
return als.run({ reqId }, fn);
},
get(): string | undefined {
return als.getStore()?.reqId;
},
};

入口处包一层:

1
2
3
4
5
6
7
8
app.addHook("onRequest", async (req) => {
const reqId = req.headers["x-request-id"] as string ?? randomUUID();
req.id = reqId;
// 把 reqId 钉进异步上下文,后续 await 链全部可见
return new Promise<void>(resolve =>
requestContext.run(reqId, () => resolve())
);
});

下游 SyncQueue.enqueuerunSync 自动把当前 reqId 写到 log:

1
2
3
4
5
6
const log = (msg: string, fields = {}) => {
console.log(JSON.stringify({
msg, ...fields,
triggerReqId: requestContext.get(), // 自动跨边界
}));
};

最终效果:

1
2
3
4
5
POST /api/webhooks/git  reqId=abc123
→ syncQueue.enqueue triggerReqId=abc123
→ runSync start triggerReqId=abc123
→ git fetch ok triggerReqId=abc123
→ index updated triggerReqId=abc123

排障时按 triggerReqId=abc123 过滤一条命令出整条链路。

加上几个观测端点:

  • /api/health 返回 JSON 健康
  • /api/sync-state 返回 head sha、上次同步时间、状态、lastError
  • /ready 在首次成功 sync 之前返回 503,让容器编排不会把流量打进来

/api/sync-state 暴露 lastError 是个故意决策。内网服务的诊断比信息收敛重要,但凭证必须脱敏。


第 13 层:Graceful Shutdown

容器编排发 SIGTERM,30 秒后 SIGKILL。这 30 秒里要做四件事:

  1. 拒绝新请求(health 改成 503)
  2. 等正在跑的 sync 任务结束
  3. flush metrics(第 6 层欠下的写盘)
  4. 关闭 HTTP server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let shuttingDown = false;
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
log(`received ${signal}, shutting down`);

// 1. 让 /ready 立刻 503
health.markNotReady();

// 2. 等同步队列空
await syncQueue.waitDrain(20_000);

// 3. flush metrics 缩小丢数据窗口
await metricsStore.flush();

// 4. close fastify
await app.close();
process.exit(0);
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

还有一个隐患:git 子进程没有超时。一次 hang 住的 git fetch 会把 sync 队列卡死,shutdown 也等不出来。

1
2
3
function git(...args: string[]) {
return execa("git", args, { timeout: 120_000 }); // clone 用 300_000
}

120 秒超时配上 fetch 失败重试 1 次、3 秒指数退避,把「永久挂起」转成「显式失败」,sync 队列继续往下流。


第 14 层:部署形态对代码组织的反向影响

产品功能到这里基本完成。部署形态会反过来重塑代码组织。

单 CJS bundle。容器平台只跑 node bin/server.js,期待找到 dist/server/index.js

1
2
3
4
5
{
"scripts": {
"build:server": "esbuild src/server/index.ts --bundle --platform=node --target=node18 --format=cjs --outfile=dist/server/index.js"
}
}

第一版加 --packages=external,不打包 node_modules,体积更小。部署上去发现找不到 fastify,因为部署机镜像里没有 npm,也没有 node_modules。改回内联依赖(去掉 --packages=external 即可)。

bundle 从 200 KB 变 2.9 MB,但能跑。

dist 入 git 跟踪。这一条违背常规直觉:

1
2
3
# .gitignore
# dist/ ← 不能 ignore
dist/public/assets/*.map

dist/server/index.jsdist/public/ 都要提交进仓库。前提条件是部署机没有构建环境。脱离这个前提下结论就反过来。

sourcemap linked:

1
--sourcemap=linked

prod 栈跟踪能反映射回 TS 源行。栈丢源行后线上 bug 等于黑盒。

bundle audit:

1
--metafile=dist/server/meta.json

跑一个简单脚本展开 meta.json,看 2.9 MB bundle 里哪些库占了大头。常见的发现是某个意想不到的 transitive dep 占了 30% 体积。

周期同步开关。线上同时开 webhook 和 poll,缺一不可。webhook 平台漏发、配置失效、网络抖动一次,poll 兜底;没有 poll 的话索引可能落后几小时没人发现。

1
2
3
if (config.syncIntervalMs > 0) {
setInterval(() => syncQueue.enqueue({ reason: "poll" }), config.syncIntervalMs);
}

留给下一轮迭代的优化

每个项目都有一份「评估过但还没做」的清单。这种清单的价值是不重复评估,看到「已评估暂未做」可以直接抄结论。

A. 同步阶段把 markdown 预渲染成 HTML。

详情页 bundle 里 react-markdownremark-gfmrehype-sanitize 占了约 80 KB(gzipped 25 KB)。每次进详情页都在客户端 parse 一次同一段 markdown。

方案是索引时在 server 端用同一套 sanitize schema 渲成 HTML,存进 skills.json,前端 dangerouslySetInnerHTML 直发。前后端共享一份 sanitize schema 是关键。

风险点:skills.json 变大几 MB;schemaVersion 升到 2 时发布顺序必须是「先发能读 v1/v2 的新 server,再发 v2 index」。

B. 静态产物预压缩加 Brotli 优先。

构建时产 *.gz*.br,nginx 上开 gzip_static onbrotli_static on 直发,runtime CPU 归零。

风险点是反向代理是否编译了 brotli 模块,没编译就只开 gzip。

C. git cat-file --batchls-tree -r

索引时几百次 fs.readFilefs.stat 改成一次常驻 cat-file --batch 子进程加一次 ls-tree -r --long。Tree 接口也走 ls-tree 不走 fs。

收益:sync 从秒级降到亚秒级,tree 冷启动从几十毫秒降到几毫秒。

风险:cat-file --batch 是常驻进程,崩溃要能自愈,复杂度上升。先做 ls-tree(一次性子进程,简单)再上 batchReadBlobs。


一些可迁移的判断

14 层走完,回头看哪些判断是这种「只读索引型服务」普遍适用的,列在下面备查。

源与运行时分离:git 是源,本地 mirror 加内存索引是运行时。第 1 层就该定下来。

多触发器并存时用串行队列收口:任何「事件可能来自多个来源」的子系统都需要一个 single-writer 队列加 debounce 加优先级。

失败时保留旧数据:新数据未就绪时旧数据继续服务,对只读索引型服务特别适用。

JSON 持久化的最小安全姿势:tmp 加 fsync 加 rename 加 schemaVersion envelope。

sync 与 read 的并发竞争是隐藏雷区:per-commit snapshot 加原子 symlink swap 是 git 类后端的常见解法。

缓存粒度跟着不变量走:能用 commitSha 当 ETag 就别拍脑袋 timestamp。

安全设施 fails closed:secret 未配置时端点 503,或者路由根本不挂载,比「未配置时放行」安全。

AsyncLocalStorage 把跨边界的因果串起来:不用每行 log 自己拼 reqId。

graceful shutdown 是线上稳定性的最后一公里:有缓冲的写都要在 SIGTERM 路径上 flush。

部署形态会反过来塑造代码:不要在部署机的约束之上盖一层「漂亮抽象」,约束本身是设计的一部分。

附:上面这些改进几乎每一条都对应一个单 commit 单意图的实际修改。Git log 是最忠实的架构演化记录。任何一份按主题归类附 commit SHA 的「优化历史」清单都比静态架构图更适合作为新人 onboarding 入口,因为它回答的是「为什么这样写」,而不是「这是什么样子」。