文章

Waline 评论系统改造:Netlify + GitHub 免数据库分片存储|软件开发

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 MB0.20 ms · 下载 0 B
一次列表页完整渲染(5 万条)4547.9 ms · 下载 91.15 MB1.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 accessOnly select repositories → 选上一步那个数据仓库
Permissions → ContentsRead and write

生成后复制 github_pat_... 那串,下一步会用到。它只会出现一次。

Fork 并接入 Netlify

  1. 点仓库右上角 Fork(或用 “Use this template”)到你自己的账号。
  2. Netlify → Add new site → Import an existing project → 选你 fork 的仓库。
  3. 构建设置保持默认,仓库已用 netlify.toml 配好:

    项值
    Build command留空(依赖由 Netlify 自动安装并打包函数)
    Publish directory留空(纯 Functions 站点,没有静态产物)
    Functions directorynetlify/functions/(已在 netlify.toml 里)
    Node version24(已在 netlify.toml 里)
  4. 先别急着部署,去 Site configuration → Environment variables 配好变量(见下一节),再触发部署。

Fork 后有一处必须改:netlify.toml 末尾有一条 favicon 重定向指向本仓库作者的站点,请改成你自己的图标地址,或者直接删掉那一段。

配置环境变量

必填项:

变量示例说明
GITHUB_TOKENgithub_pat_xxx上一步生成的令牌
GITHUB_REPOyour-name/waline-data数据仓库;贴完整 URL 或带 .git 也会自动归一化
GITHUB_BRANCHmain数据仓库分支名,默认 main
JWT_TOKEN一串随机字符串管理员登录态签名密钥。不设则每次重新部署都要重新登录,且会退化用 GITHUB_TOKEN 当密钥

常用 Waline 变量(与官方完全一致,按需添加):

变量说明
SITE_NAME / SITE_URL站点名与地址,用于邮件通知里的链接
SECURE_DOMAINS允许嵌入的域名,防止别人盗用你的后端
COMMENT_AUDITtrue 时新评论默认 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。

验证清单

  1. 打开 https://<站点>/.netlify/functions/comment(Waline 自带示例页,可直接发一条评论)。
  2. 发完后去数据仓库看,应出现 data/comments/<xx>/<xxxx>.csv 与 data/comments/_manifest.csv。
  3. 调一次 GET /csv-store/stats(需管理员 token,见后文「备份与运维」),确认 shards.Comment ≥ 1、pending = 0。
  4. 用 /ui/login 登录后台,确认能看到刚才那条评论。
  5. 刷新前端页面,评论应正常显示。

数据长什么样

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路径
Commenturlcomments/<sha1(url)[0:2]>/<sha1(url)[0:4]>.csv
Counterurlcounters/<sha1(url)[0:2]>/<sha1(url)[0:4]>.csv
UsersobjectIdusers/<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、自定义计数器类型等动态列会以“规范列 ∪ 实际出现的动态列”的形式写进表头(动态列按字母序追加),因此表头稳定且不丢字段。

表列
CommentobjectId, user_id, comment, insertedAt, ip, link, mail, nick, pid, rid, status, ua, url, createdAt, updatedAt
UsersobjectId, display_name, email, password, type, url, avatar, label, github, twitter, facebook, google, weibo, qq, oidc, createdAt, updatedAt
CounterobjectId, 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 只在同族内合并,也不会把它们带回来。

