orblog性能调优复盘

一、问题背景
本次优化围绕文章详情页 /posts/[slug] 展开,重点压测页面是 /posts/vpn。
最初现象是:
- 直接压应用
5000端口 300并发持续60s- CPU 和内存都没有打满
- 但
p95延迟已经到了4s+
这说明问题不是简单的“机器资源不够”,而更像是请求链路里有某种高成本处理,或者单实例吞吐已经先达到上限。
二、整体优化顺序
这次优化不是一步完成的,而是分成了三个非常清晰的阶段:
- 先把文章正文渲染从“请求期”挪到“写入期”
- 再把 Nginx 从单后端切到 3 个 Node 实例
- 最后把文章页做成真正的页面级缓存,也就是
generateStaticParams + ISR
这三步分别解决的是三类不同的问题:
- 第一步解决“请求太重”
- 第二步解决“单实例排队”
- 第三步解决“整页每次都要重新 SSR”
最后又通过公网压测发现,应用层问题基本清掉之后,真实线上瓶颈变成了公网 EIP 的 5 Mbps 带宽上限。
三、第一阶段:先缓存 contentHtml
1. 初始瓶颈是什么
一开始文章详情页是动态 SSR,并且每个请求都会:
- 查文章数据
- 查博客壳数据
- 在服务端重新解析 Markdown
- 在服务端重新做代码高亮
这使得 /posts/[slug] 每次请求都在重复做一整套较重的 CPU 工作。
2. 改法
把 Markdown 转 HTML 从“请求期”挪到“写入期”:
- 创建文章时,生成
contentHtml - 更新文章时,重新生成
contentHtml - 详情页直接读取
contentHtml
也就是说,SQLite 里同时保留:
content:原始 MarkdowncontentHtml:预编译后的 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.servicebrainstorm@5001.servicebrainstorm@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. 观察到的效果
这一步是收益最大的阶段。
可以把应用侧性能演进理解成:
- 最初:
4s+ contentHtml预编译后:约2s- Nginx 挂 3 实例后:约
1s - 页面级缓存 / ISR 后:进一步压到
200ms级别
也就是说:
- 多实例主要解决“排队”
- ISR 页面缓存主要解决“整页是否还要每次重渲染”
后者的收益更大,也更接近这次最关键的转折点。
六、ISR 的实际含义
ISR 是 Incremental Static Regeneration,核心思想是:
- 先静态化
- 再增量更新
在这个项目里,它的含义是:
- 构建时通过
generateStaticParams()预生成已发布文章页 - 运行时优先直接返回现成页面产物
- 在
revalidate = 300的窗口内复用旧页 - 超过窗口后,下一次访问触发后台再生成
- 编辑文章时,还可以通过
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: HITx-nextjs-prerender: 1Cache-Control: s-maxage=300
这说明:
- 应用没问题
- Nginx 站点配置没问题
- ISR / 页面级缓存已经生效
2. 但基于域名和公网 IP 压测却很差
压:
https://www.orblog.cn/posts/vpnhttps://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带宽上限
也就是说:
- 本机回环压测测出来的是应用本身能力
- 公网入口压测测出来的是应用能力加公网出口带宽的共同结果
十、这次完整复盘的核心结论
整个过程按先后顺序总结,就是:
- 初始瓶颈在请求期 Markdown 渲染
- 先通过
contentHtml预编译,把正文渲染移到写入期 - 再通过 Nginx 挂 3 个实例,降低单实例排队
- 再通过
generateStaticParams + ISR把文章页变成真正的页面级缓存 - 应用侧延迟从
4s+逐步压到200ms级 - 最后发现真实公网入口仍然慢,不是代码问题,而是公网 EIP
5 Mbps带宽限制
十一、适用于博客类场景的经验
对于“读多写少、文章结构稳定、发布频率不高”的博客系统,一个很有效的优化顺序通常是:
- 先把重 CPU 处理移出请求期
- 再解决单实例吞吐和排队
- 再把详情页做成页面级缓存
- 最后一定要区分“应用本机性能”和“公网入口性能”
这次最大的经验不是某一行代码,而是:
- 应用问题和公网带宽问题要分开看
- 本机压测和真实公网压测要分开看
- 当应用层已经很快时,真实瓶颈可能会下沉到更底层的网络资源
十二、后续建议
基于这次复盘,下一步更值得优先做的是:
- 提高公网带宽上限,比如先提升到
20 Mbps或更高 - 重新做公网域名压测
- 继续把“应用本身性能”和“公网入口性能”分开观察
- 后续如果功能继续扩展,再评估 SQLite WAL、写入竞争、PostgreSQL 等问题
一句话总结:
这次优化最终证明,应用层面的主要瓶颈已经基本清掉了,剩下最真实的限制,是公网 EIP 只有 5 Mbps。