我的博客挂在一台腾讯云轻量服务器上,图省事的代价是:带宽只有 4 Mbps。这个数字平时看不出来,只有当我自己在外地用手机打开一篇文章时才会暴露——整页资源 4 MB 出头,其中图片占了 3.4 MB,算下来要八九秒才能把首屏铺满。有几次我自己都以为是网站挂了,刷新一遍,才发现是图还没加载完。
这事拖了很久。我先后试过两条路,都不算成功,最后是一个看起来最保守的做法解决了问题:给图片单独开一个子域,用 CDN 发图,主域名一个字节都不动。改造完实测,源站的出站流量降了 86%,而博客、测速站、IP 检测页、甚至我自用的几个非标端口服务,全部照常。
先说结论
| 项目 | 结果 |
|---|---|
| 方案 | 独立静态子域 cdn.xiaozou123.cn,只前置 uploads 目录 |
| 主域名解析 | 一行未动 |
| 数据库改动 | 零 |
| 源站出站(单页 3.39 MB 图片) | 降到 488 KB,卸载 86% |
| 回滚 | 删一个文件 + 清一次页面缓存 |
如果只想看结论,到这里就够了。下面是我绕的那两个弯,以及最后为什么选了这条看起来最没技术含量的路。
CDN 到底在做什么
在讲踩的坑之前,先把原理对齐,不然后面很多选择会显得莫名其妙。很多人以为 CDN 是“给网站加速的技术”,其实它做的事可以用一句话概括:把内容提前抄一份放在离用户近的地方,让用户别跑到你的服务器上来取。
这里的判断依据是 URL。边缘节点记的是“这个地址我手上有没有副本”,而它记的键就是 URL 本身。同一个人来第二次、另一个人来第一次,只要地址相同,拿到的就是同一份副本。有副本就直接给,这叫命中;没有就替访客去源站取一次,取回来顺手存一份,这叫回源。
这个机制有个直接推论:只要内容因人而异,就绝对不能缓存。登录后会多出工具栏的页面、带一次性校验码的评论区,都属于这一类。这也是为什么 WordPress 这类动态站的 HTML 通常不该交给 CDN 缓存——不是 CDN 不行,是这类内容本来就不适合被复制分发。
我这次交给它的都是图片和字体,这些东西对谁都一样,是缓存的最佳对象。判断有没有真的生效也不复杂,看响应头里的 EO-Cache-Status:第一次是 MISS,第二次就应该是 HIT。如果一直是 MISS,说明缓存规则没配好,或者请求带着让它不敢缓存的头。
第一条弯路:全站转 WebP
第一反应是压图片。我的 uploads 目录里躺着近 6000 个文件、800 多兆,其中一大半是 PNG。稍微一测就发现这些 PNG 里绝大部分是“假透明”——它们本来是照片或截图,被存成了 PNG,白白多占了五到二十倍的空间。同一张图转成 WebP,体积能掉九成以上,这个数字摆在眼前,很难不动心。
于是我写了个脚本,把整个 uploads 目录扫了一遍,统一转成 WebP,质量参数定在 82,然后替换掉数据库里的引用。从指标上看,这是一次非常成功的优化:总体积掉了七成多,网页加载的资源量肉眼可见地变小。
问题是,指标好看不代表画面对。我抽查的时候拿的都是照片类的图,PSNR 测出来四十多分贝,看着非常安全。但文章里不只有照片——还有大量渐变背景、大片纯色过渡的示意图,以及从终端里截下来的、带细密文字的截图。有损编码遇到这类内容,恰好是最容易出问题的:平滑区域会浮出色带,细小文字边缘会糊成一团。
我自己没看出来,但把页面给人一看,第一眼就被指出了:部分文章的图片出现了色彩断层。这一下就把整个方案否掉了。回头看,失误不在于选了 WebP,而在于我把一个质量参数统一套到了几千张性质完全不同的图上,并且抽样时只抽了最容易通过的那一类。
这次的教训我记到现在:凡是要动画面的事,“省了多少体积”根本不算验收指标,必须拿真实页面给人看。而且抽样得覆盖最容易坏的那类图,不能只挑照片——照片是宽容度最高的内容,它过了不代表别的过了。
WebP 本身没问题。它的有损模式确实比 JPEG 小两三成,它的无损模式也确实能替代 PNG。但“大幅压缩”和“保留细节”是它的两种互斥模式,不可能同时要。更重要的是,换格式这条路的天花板是有限的,而我的瓶颈根本不在这里。
第二条弯路:拿主域名整体接 CDN
更早之前我其实试过 CDN,那时候的思路很直接:把域名整体接进 EdgeOne,改 DNS 指过去,网站不就加速了吗。结果是折腾了一晚上,全部回退。踩的坑现在还能一条条数出来。
第一个是真实 IP 丢了。流量经过 CDN 回源之后,nginx 日志里记的来源地址变成了 CDN 节点的地址,不再是访客的。这不只是“日志不好看”的问题——我的 fail2ban 规则是按日志里的 IP 封禁的,一旦它把某个 CDN 节点当成攻击者封掉,所有经过那个节点回源的访客就全被挡在门外了,那是全站级的故障。
第二个更隐蔽。我在同一台服务器、同一个域名上还挂着别的东西:一个测速站跑在 8090,一个IP 检测页跑在 8443 和 8444,还有几组用于NAT 类型测试的端口。这些东西共用 xiaozou123.cn 这个域名,只是端口不同。而 CDN 只认域名和 80、443,它不认识端口——DNS 只能把域名指向某个地址,没法表达“443 走这边、8444 走那边”。域名被 CDN 接管之后,那些跑在非标端口上的服务,各条线路的用户时通时不通。
这两件事叠在一起,让我当时得出一个结论:网站和这些工具服务挤在同一个域名上,是这一切麻烦的根源。
换一个思路:只动图片,不动网站

