文章

这个博客是什么架构

1 小时前 · 3518 字 · 约 12 分钟

目录(11 节)
  1. 一句话版本
  2. 一次访问经过什么
  3. 为什么只有一个进程
  4. Markdown 是内容,SQLite 是索引
  5. 为什么数据库是 SQLite
  6. 页面在服务端拼好
  7. 图片也没有交给第三方
  8. 后台和评论也在同一个进程
  9. 运维也尽量做进应用
  10. 这套架构的代价
  11. 所以到底怎么回答

好几个博友问过:“你这个博客是什么架构的?”

其实我每次都不知道怎么回答。因为全都是让 AI 弄得,我只是一个负责提要求的。说静态博客吧,评论、后台、友链检查都是动态的;说动态博客吧,正文又确实是一个个 Markdown 文件,删掉数据库还能把文章索引重建回来;说“Node 全栈”吧,前端连个框架都没有,听起来像在蹭词。

这次索性写清楚。先给一句能直接抄走的答案,再拆开讲每一层为什么这么选、要付什么代价。

一句话版本

单进程、单机自托管、服务端渲染的个人博客。Markdown 文件是内容真源,SQLite 存索引和动态数据,浏览器端只用原生 JavaScript 做渐进增强。

具体一点:Node.js 24 + Hono + node:sqlite + markdown-it + LiquidJS。没有 MySQL / PostgreSQL,没有 Redis,没有消息队列,没有 React / Vue,没有 Vite / Webpack。

如果对方需要一个熟悉的参照,我会说:它更像“自己写了一个很小的 WordPress”,而不是 Hexo / Hugo 那种静态生成器。页面是请求时拼出来的,评论和后台也在这个进程里。

一次访问经过什么

浏览器
  │ HTTPS
  ▼
Nginx(TLS 终止、反向代理)
  │ 127.0.0.1:8787
  ▼
Node.js + Hono(一个进程)
  ├─ 前台:首页 / 文章 / 笔记 / 归档 / 搜索 / 友圈 / 友链 / Feed
  ├─ 后台:写作 / 评论审核 / 媒体库 / 主题 / 设置
  ├─ 渲染:内置 HTML 渲染 + 可选 Liquid 主题
  ├─ 缓存:匿名 HTML 缓存、静态资源预压缩
  └─ 定时任务:友圈抓取 / 友链检查 / 备份 / 日志轮转 / 看门狗
  │
  ├─ SQLite(单文件,WAL):索引、评论、设置、会话、修订、发信队列
  ├─ content/:Markdown 原稿
  ├─ data/uploads:原图与缩略图
  └─ data/backups:备份与异地副本

Nginx 只做两件事:终止 TLS,把请求转给本机 127.0.0.1:8787。Node 只监听回环地址,不直接暴露公网。再往前是 systemd,负责进程挂了自动拉起。

请求进来后,Hono 按路径分发。文章页大致是:按 slug 查 SQLite 里的索引 → 取出同步时已经渲染好的 body_html → 套上页面外壳 → 返回完整 HTML。Markdown 不是每次请求现渲染的,只在内容同步时渲染一次,结果连同目录一起入库。

静态资源走另一条路:CSS、JS、图片的 URL 带内容指纹,响应头设长缓存;HTML 反而每次回源校验,避免页面里一直引用旧指纹。匿名页面在进程内还有一层 60 秒的 HTML 缓存,内容、评论、设置、媒体、主题任一变化都会把修订号推上去,旧缓存自然失效。后台页面不参与这套缓存。

写路径是反过来的:后台提交 → 鉴权与 CSRF 校验 → 进全局写队列排队 → 先原子写回 Markdown 文件,再提交数据库事务 → 修订号变化。请求路径上的写入被串行化,大部分 SQLITE_BUSY 重试就省掉了。

为什么只有一个进程

前台、后台、评论接口、Feed、定时任务全在同一个 Node 进程里。没有单独的 API 服务,没有 worker 进程,没有 cron 容器。

好处很实际:部署就是重启一个 systemd 服务;日志只有一个来源;本地开发 npm start 就能跑起整站;备份只要收几个目录——数据库、Markdown 目录、上传目录、主题目录。

代价也很清楚:这是彻头彻尾的单点。 进程挂掉,全站一起挂;一台机器挂了,没有第二台接管;写入量再大,也只能给这台机器加配置,不能加实例。它不是高可用架构,连“高可用”的边都不沾。

