静态资源版本化管理:解决版本号变了缓存没变

一个日 PV 两万左右的小站,图片、CSS、JS 加起来大约 12MB,来自三个不同的目录:主题里的静态资源、用户上传的附件、以及第三方 CDN 上的字体。每次发版都要手动改 style.css?v=3 里的数字,改了三次之后出现了四种版本号并存——因为有一处忘了改,有一处改成了日期,还有一处因为模板缓存没清而没生效。

这就是典型的「没有版本管理」的静态资源状态。它的直接后果是缓存失效不可控:浏览器可能拿着三年前的文件,也可能每次请求都重新下载 12MB。这篇把静态资源版本化这件事从原理到落地讲清楚,重点放在那些版本号明明变了、用户却还看到旧文件的场景上。

先明确要解决的是什么问题

静态资源有两个互相矛盾的目标:加载要快(所以让浏览器长期缓存)和更新要立刻生效(所以不能让浏览器缓存)。这两个目标的调和办法只有一个,而且只有这一个:内容变了,URL 就必须变。

URL 一变,对浏览器来说就是一个全新的资源,不存在缓存冲突;只要内容不变,URL 就不变,浏览器可以放心缓存一年。这就是所谓「内容指纹」或「缓存击穿」策略的全部逻辑。所有「改个版本号参数」的做法,本质都是在模拟这件事,只是模拟得好不好而已。

先把几种做法摆出来对比:

做法示例能穿透的缓存主要问题
查询参数style.css?v=3浏览器、部分 CDN部分 CDN 默认忽略查询串;手动维护易漏
查询参数(内容哈希)style.css?v=a1b2c3d4浏览器、CDN需要构建时注入;CDN 忽略查询串时失效
文件名哈希style.a1b2c3d4.css所有层级需要构建流程和旧文件保留策略
目录哈希/static/v3/style.css所有层级改一个文件全站失效,浪费带宽

结论很明确:文件名哈希是最彻底的做法,查询参数是最省事但有漏洞的做法。个人站不一定要上构建链,但要清楚自己用的是哪一种、它在哪里会失灵。

查询参数方案的三个失灵点

先说最常用的 ?v=,因为大部分小站都在用它,而它有三个大多数人不知道的失灵点。

失灵点一:CDN 忽略查询串。 很多 CDN 的缓存键默认只包含「协议 + 主机 + 路径」,?v=3 和 ?v=4 在它们眼里是同一个资源。这时候你在源站改了文件、改了版本号,CDN 边缘节点仍然返回旧内容,浏览器也就一直拿到旧的。验证方法很直接:curl -sI "https://站点/style.css?v=1" 和 curl -sI "https://站点/style.css?v=2",看两次响应的 ETag、Last-Modified、Age 是否一致。一致就说明这个 CDN 不区分查询串。

补救办法是在 CDN 里把查询串加进缓存键(多数厂商的术语叫「忽略 URL 参数」的开关,关掉它),或者在 Nginx 侧把版本号提取成缓存键的一部分。但注意:一旦缓存键包含任意查询串,?utm_source=xxx 这种参数也会打散缓存,命中率会掉得很难看。正确的做法是在缓存键里只保留白名单参数:

map $args $cache_key_args {
    default                       "";
    "~*(?:^|&)v=([a-z0-9]+)"    "v=$1";
}

proxy_cache_key "$scheme$host$uri?$cache_key_args";

这样一来只有 v= 参与缓存键,其他跟踪参数被剔除,命中率和可穿透性都保住了。

失灵点二:手动维护漏改。 一个模板里引用静态资源的地方可能有二十处,分布在 header.php、footer.php、几个 widget、以及 CSS 内部的 @import 和 url()。手动改必然漏。解决办法是把版本号集中到一个地方,所有引用都从那里取:

<?php
// config.php 里只留一行
define('ASSET_VER', '20261024a');

// 模板里统一走一个函数
function asset($path) {
    return '/static/' . ltrim($path, '/') . '?v=' . ASSET_VER;
}
?>
<link rel="stylesheet" href="<?php echo asset('css/style.css'); ?>">