想通之后方案就变得很简单了。真正需要 CDN 的是图片,而图片全都在一个固定的路径下——wp-content/uploads。既然如此,我可以给这个目录单独开一个子域,让图片从这个子域加载,网站本身还是走原来的域名。
这么一拆,上面两个坑就都不存在了。主域名解析没有变化,访客仍然直连源站,nginx 日志里的来源地址还是真实访客,fail2ban 的规则不用改也不用担心;那些跑在非标端口上的服务,因为共用主域名,也跟着一起安然无恙。
代价是只能拿到 CDN 的一部分能力。这次接管的是图片和字体,页面的 HTML 仍然由源站直出——不过这部分我早前已经装了页面缓存,命中之后只要 8 毫秒,本来也不构成瓶颈。
源站侧:一个子域、一张证书、一个 vhost
第一步是在 DNS 里加一条记录,把 cdn 指向源站。这里有个细节值得提醒:DNSPod 的“主机记录”只填前缀,填 cdn 就行;我一开始图省事填了完整域名,结果系统自动又拼了一次,变成了 cdn.xiaozou123.cn.xiaozou123.cn,解析当然查不到。这类低级错误最耽误时间,因为第一反应总是怀疑服务商没生效。
第二步是证书。这一步不能省,因为主站的 HSTS 里带了 includeSubDomains,子域如果没有合法证书,浏览器会直接硬拦截,连“继续访问”都不给点。用 acme.sh 走 HTTP-01 验证很顺利:
/root/.acme.sh/acme.sh --issue -d cdn.xiaozou123.cn \
--webroot /usr/local/lighthouse/softwares/wordpress \
--keylength ec-256 --server letsencrypt
签完之后要把证书装到固定位置,再注册一个续期后自动重载 nginx 的命令,这样以后就不用管它了。
第三步是写一个专门的 vhost。这个配置文件是整个方案里最要紧的一段,因为它的职责不只是“把图发出去”,还包括“别的什么都不做”。
server {
listen 443 ssl;
server_name cdn.xiaozou123.cn;
# 只服务 uploads,其余一律 404 —— 这个子域不承载任何页面
location ^~ /wp-content/uploads/ {
# uploads 下绝不允许执行 PHP
location ~ \.php$ { deny all; }
# 不存在的文件直接 404,不回落 index.php
try_files $uri =404;
expires 30d;
}
location / { return 404; }
}
这里面有两行是刻意加的。一行是禁止执行 PHP:uploads 是唯一允许访客写入内容的目录,历史上不少 WordPress 被入侵的案例就是往这里传一个图片马再想办法让它跑起来,所以在最外层再堵一次不亏,多一道闸门而已。
另一行是 try_files $uri =404。如果不写这句,nginx 在找不到文件时会去找默认的处理程序,而 WordPress 的默认处理程序是 index.php——那意味着任何一张拼错的文件名都会拉起一整个 WordPress 进程,等于给了一个廉价的资源消耗点。直接返回 404 更干净,也更省。
实测下来这几条都符合预期:图片正常返回,其他路径全部 404,uploads 下随便放个 php 文件也只会得到 403。
EdgeOne 侧:几个容易选错的选项
控制台里的配置反而不多,但有几处选错了会很难受。
| 选项 | 怎么选 | 为什么 |
|---|---|---|
| 加速区域 | 全球可用区 | 免费版不支持单独选“中国大陆可用区”,但已备案域名用“全球可用区”本身就含大陆节点 |
| 源站类型 | IP,填服务器公网 IP | 填域名会与 CNAME 形成回源环路 |
| 回源协议 | HTTPS 443 | 源站已有该子域证书 |
| 回源 HOST | cdn.xiaozou123.cn | 必须与源站 vhost 的 server_name 一致,否则落到默认站点 |
加速区域要注意,它选定之后不能改,只能把站点删掉重建。我第一次就选成了“全球可用区(不含中国大陆)”,这意味着国内访客会被调度到境外节点,然后从境外跨境回源到我的境内服务器——绕了一大圈,比直连还慢。发现自己选错的时候站点还没配好,删了重建才改过来。
回源 HOST 这一项也值得多说一句。因为我的源站上跑着好几个站点,nginx 是靠请求头里的域名来决定交给哪个配置处理的。如果回源时带的是别的名字,请求会落到默认站点上,返回的要么是 404 要么是别的网站的内容。这一项必须和源站里那个 vhost 的 server_name 对得上。
还有个贴心的地方:因为域名托管在 DNSPod,接入方式选“DNSPod 托管”之后,EdgeOne 会自己把 CNAME 写进去,不需要我手改,也不需要加验证用的 TXT 记录。改完我去 DNSPod 看了一眼,备注栏写着“由 EO 修改用于提升安全”。
不过生效不是即时的。我配好之后盯着解析查了十几分钟,一直是域名不存在,差点以为配置有问题去翻文档。实际情况是边缘节点下发需要时间,大约四十分钟后才解析出来。这类等待没必要焦虑,但也不该在没生效的时候就急着往下走。
最难的一步:让页面上的图片换个域名
前面的东西都是标准操作,真正需要动脑筋的是这一步:怎么让页面里所有的图片地址,都从 xiaozou123.cn 换成 cdn.xiaozou123.cn。
第一反应当然是改数据库,把里面的地址批量替换掉。我先跑了一次空转看看规模,结果有两千多处,分布在文章正文、选项、元数据里。但看的时候我注意到两个不能忽视的问题。
一个是 wp_posts.guid 那个字段,一家伙就占了一千处。这个字段是文章在 RSS 里的唯一标识,改掉它会让所有订阅者收到全部历史文章的重复推送,是 WordPress 官方明确建议不要碰的。所以批量替换必须把它排除掉,实际要动的是一千八百多处。
另一个问题更根本。WordPress 里图片地址有两个来源:一部分是写死在文章内容里的,比如我插图时贴进去的链接;另一部分是渲染时现场拼出来的——附件地址、特色图片、srcset 里的那些尺寸变体,都是程序拿着站点地址和文件名现拼的,数据库里根本没有这个字符串。这意味着只改数据库的话,会出现一半改了一半没改的尴尬局面:正文里的图走 CDN,而文章列表的封面图还是从源站出来。
想清楚这点之后,方案就换成了从出口下手。不管一个地址是数据库里存的,还是程序现拼的,只要它要发给浏览器,就一定经过页面输出的最后一道关口。在这道关口上做一次字符串替换,就能一次覆盖全部。
具体做法是挂一个 mu-plugin,在页面开始渲染时开启输出缓冲,等页面生成完毕再统一替换:
add_action('template_redirect', function () {
if (is_admin() || (defined('DOING_AJAX') && DOING_AJAX)) return;
if (defined('DOING_CRON') && DOING_CRON) return;
if (defined('REST_REQUEST') && REST_REQUEST) return;
ob_start(function ($buffer) {
return str_replace(
'https://cdn.xiaozou123.cn/wp-content/uploads',
'https://cdn.xiaozou123.cn/wp-content/uploads',
$buffer
);
});
}, 1);

