orblog性能调优复盘

·39 阅读

blog-performance-summary

一、问题背景

本次优化围绕文章详情页 /posts/[slug] 展开,重点压测页面是 /posts/vpn

最初现象是:

  • 直接压应用 5000 端口
  • 300 并发持续 60s
  • CPU 和内存都没有打满
  • p95 延迟已经到了 4s+

这说明问题不是简单的“机器资源不够”,而更像是请求链路里有某种高成本处理,或者单实例吞吐已经先达到上限。

二、整体优化顺序

这次优化不是一步完成的,而是分成了三个非常清晰的阶段:

  1. 先把文章正文渲染从“请求期”挪到“写入期”
  2. 再把 Nginx 从单后端切到 3 个 Node 实例
  3. 最后把文章页做成真正的页面级缓存,也就是 generateStaticParams + ISR

这三步分别解决的是三类不同的问题:

  • 第一步解决“请求太重”
  • 第二步解决“单实例排队”
  • 第三步解决“整页每次都要重新 SSR”

最后又通过公网压测发现,应用层问题基本清掉之后,真实线上瓶颈变成了公网 EIP 的 5 Mbps 带宽上限。

三、第一阶段:先缓存 contentHtml

1. 初始瓶颈是什么

一开始文章详情页是动态 SSR,并且每个请求都会:

  • 查文章数据
  • 查博客壳数据
  • 在服务端重新解析 Markdown
  • 在服务端重新做代码高亮

这使得 /posts/[slug] 每次请求都在重复做一整套较重的 CPU 工作。

2. 改法

把 Markdown 转 HTML 从“请求期”挪到“写入期”:

  • 创建文章时,生成 contentHtml
  • 更新文章时,重新生成 contentHtml
  • 详情页直接读取 contentHtml

也就是说,SQLite 里同时保留:

  • content:原始 Markdown
  • contentHtml:预编译后的 HTML

3. 这一阶段解决了什么

这一阶段解决的是“正文渲染成本太高”。

它不是页面级缓存,而是数据级预处理。页面请求时虽然还会执行整页 SSR,但至少不需要每次都重跑整篇 Markdown 渲染。

4. 观察到的效果

这一阶段之后,压测表现明显改善:

  • 单请求明显变轻
  • 50 并发下延迟降低
  • 300 并发时,p95 大致从 4s+ 降到 2s 左右

说明最初最大的瓶颈确实是请求期 Markdown 渲染。

四、第二阶段:Nginx 挂 3 个 Node 实例

1. 为什么第二步不是继续抠业务代码

contentHtml 已经预编译之后,页面本身已经轻很多了,但高并发下仍然有明显延迟。

这时问题更像是:

  • 只有一个 next-server
  • 单实例吞吐有限
  • 并发一高就排队

这类问题继续抠文章查询、Markdown 或小函数意义不大,更有效的是先拆分单实例压力。

2. 改法

部署层面做了两件事:

  • Nginx 改成 upstream,后面挂 3 个应用实例
  • systemd 变成 3 个服务实例,由 deploy 脚本统一重启

也就是说,从单实例变成了:

  • brainstorm@5000.service
  • brainstorm@5001.service
  • brainstorm@5002.service

3. 这一阶段解决了什么

这一阶段不是解决“请求太重”,而是解决“单实例排队”。

4. 观察到的效果

这一阶段的应用侧收益可以概括成:

  • 单实例阶段,高并发下延迟大概仍在 2s 量级
  • 上 3 实例之后,应用侧延迟大致进一步压到 1s 量级

这个阶段的本质收益,是把排队压力分散掉。

五、第三阶段:页面级缓存 / ISR

1. 为什么 contentHtml 还不够

contentHtml 解决的是“正文别每次重新渲染”,但它没有解决“整页还要不要每次重新 SSR”。

也就是说,虽然正文处理已经变轻,但每次请求仍然要:

  • 进入 App Router
  • 执行页面组件
  • 取数据
  • 拼整页
  • 输出 HTML / RSC

所以还没有吃满收益。

2. 改法

文章页最终改成:

  • generateStaticParams()
  • export const revalidate = 300
  • 写操作后配合 revalidatePath('/posts/${slug}')

这一套让 /posts/[slug] 进入真正的页面级缓存。

3. 页面级缓存和数据级缓存的区别

这是整个过程中最重要的概念之一。

数据级缓存 / 预处理

contentHtml 这种,本质上缓存的是“页面的一部分数据结果”。

它解决的是:

  • 正文别每次重算

但页面函数还是要跑。

页面级缓存

页面级缓存缓存的是:

  • /posts/vpn 这个 URL 对应的整页结果

它解决的是:

  • 整页不要每次重新 SSR

4. 观察到的效果

这一步是收益最大的阶段。

可以把应用侧性能演进理解成:

  1. 最初:4s+
  2. contentHtml 预编译后:约 2s
  3. Nginx 挂 3 实例后:约 1s
  4. 页面级缓存 / ISR 后:进一步压到 200ms 级别

也就是说:

  • 多实例主要解决“排队”
  • ISR 页面缓存主要解决“整页是否还要每次重渲染”

后者的收益更大,也更接近这次最关键的转折点。

六、ISR 的实际含义

ISR 是 Incremental Static Regeneration,核心思想是:

  • 先静态化
  • 再增量更新