更省事的做法是干脆不要手写版本号,让它自动变化——用主题目录里最晚修改的那个文件的时间戳:

define('ASSET_VER', max(
    filemtime(__DIR__ . '/style.css'),
    filemtime(__DIR__ . '/app.js')
));

部署后只要文件被写过,filemtime 就变,版本号自动更新,永远不会漏。但这里有个坑:如果用 rsync、git checkout 或者打 tar 包部署,文件时间戳可能被重置(比如 rsync -a 保住了旧时间,或者 CI 里 git archive 打出来的包把所有文件 mtime 设成打包时间),于是「文件变了但版本号没变」,或者「文件没变但版本号变了导致全站重下载」。这是这套方案最隐蔽的失效方式。

所以如果要自动化,正确做法是用内容哈希而不是时间戳:

define('ASSET_VER', substr(md5(
    file_get_contents(__DIR__ . '/style.css') .
    file_get_contents(__DIR__ . '/app.js')
), 0, 8));

内容变了哈希必变,内容没变哈希必不变,跟时间戳、跟部署方式都无关。代价是每次请求都要读这两个文件并算一次 md5——十几 KB 的文件在 PHP 里算 md5 是微秒级,可以接受;但如果资源有几十个、或者文件有几百 KB,就应该把结果写进一个缓存文件,部署时重新生成。

失灵点三:CSS 内部引用的资源不受版本号影响。 这是一个几乎人人都踩过的坑。你把 style.css?v=4 改了,浏览器重新下载了 CSS,但 CSS 里面写着 background: url(../images/bg.png),这个 bg.png 仍然走浏览器缓存,可能还是三个月前那张。

解决办法有两个。轻量的做法是在 CSS 里也给关键资源加版本参数:url(../images/bg.png?v=4),代价是要在构建时替换;更彻底的做法是把图片也迁到带哈希的文件名上。如果都不做,至少要让图片目录的缓存时间短一些(比如 7 天而不是一年),这样最多一周后能自愈。

真正可靠的做法:文件名里带哈希

如果愿意加一步构建,这件事就彻底解决了。思路是:构建时算出每个文件的内容哈希,把哈希写进文件名,同时输出一份「原始名 → 带哈希名」的映射表,模板通过映射表查找。

纯 shell 也能做,不一定要 webpack:

#!/bin/bash
# build-assets.sh —— 生成带哈希的静态资源
set -euo pipefail
SRC=src/static
OUT=public/static
MAP=public/static/manifest.json
mkdir -p "$OUT"
echo '{' > "$MAP"
first=1

