🤖
AI审核中

一台小服务器的全面体检:一天里做完的十几项优化

原创 本站实践 9分钟 102浏览 0评论

这台服务器只有 2 核、1.7 GB 内存,出口带宽大约 4 Mbps,却同时跑着博客、AI 对话、打牌记账、步数助手、炸金花和 QQ 空间归档六个 Spring Boot 应用——它们被打进同一个 jar、跑在同一个 JVM 里。

平时它“能用”,但能用不等于没问题。这一次,我没有凭感觉去调参数,而是先把服务器和代码完整体检一遍,拿到数据再决定改什么。一天下来,前后处理了十几项,有些收益立竿见影,有些是在给以后的自己省事,还有一项在测完数据后决定不做

本文按问题的轻重缓急,把整个过程和其中踩到的坑记录下来。

一、先体检,再动手

体检分两轮:第一轮看基础设施,第二轮看应用本身。

graph LR
    A["资源与服务<br/>内存 磁盘 进程"] --> D["按紧急程度分级"]
    B["访问日志<br/>状态码 耗时 流量"] --> D
    C["应用日志 数据库<br/>JVM 堆快照"] --> D
    D --> E["逐项处理"]
    E --> F["线上逐项验证"]

几个比较有用的数据来源:

nginx 访问日志里的 rturtrt 是整个请求的耗时,urt 是后端处理耗时。两者一对比,就能分清慢在程序还是慢在网络。

kill -3 打出的线程快照。服务器上只有 JRE,没有 jstatjcmd。但对 Java 8 进程发 SIGQUIT,它会把所有线程栈和堆的使用情况打印到标准输出,进程照常运行,可以当作一个最朴素的采样工具。

MySQL 的状态计数器。间隔十秒读两次 Com_selectQuestions,就能知道空闲时数据库到底有多忙。

体检结论可以先放在这里:

类别 发现的问题 紧急程度
证书 两张证书两周内到期,没有自动续期
备份 数据库没有定时备份,只在发版时顺带 dump
发版 157 MB 的 jar 整包上传要 45~50 分钟
带宽 应用本身很快,瓶颈在 4 Mbps 出口
前端 文章页 HTML 有 85% 是内联脚本
依赖 多个页面从海外 CDN 加载前端库,国内要 3~5 秒
SEO 不存在的页面返回 200,没有 robots 和 sitemap
日志 大量无意义告警,控制台日志从不清理

二、证书:用 acme.sh 自动续期

两张证书分别是云厂商的免费证书,一张 13 天后到期,一张 17 天后到期,服务器上没有任何续期工具。

改用 acme.sh 签发 Let's Encrypt 证书,以 webroot 方式校验:Let's Encrypt 会通过 80 端口访问 /.well-known/acme-challenge/ 下的一个文件,确认域名归属。

这里有一个 nginx 的细节容易踩:原来 80 端口的 server 块只有一行 return 301 https://...写在 server 层的 return 会在匹配 location 之前执行,所以哪怕再加一个 acme 的 location,校验请求也会先被 301 带走。正确的写法是把跳转收进 location /