我的判断是,个人博客的读写量根本不值得为多实例付复杂度。几十篇文章、几百条评论、一个人更新,单进程绰绰有余。真正会出问题的地方是备份没做、磁盘满了、证书过期了,而不是并发不够。

Markdown 是内容,SQLite 是索引

这是整套架构里我最在意的一条边界。

文章、笔记、固定页、碎语都保存在 content/ 下,是带 YAML front matter 的 Markdown 文件:

---
title: 第一篇文章
slug: first-post
date: 2026-11-02 10:00
---

正文。

启动时扫一遍目录,解析标题、日期、slug、草稿状态,用 markdown-it 渲染成 HTML,再写进 posts 表。后台保存文章时反过来:先把内容原子写回 .md 文件,再更新数据库索引和修订历史。

所以内容不被数据库锁住。把 content/ 拷到另一台机器,换一套程序也能读;用 Git 管理,每次改动能看到 diff;哪天不想用这套系统了,迁移成本只是搬文件。

但要说清楚:不是所有东西都能从 Markdown 重建。 文章索引可以,评论、修订历史、设置、会话、发信队列不能。它们只存在 SQLite 里,所以备份仍然是数据库、内容、上传、主题四样,一样都不能少。

这条边界也有代价。同一篇文章要写两个地方,就得处理“文件写成功了、数据库事务失败了”这种中间状态。为此写路径用了原子写入、全局写队列、启动时的恢复日志,以及一把维护锁,防止后台任务和离线脚本同时动同一份资源。这些机制不优雅,但比“写了一半”要好收拾。

为什么数据库是 SQLite

因为 node:sqlite 是 Node 内置的。不需要装数据库服务,不需要编译原生模块,也不需要在部署时处理 better-sqlite3 和 Node ABI 对不上的问题。

连接参数是常规组合:WAL、busy_timeout=5000、外键约束、synchronous=NORMAL。WAL 让读写不互相阻塞,NORMAL 少一次强制 fsync;应用崩溃不会丢已提交的事务,操作系统或断电才可能丢最后一个 checkpoint 之后的数据。这个取舍对个人站点划算。

备份用 VACUUM INTO 拿一致快照,不是直接 cp 数据库文件——WAL 模式下拷文件可能得到一个缺了半截事务的库。备份前后还会比对 data_version 和内容目录的文件清单,对不上就重来。

没有 ORM,SQL 手写,表结构用迁移文件管理。查询量太小了,ORM 带来的那层抽象在这里没有收益。

它不适合什么场景也值得说:要多进程并发写、要团队里有人拿图形客户端连生产库、要频繁大改表结构,那就该上 PostgreSQL。个人博客不是这些场景。

页面在服务端拼好

首屏 HTML 是服务端直接输出的,不是浏览器里渲染出来的。这样做最直接的好处是:脚本挂了、被 CSP 拦了、用户关了 JavaScript,页面仍然完整可读。

渲染分两层。主路径是内置渲染,负责文章、列表、归档、搜索这些页面的结构和内容;主题层是 Liquid 模板包,可以按 home、single-post、page、archive、search、404 这些模板名逐级覆盖,缺哪个就用回退,渲染失败也回退到内置渲染,不会白屏。

主题不能执行服务端 JavaScript,只能写模板。这是故意的:第三方主题是别人发来的压缩包,能执行代码就等于把服务器交出去。代价是主题表达力有限,复杂逻辑只能留在内置渲染里。

浏览器端没有框架运行时。几个原生脚本负责主题切换、无刷新跳转、灯箱、评论提交和后台编辑器,全部渐进增强。生产构建也不上 Vite / Webpack:一个自写脚本把 CSS 和 JS 精简、把前台脚本合并成一个 site.js、按页面切出精简版样式、再生成 Brotli / gzip 预压缩文件,最后校验传输体积预算。构建产物不存在时,服务端自动退回运行时精简,不会因为忘了构建就发旧资源。

这不是“反打包器”,只是这个站没有需要它的地方。哪天要在页面里 import 一个第三方库,再上也不迟。

图片也没有交给第三方

上传的图片按内容哈希存进 data/uploads,sharp 生成缩略图和多档 WebP(240 到 2560),正文里的 <img> 自动带 srcset。灯箱默认取体积更小的最大档 WebP;动画图则保留原图,因为只有原图带全部帧。图片处理也在这个进程里,没有单独的图片服务。代价是 sharp 的原生模块和 Node 版本绑定,这一点后面还会提到。

后台和评论也在同一个进程