在这个项目里,它的含义是:

  1. 构建时通过 generateStaticParams() 预生成已发布文章页
  2. 运行时优先直接返回现成页面产物
  3. revalidate = 300 的窗口内复用旧页
  4. 超过窗口后,下一次访问触发后台再生成
  5. 编辑文章时,还可以通过 revalidatePath() 主动让页面失效

所以它不是“只有 build 才更新”,而是:

  • build 负责生成第一版
  • ISR 负责在运行时逐步更新
  • revalidatePath() 负责内容变更后的主动失效

七、SQLite 和页面缓存的关系

这次过程中一个反复出现的误区是:

  • “博客数据不是都在 SQLite 里了吗,为什么还要页面缓存?”

答案是:

  • SQLite 里存的是业务数据
  • 页面缓存存的是最终页面结果

SQLite 里存的是什么

主要是:

  • 标题
  • slug
  • Markdown 原文
  • contentHtml
  • 分类、阅读量、时间等字段

这些都是“原材料”。

页面缓存存的是什么

页面缓存存的是某个 URL 的最终页面,比如 /posts/vpn 这整页:

  • 标题
  • 时间
  • 分类
  • 侧栏
  • 布局
  • 正文 HTML

SQLite 负责存数据,页面缓存负责存“这个 URL 最终返回什么”。

八、中间出现的关键误判:本机压测很好,但域名压测很差

当页面缓存生效后,出现了一个很重要的现象:

1. 服务器本机压测非常好

压:

  • https://127.0.0.1/posts/vpn -H "Host: www.orblog.cn"

结果很好:

  • 平均延迟约 110ms
  • 300 并发下约 2700 req/s

同时响应头也显示:

  • x-nextjs-cache: HIT
  • x-nextjs-prerender: 1
  • Cache-Control: s-maxage=300

这说明:

  • 应用没问题
  • Nginx 站点配置没问题
  • ISR / 页面级缓存已经生效

2. 但基于域名和公网 IP 压测却很差

压:

  • https://www.orblog.cn/posts/vpn
  • https://118.196.103.175/posts/vpn -H "Host: www.orblog.cn"

结果都很差:

  • 大概只有 8 req/s
  • 平均延迟 3 到 4 秒
  • 大量 timeout

这一步很关键,因为它说明:

  • 不是应用性能问题
  • 不是域名解析问题
  • 不是站点 vhost 配置问题
  • 问题出在公网入口本身

九、最终确认的根因:EIP 只有 5 Mbps

后续查看云控制台,确认公网 EIP 带宽上限只有:

  • 5 Mbps

这就把前面的现象全部解释通了。

1. 为什么它会直接成为瓶颈

文章详情页响应体大约是:

  • 33729 bytes
  • 33 KB

5 Mbps 的理论出口带宽折算后大约是:

  • 0.625 MB/s
  • 也就是约 640 KB/s

粗算理想上限:

  • 640 KB/s / 33 KB ≈ 19 req/s

这还没算:

  • TLS
  • HTTP 头
  • TCP 重传
  • 并发排队
  • 连接管理损耗

所以真实公网压测只能剩下大约 8~16 req/s,和实测结果是对得上的。

2. 最终结论是什么

到这一阶段,应用侧瓶颈已经基本被清掉了。

真实线上剩下最硬的瓶颈,不是 Next、不是 SQLite、不是 Nginx 配置,而是:

  • 公网 EIP 的 5 Mbps 带宽上限

也就是说:

  • 本机回环压测测出来的是应用本身能力
  • 公网入口压测测出来的是应用能力加公网出口带宽的共同结果

十、这次完整复盘的核心结论

整个过程按先后顺序总结,就是:

  1. 初始瓶颈在请求期 Markdown 渲染
  2. 先通过 contentHtml 预编译,把正文渲染移到写入期
  3. 再通过 Nginx 挂 3 个实例,降低单实例排队
  4. 再通过 generateStaticParams + ISR 把文章页变成真正的页面级缓存
  5. 应用侧延迟从 4s+ 逐步压到 200ms
  6. 最后发现真实公网入口仍然慢,不是代码问题,而是公网 EIP 5 Mbps 带宽限制

十一、适用于博客类场景的经验

对于“读多写少、文章结构稳定、发布频率不高”的博客系统,一个很有效的优化顺序通常是:

  1. 先把重 CPU 处理移出请求期
  2. 再解决单实例吞吐和排队
  3. 再把详情页做成页面级缓存
  4. 最后一定要区分“应用本机性能”和“公网入口性能”

这次最大的经验不是某一行代码,而是:

  • 应用问题和公网带宽问题要分开看
  • 本机压测和真实公网压测要分开看
  • 当应用层已经很快时,真实瓶颈可能会下沉到更底层的网络资源

十二、后续建议

基于这次复盘,下一步更值得优先做的是:

  1. 提高公网带宽上限,比如先提升到 20 Mbps 或更高
  2. 重新做公网域名压测
  3. 继续把“应用本身性能”和“公网入口性能”分开观察
  4. 后续如果功能继续扩展,再评估 SQLite WAL、写入竞争、PostgreSQL 等问题

一句话总结:

这次优化最终证明,应用层面的主要瓶颈已经基本清掉了,剩下最真实的限制,是公网 EIP 只有 5 Mbps