本篇是《手写一个内容型技术博客站》系列第 5 篇。
内容站要有评论,但我不想为"十几条评论"上一个数据库。这篇讲纯文件存储的评论系统如何做到并发安全、不丢数据、能过滤垃圾——架构不复杂,但每个决策都有讲究。
一、存储形态:每页一个 JSON 文件
评论数据存 src/data/comments/,每篇文章一个 JSON 文件,文件名由页面 ID 派生(/ 和 . 替换为 _):
src/data/comments/
├── posts_my-article.json # 一篇文章的评论
├── site_total.json... # (示意)
└── views.json # 全站阅读数为什么按页拆文件?写放大小。评论是高并发写入点:如果全站评论存一个文件,每次新评论都要重写整个大文件。按页拆,写入只影响单篇,且天然分段隔离。
二、评论关联:commentId 保证"改路径不丢评论"
评论要挂在文章上。不能依赖文章的文件路径——因为文章可能改 slug、移动目录,评论不能跟着丢。
方案:每篇文章 frontmatter 固化一个 commentId:
# article.md
commentId: cid_61d7ddb9da # 固化在 frontmatter- 读取时:
(data.commentId) || deriveCommentId(路径)——有固化值用固化值,没有就用路径派生的兜底 - 首条评论时:
ensureCommentIdInFrontmatter()自动把派生 cid 固化到 frontmatter - 评论数据:按
commentId组织,不按路径
于是改 slug、移动文件,评论 ID 不变(文件路径变了但 frontmatter 的 cid 还在),评论不丢。这是"稳定标识"设计:用不变的 ID 关联,不用会变的路径关联。
三、并发安全:进程内锁 + 原子写
文件存储最大的问题是并发写。两个用户同时评论,都读文件 → 都改 → 都写,后写的覆盖先写的,丢一条评论。
原子写
先保证"写不写花":
// 写整个 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 竞争)。需要串行化读-改-写:
// 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,同一时刻只有一个人能改这个文件。
await withFileLock(`comments:${pageId}`, async () => {
const data = readComments(pageId);
data.push(newComment);
writeComments(pageId, data);
});超时自愈
锁最大的风险是持锁任务卡死,如果锁没有超时,后面所有评论请求全部永久排队。所以 withFileLock 加了超时:
// 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 里,届时只改一处实现,业务代码不动。
四、一个评论的完整生命周期(含风控)
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 是文本长度,与词库大小无关)。
// 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评论加载中…