后台不是一个独立的管理系统,就是同一套 Hono 路由下的 /admin。写作、媒体上传、评论审核、主题安装、站点设置都在这里。

评论走的是“先落库,再按审核状态决定是否展示”的流程:提交后按状态分为待审、通过、垃圾、删除;来源有频率限制;作者回复单独鉴权;通知邮件不直接发,而是写进 mail_outbox,由队列按退避间隔重试。SMTP 抖动不会让评论提交失败,也不会让通知悄悄丢掉。

定时任务也跑在进程里:友圈定时抓取、友链健康检查、自动备份、日志轮转、SQLite 维护、进程内看门狗。没有系统 crontab,也没有单独的调度服务。好处是这些任务的状态能在后台和 /healthz 里看到;坏处是进程停了任务也停——不过 systemd 会把它拉起来。

抓取和检查这类外部 IO 不占全局写队列,只在真正落库时进写屏障。否则一个慢吞吞的友链站点能把所有评论提交堵在后面。对外发起请求前,还会先检查内网主机名、私网地址和端口,避免被当成 SSRF 跳板。

运维也尽量做进应用

这套东西跑在腾讯云的一台 Ubuntu 上:Nginx 终止 TLS,systemd 托管 Node,代码在 /srv/blog,数据在 /srv/blog/state。

发布不是原地覆盖文件,而是把新版本解到独立目录,跑语法检查、装依赖、构建静态资源、健康检查,通过了才切过去;失败自动回滚到上一份完整源码。发布脚本还会保留最近几个版本,手动回滚有现成命令。

监控有两层:进程内看门狗每 5 分钟探一次自己的公网 /healthz,连续两次失败才发信,恢复后再发一封;另外还有一个外部探针脚本,适合放在另一台机器上,专门发现“进程已经挂了”的情况。/healthz 会看数据库可读性、备份年龄、异地副本状态、磁盘剩余空间和 WAL 大小——带 token 才返回内部字段,匿名只回存活状态。

备份由进程内的调度器自动跑,失败按 5 分钟、15 分钟、1 小时、6 小时退避重试,同时发告警。备份包里的附件按内容哈希命名,内容没变时文件名不变,rsync 才会真的跳过。异地副本由家里那台 Mac 每 6 小时 pull 一次。恢复也有演练脚本,避免“备份看着有,真恢复时起不来”。

评论邮箱和 SMTP 密码用 AES-GCM 加密后落库,密钥单独放在 data/.app-secret,故意不放进备份。所以恢复备份到新机器时,除了上面那些目录,还得把这个密钥一起带上;反过来说,备份泄漏也不会顺带泄漏密钥。

这些东西听起来不像博客该操心的事,但自托管站点的可用性,最后就落在这几件无聊的事情上。

这套架构的代价

列一下,不掩饰:

  • 单点。 一台机器、一个进程、一个数据库文件。硬件故障、机房故障、误删数据库,都只能靠备份和异地副本兜底。
  • 不能横向扩展。 SQLite 文件不能被多个实例共享;真要加实例,第一步就得换数据库。
  • 绑 Node 版本。 node:sqlite 还是内置实验能力,升级大版本要把数据库和备份路径纳入回归;sharp 的原生 ABI 也和 Node 绑定,生产、发布脚本、systemd 必须用同一个 Node。
  • 工具链是自己养的。 资源构建、测试运行器、发布脚本、备份脚本都在仓库里,升级时要一起维护。
  • 主题能力有限。 Liquid 模板不执行服务端 JS,想要复杂功能只能改内置渲染,或者扩展主题 API。

反过来说,它适合的场景也很明确:一个人维护、内容为主、写少读多、在意数据所有权、能接受“机器挂了要手动处理”的单点风险。

如果你要的是多实例、高并发写入、复杂前端交互、插件市场,这套东西不该选。WordPress、Next.js + Postgres、Hugo + CDN,各有各的合适位置。

所以到底怎么回答

下次再有人问,我大概会这么说:

单进程的 Node.js 博客,Hono 做路由,服务端渲染 HTML;内容存在 Markdown 文件里,SQLite 只做索引和评论这些动态数据;前端没有框架,就几个原生脚本。整站跑在一台 VPS 上,Nginx 反代,systemd 托管。

如果对方追问“为什么不用 xxx”,答案通常不是“不会”,而是“这个站点不需要”。每多一个部件,就多一个会在半夜坏掉、而且需要我去修的东西。一个人维护的博客,架构的第一目标不是先进,是一年之后我还看得懂、敢动、能恢复。

评论0