现在的处理是自愈:

  1. 刷新时若 manifest 的 min_key === max_key 却哈希不到该文件所在的族,就把这个文件登记为该 key 规范族的别名分片(旧评论立即可读);
  2. 同一次刷新里把全部错位行搬回各自的规范分片,它们因此在下次提交中落到正确的文件里;旧文件被清空后删除,别名登记随之消失;
  3. 第 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} 个分片`);
}

迁移完还有四件事要做:

  1. 提交并推送数据仓库——站点读的是 GitHub 上的分支,本地改完必须推送才生效。
  2. 删掉 data/Comment.csv、data/Counter.csv、data/Users.csv:留着不会被读到,但会一直被快照带着。
  3. 删掉引用旧路径的旧快照(_snapshots/ 下引用 data/Comment.csv 的那些):恢复它们会把旧布局覆盖回去,评论又读不到了。
  4. 访问一次站点,用 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,所以“会不会丢评论”是它最需要交代清楚的问题。下面八条是设计上的回答。

  1. 响应前落盘:中间件在每次请求返回前 await flush()。Netlify/Lambda 实例在响应后会被冻结,只靠定时器刷盘不可靠。落盘失败会把响应改成 5xx,绝不让客户端以为“保存成功”;并发请求也不会因为复用了在途提交而漏掉自己的写入。
  2. 写后读一致:变更先应用到内存(含索引)再异步提交。add() 返回后立刻 select() 就能看到,add() 返回的主键也一定有效。
  3. 原子提交:分片与 manifest 在同一次 Git Data API 提交里落地,要么全部生效,要么全部不生效。
  4. 乐观锁:读取顺序固定为“先读 head、再读数据”,提交时 force=false 更新 ref。只要在读取数据之后有人提交过,我们的非快进更新就会被 GitHub 拒绝。
  5. 冲突合并:被拒后重新拉取 manifest,只下载 SHA 变化的分片,按“本地优先 + 尊重本实例删除”的规则合并,再重试(最多 MAX_RETRIES 次,带退避)。计数类表(Counter)例外:数值列按“远端值 + 本实例增量”合并,否则两个实例各加一次的阅读量会被后提交者覆盖掉一个。
  6. 失败回滚:重试耗尽后把内存恢复到“最后一次提交成功”的状态并丢弃待提交标记,让进程内状态与远端重新一致,同时把错误抛给调用方。
  7. 幂等去重:mail + url + 内容哈希 相同且落在 DEDUP_WINDOW_SECONDS 内的提交直接返回既有记录,避免刷新/重放导致的重复写入。
  8. 限流友好:GitHub 次级限流(403 + rate limit 文案)与 429/5xx 都会退避重试并遵循 Retry-After;鉴权类错误不重试,直接报错。

与上游实现的差异

本改造只替换底层存储适配器,没有改动任何上层业务逻辑。为了能正常工作,下列几处必须与 Waline 的实际契约对齐,与上游行为存在(有意为之的)差异:

  1. CSV 列名:以上层 controller 真正读写的字段为准(comment / insertedAt / link / objectId …),而不是任何“看起来更合理”的命名。
  2. update 支持函数返回补丁:上游 update(data, where) 在 data 是函数时会丢弃返回值,导致 GitHub 存储下阅读量(Counter)永远不增长。这里同时支持“函数就地修改”与“函数返回补丁对象”。
  3. LIKE 转义正则元字符:上游把关键字直接拼进 new RegExp,含 (、[ 等字符的搜索关键词会抛错。这里统一转义。
  4. > 做真正的时间比较:上游用 item[k] >= where[k][1],当右侧是 Date 时字符串与日期比较得到 NaN,条件恒为 false(评论频率限制因此从未生效)。这里对日期做显式归一化后按 > 语义比较。
  5. 动态列不丢:上游写盘用 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-engineempty-stub这些重依赖本站用不到(数学公式已关闭),留着会把 Netlify 的函数包顶到限额之外
@mdit/plugin-emojiplugin-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.jsNetlify Function 入口(注入 model 与插件,是唯一改动的上游文件)
overlay/csv-store/github-client.jsGitHub 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.jsWaline 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/ 的是目录(放我们自己的改造),两者不是一回事。

已知限制

  1. 写入依赖 GitHub API:每条评论约 7 次 API 调用(head/manifest 校验 + blob + tree + commit + ref)。令牌配额 5000 次/小时,博客量级绰绰有余,但不适合高频率写入场景。
  2. 仓库体积只增不减:每次写入都会重写整个分片并产生新 blob。用 POST /csv-store/reset 周期性清零历史。
  3. 后台“全部评论”列表会整表加载:数据量大时首次打开较慢(按需加载无法收敛到族)。
  4. 跨实例读有 TTL 延迟(默认 60s):A 实例写入后,B 实例最多 60s 后才看到。
  5. 数据仓库只服务一个站点:多站点共用一个仓库会让提交互相干扰。
  6. 数据里含 PII(mail、ip、ua):数据仓库建议设为 Private。
  7. 不支持全文检索:后端只实现了 Waline 实际用到的 where 子集(等值 / IN / NOT IN / LIKE / > / _complex)。

最后几句

这套方案的本质,是把“评论数据放哪”这个问题回答成“放在你自己的 git 仓库里”。它换来的是零外部依赖的备份、可回溯的变更记录,以及查询时不再下载整个数据集;代价是写入路径变长(一次评论一次提交)、仓库体积需要定期用 reset 清理,以及读跨实例有最长 60 秒的延迟。

如果站点规模在几十万条评论以内、写入不频繁,这些代价基本可以忽略;反过来,如果需要全文检索或者写入压力很大,还是老老实实换回带数据库的方案更合适。

本文由作者按照 CC BY 4.0 进行授权