这样做有几个额外的好处。数据库一个字没动,回滚的时候删掉这个文件就行,没有不可逆的部分;替换规则只针对 uploads 这一个前缀,主题和插件的资源不会被误伤;排除掉后台和 AJAX 之后,编辑器和接口也不受影响。
不过有个顺序关系必须搞清楚,否则会看到一个很诡异的现象。我在这之前已经装了页面缓存插件,它也会占用输出缓冲。两个缓冲叠在一起时,后启动的那个先执行——我的插件挂在渲染阶段,比缓存插件晚,所以替换发生在缓存文件被写下来之前,缓存里存的就是替换之后的结果。
这带来一个操作上的约束:启用或停用这个插件之后,都必须清一次页面缓存。否则缓存文件里存的还是上一轮的结果,你改了规则却看不到变化。第一次验证的时候我就卡在这儿,明明替换代码是对的,页面上的地址却没变,盯着看了半天才想起缓存这一层。
为什么不用现成的 CDN 插件
这一步其实有现成的轮子。WordPress 生态里做静态资源重定向的插件不少,功能比我这个二十行的版本齐全得多,还带界面、带各种开关。但我看了一圈之后还是决定自己写,理由有两个。
一个是我只需要它做一件事。现成插件通常默认接管全站静态资源,包括主题的 CSS 和 JS,而我的 CDN 子域只服务 uploads 这一个目录,主题文件根本没配进去——如果让它顺手把这些也改了,那些地址会指向一个返回 404 的子域,页面样式当场就崩了。要关掉这些默认行为,得在设置里翻半天。
另一个是插件列表的清净。我做成了 mu-plugin,也就是必须使用型插件,它不会出现在后台的插件列表里,自然也就没有更新提示、没有推广横幅。功能单一的插件,自己维护的成本其实比调教一个通用插件更低。
验证
改完之后我把站点上各种类型的页面都抓了一遍,逐个统计页面里还有多少图片地址指向源站。
| 页面 | 状态 | 走 CDN 的图 | 仍指向源站 |
|---|---|---|---|
| 首页 | 200 | 44 | 0 |
| 拆解长文 | 200 | 133 | 0 |
| 独立页面 | 200 | 14 | 0 |
| 分类页 / 分页 | 200 | 38 – 44 | 0 |
| 搜索页 | 200 | 43 | 0 |
| RSS 订阅源 | 200 | 60 | 0 |
| 404 页面 | 404 | 11 | 0 |
| 登录页 | 200 | 0 | 4 |

