Waline 评论系统改造:Netlify + GitHub 免数据库分片存储|软件开发
9 月那篇博客搬家记录的结尾留了个尾巴:文中的 Waline 改造后来又被我重新写了一遍,篇幅所限没有展开,只留了一句「欢迎访问仓库」。这篇文章把这件事补上:我用一套自定义存储适配器替换了 @waline/vercel 的「单文件 CSV」实现,把评论数据以 CSV 分片文件的形式存放在 GitHub 仓库里。改造只换底层存储,上层业务逻辑零改动。
不用数据库,也不用对象存储,换来的好处很实际:备份即仓库备份,每一次数据变更都留有 git 历史。下面按「为什么改、改完是什么样、怎么部署、有哪些坑」的顺序来说。
为什么要换掉单文件 CSV
这个仓库建立在 walinejs/netlify-starter 之上。上游的 CSV 存储方案把全部评论放在 data/Comment.csv 一个文件里,读一次列表就要下载整个文件、在 JS 里逐行过滤,写一条评论则要重写整个文件。评论一多,这套做法很快就不行了。
| 上游单文件方案 | 本仓库多分片方案 | |
|---|---|---|
| 数据布局 | data/Comment.csv 一个文件 | data/comments/<xx>/<xxxx>.csv 按文章分片 + _manifest.csv |
| 查询 | 下载整个 CSV → JS 逐行过滤 | 按分片族按需加载 + 内存索引,只碰命中分片 |
| 写入 | 重写整个 CSV | 只重写受影响分片,一个请求合并成一次 Git 提交 |
| 并发安全 | 无(后写覆盖先写) | 分片 SHA 校验 + ref 非快进拒绝 + 冲突自动合并重试 |
| 备份 | 无 | 每日快照 + 一键恢复 + 仓库瘦身 |
差距有多大,用仓库里的基准脚本测一次最清楚(npm run benchmark 生成,结果在 overlay/benchmark/REPORT.md):
| 场景 | 上游单文件方案 | 本仓库多分片方案 |
|---|---|---|
| 单次评论列表查询(5 万条 / 990 个分片) | 1141.9 ms · 下载 22.79 MB | 0.20 ms · 下载 0 B |
| 一次列表页完整渲染(5 万条) | 4547.9 ms · 下载 91.15 MB | 1.76 ms · 下载 0 B |
| 冷启动首次查询 | 成本随全站数据量增长 | 4 次 API 调用 · 167.6 KB · 75.8 ms |
「下载 0 B」不是夸张:分片已经在内存里,查询不再碰网络。冷启动之所以快,是因为它只读三张表的 manifest,再加上命中文章的那一两个分片,成本与全站评论总量基本无关。
适合:个人博客与中小站点,评论量在几十万条以内,希望零运维(没有数据库需要备份)。不适合:高并发写入、需要全文检索、或数据量大到 GitHub 仓库体积成为瓶颈的场景,具体见文末的「已知限制」。
五分钟上手
1
2
3
4
5
1. 新建一个【空的 GitHub 仓库】当数据仓库(建议 Private)
2. 申请一个 Fine-grained token,只给这个仓库 Contents: Read and write
3. Fork 本仓库 → Netlify "Import from Git" → 填 3 个环境变量 → 部署
4. 打开 https://<站点>/.netlify/functions/comment/ui/register 注册管理员
5. 前端把 serverURL 指到 https://<站点>/.netlify/functions/comment
⚠️ 不要把数据仓库设成站点仓库自己。 每次写评论都会向数据仓库提交一次 commit。两者若是同一个仓库,Netlify 会把每次提交都当成部署触发,形成“写入 → 部署 → 写入”的循环。
完整部署教程
准备数据仓库
新建一个 GitHub 仓库(例如 your-name/waline-comments),其余保持默认:
- 建议设为 Private:评论 CSV 含
mail、ip、ua等字段,公开仓库等于公开这些信息。 - 不要勾选 Add a README:本方案在完全空白的仓库上也能工作,首条评论写入时会自动创建分支与首个提交。
- 若仓库已有内容且有
main分支,把GITHUB_BRANCH指到那个分支即可。 - 一个数据仓库只服务一个站点,多站点共用会让提交互相干扰。
准备 GitHub 访问令牌
GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token:
| 配置项 | 填什么 |
|---|---|
| Token name | 随意,例如 waline-comments |
| Expiration | 建议 90 天或自定义(到期不换,评论会开始 500) |
| Repository access | Only select repositories → 选上一步那个数据仓库 |
| Permissions → Contents | Read and write |
生成后复制 github_pat_... 那串,下一步会用到。它只会出现一次。
Fork 并接入 Netlify
- 点仓库右上角 Fork(或用 “Use this template”)到你自己的账号。
- Netlify → Add new site → Import an existing project → 选你 fork 的仓库。
构建设置保持默认,仓库已用
netlify.toml配好:项 值 Build command 留空(依赖由 Netlify 自动安装并打包函数) Publish directory 留空(纯 Functions 站点,没有静态产物) Functions directory netlify/functions/(已在netlify.toml里)Node version 24(已在 netlify.toml里)- 先别急着部署,去 Site configuration → Environment variables 配好变量(见下一节),再触发部署。
Fork 后有一处必须改:
netlify.toml末尾有一条 favicon 重定向指向本仓库作者的站点,请改成你自己的图标地址,或者直接删掉那一段。
配置环境变量
必填项:
| 变量 | 示例 | 说明 |
|---|---|---|
GITHUB_TOKEN | github_pat_xxx | 上一步生成的令牌 |
GITHUB_REPO | your-name/waline-data | 数据仓库;贴完整 URL 或带 .git 也会自动归一化 |
GITHUB_BRANCH | main | 数据仓库分支名,默认 main |
JWT_TOKEN | 一串随机字符串 | 管理员登录态签名密钥。不设则每次重新部署都要重新登录,且会退化用 GITHUB_TOKEN 当密钥 |
常用 Waline 变量(与官方完全一致,按需添加):
| 变量 | 说明 |
|---|---|
SITE_NAME / SITE_URL | 站点名与地址,用于邮件通知里的链接 |
SECURE_DOMAINS | 允许嵌入的域名,防止别人盗用你的后端 |
COMMENT_AUDIT | true 时新评论默认 waiting,需管理员批准 |
AKISMET_KEY | 反垃圾评论 |
SMTP_SERVICE / SMTP_USER / SMTP_PASS / AUTHOR_EMAIL | 邮件通知与找回密码 |
其余变量见 Waline 官方文档。本改造不改变任何上层行为,只替换底层存储。
注册管理员
部署成功后访问(注意是 Netlify 的函数路径,没有 /api 前缀):
1
https://<你的站点>/.netlify/functions/comment/ui/register
第一个注册的账号自动成为管理员,后台入口是同目录下的 /ui/login。如果注册页返回 500,先看 Netlify 的 Function 日志,多半是 GITHUB_TOKEN / GITHUB_REPO 配错了。
前端接入
1
2
3
4
5
6
7
8
9
10
<link rel="stylesheet" href="https://unpkg.com/@waline/client@v3/dist/waline.css" />
<div id="waline"></div>
<script type="module">
import { init } from 'https://unpkg.com/@waline/client@v3/dist/waline.js';
init({
el: '#waline',
serverURL: 'https://<你的站点>/.netlify/functions/comment',
});
</script>
框架用户(Hexo / Vitepress / Astro …)同理,把 serverURL 指过去即可。serverURL 末尾不要带斜杠,也不要加 /api:本仓库没有配置 /api 重定向。
嫌路径长可以在 netlify.toml 里加一条重定向:
1
2
3
4
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/comment/:splat"
status = 200
之后 serverURL 就可以写成 https://<你的站点>/api。
验证清单
- 打开
https://<站点>/.netlify/functions/comment(Waline 自带示例页,可直接发一条评论)。 - 发完后去数据仓库看,应出现
data/comments/<xx>/<xxxx>.csv与data/comments/_manifest.csv。 - 调一次
GET /csv-store/stats(需管理员 token,见后文「备份与运维」),确认shards.Comment ≥ 1、pending = 0。 - 用
/ui/login登录后台,确认能看到刚才那条评论。 - 刷新前端页面,评论应正常显示。
数据长什么样
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
data/ # 数据根目录(CSV_STORE_DIR,默认 data)
├── comments/ # Comment 表
│ ├── ab/
│ │ ├── abcd.csv # 分片:同一篇文章的评论都在这一族里
│ │ └── abcd-1.csv # 行数达到 SHARD_MAX_ROWS 后裂变出来的分片
│ └── _manifest.csv # 分片清单
├── users/ # Users 表
│ ├── a1/
│ │ └── a1.csv
│ └── _manifest.csv
├── counters/ # Counter 表(阅读量等)
│ ├── 3f/
│ │ └── 3f9c.csv
│ └── _manifest.csv
└── _snapshots/ # 每日快照:一天一个清单文件(path,sha)
└── 2026-09-25.csv
分片规则(稳定可预测,只由 key 决定)
| 表 | 分片 key | 路径 |
|---|---|---|
Comment | url | comments/<sha1(url)[0:2]>/<sha1(url)[0:4]>.csv |
Counter | url | counters/<sha1(url)[0:2]>/<sha1(url)[0:4]>.csv |
Users | objectId | users/<objectId[0:2]>/<objectId[0:2]>.csv |
- 为什么 Comment/Counter 用 url 哈希做文件名:同一篇文章的评论必然落在同一个分片族里,按文章查询只需读一个分片,局部写入也只影响一个分片。
- 为什么 Users 只用 2 位前缀且“目录名 = 文件名”:Users 的 key 是一行一个的
objectId,照搬 4 位前缀会让 10 万用户产生约 10 万个只有一行的小文件;2 位前缀把分片数收敛到有限个桶,每片容纳成百上千行。 - 裂变:族内任一文件达到
SHARD_MAX_ROWS行后,新记录写入<前缀>-1.csv、-2.csv…,保证单个 CSV 不会无限增长。 - 合并:族内总行数回落到阈值以内时,
POST /csv-store/compact可把它压回基础分片。 - 搬迁(页面换地址):只在外部改了 CSV 的
url列却没挪文件时,行会留在旧地址哈希出的分片里成为“错位行”。系统会自动把它们搬回新地址对应的族,见后文「页面换地址后评论不见了」。
_manifest.csv
shard_path,row_count,sha,updated_at,min_key,max_key
comments/ab/abcd.csv,1832,3f9c…,2026-09-25T10:00:00.000Z,/posts/hello,/posts/hello
manifest 是读路径的核心:刷新时只拉取三张表的 manifest(3 次 API 调用),逐条比对 sha,只有真正变化的分片才会被下载。manifest 与分片永远落在同一次 Git 提交里,不会出现两者不一致的中间状态。
CSV 列
每张表的规范列与 Waline 上层实际读写的字段一致;sticky、like、2fa、自定义计数器类型等动态列会以“规范列 ∪ 实际出现的动态列”的形式写进表头(动态列按字母序追加),因此表头稳定且不丢字段。
| 表 | 列 |
|---|---|
| Comment | objectId, user_id, comment, insertedAt, ip, link, mail, nick, pid, rid, status, ua, url, createdAt, updatedAt |
| Users | objectId, display_name, email, password, type, url, avatar, label, github, twitter, facebook, google, weibo, qq, oidc, createdAt, updatedAt |
| Counter | objectId, time, url, createdAt, updatedAt |
文件编码 UTF-8、换行 LF、仅在必要时加引号(RFC 4180 转义)。时间列统一为 ISO 8601(字典序即时间序),手工编辑时请保持这个格式。
环境变量清单
| 变量 | 必填 | 默认 | 说明 |
|---|---|---|---|
GITHUB_TOKEN | ✅ | 需要数据仓库的 contents: read & write 权限 | |
GITHUB_REPO | ✅ | owner/repo(贴完整 URL 或带 .git 也能自动归一化) | |
GITHUB_BRANCH | main | 数据所在分支 | |
CSV_STORE_DIR | data | 数据根目录 | |
SHARD_MAX_ROWS | 2000 | 单个分片行数上限,超过则裂变 | |
SHARD_HASH_LEN | 2 | 一级目录哈希前缀长度(越大分片越多越小) | |
FLUSH_INTERVAL | 30000 | 定时刷盘间隔(ms);serverless 下主要靠“响应前刷盘” | |
MAX_BATCH_SIZE | 50 | 单次 Git 提交最多包含的分片数 | |
MAX_RETRIES | 3 | 一次 flush 的最大尝试次数(含冲突重试) | |
CSV_CACHE_TTL | 60000 | 内存缓存有效期(ms),到期后检查 manifest 变化 | |
DEDUP_WINDOW_SECONDS | 60 | 重复提交去重窗口;0 表示关闭 | |
SNAPSHOT_KEEP_DAYS | 30 | 快照保留天数;0 表示关闭快照功能 | |
SNAPSHOT_HOUR | 3 | 每日快照的触发时点(本地时间的整点) | |
JWT_TOKEN | 建议 | 与 Waline 本身一致,用于管理员登录态 | |
GITHUB_API_BASE | https://api.github.com | 仅用于测试/自建代理 |
COMMENT_AUDIT=true时新评论默认waiting,管理员在后台批准后才会出现在前台。这是 Waline 自身的审核工作流,存储层只如实保存status字段。
备份与运维
所有接口都需要管理员身份(Authorization: Bearer <登录 token>)。完整路径是 https://<站点>/.netlify/functions/comment/csv-store/<action>(若你配了 /api 重定向,就是 https://<站点>/api/csv-store/<action>)。
| 方法 | 路径 | 作用 |
|---|---|---|
POST | /csv-store/snapshot | 立即创建快照并清理过期快照 |
GET | /csv-store/snapshot | 列出已有快照日期 |
POST | /csv-store/restore | 用快照覆盖恢复,body/query 传 date=YYYY-MM-DD |
POST | /csv-store/compact | 把行数回落的分片族压回基础分片 |
POST | /csv-store/rebuild | 以仓库真实文件为准核对分片并修正 manifest(人工改过仓库后使用) |
POST | /csv-store/repair | 把错位分片的行搬回规范分片并返回搬迁报告(正常情况下刷新时自动执行) |
POST | /csv-store/flush | 手动把内存中待提交的变更落盘 |
GET | /csv-store/stats | 运行指标:提交/冲突/重试/回滚次数、GitHub 请求数、各表分片数、待提交数、未归位的错位分片数(misplaced) |
POST | /csv-store/reset | 不可逆:丢弃全部历史提交,只保留当前数据(需显式 confirm=1) |
1
2
3
4
5
6
7
8
9
BASE=https://<站点>/.netlify/functions/comment/csv-store
TOKEN=<后台登录后浏览器里拿到的 token>
curl -X POST "$BASE/snapshot" -H "Authorization: Bearer $TOKEN"
curl "$BASE/snapshot" -H "Authorization: Bearer $TOKEN"
curl "$BASE/stats" -H "Authorization: Bearer $TOKEN"
curl -X POST "$BASE/restore?date=2026-09-25" -H "Authorization: Bearer $TOKEN"
# 仓库瘦身:丢弃历史提交(confirm 必填;快照默认一并丢弃,加 keepSnapshots=1 可保留)
curl -X POST "$BASE/reset?confirm=1" -H "Authorization: Bearer $TOKEN"
token 可以这样拿:登录后台后打开 DevTools → Network,随便挑一个后台请求,复制它的 Authorization: Bearer xxxxx 那段即可。接口只认 Authorization 头,不支持把 token 放在 URL query 里(会被代理日志记下)。restore 的 date 必须是 YYYY-MM-DD,非法值直接报错,不会拿它去拼路径。
每日快照
默认每天(SNAPSHOT_HOUR 之后)由第一个到达的请求触发一次,当天只执行一次。快照只引用已有 blob,零内容上传,几百个分片也只是一次提交。
需要精确时点,用平台的 Scheduled Function 或 VPS 的 crontab 定时调 POST /csv-store/snapshot。
恢复
- 推荐:
POST /csv-store/restore,用某个日期的快照整体覆盖data/(同时删掉快照之后多出来的文件,恢复后状态与该快照完全一致)。 - 手工:快照清单
_snapshots/<date>.csv就是一份path,sha列表,照它把对应 blob 放回原路径即可,数据本身就在 git 里。
仓库瘦身:POST /csv-store/reset
写入即提交,历史里因此堆着每一次分片重写产生的 blob,仓库体积只增不减,时间久了会逼近 GitHub 的建议上限。评论数据的历史提交没有价值——任何时点都能用快照恢复。reset 做的事是:
- 用当前
data/下的文件构造一个没有父提交的 commit,并强制更新分支指针; - 仓库回到“一个提交”的状态,
.git体积随之降到当前数据大小; - 快照目录(同样只是历史负担)默认一并丢弃,需要保留就传
keepSnapshots=1; - 比“新建仓库再切环境变量”更直接:没有两份数据并存的切换窗口,也不需要改配置。
操作前建议先 POST /csv-store/snapshot 拍一张,确认无误再 reset。
重置会不会删掉评论?不会。 reset 只把 git 历史清零。它把
data/下全部当前文件(含data/comments/**、data/users/**、data/counters/**)以{sha}形式原样带进新提交,引用现有 blob,内容一个字节都不动——重置后评论、用户、阅读量与重置前完全一致。唯一会丢掉的是_snapshots/快照目录,想留住就传keepSnapshots=1。
页面换地址后评论不见了
页面 A 迁到页面 B(例如 /friends/ → /links/)时,如果只把 CSV 里的 url 列改成新地址,文件却仍留在 sha1('/friends/') 对应的分片里,就会产出错位行:
1
2
comments/fc/fca4.csv sha1('/links/') ← 新评论会写到这里
comments/1b/1bd2.csv sha1('/friends/') ← 迁移过来的旧评论留在这里,读不到
整套存储的前提是“由 key 算出族、再去那个族里找”,所以 1bd2 里的旧评论在前台一条都不会显示;而 compact 只在同族内合并,也不会把它们带回来。
现在的处理是自愈:
- 刷新时若 manifest 的
min_key === max_key却哈希不到该文件所在的族,就把这个文件登记为该 key 规范族的别名分片(旧评论立即可读); - 同一次刷新里把全部错位行搬回各自的规范分片,它们因此在下次提交中落到正确的文件里;旧文件被清空后删除,别名登记随之消失;
- 第 1 步只能覆盖“整片同一个 key”的情况。一个文件里混着多个 key 时,由
POST /csv-store/repair、POST /csv-store/compact、POST /csv-store/rebuild这类整表操作逐行核对搬迁。
代价是每个进程最多付一次全表加载,数据归位后不再触发。想立刻看报告,可以直接调 POST /csv-store/repair;GET /csv-store/stats 里的 misplaced 表示还有多少个别名分片没归位,正常情况下应为 0。
从旧版迁移数据
新方案不会读取 data/Comment.csv(上游单文件布局)。如果你之前用的是官方方案,需要把三个文件拆成分片;没有历史数据的话跳过本节。
这是一次性的本地操作:拆分规则就是上文那张分片规则表,下面这段脚本复用 overlay/csv-store 的分片规则,保证与线上用的是同一套逻辑(需要仓库已 npm install)。
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// migrate.js —— 用法:node migrate.js ./waline-data/data
// 参数是数据仓库里的 data 目录本身(里面放着 Comment.csv / Counter.csv / Users.csv)
const fs = require('node:fs');
const path = require('node:path');
const { ShardManager, DATE_FIELDS } = require('./overlay/csv-store/shard-manager');
const { gitBlobSha } = require('./overlay/csv-store/github-client');
const { TABLES } = require('./overlay/csv-store/csv-shard-cache');
const DATA_DIR = path.resolve(process.argv[2]); // 例:…/waline-data/data
const LEGACY = { Comment: 'Comment.csv', Counter: 'Counter.csv', Users: 'Users.csv' };
const manager = new ShardManager({ storeDir: 'data', shardMaxRows: 2000, hashLen: 2 });
const toFsPath = (repoPath) => path.join(DATA_DIR, repoPath.slice(manager.storeDir.length + 1));
for (const table of TABLES) {
const src = path.join(DATA_DIR, LEGACY[table]);
if (!fs.existsSync(src)) {
console.log(`${table}:没有旧文件,跳过`);
continue;
}
const shards = new Map();
for (const record of manager.parseShard(fs.readFileSync(src, 'utf8'))) {
const row = manager.normalizeRow(table, record);
// 旧数据是 Date.toString(),统一成 ISO 8601
for (const field of DATE_FIELDS) {
if (!row[field]) continue;
const t = Date.parse(row[field]);
if (!Number.isNaN(t)) row[field] = new Date(t).toISOString();
}
if (!row.objectId) continue;
// 填满优先:基础分片 → -1 → -2 …
const prefix = manager.shardPrefix(table, row[manager.shardKeyField(table)] ?? '');
let target = null;
for (let suffix = 0; suffix < 4096; suffix += 1) {
const candidate = suffix === 0 ? `${prefix}.csv` : `${prefix}-${suffix}.csv`;
if (!shards.has(candidate)) { shards.set(candidate, []); target = candidate; break; }
if (shards.get(candidate).length < manager.shardMaxRows) { target = candidate; break; }
}
shards.get(target).push(row);
}
const entries = new Map();
for (const [shardPath, rows] of shards) {
const content = manager.serializeShard(table, rows, manager.buildColumns(table, rows));
const target = toFsPath(shardPath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content, 'utf8');
entries.set(shardPath, manager.manifestEntry(table, shardPath, rows, gitBlobSha(content)));
}
const manifest = toFsPath(manager.manifestPath(table));
fs.mkdirSync(path.dirname(manifest), { recursive: true });
fs.writeFileSync(manifest, manager.serializeManifest(entries), 'utf8');
console.log(`${table}:${[...shards.values()].reduce((n, r) => n + r.length, 0)} 行 → ${shards.size} 个分片`);
}
迁移完还有四件事要做:
- 提交并推送数据仓库——站点读的是 GitHub 上的分支,本地改完必须推送才生效。
- 删掉
data/Comment.csv、data/Counter.csv、data/Users.csv:留着不会被读到,但会一直被快照带着。 - 删掉引用旧路径的旧快照(
_snapshots/下引用data/Comment.csv的那些):恢复它们会把旧布局覆盖回去,评论又读不到了。 - 访问一次站点,用
GET /csv-store/stats确认分片数与评论条数符合预期,再拍一张新快照。
本地开发
1
2
3
4
npm install
npm test # 60 个用例:单元 + 集成 + 真实 Waline 运行时端到端
# + 并发/限流可靠性 + 内存预算
npm run benchmark # 生成 overlay/benchmark/REPORT.md
测试不需要任何真实凭据:overlay/csv-store-tests/mock-github.js 是一个会讲 HTTP 的内存版 GitHub(真的维护 blob/tree/commit/ref 四层对象,真的会拒绝非快进更新),所以“并发冲突”“重试”“回滚”这些行为测到的就是线上行为。
本地必须也验一遍生产运行时:Lambda 的 Node 24 没有开启 require(esm),而本地 Node 默认开着,所以“某个依赖变成纯 ESM 却被同步 require”这类问题本地跑得好好的、上线才炸(1.43.4 的 @mdit/plugin-emoji 就是这么踩的)。npm test 里已有一个守卫用例会开子进程用 --no-experimental-require-module 加载函数入口,想手工确认也可以直接跑:
1
2
# 模拟生产运行时,确认函数入口能被加载(这是把 1.43.4 跑挂的那个坑)
node --no-experimental-require-module -e "require('./netlify/functions/comment.js')"
想拿真实仓库跑,可以另建一个测试仓库,把 GITHUB_TOKEN / GITHUB_REPO 写进 .env 后 netlify dev(需要 Netlify CLI)。不要直接拿线上数据仓库做实验,先 POST /csv-store/snapshot。
故障排查
| 现象 | 原因 | 处理 |
|---|---|---|
冷启动报 ERR_REQUIRE_ESM(函数 502,日志有 Init Error) | 某个依赖只有 ESM 构建,却被 waline 在模块加载期同步 require;Lambda 的 Node 24 没开 require(esm) | 仓库已用 overlay/plugin-emoji-cjs 垫片解决 1.43.4 的这类问题;升级 waline 后再遇到,用 node --no-experimental-require-module -e "require('./netlify/functions/comment.js')" 定位到具体包,再按同样方式加垫片 |
评论提交返回 500 评论存储提交失败 | 提交 GitHub 失败且重试耗尽;内存变更已回滚,数据没有丢一半 | 检查 GITHUB_TOKEN 权限/有效期与 GitHub 状态 |
日志出现 无法访问 GitHub 仓库 | GITHUB_REPO 不是 owner/repo,或 token 未授权该仓库 | 修正环境变量后重新部署 |
/ui/register 打不开或 404 | 路径不对(Netlify 下没有 /api 前缀)或函数没起来 | 用 /.netlify/functions/comment/ui/register;看 Function 日志 |
| 页面读到的评论变少了 | 内存缓存尚未过期(默认 60s),其他实例刚写入 | 等一个 CSV_CACHE_TTL 周期;或调小该值 |
| 有人直接在 GitHub 上改了 CSV,前台没变 | 读路径只信任 manifest 记录的 SHA | 调 POST /csv-store/rebuild 核对并同步 |
| 页面换过地址后评论变少了 | 旧地址哈希出的分片里留着改过 url 的错位行 | 通常已被自动搬迁;没生效就调 POST /csv-store/repair |
| 部署后站点无限重新构建 | 数据仓库指向了站点仓库自己 | 换成独立的专用数据仓库 |
Git Trees API 返回被截断 | 仓库文件数超过 10 万,无法安全做快照 | 拆分仓库,或 POST /csv-store/reset 清理历史 |
| 数据仓库体积持续增大 | 每次写入都会重写整个分片并产生新 blob,历史只增不减 | POST /csv-store/reset;或调小 SHARD_MAX_ROWS |
| 阅读量比实际访问少 | 多实例并发自增(已用“远端值 + 本地增量”合并修掉绝大多数) | 极端并发仍可能少计 1~2 次,计数类数据本就不要求精确 |
| 写入偶发超时 | GitHub API 慢 + 函数超时 | 已设 26s;仍超时可减小 SHARD_MAX_ROWS 让单次提交更小 |
| 分片文件太多 | 文章数很多,一片一族是设计使然 | 调小 SHARD_HASH_LEN 会把文章合并到更少的分片 |
写入可靠性设计:为什么不会丢评论
这套方案跑在 serverless 上,写入又完全依赖外部 API,所以“会不会丢评论”是它最需要交代清楚的问题。下面八条是设计上的回答。
- 响应前落盘:中间件在每次请求返回前
await flush()。Netlify/Lambda 实例在响应后会被冻结,只靠定时器刷盘不可靠。落盘失败会把响应改成 5xx,绝不让客户端以为“保存成功”;并发请求也不会因为复用了在途提交而漏掉自己的写入。 - 写后读一致:变更先应用到内存(含索引)再异步提交。
add()返回后立刻select()就能看到,add()返回的主键也一定有效。 - 原子提交:分片与 manifest 在同一次 Git Data API 提交里落地,要么全部生效,要么全部不生效。
- 乐观锁:读取顺序固定为“先读 head、再读数据”,提交时
force=false更新 ref。只要在读取数据之后有人提交过,我们的非快进更新就会被 GitHub 拒绝。 - 冲突合并:被拒后重新拉取 manifest,只下载 SHA 变化的分片,按“本地优先 + 尊重本实例删除”的规则合并,再重试(最多
MAX_RETRIES次,带退避)。计数类表(Counter)例外:数值列按“远端值 + 本实例增量”合并,否则两个实例各加一次的阅读量会被后提交者覆盖掉一个。 - 失败回滚:重试耗尽后把内存恢复到“最后一次提交成功”的状态并丢弃待提交标记,让进程内状态与远端重新一致,同时把错误抛给调用方。
- 幂等去重:
mail + url + 内容哈希相同且落在DEDUP_WINDOW_SECONDS内的提交直接返回既有记录,避免刷新/重放导致的重复写入。 - 限流友好:GitHub 次级限流(403 + rate limit 文案)与 429/5xx 都会退避重试并遵循
Retry-After;鉴权类错误不重试,直接报错。
与上游实现的差异
本改造只替换底层存储适配器,没有改动任何上层业务逻辑。为了能正常工作,下列几处必须与 Waline 的实际契约对齐,与上游行为存在(有意为之的)差异:
- CSV 列名:以上层 controller 真正读写的字段为准(
comment/insertedAt/link/objectId…),而不是任何“看起来更合理”的命名。 update支持函数返回补丁:上游update(data, where)在data是函数时会丢弃返回值,导致 GitHub 存储下阅读量(Counter)永远不增长。这里同时支持“函数就地修改”与“函数返回补丁对象”。LIKE转义正则元字符:上游把关键字直接拼进new RegExp,含(、[等字符的搜索关键词会抛错。这里统一转义。>做真正的时间比较:上游用item[k] >= where[k][1],当右侧是Date时字符串与日期比较得到NaN,条件恒为 false(评论频率限制因此从未生效)。这里对日期做显式归一化后按>语义比较。- 动态列不丢:上游写盘用
headers: true,会把首个写入行之外的额外字段整列丢弃(sticky、like、2fa、自定义计数器类型都会丢)。这里用“规范列 ∪ 动态列”。
代码结构
仓库刻意保持“上游 + 补丁”的形状——所有改造都收在 overlay/ 一层目录里,根目录不新增任何平级目录,因此和上游 starter 的 diff 一眼能看清:
1
2
3
4
5
6
7
8
9
10
netlify/functions/comment.js # 唯一的上游接线点:Waline({ model, plugins })
overlay/ # ← 我们做的全部改造都在这一层里
├── csv-store/ # 多 CSV 分片存储(适配器 + GitHub 客户端 + 快照 + 入口)
├── csv-store-tests/ # 内存版 GitHub API 与用例
├── benchmark/ # 性能对比基准与生成的报告
├── empty-stub/ # 空实现桩的源码(npm run pack:stubs 打出下面的 tarball)
│ └── … # 替换 @mathjax/*、leancloud-* 等重依赖
├── plugin-emoji-cjs/ # emoji 垫片的源码(把 ESM-only 的包以 CJS 形式提供)
├── empty-stub-1.0.0.tgz # ↑ 两个本地包的打包产物(package.json 的 overrides 指向它们)
└── plugin-emoji-cjs-1.0.0.tgz
两个本地包:为什么必须打成 tarball 并提交
package.json 的 overrides 会把两个第三方包替换成 overlay/ 下的本地包:
| 被替换的包 | 换成 | 原因 |
|---|---|---|
@mathjax/*、leancloud-*、speech-rule-engine | empty-stub | 这些重依赖本站用不到(数学公式已关闭),留着会把 Netlify 的函数包顶到限额之外 |
@mdit/plugin-emoji | plugin-emoji-cjs | 它只有 ESM 构建,而 waline 在模块加载期同步 require 它,禁用 require(esm) 的运行时(Lambda 的 Node 24)会直接 ERR_REQUIRE_ESM |
为什么用 tarball 而不是目录:目录形式的桩(file:./overlay/empty-stub)会被 npm 建成“链接”节点;一旦需要重新解析依赖树(升级任何依赖、改版本范围),arborist 会在链接目标还没建立时去比较它,直接抛 Cannot read properties of null——也就是说目录桩会让依赖再也升不动。tarball 形式的桩被 npm 当作普通包安装,不产生链接节点:升级、降级、npm ci、Netlify 构建全部正常,不需要任何额外开关。
为什么必须提交这两个 tarball:npm 在解析依赖树阶段就要按 file: 路径打开它们,这发生在任何 preinstall/postinstall 脚本之前,所以没法在安装时现打。它们合起来只有 1.3KB,且 lockfile 里记录了各自的 integrity 哈希——换句话说,它们和 package-lock.json 是同一类东西:可复现安装所需的输入。
改了本地包的内容或版本号之后,按这个顺序做(否则 npm 会拿旧 lockfile 里的条目去替换,报出 ENOENT ... overlay/xxx.tgz 或 tarball 损坏之类的怪错):
1
2
3
npm run pack:stubs # 重新打包两个本地包 → overlay/*.tgz
rm package-lock.json # 让 npm 从零解析(overrides 变动时必须)
npm install # 重建 node_modules 与 lockfile
想彻底不提交 tarball,唯一的替代是把这些本地包发布到 npm、然后
overrides写版本号(例如"@mdit/plugin-emoji": "npm:@your-scope/plugin-emoji-cjs@^1")。代价是多一个需要自己维护的已发布包;本仓库选择提交 1.3KB。
| 文件 | 职责 |
|---|---|
netlify/functions/comment.js | Netlify Function 入口(注入 model 与插件,是唯一改动的上游文件) |
overlay/csv-store/github-client.js | GitHub Contents API / Git Data API 封装、SHA 校验、批量提交、重试 |
overlay/csv-store/shard-manager.js | 分片命名与路由、manifest、CSV 编解码、裂变与合并规划 |
overlay/csv-store/csv-shard-cache.js | 分片按需加载、内存索引、条件查询(where/order/limit)、变更与回滚、错位分片自愈 |
overlay/csv-store/shard-write-queue.js | 写入合并、按分片提交、冲突检测与重试 |
overlay/csv-store/csv-model.js | Waline CustomModel 适配器实现 |
overlay/csv-store/snapshot.js | 快照创建、保留策略、恢复、仓库重置 |
overlay/csv-store/index.js | 入口:模型注入、中间件、运维接口、管理员鉴权 |
overlay/csv-store-tests/ | 内存版 GitHub API 与 60 个用例(真实 Waline 运行时端到端、并发冲突、分片裂变、快照恢复、内存预算、生产运行时加载守卫、页面迁移自愈) |
overlay/benchmark/ | 性能对比基准与报告生成 |
overlay/empty-stub/ | 重依赖的空实现桩源码(打包产物由 package.json 的 overrides 指向) |
overlay/plugin-emoji-cjs/ | emoji 垫片源码:把只有 ESM 构建的 @mdit/plugin-emoji 以 CJS 形式提供给 waline 的同步 require |
依赖桩让外置包总体积保持在约 94MB,低于 Netlify 的 250MB 函数包限额。它与 csv-store 无关,只是同样属于“我们对上游的改写”。
加载范围:按分片族按需加载
缓存里是两层状态:entries 是 manifest 里的权威清单(全部分片,无论是否读过),shards 才是真正加载进内存的分片内容。查询进来时先算出目标族,只加载命中族:
| 查询形态 | 加载范围 |
|---|---|
select({ url }) / url IN (…)(评论列表、计数) | 命中族(一篇文章 1~2 个分片) |
Users 按 objectId 查(鉴权、用户资料) | 命中族(objectId 前两位决定的桶) |
后台列表、模糊搜索、按 pid/rid 删除、count({}) | 整表(无法收敛到族,宁可多读也不给不完整结果) |
因此冷启动不再需要把整个数据集读进内存:只读 manifest(每表 1 次请求)+ 命中分片。只有真正需要全局视图的操作才会把整表读进来,并把该表标记为“已全量加载”。
内存占用
已加载的分片常驻内存(只在远端 SHA 变化时重新下载),这是“查询不碰网络”的前提。
overlay/csv-store-tests/memory.spec.js 会钉死全量加载时的上界:10 万条评论(1973 个分片)的行对象加上 byId / byField / 分片族索引,实测常驻约 88.7 MB,低于 100MB 预算,超出即测试失败。
按需加载让常见路径的实际占用远低于这个上界。索引是分片级增量维护的,只有分片合并与回滚这类整表重写才做一次全量重建。
索引刻意保持精简:主键直达(byId)、等值/IN 的倒排桶(byField)、分片族索引(新增记录时 O(族内分片) 定位落点)。重复提交的指纹不做全局索引——10 万条评论下那份 Map<hash, Set> 实测要占 20MB 以上(接近总量四分之一),而它只在新增评论时用一次,因此改成在“同一 url 的候选行”上现算。
同步上游与升级
上游 starter 只有依赖升级类提交,同步是安全的:
1
2
3
git remote add upstream https://github.com/walinejs/netlify-starter.git
git fetch upstream
git merge upstream/master --no-ff
package-lock.json 几乎必然冲突:保留本地版本(git checkout HEAD -- package-lock.json)。仓库的 lockfile 是 v3 且已按 overrides 裁剪过,混进上游的 v2 结构会让 Netlify 依赖体积失控。
@waline/vercel 目前在 1.43.4。要跟进上游新版本时注意:升级会牵动 netlify/functions/comment.js 的 model / plugins 注入契约与数据表列,升完必须跑完 npm test(含真实 Waline 运行时端到端用例)再上线。
注意
overrides不能改名:package.json里的overrides是 npm 自己的字段(官方文档),不是本仓库的命名约定。改成别的键名会被 npm 当成未知字段静默忽略,于是@mathjax/*、leancloud-*会被真实安装,函数包体积直接冲破 Netlify 的限额。仓库里叫overlay/的是目录(放我们自己的改造),两者不是一回事。
已知限制
- 写入依赖 GitHub API:每条评论约 7 次 API 调用(head/manifest 校验 + blob + tree + commit + ref)。令牌配额 5000 次/小时,博客量级绰绰有余,但不适合高频率写入场景。
- 仓库体积只增不减:每次写入都会重写整个分片并产生新 blob。用
POST /csv-store/reset周期性清零历史。 - 后台“全部评论”列表会整表加载:数据量大时首次打开较慢(按需加载无法收敛到族)。
- 跨实例读有 TTL 延迟(默认 60s):A 实例写入后,B 实例最多 60s 后才看到。
- 数据仓库只服务一个站点:多站点共用一个仓库会让提交互相干扰。
- 数据里含 PII(
mail、ip、ua):数据仓库建议设为 Private。 - 不支持全文检索:后端只实现了 Waline 实际用到的
where子集(等值 / IN / NOT IN / LIKE />/_complex)。
最后几句
这套方案的本质,是把“评论数据放哪”这个问题回答成“放在你自己的 git 仓库里”。它换来的是零外部依赖的备份、可回溯的变更记录,以及查询时不再下载整个数据集;代价是写入路径变长(一次评论一次提交)、仓库体积需要定期用 reset 清理,以及读跨实例有最长 60 秒的延迟。
如果站点规模在几十万条评论以内、写入不频繁,这些代价基本可以忽略;反过来,如果需要全文检索或者写入压力很大,还是老老实实换回带数据库的方案更合适。