server {
    listen 80;
    server_name example.com www.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/acme;
        default_type text/plain;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

签发时一个域名失败了一次,原因是 Let's Encrypt 的“多地校验”中,有一个海外节点连接超时。重试一次就通过了,这种偶发失败不用改配置。

证书安装到原来的路径,并注册了续期后的 nginx -t && systemctl reload nginx。nginx 配置一个字都不用改,以后每天自动检查、到期前自动续。

三、备份:补上每天一次的数据库备份

原来唯一的数据库备份,是发版脚本在停服务之后顺带做的 dump。不发版,就没有备份。

新增一个 systemd timer,每天凌晨 4 点多执行:按每个应用自己的数据库配置逐库 mysqldump --single-transaction,gzip 压缩、校验、生成 SHA256 清单,最后清理 14 天前的目录。几个库加起来压缩后不到 10 MB,保留两周也只占一百多 MB。

备份脚本故意写成先写到 .partial 目录、全部成功后再改名,这样中途失败不会留下一份看起来完整、实际缺库的备份。

四、发版:从 50 分钟到十几秒

整个聚合工程打出来是一个 157 MB 的 jar,而本地到服务器的上行只有约 50 KB/s,每次发版光上传就要 45~50 分钟。

思路是以线上正在运行的 jar 为基准,用 rsync 只传差异。Spring Boot 的 fat jar 里,依赖包是以不压缩的方式存放的,没变的依赖在两个版本的 jar 里字节完全一样,rsync 的滚动校验可以直接匹配上。

但第一次试下来,效果只有一半:没改代码的模块,每次打包后字节也在变。原因是 jar 里的每个条目都带着打包时间。解决办法是在父 POM 里固定时间戳,做成可复现构建:

<properties>
    <project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>
</properties>

之后同一份源码连续打两次包,SHA256 完全一致。

graph LR
    A["本地打包<br/>固定时间戳"] --> B["服务器复制线上 jar<br/>作为 rsync 基准"]
    B --> C["rsync 只传差异"]
    C --> D["两端 SHA256 比对"]
    D --> E["部署脚本切换 自动验收"]
发版方式 实际传输 耗时
整包 scp 157 MB 45~50 分钟
rsync,旧包未固定时间戳 8.1 MB 约 9 分钟
rsync,两端都是可复现构建 0.1~0.7 MB 十几秒

五、带宽才是瓶颈:减少每次访问要下载的字节

访问日志里有个很反直觉的现象:首页的后端处理时间中位数只有 0.2 秒,但一些静态资源的请求耗时长达 30 秒。

原因是大文件下载的速度中位数只有约 520 KB/s。程序不慢,是管道太细。所以这一轮的重点不是让程序更快,而是让每次访问少传点东西。

1. 正文字体挪到 CDN

全站流量第一的文件,是一份 840 KB 的霞鹜文楷字体子集。它早就做过按站点用字裁剪、一年强缓存,剩下的问题只是从哪里下。

字体上传到对象存储的 CDN,文件名带内容哈希;@font-face 里把 CDN 地址放在第一个 src,本站地址作为第二个,CDN 不可用时浏览器会自动退回:

@font-face {
  font-family: 'LXGW WenKai Screen';
  font-display: swap;
  src: url('https://cdn.example.com/fonts/lxgwwenkaiscreen-site-11c8ed998a.woff2') format('woff2'),
       url('./files/lxgwwenkaiscreen-site.woff2?v=20260914b') format('woff2');
}

注意 preload 的地址必须和 CSS 首选的 src 逐字一致,差一个参数就是两个 URL,字体会被下两次。

2. 把 424 KB 的内联脚本搬出页面

这是收益最大的一项。拆开文章页的 HTML 一看:

页面 HTML 大小 其中内联脚本 占比
文章页 307 KB 261 KB 85%
留言板 279 KB 208 KB 74%
首页 117 KB 56 KB 47%

正文本身只有 2.6 KB,其余都是每篇文章都一模一样的脚本,却要随 HTML 一起重复下载。

搬迁本身不难,难的是保证搬完之后行为一模一样。我的做法是三条规则:

只搬不含模板变量的脚本块。Thymeleaf 会处理脚本里的 [[${...}]],一旦搬成静态文件,这些表达式就不会再被替换。34 个大块里只有一处引用了文章 ID,把它改成页面里一行配置 window.BLOG_POST_ARTICLE_ID = ...,其余原样搬走。

放回原来的位置,用普通的 <script src>。不加 defer、不加 async,外部脚本和内联脚本一样会阻塞解析、按顺序执行,时序不变。

搬之前与线上逐字节比对。每一块脚本的内容,都必须在线上当前渲染出的页面里原样出现,才允许替换。这一步证明了 Thymeleaf 没有改动过这段内容,静态文件和原来输出的完全相同。

graph TD
    A["遍历模板里的内联脚本"] --> B{"含模板表达式?"}
    B -- "是" --> C["留在页面或只留一行配置"]
    B -- "否" --> D{"与线上渲染结果逐字节一致?"}
    D -- "否" --> E["中止 不做任何修改"]
    D -- "是" --> F["写成外部文件<br/>原位置改为 script src"]

静态文件的 URL 由 Spring 的 VersionResourceResolver 加上内容哈希,nginx 对带哈希的 JS 设置一年 immutable 缓存。结果:

页面 每次访问传输(gzip) 优化后
文章页 67 KB 16 KB
留言板 58 KB 17 KB
首页 24 KB 16 KB

模板契约测试里有十几个是直接读模板查 JS 字符串的,搬完后全挂了。没有逐个改断言,而是让测试读取模板的辅助类,把抽走的脚本按原位置“放回去”再交给断言,测试的语义保持不变。另外加了一个预算测试,防止大段内联脚本以后又慢慢长回来。

3. 第三方前端库不再依赖海外 CDN

AI 对话和步数助手的页面,Vue、Element UI、Font Awesome 都直接引用 unpkg、cdnjs、jsdelivr,还有一处 Google Fonts。它们放在 <head> 里阻塞渲染,从国内机房实测:

资源 海外 CDN 固定版本副本
element-ui 脚本 4.6 s 0.29 s
element-ui 样式 3.7 s 0.22 s
vue 2.9 s 0.44 s

顺带发现了几处隐患:11 个页面的 element-ui 没写版本号,上游一发版页面就会被悄悄换掉;9 个页面用的是 Vue 开发版。

处理方式是把每个库固定版本后上传到对象存储,按 <包名>@<版本>/原始路径 组织,这样 CSS 里用相对路径引用的图标字体也能对上。上传前用 npm 仓库登记的 sha512 校验每个压缩包——第一次校验就拦下了问题:镜像站返回的 tarball 地址是 302 跳转,curl 没加 -L,下载到的其实是一个 76 字节的跳转页。

最后在三个模块里各加了一个测试,扫描所有页面,发现海外 CDN 或未固定版本的地址就失败。

4. 其它小项

favicon 原来是一张 128×128 的图标,66 KB,缩成 48×48 后不到 10 KB。归档页的服务端耗时也一并优化了,见下一节。

六、归档页:260 ms 到 40 ms

归档页热缓存下仍稳定在 260 ms,而数据早已缓存、SQL 只取了必要字段。

一边循环请求归档页,一边每隔半秒 kill -3 一次,统计线程栈,热点几乎都落在 Thymeleaf 的 SpEL 表达式求值上:358 篇文章,每篇 5 个表达式,每次请求都要把近两千个表达式重算一遍,而这些数据在两次改文章之间根本不会变

于是把年份、月份、文章列表在服务端直接拼成 HTML,和原来的数据缓存放在同一个缓存空间里,文章增删改时一并失效,模板只负责 th:utext 输出。为了确保拼出来的结构和原模板一致,测试里保留了一份旧模板,用同样的数据分别渲染后逐节点比较。

结果是 260 ms 降到约 40 ms。

七、SEO 与安全的几处修补

不存在的页面终于返回 404。自建页面的路由是 /{articleUrl},找不到时渲染了 404 模板,但状态码仍是 200。于是 /robots.txt/phpinfo.php 这类地址都被当成正常页面,还可能被搜索引擎收录。修复后顺便新增了真正的 robots.txt 和动态生成的 sitemap.xml,站点地图用一条只取链接和时间的查询,不把文章正文加载进内存。

扫描器请求在 nginx 层直接断开。用一周的访问日志回放,扫 WordPress、PHP 和 .env 的请求约占 8.5%,每一条都会进 Java、刷一条告警。本机没有任何 PHP 应用,于是加一条正则 location 直接 return 444。回放时同时确认了:被拦下的路径里,没有一条是正常页面。

数据库账号最小权限。几个应用原来都用 root 连库,还共用同一个密码。改成每个库一个专用账号,只授权自己的库。执行时第一次失败了:线上开启了密码强度策略,而脚本生成的随机密码只有字母和数字。好在脚本是先建号、验证登录、再改配置,第一步失败时什么都没改。

评论邮件发送失败。十几封通知都卡在同一个错误上,收件人地址是 xxx@qq.com@qq.com——访客把完整邮箱填进了 QQ 号输入框,页面又自动拼了一次后缀。前端去掉重复后缀,后端在提交和建单时统一规范化,不合法的地址直接跳过,不再建一张注定失败还要重试三次的单。

八、后台:编辑器实时预览与全页面排版检查

写文章时最常见的问题是:编辑器里的 Markdown 预览,和发布后的样子不一样。

新的预览放在 PC 端右侧的 iframe 里,加载的样式表和前台文章页完全相同,跟随前台的深浅色主题;正文用的是发布时同一个 simplemde.markdown(),所以预览的 HTML 与存进数据库的内容逐字节一致。手机和平板上预览栏不显示,iframe 也不会加载。

后台排版检查的难点是后台需要登录。解决办法是写一个默认关闭的测试,用接近真实的假数据(超长标题、超长链接、宽表格)把 23 个后台页面离线渲染成静态 HTML,再在浏览器里按 320 到 1920 px 七个宽度逐页检查。发现并修掉的问题里,最典型的是一条全局规则:表格为了手机横向滚动设了 680 px 的最小宽度,结果在 PC 上半宽的列表里,把“操作”列挤到了滚动区外面。

九、音乐播放器:“版权受限”的误判

后台搜索网易云歌曲时,有两首都显示“版权受限”:一首前台能完整播放,另一首只能播 30 秒。

原来的判断是“详情里的 fee 不为 0 就算受限”。实测下来:

歌曲 fee 不带 Cookie 请求外链 带网易云 Cookie
起风了 8 完整音频 5.2 MB
老街 1 跳到 404 网页 30 秒试听片段

fee=8 是标准音质免费,外链给的就是整首;fee=1 是 VIP 歌曲,没有网易云 Cookie 的访客一秒都播不了,而博主自己的浏览器登录过网易云,恰好拿到了 30 秒试听——后台试听能响,恰恰掩盖了访客那边完全不能播的事实

新的判断不再看 fee,而是像一个普通访客那样去请求:

graph TD
    A["请求外链<br/>不带 Cookie"] --> B{"是否跳转?"}
    B -- "是" --> A
    B -- "否" --> C{"内容是音频?"}
    C -- "否" --> X["访客无法播放 拒绝添加"]
    C -- "是" --> D{"总大小 ≥ 时长 × 64kbps?"}
    D -- "否" --> Y["只有试听片段 拒绝添加"]
    D -- "是" --> Z["可完整播放 允许添加"]

请求时带上 Range: bytes=0-0,只取 1 个字节,从 Content-Range 读出文件总大小。30 秒试听按 128 kbps 约 480 KB,而一首 5 分钟的歌按 64 kbps 下限也要 2.4 MB 以上,两者不会混淆;不到 45 秒的曲子则不按大小判断,避免误伤。搜索结果并发检测,一键添加在入库前再测一次,放不了整首就直接拒绝。

十、测完数据后决定不做的事

每天 23:49,抖音续火花的夜间任务会先停掉 Java,给浏览器腾内存,发完消息再把 Java 拉起来,六个站点每晚停机约 3.7 分钟。

JVM 的堆快照显示,老年代只用了 88 MB,存活对象约 100 MB,但 ParallelGC 把新生代撑到了 320 MB 且不归还,进程常驻内存 750 MB。看上去,把堆收紧就能让两者同时运行。

但浏览器容器的 cgroup 记录显示,发送期间内存峰值约 939 MB,而 Java 运行时系统只剩约 260 MB 可用。就算省出 300 MB,发送时仍会有几百 MB 被挤进交换分区,Java 和 MySQL 都会跟着变慢。为了不停机而牺牲访问体验,得不偿失,所以保留停机,只把定时器从 23:49:00 推迟到 23:49:40——脚本停掉 Java 后本来就要空等到 23:50 才发送,这样每晚少停约 55 秒。

这里还踩了一个坑:修改 systemd 定时器的触发时间后执行 daemon-reload它立刻补触发了一次任务。幸好任务脚本自带“只能在 23:49 启动”的检查,当场退出,Java 没有被停。改定时器之前,一定要先确认任务本身有这类防护。

总结

项目 优化前 优化后
证书 两周内到期,手动续 自动续期
数据库备份 仅发版时 每天一次,保留 14 天
发版上传 45~50 分钟 十几秒
文章页传输(gzip) 67 KB 16 KB
前端库加载(国内) 3~4.6 s 0.2~0.5 s
归档页服务端耗时 260 ms 40 ms
不存在的页面 200 404
扫描器请求 进 Java 刷日志 nginx 直接断开
每晚停机 约 3.7 分钟 约 2.8 分钟

回头看,这一天最有价值的不是某一项具体的改动,而是几条反复被验证的原则:

先测量,再判断。带宽瓶颈、归档页的热点、浏览器的内存峰值,都和直觉不一样。

改动要可验证。内联脚本逐字节比对、归档页逐节点比对、前端库哈希校验,每一步都有证据,而不是“看起来没问题”。

用户体验优先于技术上的漂亮。能不停机当然更好,但前提是不让访问变慢。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论