原创

Node.js 内容管线从 JSON 迁移到 SQLite:表结构、事务、备份与回滚

一套适合小型 Node.js 内容站的渐进式迁移方案:把 JSON 文件中的文章移入 SQLite,补齐约束、事务、备份、校验和可回滚切换,同时保留 JSON 导出能力。

AI 内容工程专题 · 第 6/11 篇查看专题目录 →

JSON 文件很适合内容站的第一版:无需数据库服务、可直接查看,也方便放进备份。但当抓取、AI 总结、人工审核和定时发布同时读写一个文件时,问题会逐渐出现:一次写入可能覆盖另一个任务的结果,按标签或状态查询需要每次加载全部数据,唯一性只能依赖应用代码,失败后的恢复也常常是整文件回退。

SQLite 仍然是单文件,却能提供事务、索引、唯一约束和成熟的备份工具。对单机部署的 Node.js 内容管线,它通常是从 JSON 向数据库演进时成本较低的一步。本文给出一条可暂停、可校验、可回滚的迁移路径,不假设迁移后性能一定提升,也不把示例结果冒充线上实测。

什么时候值得迁移

不要只因为文章数量增长就立即换数据库。以下信号同时出现两项以上,迁移通常更有价值:

  • 抓取任务、AI 归纳和后台编辑可能并发写入;
  • 需要按 status、workflowStatus、分类或发布时间组合查询;
  • slug、外部文章 URL 或任务 ID 必须唯一;
  • 一篇文章更新失败时,不希望影响整个内容文件;
  • 发布前需要稳定快照,出错后要快速恢复;
  • JSON 文件已经频繁产生难以审阅的大段 diff。

如果站点始终是单进程、少量内容、只由一个管理员更新,继续使用 JSON 并采用“临时文件写入后原子替换”也完全合理。

先定义迁移边界

这次只替换文章的持久化层,不同时重写抓取器、模型调用和页面渲染。先抽出仓储接口:

export interface ArticleRepository {
  list(options): Promise<Array>;
  getBySlug(slug): Promise<object | null>;
  upsert(article): Promise<void>;
  publish(id): Promise<void>;
}

JavaScript 本身不识别上面的 interface,它只是接口约定。如果项目使用纯 JavaScript,可以直接实现同名方法;使用 TypeScript 时再写成正式类型。迁移期间保留 JsonArticleRepository,新增 SqliteArticleRepository,由环境变量或配置选择。这样回滚不需要改业务调用链。

表结构:稳定字段列化,扩展字段保留 JSON

下面示例使用 Node.js 22.5 以后提供的 node:sqlite。该模块在不同 Node 版本中的稳定性标记和 API 可能不同,生产使用前应核对当前运行时文档;如果项目使用 LTS 旧版本,也可以把数据库调用替换为 better-sqlite3,表结构和迁移原则不变。

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS articles (
  id TEXT PRIMARY KEY,
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  summary TEXT NOT NULL DEFAULT '',
  content TEXT NOT NULL DEFAULT '',
  source TEXT NOT NULL,
  author TEXT NOT NULL DEFAULT '',
  category TEXT NOT NULL DEFAULT '',
  status TEXT NOT NULL CHECK (status IN ('active', 'draft', 'archived')),
  workflow_status TEXT NOT NULL,
  content_type TEXT NOT NULL DEFAULT 'guide',
  primary_keyword TEXT NOT NULL DEFAULT '',
  search_intent TEXT NOT NULL DEFAULT '',
  article_url TEXT NOT NULL DEFAULT '',
  cover_url TEXT NOT NULL DEFAULT '',
  published_at TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  next_review_at TEXT,
  content_version TEXT NOT NULL DEFAULT '1.0',
  reviewed_by TEXT NOT NULL DEFAULT '',
  tags_json TEXT NOT NULL DEFAULT '[]',
  sources_json TEXT NOT NULL DEFAULT '[]',
  test_notes_json TEXT NOT NULL DEFAULT '[]',
  related_site_ids_json TEXT NOT NULL DEFAULT '[]'
);

CREATE INDEX IF NOT EXISTS idx_articles_publish
  ON articles(status, workflow_status, published_at DESC);
CREATE INDEX IF NOT EXISTS idx_articles_category
  ON articles(category, published_at DESC);

标题、状态、发布时间等高频过滤字段适合独立列;标签、资料来源等目前只需整体读写的数组可以先存 JSON 文本。不要把整篇文章继续塞进一个 JSON 列,否则会失去约束与索引的主要收益。

时间统一保存 ISO 8601 字符串,并明确时区。SQLite 没有专用日期类型,混用本地时间和 UTC 会让排序与边界查询变得不可预测。

