首页›文章›文件级评论系统的设计:零数据库内容站的心脏

文件级评论系统的设计:零数据库内容站的心脏

计算机技术 · web 开发2026-09-191次浏览0 个评论

本篇是《手写一个内容型技术博客站》系列第 5 篇。

内容站要有评论,但我不想为"十几条评论"上一个数据库。这篇讲纯文件存储的评论系统如何做到并发安全、不丢数据、能过滤垃圾——架构不复杂,但每个决策都有讲究。

一、存储形态:每页一个 JSON 文件

评论数据存 src/data/comments/,每篇文章一个 JSON 文件,文件名由页面 ID 派生(/ 和 . 替换为 _):

text
src/data/comments/
├── posts_my-article.json     # 一篇文章的评论
├── site_total.json...        # (示意)
└── views.json                # 全站阅读数

为什么按页拆文件?写放大小。评论是高并发写入点:如果全站评论存一个文件,每次新评论都要重写整个大文件。按页拆,写入只影响单篇,且天然分段隔离。

二、评论关联:commentId 保证"改路径不丢评论"

评论要挂在文章上。不能依赖文章的文件路径——因为文章可能改 slug、移动目录,评论不能跟着丢。

方案:每篇文章 frontmatter 固化一个 commentId:

yaml
# article.md
commentId: cid_61d7ddb9da  # 固化在 frontmatter
  • 读取时:(data.commentId) || deriveCommentId(路径)——有固化值用固化值,没有就用路径派生的兜底
  • 首条评论时:ensureCommentIdInFrontmatter() 自动把派生 cid 固化到 frontmatter
  • 评论数据:按 commentId 组织,不按路径

于是改 slug、移动文件,评论 ID 不变(文件路径变了但 frontmatter 的 cid 还在),评论不丢。这是"稳定标识"设计:用不变的 ID 关联,不用会变的路径关联。

三、并发安全:进程内锁 + 原子写

文件存储最大的问题是并发写。两个用户同时评论,都读文件 → 都改 → 都写,后写的覆盖先写的,丢一条评论。

原子写

先保证"写不写花":

ts
// 写整个 JSON:先写临时文件,再 rename(原子替换)
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
fs.renameSync(tmp, file仍在); // rename 保证要么旧版要么新版,绝无半截文件

renameSync 是原子的:哪怕进程崩溃,磁盘上要么是完整的旧文件,要么是完整的新文件,绝不会出现"写了一半"的损坏文件。

进程内互斥锁

但原子写不解决"丢失更新"(read-modify-write 竞争)。需要串行化读-改-写:

ts
// src/lib/file-lock.ts:进程内 promise 队列互斥锁
const queues = new Map<string, Promise<unknown>>();

export async function withFileLock<T>(
  key: string,
  fn: () => Promise<T> | T,
  timeoutMs = 10000
): Promise<T> {
  const prev = queues.get(key) || Promise.resolve();
  let release!: () => void;
  const gate = new Promise<void>((r) => (release = r));
  queues.set(key, prev.then(() => gate));
  await prev.catch(() => {});
  try {
    const result = await Promise.race([Promise.resolve().then(fn), timeoutReject(key, timeoutMs)]);
    return result as T;
  } finally {
    release();
    if (queues.get(key) === prev.then(() => gate)) queues.delete(key);
  }
}

用法:按评论页面 ID 加锁,读-改-写整体包进 withFileLock,同一时刻只有一个人能改这个文件。

ts
await withFileLock(`comments:${pageId}`, async () => {
  const data = readComments(pageId);
  data.push(newComment);
  writeComments(pageId, data);
});

超时自愈

锁最大的风险是持锁任务卡死,如果锁没有超时,后面所有评论请求全部永久排队。所以 withFileLock 加了超时:

ts
// LockTimeoutError:fn 卡死超时后抛错,自动断链
const timeoutPromise = new Promise<never>((_, reject) => {
  timer = setTimeout(() => reject(new LockTimeoutError(key, timeoutMs)), timeoutMs);
});
const raced = Promise.race([run, timeoutPromise]);

卡死的任务被超时踢出,后续任务照常执行——杜绝"某篇文章永远无法评论"。

多实例的正确性声明

这套锁是进程内的,单 Node 进程(一台 ECS)下完全正确。如果将来横向扩到多实例,同一文件可能被不同进程同时改——需要换成外部锁(Redis 等)。架构上已经把加锁封装在 withFileLock 里,届时只改一处实现,业务代码不动。

四、一个评论的完整生命周期(含风控)

text
POST /api/comments
  ├─ 1. 评论开关校验(comments.yaml enabled,false → 403)
  ├─ 2. 蜜罐:隐藏字段 website 被填 → 静默 201 不落库(机器人识别)
  ├─ 3. 必填 + 长度校验(昵称≤30字 / 内容≤2000字)
  ├─ 4. URL 数量限制(≤2)+ 域名黑名单(t.cn 等短链拒绝)
  ├─ 5. 敏感词 AC 匹配(命中 → 400)
  ├─ 6. IP 频率限制(60s/3次 → 429)
  └─ 7. withFileLock 包住读-改-写,落盘

每个环节都是独立函数,可单测。

五、敏感词引擎:从黑名单到 AC 自动机

评论过滤第一个想到的可能是"遍历黑名单数组 includes"。但词库一上来就踩线:几万条敏感词,逐条 includes 是 O(N×M) 灾难。

换成 AC 自动机(Aho–Corasick):多模式匹配,一次扫描文本同时命中所有词,O(N) 复杂度(N 是文本长度,与词库大小无关)。

ts
// src/lib/sensitive-matcher.ts:零依赖手写 AC 自动机
// 构建失败/fail 指针 → 词库模板:O(N) 多模式匹配
// 实测:全量 5.1 万词构建 1.8s(一次性),单条评论匹配 0.01ms

词库热加载:词库在 src/data/sensitive-lexicon/,用 mtime+size+内容 md5 三重签名比对,改 txt 文件无需重启,下个请求自动重建。这个"配置热生效"的思路贯穿全站(评论参数、站点设置都是运行时读文件)。

六、后台管理:软删两级 + 防呆(补一句)

评论管理后台(本系列第 7 篇详讲)有删除分级:

  • 软删除:可恢复,进"已删除"区
  • 彻底删除(purge):不可逆,需确认 Modal 防呆

数据层操作都是 read → mutate → writeComments(原子写 + 锁),与前台共用同一套存储压力测试。

七、这套方案的取舍

优点:

  • 零依赖(无数据库),备份 = 拷目录
  • 部署简单:一个 Node 进程搞定
  • 评论量小(内容站量级)时性能完全够

代价:

  • 评论量极大时不适用(单文件读写在万级评论后变慢)
  • 多实例需引入外部锁
  • 没有 SQL 查询能力(统计、聚合要自己写)

判断标准:你的站点是"内容为主、评论为辅"还是"评论是核心资产"?前者文件存储足够,后者老实上数据库。


给普通人的一句话:很多看似"必须用数据库"的功能,在小规模下用文件 + 几个技巧就能做得又稳又简单。

参考

  • Aho–Corasick 算法:https://en.wikipedia.org/wiki/Aho%E2%80%93Corasick_algorithm

评论

0

评论加载中…

发表评论

0/2000