博客架构演进:从纯静态到混合渲染

1130 字
4 分钟
博客架构演进:从纯静态到混合渲染

缘起#

BlogFirefly 最初是一个基于Astro 5 Astro 6 的纯静态博客,所有页面在构建时预渲染为 HTML,部署到 Nginx 直接 serve。这种方式速度极快,但有一个硬伤——每次修改内容都必须重新构建、重新部署。哪怕只是改一个字,也要 astro build && pm2 restart 整一遍。

随着文章增多,全量构建的等待时间变得难以忍受。

正文内容通过 .md 文件管理,用 getCollection("posts") 在构建时读取所有 Markdown 文件,解析 frontmatter 后生成静态页面。

这个阶段的最大问题:

  • 每次内容变更必须全量构建(即使只改了一行)
  • 构建消耗时间和服务器资源(1.6G 内存机器)
  • 没有编辑界面,只能本地改文件提交,无法实时查看修改之后的效果除非使用 pnpm dev启动开发服务器,但我认为这样不够优雅

解决思路很自然:把内容的管理和渲染分离开,引入数据库和运行时渲染。

第一阶段:数据上库 + 管理后台#

第一步是让文章脱离构建过程。引入 PostgreSQL + drizzle-orm,将文章元数据和正文存入数据库。

@startuml
skinparam backgroundColor transparent
skinparam componentStyle rectangle

rectangle "数据层" {
  [posts] as POSTS
  [post_contents] as CONTENTS
  [users] as USERS
}

