
HY
折腾进行时
Hello, I'm HY.








暂未播放

博客一直挂在 Vercel 上,部署是省心,但有个小毛病:每次重新部署完,站点就像被人清空了记忆,第一个打开页面的人总要等上一小会。看 mysticstars.cn 和 upxuu.com 两篇文章,都是在讲怎么让 Vercel 站点在国内测速”全绿”,核心手段就是边缘缓存。看的时候一堆英文术语差点把我绕晕,折腾完顺手把这次的全过程记下来。
搞缓存绕不开一屏幕英文,先把它们按”谁在看、谁在听”分好类,后面看配置就不晕了:
| 术语 | 一句话解释 |
|---|---|
Cache-Control | 响应头,告诉浏览器和 CDN”这个页面能缓存多久、要不要缓存”。本文所有配置都是在写它 |
max-age | 缓存有效期,单位秒。写在 Cache-Control 里,浏览器认这个 |
s-maxage | 和 max-age 一样是有效期,但只有**共享缓存(CDN)**认,浏览器直接忽略 |
immutable | “这文件永远不会变”,浏览器连重新验证都省了,连缓存都懒得检查。只能配给带哈希文件名的资源 |
must-revalidate | 缓存过期后必须回源问一声”有没有新版”,不许直接用旧的 |
stale-while-revalidate | 缓存过期了先照旧用,同时后台悄悄去更新,下次就有新版了 |
ETag | 服务器给每个版本的内容算的一个标签,重新验证时比对一下就知道变没变 |
X-Vercel-Cache | Vercel 加的响应头,HIT 表示”这次是缓存直接吐给你的”,MISS 表示”这次回源现取的” |
Age | 这份缓存已经在边缘节点里躺了多少秒 |
哈希文件名 | 构建时把内容哈希写进文件名(_..BulBwWNp.css 这种),内容变了文件名就变,天然不怕缓存旧版 |
几个关键关系再说细一点:
max-age 和 s-maxage 是分开的两个开关:s-maxage 拉得再长,浏览器也只认 max-age。所以可以做到”CDN 缓存一天,浏览器每次重新验证”。HIT 不丢人,MISS 也不丢人。第一次请求必然是 MISS(缓存是空的),第二次开始才是 HIT。判断缓存配好没,就是看同一个页面第二次请求是不是 HIT。思路很简单:不同文件用不同缓存策略。哈希资源一年不用动,HTML 要保证发新文章立即可见,数据接口折中。我的 vercel.json 最终长这样(安全头那部分是之前就有的):
1{2 "headers": [3 {4 "source": "/(.*)",5 "headers": [6 { "key": "X-Content-Type-Options", "value": "nosniff" },7 { "key": "X-Frame-Options", "value": "SAMEORIGIN" },8 { "key": "X-XSS-Protection", "value": "1; mode=block" },9 { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },10 { "key": "Cache-Control", "value": "public, s-maxage=86400, stale-while-revalidate=604800, max-age=0, must-revalidate" }11 ]12 },13 {14 "source": "/_astro/(.*)",15 "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]16 },17 {18 "source": "/assets/(.*)",19 "headers": [{ "key": "Cache-Control", "value": "public, max-age=2592000, stale-while-revalidate=604800, must-revalidate" }]20 },21 {22 "source": "/(pagefind|pio)/(.*)",23 "headers": [{ "key": "Cache-Control", "value": "public, max-age=2592000, stale-while-revalidate=604800, must-revalidate" }]24 },25 {26 "source": "/api/(.*)",27 "headers": [{ "key": "Cache-Control", "value": "public, s-maxage=3600, max-age=0, must-revalidate" }]28 }29 ]30}每条规则的意图:
| 路径 | 策略 | 为什么 |
|---|---|---|
| 其他所有(HTML 页面) | 边缘缓存 1 天,过期后 7 天内先吐旧页面同时后台更新,浏览器每次重新验证 | CDN 直接吐页面,但发新文章后老读者刷新立刻看到新版;缓存过期瞬间访客不用等回源,直接拿旧页面秒开 |
/_astro/ | 一年 + immutable | 全是带哈希的构建产物,文件名变了缓存自然换新 |
/assets/、/pagefind/、/pio/ | 30 天 + 过期后后台更新 | 手动放进去的静态文件,搜索索引、阅读器组件这类 |
/api/ | 边缘缓存 1 小时,浏览器不缓存 | 文章元数据、评论数据,旧一小时无伤大雅 |
那两篇文章(尤其第二篇)建议把 HTML 边缘缓存直接拉到十年,理由是”重新部署会自动失效,设多长都行”。理是这个理,但我想的是:万一哪天 Vercel 某个区域的失效信号出岔子,TTL 一天最多让那个区域吐一天旧页面,十年就是吐到你手动清缓存为止。正常场景下两者效果完全一样,那我选风险小的。
上面这套配置跑了几天,有个小毛病一直没解决:每天凌晨缓存过期那一刻,第一个访客要干等回源,快则几百毫秒,慢则一两秒,全看 Vercel 源站当时的状态。
2026-09-10 给 HTML 规则加了 stale-while-revalidate=604800:
1"public, s-maxage=86400, max-age=0, must-revalidate"2"public, s-maxage=86400, stale-while-revalidate=604800, max-age=0, must-revalidate"意思是:缓存过期后 7 天内,CDN 先把旧页面秒开给访客,同时在后台悄悄去源站拉新页面,拉完更新缓存,下一个访客看到的就是新的。这 7 天是兜底窗口,正常部署会主动清缓存,根本用不上;真出了异常也有旧页面顶着,不会白屏。
改完用 curl -sI 验证,响应头里能看到新指令:
1curl -sI https://www.9ll.uk/ | grep -i cache-control2# Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800, max-age=0, must-revalidate这个改动和原来的 s-maxage=86400 是互补关系:一天内正常缓存,一天到七天之间过期续命,七天之后彻底重新验证。浏览器端 max-age=0 不变,发新文章依旧立即可见。
配置完之后还有一个空窗期:新部署的缓存是空的,每个页面都要等第一个访客来”暖场”,第二个访客才能吃到缓存。我的站一共六十多个页面,全等真人来暖,得等挺久。所以抄第二篇文章的思路,加了个 GitHub Actions 工作流。
逻辑是这样的:
1on:2 push:3 branches: [HY] # 我的部署分支4 workflow_dispatch: # 也能手动跑5
6jobs:7 warm:8 steps:9 - name: 等待 Vercel 完成新部署10 if: github.event_name == 'push'11 run: sleep 12012 - name: 预热并统计命中率13 run: bash .github/scripts/cache-warm.sh脚本干三件事:从 sitemap-index.xml 逐片抓全站 URL → 并发 curl 每个页面一遍 → 读响应里的 X-Vercel-Cache 统计 HIT 率,没达标就歇 20 秒再来一轮,最多四轮。原文的做法是写个 HIT.txt 提交回仓库,我嫌提交污染提交历史,改成直接把统计打到 Actions 的运行日志里,点开就能看。
有个物理限制得说清楚:GitHub Actions 的机器在美国,预热的是美区边缘节点。Vercel 边缘缓存按区域分片,所以这个工作流不能保证国内节点也全绿——国内访客第一次访问某页面时,就近节点可能还是 MISS,回源一次之后就好了。想连这块都解决就得动 DNS 优选,那是另一个话题。
第一版配置我是把 HTML 那条规则单独放在 headers 数组最后面,想着 Vercel 应该会”更细的规则覆盖更宽的”。结果上线一测,/_astro/ 的 CSS 返回的头居然是 s-maxage=86400, max-age=0——immutable 没了。
查了文档才知道:Vercel 的 headers 匹配是先到先得,第一条 /(.*) 基础规则把后面所有细规则全挡了。修复也简单:把 HTML 的缓存头直接并进第一条基础规则,让每个请求在第一条就拿到该拿的头,后面的细粒度规则才有机会覆盖。
修完重新部署,再测:
1curl -sI https://www.9ll.uk/ | grep -i cache-control2# Cache-Control: public, s-maxage=86400, max-age=0, must-revalidate3
4curl -sI https://www.9ll.uk/_astro/_..BulBwWNp.css | grep -i cache-control5# Cache-Control: public, max-age=31536000, immutable这回对了。教训记下来:在 Vercel 配 headers,永远先声明通用头,再声明细粒度头,同 key 谁先声明谁赢。
配置生效后,冷态实测(第一次就请求):
| 页面 | 第一次 | 第二次 |
|---|---|---|
| 首页 | MISS | HIT,Age 增长 |
| 关于页 | HIT(被预热工作流填过) | HIT |
| 朋友圈 | HIT | HIT |
| 标签页 | HIT | HIT |
| 随机老文章 | MISS(美区预热覆盖不到) | HIT |
预热脚本本地冒烟测试的数据也放一下:64 个页面,第一轮 HIT 28/64(43.8%),第二轮 64/64(100%)。这就是”暖场”的效果,第一遍全是 MISS,第二遍全 HIT。
发新文章会不会被缓存卡住?不会。浏览器端是 max-age=0, must-revalidate,每次都会带着 ETag 去问边缘节点有没有新版,部署一完成,刷新就是新内容。
整套东西在 Vercel 上就是一个 vercel.json + 一个 Actions 工作流,后台什么都不用点。对静态博客来说,s-maxage 边缘缓存加哈希资源 immutable 基本就是全部了,剩下的预热属于锦上添花。如果你也想抄,顺序记住:
vercel.json 里通用头写进第一条规则,细粒度规则放后面s-maxage 控制边缘、max-age=0 控制浏览器immutable配置完用 curl -sI 看响应头就行,同一个 URL 连请求两次,第二次 X-Vercel-Cache: HIT 就成了。
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!
部分内容可能已过时
分享你的想法,与大家交流讨论