3161ms 到 1.2s,我把博客首屏传输量砍掉了 76%

站点是自己的,慢不慢其实心里早有数——首页打开要转两秒多的圈,震惊我了。
之前明明感觉还行,后面配图文件太大了,服务器带宽有限,就有点性能瓶颈了。
这篇文章记录这次完整的排查:从 curl 分层计时开始,到 DevTools 瀑布图,再到后端代码里翻出早已存在却没人用的图片压缩能力。中间还顺手挖出一个 15.9 秒的超时接口和两个历史遗留 bug。
先交代架构
站点 sxapex.com,跑在腾讯云国际的一台机器上,从我这里 RTT 大约 32ms,没挂 CDN。链路不复杂如下图:
PLAINTEXT
nginx → Go (gin) → 页面缓存(15s TTL, 内存)
↓ 未命中
反代内置 Next.js (standalone) SSR
↓
Go API + PostgreSQL
本站博客是由anheyu框架二开而来,所以继承了anheyu的历史包袱。
前端最早是 Vue SPA,后来整体迁到 Next.js。
迁移的时候为了省事,把客户端取数的模式原样平移了过来——整棵组件树都是 "use client",只有文章详情页 /posts/[id] 做了服务端取数。这个包袱后面会成为关键证据。
现象与分层计时
先排除网络问题。用 curl 的 -w 把 DNS、TCP、TLS、TTFB 拆开看:
BASH
curl -o /dev/null -s -w 'dns:%{time_namelookup} tcp:%{time_connect} \
tls:%{time_appconnect} ttfb:%{time_starttransfer}\n' https://sxapex.com
结果分两种情况。新建连接时 TLS 握手要 0.3~0.6s;复用连接(curl 同一命令连续请求两次)TTFB 只有 0.35s。这说明后端链路本身不慢,Go 加 PostgreSQL 的响应是健康的,慢的是连接建立和应用层的渲染路径。
TLS 这部分后面可以靠会话复用和 CDN 解决,但不是当下的大头。
Performance Trace 拆 LCP
打开 Chrome DevTools 录一条 Performance Trace,首页 LCP 是 3161ms,四段分解如下:
阶段 | 耗时 |
|---|---|
TTFB | 815ms |
load delay | 1145ms |
load(资源加载) | 521ms |
render delay | 680ms |
TTFB 815ms 有点高但不是主因。最大的一段是 load delay——1145ms
花在“浏览器还不知道要加载这张图”上。原因是 LCP 图片要等客户端 JS 跑完、API 请求返回之后才被 React 渲染出来,浏览器才发现有这张图。
典型的首屏客户端渲染问题,和上面说的 Vue 迁移历史完全对得上。
网络瀑布中的大图片
光知道渲染慢还不够,接着看请求。在控制台跑一行:
JS
performance.getEntriesByType('resource')
.reduce((acc, e) => { acc[initiatorType] ||= 0; ... }, {})
首页 120+ 个请求,逐类求和后的数字相当刺眼:
图片合计 12.1MB,全部走
/api/f/原图直出。有一张 2.5MB 的 PNG,实际显示宽度只有 468px;JS 合计 624KB,拆成 42 个 chunk;
更离谱的是图标:
@iconify/react运行时直连api.iconify.design,首页加载时外联了 14 次。访客的浏览器绕过我的服务器去第三方拉 SVG。
站内 A/B:详情页就是对照组
这时候有个现成的对照实验。同一个站,文章详情页是服务端取数,首页是客户端取数,其他条件几乎一致。实测 LCP:详情页 829ms,首页 3161ms。差了近 4 倍。
这组数据比任何理论推断都有说服力——它证明瓶颈确实在客户端渲染路径上,而不是网络或机器配置。首页 SSR 化列入待办,但那是重活,先把便宜的收益拿完。
复现单点:15.9 秒的音乐接口
瀑布图里还有一条异常长的请求:GET /api/public/music/playlist,实测 15.9s 超时后返回 500。查代码发现是后端同步外呼一个第三方接口 metings.qjqq.cn——这个服务早就失效了,但每次请求还是会傻等它超时。
首页挂着音乐播放器,等于每个访客都在替这个死掉的接口陪葬。
代码侧验证:图片压缩功能
图片这块,让AI去后端翻代码后发现 image_style 服务已经支持动态参数,URL 形如 ?w=&fit=inside&fm=webp&q=,matcher 是个决策树:命名样式 > 动态参数 > 默认样式 > 自动压缩。
但查数据库,所有存储策略的 image_process 配置全是空的。
这是我之前对文章封面和配图单独新增的压缩图片功能,后端压缩能力写好了,但是从来没给首屏使用和对老图片使用。所以12.1MB 的原图就这么直出了几个月(还已经是我之前对新发布文章配图压缩后的结果)。
动手修
音乐接口(Go):加了 10 分钟内存缓存 + singleflight 单飞回源(并发请求只放一个出去)+ 5s 超时;失败时回落到过期的缓存;冷启动也失败就降级返回空列表——HTTP 200 带 degraded: true,前端拿到 0 首直接跳过播放器初始化。死接口从阻塞主路径变成无感降级。
图片(前端):写了个 optimizedImageSrc 工具函数,给所有 /api/f/ 直链追加 ?w=640&fit=inside&fm=webp&q=78,按场景区分——列表 640、横幅 1280、缩略 480,接入了 9 处封面渲染点。安全性上做了两层兜底:后端没开启配置时参数会被忽略、安全回退到原图,不会裂图;图片引擎缺 libvips 时自动降级输出 JPEG。
图标(前后端):后端新增 GET /api/iconify/{prefix}.json 代理接口,24h 内存缓存加过期兜底;前端用 addAPIProvider 把 @iconify/react 的图标源指向本站。43 个组件一个都没动——改的是数据源,不是消费方。这种低风险高收益的改法,比大重构划算得多。
意外收获:历史数据坑了新校验
改图的过程中踩到一个不相干的坑:
存量存储策略的 VirtualPath 都是 "/",导致任何编辑操作都被校验逻辑拦下来。
前后修了两轮——第一轮允许保持原值,第二轮发现改 BasePath 时应该保留原值而不是报错。
这类问题的共性是:老数据的结构和校验逻辑诞生于不同时期,新逻辑没考虑历史形态。排查的时候得有“这可能是历史遗留,不是新 bug”的敏感度,否则容易顺着错误方向修。
四轮验证
部署不是一把梭,每改一处就复测一轮,数据是这么攒出来的:
指标 | ① 优化前 | ② 镜像更新后 | ③ 图片配置前 | ④ 图片配置后 |
|---|---|---|---|---|
LCP | 3161ms | 1468ms | 1240ms | 1695ms |
TTFB | 815ms | 237ms | 108ms | 113ms |
首页总传输 | 12.95MB | 12.1MB | 12.1MB | 3.08MB |
图标外联 | 14 次 | 0 | 0 | 0 |
音乐接口 | 15.9s / 500 | 秒回降级 | 秒回降级 | 秒回降级 |
第四轮 LCP 反弹到 1695ms 需要解释一下:首图是第一次经过压缩处理,有额外的处理开销,加上当时的渲染抖动。缓存预热之后稳定在 1.21.4s。整体算下来 LCP 改善 5061%,进入 Core Web Vitals 的“良好”区间(<2.5s);总传输从 12.95MB 降到 3.08MB,砍掉 76%。
图片的收益很直观:多数单图降到 6~50KB。举一个具体的例子,某张图处理前 112989 字节,处理后 9004 字节,减少 92%,响应的 Content-Type 也变成了 image/webp。
还剩个尾巴:有两张老图(1.9MB 和 0.9MB)挂在另一个还没配置的策略上。等把全部策略配完,首页总传输预计能到 0.5MB 左右。
几条教训
先测量,再动手,改一处测一处。 四轮数据就是这么做出来的。如果一次性把所有改动堆上去再测,第四轮那个 LCP 波动就解释不清,也说不清每项改动各自的贡献。
自己站内找对照组。 详情页 829ms 对首页 3161ms,同一台机器、同一条网络,比任何基准测试报告都有说服力。
优化不一定是重写。 图片压缩的后端能力早就存在,缺的是一行数据库配置和前端 URL 拼接;图标问题没有去重构 43 个组件,改了数据源就完事。动手之前先看看系统里已经有什么。
降级设计让激进改动变安全。 图片参数无效就回退原图,音乐接口失败就返回空列表——正因为有这些兜底,才敢上线这些改动而不怕边界情况。
对历史数据保持警惕。 老配置结构会让新校验逻辑误伤存量数据,VirtualPath="/" 那个坑就是这么来的。
剩下的路也清楚: 首页 SSR 化是最大的一块(详情页 829ms 就是标杆,LCP 预计还能再降一截),然后是页面缓存 TTL 延长、上 CDN、开 TLS 会话复用。慢慢来,每一步都留好数据。