rectangle "管理端" {
  (管理后台)

数据库设计#

posts
├── id SERIAL PRIMARY KEY
├── code TEXT UNIQUE NOT NULL -- "cv1", "cv2"...
├── slug TEXT UNIQUE NOT NULL -- 兼容旧 .md 路径
├── title TEXT
├── description TEXT
├── category TEXT
├── tags TEXT[]
├── cover_image TEXT
├── status ENUM(published|hidden|draft)
├── pinned BOOLEAN DEFAULT false
├── word_count INTEGER
├── published_at TIMESTAMP
├── created_at TIMESTAMP
└── updated_at TIMESTAMP
post_contents
├── id SERIAL PRIMARY KEY
├── post_id INTEGER UNIQUEposts.id (ON DELETE CASCADE)
├── content TEXT -- Markdown 正文(无 frontmatter)
└── updated_at TIMESTAMP
users
├── id SERIAL PRIMARY KEY
├── username TEXT UNIQUE
├── password_hash TEXT -- bcrypt 哈希
└── created_at TIMESTAMP
  • posts 表存储文章元数据和状态控制
  • post_contents 表存储纯 Markdown 正文,与 posts 一一对应
  • posts 行被删除时,post_contents 通过 ON DELETE CASCADE 自动清理

认证系统#

@startuml
actor 管理员 as Admin
participant Browser
participant "Astro Middleware" as MW
participant "Auth API" as API
database "users 表" as DB

Admin -> Browser : 输入账号密码
Browser -> API : POST /api/auth/
  • JWT 令牌存在 httpOnly cookie 中,有效期 7 天
  • bcryptjs 验证密码,jose 签发/验证 JWT
  • Astro middleware 拦截 /admin/* 路径,校验 token,无效则 302 跳回首页

管理后台功能#

文章列表页(/admin/posts/)
├── 统计卡片(StatCard.svelte——已发布/隐藏/草稿/总计)
│ └── 数据来自 POST /api/admin/posts/stats
│ └── 操作后触发 admin:statsChanged 事件,Svelte 组件自动刷新
├── 搜索 / 批量操作 / 排序分页
└── 编辑 / 显示隐藏 / 删除
文章编辑页(/admin/posts/cv6/)
├── ArticleMetaForm.svelte(元数据:标题、分类、标签、置顶…)
└── MarkdownEditor.svelte(CodeMirror 6 + 实时预览)
└── 预览使用完整 unified 管道(同文章页)

删除是硬删除——从 posts 表直接删行,post_contents 级联清除,同时删除对应的 .md 文件。

第二阶段:全站 SSR#

第一阶段在 SSG 模式下加数据库,但文章详情页在构建时仍然需要读取所有 .md 文件。每新增一篇文章就要构建一次。

真正的解决方案:放弃全量构建,所有页面改为 SSR。

@startuml
actor 读者 as Reader
participant Nginx
participant "Astro SSR" as SSR
participant "内存缓存\n10s TTL" as Cache
database "PostgreSQL" as DB

Reader -> Nginx : GET /posts/cv6/
Nginx -> SSR : 代理到 SSR

关键迁移#

项目 改前 改后
渲染模式 SSG(getStaticPaths SSR(prerender = false
数据源 getCollection("posts").md 文件 drizzle-orm 直查 posts
缓存 10 秒内存 TTL
路由 [...slug].astro(slug 路径) [code].astro(cv 码路径)
PostCard 调用 render(entry) 解析 frontmatter 移除 render(),数据全部来自 DB

内存缓存#

为了避免每次 SSR 请求都查数据库,首页/归档/标签/分类等列表页实现了 10 秒 TTL 缓存:

let postsCache: PostEntry[] | null = null;
let postsCacheTime = 0;
const POSTS_CACHE_TTL = 10_000;
async function getRawSortedPosts(): Promise<PostEntry[]> {
const now = Date.now();
if (postsCache && now - postsCacheTime < POSTS_CACHE_TTL) {
return postsCache; // ← 10 秒内直接返回
}
const rows = await db.select()...where(eq(status, "published"));
postsCache = entries;
postsCacheTime = now;
return entries;
}

编辑文章后,管理后台不再重启 PM2——缓存 10 秒后自动过期,下次请求读取最新数据。

cv 码路由#

URL 从 /posts/site-architecture/site-architecture/ 改为 /posts/cv4/

  • cv 码是自增短标识,与数据库自增 id 对齐
  • 主页 PostPage 通过 getPostUrlBySlug() 从 DB 查询 slug→code 映射
  • 映射表有 10 秒 TTL,新建文章后至多等 10 秒 URL 自动切换
  • 文章详情页同时支持 cv 码和 slug 兜底查询(缓存延迟时仍能正确渲染)

Markdown 渲染管道#

文章正文通过完整的 unified 管道处理,从文章页到后台预览完全一致:

@startuml
left to right direction

rectangle "remark 层" {
  (remarkParse) --> (remarkGfm)
  (remarkGfm) --> (remarkMath)
  (remarkMath) --> (remarkImageGrid)
  (remarkImageGrid) --> (remarkDirective)

16 个自定义插件,覆盖代码高亮(expressive-code)、数学公式(KaTeX)、提醒框(rehype-callouts,支持 GitHub/Obsidian/VitePress/Docusaurus 四种主题)、图表(mermaid + plantuml)、图像网格、GitHub 卡片、外部链接识别、邮件保护等功能。

PostCard 性能优化#

之前的 PostCard 在 SSR 时会调用 render(entry) 来解析 frontmatter 和正文摘要。这个操作会触发完整 unified 管道解析,对于列表页(首页/归档/标签)上有 5–20 篇文章的情况,开销很大。

优化方案:

  • 所有文章数据(标题、描述、标签、封面、字数等)直接来自 posts 表查询
  • 去掉 PostCard 中的 render() 调用
  • 首页/归档等列表页无需解析 Markdown,纯 SQL 查询即可

第三阶段:CI/CD 自动部署#

@startuml
start
:git push origin master;
fork
  :biome ci\n(代码质量检查);
fork again
  :Server Pull\nSSH → git pull\npnpm install\ndrizzle-kit push;
fork again
  :Build & Deploy\npnpm build\nrsync dist/ →

三个 job 的顺序与并行关系:

Job 运行位置 耗时 说明
quality GitHub runner ~10s biome 格式/风格检查,失败则全部中止
server-pull 生产服务器 ~5s git pull + pnpm install + 数据库推送
build-and-deploy GitHub runner ~3min pnpm build + rsync --delete + pm2 restart

build-and-deploy 依赖 qualityserver-pull 都成功后才会执行。server-pull 先同步代码并迁移数据库,build-and-deploy 再用最新的代码构建并部署。

架构总览#

@startuml
!includeurl https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml

Person(reader, "博客访客", "浏览文章与搜索内容")
Person(admin, "管理员", "通过后台管理文章")

System_Boundary(blog

关键技术栈#

层级 技术
框架 Astro 6 + Svelte 5 + Tailwind CSS 4
代码高亮 expressive-code(行号/折叠/语言徽章/双主题)
图表 PlantUML(服务端渲染)+ Mermaid(客户端渲染)
数学 KaTeX
提醒框 rehype-callouts(GitHub / Obsidian / VitePress / Docusaurus)
数据库 PostgreSQL + drizzle-orm
认证 bcryptjs + jose (JWT, httpOnly cookie)
编辑器 CodeMirror 6 (Markdown)
CI/CD GitHub Actions(quality → server-pull → build-and-deploy)
服务器 Nginx + PM2 + Node.js 22
部署方式 git push → CI/CD → rsync + pm2 restart

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

博客架构演进:从纯静态到混合渲染
http://tianyi.ink/posts/cv4/
作者
蓝厘榨只因
发布于
2026-06-17
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
蓝厘榨只因
去码头整点薯条🐦
公告
( 」゚Д゚)」<花生饮料矿泉水,有需要的看一下了啊!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
3
分类
2
标签
13
总字数
1,634
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.12.1
文章许可
CC BY-NC-SA 4.0

文章目录

管理员登录