find "$SRC" -type f \( -name '*.css' -o -name '*.js' -o -name '*.png' \
     -o -name '*.jpg' -o -name '*.svg' -o -name '*.woff2' \) | while read -r f; do
    rel=${f#"$SRC"/}
    hash=$(sha1sum "$f" | cut -c1-8)
    dir=$(dirname "$rel")
    base=$(basename "$rel")
    ext=${base##*.}
    name=${base%.*}
    target="$OUT/$dir/$name.$hash.$ext"
    mkdir -p "$OUT/$dir"
    cp "$f" "$target"
    [ $first -eq 1 ] || echo ',' >> "$MAP"
    first=0
    printf '  "%s": "%s"' "$rel" "${target#"$OUT"/}" >> "$MAP"
done
echo '}' >> "$MAP"

这个脚本有两个细节值得说。第一,用 sha1sum 取前八位而不是完整的四十位,是为了文件名别太长,八位十六进制在几百个文件的规模下碰撞概率可以忽略。第二,set -euo pipefail 三个开关都必要:-e 让错误立刻中断(否则会生成一份缺文件的清单),-u 防止变量拼错变成空字符串把文件传到根目录,-o pipefail 让管道中间的失败也能被发现。

部署时的关键策略是:新文件先上传,旧文件延迟删除。因为用户可能在发版的那一秒正在加载页面,HTML 里还是旧哈希,如果旧文件立刻删掉,那一次请求就是 404。稳妥的做法是保留最近两到三个版本的静态文件,一周后再清理。

对应的 Nginx 配置是这样的,注意 immutable 只在文件名带哈希时才敢用:

location ~* \.(css|js|png|jpg|jpeg|gif|svg|woff2?)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# HTML 绝不长缓存,否则用户会一直拿到旧页面引用的旧哈希
location ~* \.html$ {
    add_header Cache-Control "no-cache, must-revalidate";
}

immutable 的作用是告诉浏览器「这一年里别再发条件请求了,直接用缓存」。不加的话,浏览器按 F5 刷新时会带着 If-None-Match 去问一次,虽然通常还是 304,但多了一次往返。加了之后连这个往返都省了——前提是你真的保证文件名变了内容才变,否则用户按 F5 也刷不出新文件,这是 immutable 唯一的风险。

HTML 一定不能长缓存

这一条单独拎出来说,因为它是「版本号改了但用户看不到新版本」的第三大原因。静态资源带哈希只是保证了「URL 变则内容变」,而用户拿到的是 HTML,HTML 里写着哪个 URL 才是关键。如果 HTML 被缓存了,用户看到的永远是旧 HTML 引用的旧资源。

所以缓存策略必须是分层的:HTML/CDN 上短缓存或者协商缓存;带哈希的静态资源长缓存;不带哈希的资源(比如第三方脚本、favicon)中等缓存,比如 7 天。三层各管各的,不要一刀切。

还有一个细节:如果你用 CDN,注意确认它到底改写了哪些头。很多 CDN 会保留源站的 Cache-Control,但也有些会覆盖成自己的默认值(比如统一 2 小时)。验证方法就是直接对比:

# 源站
curl -sI --resolve www.example.com:443:127.0.0.1 \
  https://www.example.com/static/app.a1b2c3d4.js | grep -i cache-control

# 经过 CDN
curl -sI https://www.example.com/static/app.a1b2c3d4.js | grep -iE 'cache-control|age|x-cache'

两边不一致就说明 CDN 在改写,需要到 CDN 控制台把它改成「遵循源站」。

上线前的三条验收

第一条:新资源必须是 200 且缓存头正确。 拿真实的带哈希文件名 curl 一次,确认 Cache-Control 里有 max-age=31536000 和 immutable。如果少了 immutable,八成是 add_header 被别处的同名指令覆盖了——Nginx 的 add_header 是「子块完全覆盖父块」的语义,location 里只要出现一条 add_header,http/server 段的那些头就全部失效。这个坑非常隐蔽,因为 nginx -t 不会报错。

第二条:旧文件必须还能访问。 发版后故意请求上一版的带哈希文件名,应该还是 200 而不是 404。这一条保证了发版瞬间的在途请求不会失败。

第三条:页面里的引用必须都是新哈希。 取一张页面的源码,把所有的 .css、.js 链接抓出来,逐个请求一遍,确认没有一个返回 404。这个检查三十秒,能抓到「模板里还硬编码着旧文件名」这类错误。

curl -s https://www.example.com/ | \
  grep -oE '(href|src)="[^"]+\.(css|js)"' | \
  sed -E 's/.*="//; s/"$//' | sort -u | \
  while read -r u; do
    code=$(curl -s -o /dev/null -w '%{http_code}' "https://www.example.com$u")
    echo "$code  $u"
  done

一个提醒

静态资源版本管理这件事,最容易犯的错是「只做了一半」:加了版本号但 CDN 不认,或者 CSS 里的图片没跟着走,或者 HTML 被缓存导致新版本根本发不到用户手里。每一条单独看都是小问题,凑在一起就是「用户抱怨页面样式乱了,而你刷新三次都是好的」这种最难复现的故障。

判断自己的方案是否完整的标准只有一句话:从源站文件被改动的那一刻起,到任何一个用户的浏览器拿到新内容,中间有哪一层可能还持有旧副本? 把这一层列出来,逐个确认它的缓存键里包含了内容标识,方案就完整了。列不出来,说明还没想清楚。

Last modification:October 24th, 2026 at 12:24 pm

Leave a Comment