导入脚本:一次事务,要么全部成功

迁移依赖建议固定版本并进入锁文件。以下脚本以 Node 内置 SQLite API 为例,读取旧文件、校验基本结构,再在单个事务中导入:

// scripts/import-articles-to-sqlite.mjs
import { readFile } from 'node:fs/promises';
import { DatabaseSync } from 'node:sqlite';

const input = process.argv[2] ?? 'data/articles.json';
const output = process.argv[3] ?? 'data/content.db';
const parsed = JSON.parse(await readFile(input, 'utf8'));
const articles = Array.isArray(parsed) ? parsed : parsed.articles;
if (!Array.isArray(articles)) throw new Error('articles 必须是数组');

const db = new DatabaseSync(output);
db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;');
db.exec(`CREATE TABLE IF NOT EXISTS articles (
  id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL,
  summary TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
  source TEXT NOT NULL, author TEXT NOT NULL DEFAULT '',
  category TEXT NOT NULL DEFAULT '', status TEXT NOT NULL,
  workflow_status TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT 'guide',
  primary_keyword TEXT NOT NULL DEFAULT '', search_intent TEXT NOT NULL DEFAULT '',
  article_url TEXT NOT NULL DEFAULT '', cover_url TEXT NOT NULL DEFAULT '',
  published_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
  next_review_at TEXT, content_version TEXT NOT NULL DEFAULT '1.0',
  reviewed_by TEXT NOT NULL DEFAULT '', tags_json TEXT NOT NULL DEFAULT '[]',
  sources_json TEXT NOT NULL DEFAULT '[]', test_notes_json TEXT NOT NULL DEFAULT '[]',
  related_site_ids_json TEXT NOT NULL DEFAULT '[]'
)`);

const insert = db.prepare(`INSERT INTO articles (
  id, slug, title, summary, content, source, author, category, status,
  workflow_status, content_type, primary_keyword, search_intent, article_url,
  cover_url, published_at, created_at, updated_at, next_review_at,
  content_version, reviewed_by, tags_json, sources_json, test_notes_json,
  related_site_ids_json
) VALUES (${Array(25).fill('?').join(',')})`);

const required = value => typeof value === 'string' && value.trim();
const now = new Date().toISOString();

db.exec('BEGIN IMMEDIATE');
try {
  for (const a of articles) {
    if (!required(a.id) || !required(a.slug) || !required(a.title)) {
      throw new Error(`文章缺少 id/slug/title: ${JSON.stringify(a.id ?? null)}`);
    }
    insert.run(
      a.id, a.slug, a.title, a.summary ?? '', a.content ?? '',
      a.source ?? 'original', a.author ?? '', a.category ?? '',
      a.status ?? 'draft', a.workflowStatus ?? 'idea', a.contentType ?? 'guide',
      a.primaryKeyword ?? '', a.searchIntent ?? '', a.articleUrl ?? '',
      a.coverUrl ?? '', a.publishedAt ?? null, a.createdAt ?? now,
      a.updatedAt ?? now, a.nextReviewAt ?? null, a.contentVersion ?? '1.0',
      a.reviewedBy ?? '', JSON.stringify(a.tags ?? []),
      JSON.stringify(a.sources ?? []), JSON.stringify(a.testNotes ?? []),
      JSON.stringify(a.relatedSiteIds ?? [])
    );
  }
  db.exec('COMMIT');
} catch (error) {
  db.exec('ROLLBACK');
  throw error;
} finally {
  db.close();
}

BEGIN IMMEDIATE 会在写事务开始时获取写锁,避免导入到一半才发现另一个写入者。唯一索引冲突、字段缺失或进程异常都会让事务回滚,而不是留下半份数据。正式运行前,先用复制出来的 JSON 和临时数据库演练。

双读校验,而不是直接切换

导入完成不等于迁移完成。先让线上继续从 JSON 读取,后台执行只读对账。至少比较以下指标:

SELECT COUNT(*) AS total FROM articles;
SELECT COUNT(DISTINCT slug) AS unique_slugs FROM articles;
SELECT status, workflow_status, COUNT(*)
FROM articles GROUP BY status, workflow_status;
SELECT id FROM articles
WHERE json_valid(tags_json) = 0
   OR json_valid(sources_json) = 0
   OR json_valid(test_notes_json) = 0;

还应按 slug 抽样比较标题、正文哈希、发布时间和数组字段。正文可能含换行或 Unicode,比较规范化后的对象或稳定哈希比比较数据库文件大小更可靠。对账脚本失败时只报警,不自动修复源数据。