登录页那 4 处是故意保留的——它在 WordPress 里属于后台范围,我按保守原则把它排除在替换之外,免得登录态出什么意外。这个页面只有我自己会访问,不影响访客。
除了页面上数地址,我还检查了缓存文件本身。因为替换发生在缓存写入之前,磁盘上的缓存文件里存的应该已经是 CDN 地址了。打开一看确实如此,这同时说明了一件事:之后所有命中缓存的访问,拿到的都是新地址,不会有一条漏网的旧地址从缓存里被吐出来。
更让人放心的是源站的压力变化。完整加载一次首页,我用服务器网卡的出站计数卡了一下前后差值:一个 HTML 加上二十一张图片合计 3.39 MB,而源站实际只出了 488 KB,剩下的全部由边缘节点分发。这 488 KB 里的大头还是 HTML 本身和少数几张首次回源的图。
踩到的几个坑
DNS 缓存会制造假象。验证的时候我发现一件怪事:页面上的图片地址确实换成子域了,但请求过去之后响应头里没有 CDN 特有的标记,看起来像是走源站。折腾了一会儿才想明白,是我本机的 DNS 缓存里还留着子域早先那条指向源站的 A 记录。验证域名解析这件事,别只信 nslookup,要用加密 DNS 交叉确认一遍。
免费版不给子域提供 IPv6。查了一下,这个子域只有 CNAME 和 IPv4 地址,AAAA 记录是空的。双栈用户没问题,会自动回落到 IPv4;只有纯 IPv6 的用户取不到图。影响面很小,但如果你的站点里这类用户占比高,得在控制台里单独开启。
换图必须改文件名。EdgeOne 已经把图片缓存下来了,缓存时间按源站给的响应头走。如果以后换了图片内容但沿用同一个文件名,访客会继续拿到缓存里的旧图,而且时间可能相当长。所以换图时的习惯要改:一律用新文件名,不要原地覆盖。
单机测速的结论不要外推。我做过一组对比测试,把页面上的图片序列下载两遍,一遍走 CDN 一遍强制走源站,结果 CDN 快了大约一倍。但拆开逐张看,CDN 反而每张都略慢一点。这两个结果互相矛盾,说明瓶颈在我的测试机上,不在两边的服务器上——同一个测试机受制于自己的出口带宽,测出来的倍数根本不代表真实用户的体验。
所以最后我只报告架构层面的指标:源站出站了多少字节、边缘缓存命中与否。这些数字不受我的网络环境影响,是可信的;“提速几倍”这种话,一台机器上测不出来,也不该说。这和前面 WebP 那次翻车其实是同一类错误——把中间环节的漂亮数字,当成了终端用户的实际感受。
怎么回退
这次改造最让我安心的地方是回退成本极低。整个改动只有一个文件加一次缓存清理:
# 两步都要做,缺一不可
rm wp-content/mu-plugins/xz-cdn-uploads.php
# 然后清一次页面缓存,否则旧缓存文件仍在按 CDN 地址服务
因为数据库一个字都没改,删掉文件之后页面立刻回到原来的状态,图片重新从源站发。功能不会中断,只是又回到那根窄管子上而已。相比全站转 WebP 那次要反向替换两千多处数据库记录、删掉几千个文件,这次的退路宽得多。
最后
还有个容易混淆的地方,我想单独拎出来说一下:CDN 解决的是“由谁来把字节发出去”,不是“要发的字节太多”。这次改造之后,页面的资源总量一个字节都没少,图片该多大还是多大。变的只是它们不再从我唯一的那根 4 Mbps 管子里出,而是由一张覆盖多个地区的边缘网络分发。
所以它救的是“多人同时访问互相挤”和“源站被刷爆”这两种情况。至于单个访客在自己家网速下要等多久,CDN 帮不上——那部分只能靠真正地减小文件体积。这也是图片优化仍然值得做,但必须做得更讲究的原因:按实际显示尺寸来,按内容类型分类,一张一张看,而不是写个脚本一把梭。
这次改造的全部价值,其实归结到一句判断上:不是技术选对了,是范围划对了。只把一个目录交给 CDN,主域名的所有复杂性就都不必面对。如果哪天你也在为“业务太杂、动一处牵扯全身”发愁,先别急着换方案,试试先把要动的部分单独划出来。
写这些内容需要耗费大量的时间和金钱,觉得有用的话可以点击下面的“赞赏”支持作者,你的支持是我更新的动力。
- 博客图片压缩:自己写了个先预览、后替换的小工具WordPress
- Sakurairo 说说模块的一个坑:/shuoshuo/ 和首页内容完全重复WordPress
- 给博客做了一轮系统性的 SEO 优化,记录一下全过程WordPress
- 用 Nginx limit_req 给图片接口限流(Sakurairo 主题)Nginx
- Slim SEO与Sakurairo主题使用造成SEO冲突WordPress


Comments NOTHING