推荐的切换顺序是:

  1. 暂停定时写任务,记录切换开始时间;
  2. 备份 JSON 和现有 SQLite 文件;
  3. 完成最后一次全量导入与对账;
  4. 把读取切到 SQLite,先观察公开页面和后台列表;
  5. 再把写入切到 SQLite;
  6. 恢复定时任务,保留 JSON 只读快照。

这比长时间“双写”更容易保证一致性。双写在其中一个存储成功、另一个失败时会产生新的恢复难题;除非已经设计幂等事件日志,否则不建议把临时双写当作保险。

备份:复制数据库文件前先处理 WAL

SQLite 开启 WAL 后,最新提交可能位于 content.db-wal,直接只复制 content.db 可能得到不完整快照。更稳妥的方式是使用 SQLite 在线备份能力或官方 CLI 的 .backup 命令:

sqlite3 data/content.db '.backup backups/content-2026-08-13.db'
sqlite3 backups/content-2026-08-13.db 'PRAGMA integrity_check;'

备份文件要写入数据库目录之外,并由外部存储定期复制。仅在同一磁盘保存多个副本,无法应对磁盘损坏或主机误删。备份成功日志不代表可恢复,应该定期在临时目录恢复并运行 PRAGMA integrity_check 与关键查询。

如果运行环境没有 sqlite3 CLI,可在应用中使用所选驱动提供的备份 API;如果选择停机文件复制,应先停止所有写进程并确认 WAL 已检查点。不要在仍有写入时分别复制三个文件来赌时间窗口。

回滚:预先写清触发条件

迁移前就定义回滚条件,例如文章数量不一致、公开详情页出现 5xx、发布任务无法提交,或者关键字段抽样不一致。回滚流程可以是:

  1. 再次暂停所有内容写任务;
  2. 把仓储配置切回 JsonArticleRepository;
  3. 重启应用并验证列表、详情页、站点地图;
  4. 保留失败数据库和日志用于排查,不立即覆盖;
  5. 统计切换窗口内是否已经有 SQLite 独占的新写入。

最关键的是第 5 步。如果切换后允许管理员新增文章,再直接切回旧 JSON,这些变更会丢失。稳妥做法是在观察窗口暂时限制写入,或为 SQLite 新写入提供反向导出脚本。

保留 JSON 导出,降低锁定成本

SQLite 成为主存储后,仍可定期导出一个确定性 JSON:固定字段顺序、按 slug 或 ID 排序、格式化缩进。它可用于审阅、离线分析和灾难恢复,但不再作为并发写入目标。

const rows = db.prepare('SELECT * FROM articles ORDER BY slug').all();
const articles = rows.map(row => ({
  ...row,
  workflowStatus: row.workflow_status,
  tags: JSON.parse(row.tags_json),
  sources: JSON.parse(row.sources_json),
  testNotes: JSON.parse(row.test_notes_json)
}));

实际导出时应显式映射全部 snake_case 字段,并删除数据库内部字段,避免示例中的 ...row 把两套命名一起暴露给调用方。

上线检查清单

  • 数据库文件位于持久化挂载目录,而不是容器临时层;
  • 应用进程对数据库目录有写权限,因为 WAL 需要创建旁文件;
  • schema 迁移有版本号,同一版本可安全重复执行;
  • 所有写操作使用事务,定时任务具备幂等键;
  • slug 唯一约束和发布查询索引已创建;
  • 备份包含恢复演练,而不只是复制命令;
  • 切换前后检查文章数、slug、正文哈希和站点地图;
  • 回滚开关无需重新构建镜像;
  • 观察窗口结束前不删除旧 JSON。

结论

从 JSON 迁移到 SQLite 的重点不是把 readFile 换成 SQL,而是建立清晰的数据约束和恢复路径。先抽象仓储、在事务中导入、用双读对账确认一致,再以短暂停写窗口切换读写;同时用可验证的数据库备份和 JSON 导出保留退路。

如果你的内容管线还在同步执行,可以结合同步执行与任务队列的取舍规划写入边界;分类字段进入数据库后,也可参考Node.js 可审核的 AI 自动分类把低置信度结果留给人工审核。

实测与内容说明

实测记录

  • 本文代码按 Node.js node:sqlite 官方 API 结构进行静态审阅,未宣称在本站生产环境执行。
  • SQL 占位符数量、导入字段数量、事务提交与异常回滚分支已人工核对。
  • 迁移前应在与生产相同的 Node.js 版本和数据副本上执行导入、对账及恢复演练。

参考资料

内容版本 1.0 · 审核:推荐智能手记编辑 · 计划复审:2026-11-13