工程
可验证的缓存失效:内容站如何用版本号避免旧页面
内容站的旧页面问题不能只靠 TTL。本文用 public_version、缓存键、ETag 与可观察响应头,把缓存失效变成发布后可以验证的工程流程。
旧页面最难排查的地方,不是“缓存没有过期”,而是你不知道哪一层还在返回旧内容。浏览器、CDN、反向代理、应用内缓存都可能命中,同一个 URL 在不同地区、不同用户、不同登录状态下还可能表现不同。缩短 TTL 可以缓解,但它不是发布系统:TTL 太短,缓存价值变低;TTL 太长,读者在发布后继续看到旧页面;临时清缓存,又很难复盘。
更可靠的做法是给公开内容建立显式版本号。页面 URL 仍然保持稳定,便于 SEO 和外链传播;缓存键、ETag、调试响应头使用版本号,让“当前返回的是哪一版”可以被检查。失效不再是一句“等会儿就好了”,而是发布动作后立刻能验证的状态变化。
版本号放在哪里
不要直接把 updated_at 当成缓存版本。时间戳受精度、时区、批量导入影响,很容易出现两次更新落在同一秒,或者仅修正草稿元数据也让公开页失效。对内容页来说,版本号最好是业务字段:只有会影响公开 HTML 的变更,才递增。
一个最小的数据结构可以这样设计:
ALTER TABLE posts ADD COLUMN public_version INTEGER NOT NULL DEFAULT 1;
UPDATE posts
SET title = ?,
body = ?,
excerpt = ?,
public_version = public_version + 1,
updated_at = unixepoch()
WHERE id = ? AND status = 'published';
如果站点有草稿和发布两个状态,推荐只在“公开版本”变化时递增 public_version。编辑草稿不应该影响线上页面;草稿发布覆盖线上内容时,才让公开版本加一。这样缓存失效的语义和读者能看到的内容保持一致。
页面 URL 稳定,缓存键带版本
内容页的 canonical URL 不应加 ?v=123。搜索引擎、分享链接和站内链接都应该指向稳定路径,例如 /zh/posts/sqlite-wal-mode。版本号应该进入服务端缓存键,而不是进入页面地址。
处理请求时,可以先查一条很轻的元数据,再决定应用层缓存是否命中:
type PostMeta struct {
ID int64
Slug string
PublicVersion int64
}
func cacheKey(lang, slug string, version int64) string {
return fmt.Sprintf("post:%s:%s:v%d", lang, slug, version)
}
func etagMatches(header, etag string) bool {
for _, part := range strings.Split(header, ",") {
if strings.TrimSpace(part) == etag || strings.TrimSpace(part) == "*" {
return true
}
}
return false
}
func servePost(w http.ResponseWriter, r *http.Request) {
lang, slug := "zh", r.PathValue("slug")
meta, err := loadPostMeta(r.Context(), lang, slug)
if err != nil {
http.NotFound(w, r)
return
}
etag := fmt.Sprintf(`"post-%d-v%d"`, meta.ID, meta.PublicVersion)
w.Header().Set("ETag", etag)
w.Header().Set("X-Content-Version", strconv.FormatInt(meta.PublicVersion, 10))
w.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=300")
if etagMatches(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return
}
key := cacheKey(lang, slug, meta.PublicVersion)
html, ok := pageCache.Get(key)
if !ok {
post, err := loadPublishedPost(r.Context(), meta.ID)
if err != nil {
http.Error(w, "load post failed", http.StatusInternalServerError)
return
}
html = renderPost(post)
pageCache.Set(key, html, time.Hour)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
这段代码的关键点不是内存缓存本身,而是缓存键里包含 public_version。文章发布后版本变成 8,旧的 post:zh:slug:v7 即使命中也不会再被读取。旧缓存可以等 TTL 自然淘汰,不需要在发布路径里同步删除每一层缓存。
条件请求也要注意顺序:先用轻量元数据算出 ETag,再判断 If-None-Match,命中后直接返回 304,避免为了一个无需正文的响应去读取和渲染整篇文章。真实项目可以直接使用框架或标准库里成熟的条件请求处理,避免自己遗漏弱 ETag、多个 ETag、* 等边界。
如果页面会因为登录态、A/B 实验或地区返回不同 HTML,缓存键还必须包含对应维度,并设置正确的 Vary。版本号只能说明“同一份公开内容”的版本,不能把个性化响应硬塞进同一个公共缓存键。
静态资源和 HTML 分开处理
HTML 是会频繁变化的入口页,不适合强缓存一年。CSS、JS、字体、图片这类静态资源才适合长缓存,但它们也要有版本。最稳的方式是文件名指纹:
<link rel="stylesheet" href="/assets/app.7f3a9c2.css">
<script src="/assets/app.91b0c4d.js" defer></script>
对应响应头可以大胆设置:
Cache-Control: public, max-age=31536000, immutable
如果暂时没有构建链,查询参数也能作为过渡方案:
func assetURL(path string, buildVersion string) string {
return path + "?v=" + url.QueryEscape(buildVersion)
}
但要注意,资源版本是构建版本,内容版本是文章版本,二者不要混用。改了一篇文章,不应该让全站 CSS 失效;发了一版样式,也不应该递增所有文章的 public_version。
CDN 层需要可观察
如果前面有 CDN,页面响应至少应该能看出两件事:应用认为内容是哪一版,边缘节点是否命中了缓存。内容版本由应用产生,缓存状态通常由 CDN 或反向代理产生,例如 CF-Cache-Status、X-Cache、Age 这类头。
应用侧可以固定输出:
ETag: "post-42-v8"
X-Content-Version: 8
Cache-Control: public, max-age=60, stale-while-revalidate=300
CDN 侧再配合输出缓存状态。发布后验证应该围绕这些字段,而不是刷新浏览器凭感觉判断:
curl -I https://example.com/zh/posts/sqlite-wal-mode
如果 X-Content-Version 已经变成 8,但页面正文还是旧内容,问题在应用渲染或数据读取;如果源站是 8,CDN 边缘仍是 7,问题在 CDN 缓存键、回源策略或清理策略;如果 CDN 已是 8,用户浏览器还看到旧样式,问题多半在静态资源版本。
版本化缓存键不排斥 CDN 主动清理。对大型站点,可以额外给响应加 Surrogate-Key: post-42 tag-sqlite,发布时按 surrogate key 清边缘缓存;对小型内容站,短 HTML TTL 加版本化应用缓存通常已经足够。关键是调试字段要能说明当前返回的是哪一版。
聚合页要有自己的版本
很多旧页面问题其实不在文章页,而在首页、分类页、标签页和 RSS。文章正文已经更新,列表摘要还停在旧版本;文章已经发布,sitemap 还没有出现新 URL。解决办法不是让所有聚合页复用文章版本,而是让聚合页拥有独立版本。
小型内容站可以维护一张很简单的状态表:
CREATE TABLE cache_versions (
name TEXT PRIMARY KEY,
version INTEGER NOT NULL
);
INSERT INTO cache_versions(name, version)
VALUES ('home', 1)
ON CONFLICT(name) DO NOTHING;
UPDATE cache_versions
SET version = version + 1
WHERE name IN ('home', 'feed', 'sitemap', 'tag:sqlite');
发布文章时,根据影响范围递增对应版本。新增文章会影响首页、RSS、sitemap、分类页和标签页;只修改正文通常不影响 sitemap;只修正标题和摘要则会影响列表页。这个判断应该在发布服务里集中完成,不要散落到模板层。模板只负责渲染,发布流程负责声明“哪些可见页面需要进入下一版”。
应用层缓存键也按页面类型拆开:
func listCacheKey(name string, version int64, page int) string {
return fmt.Sprintf("list:%s:v%d:p%d", name, version, page)
}
这样一次文章更新不会击穿全站缓存,只会让真正受影响的列表进入新版本。对独立开发者和小团队来说,这个复杂度是值得的:它比接入一套重型缓存清理系统简单,又比全站短 TTL 更可控。
发布动作要原子
版本化失效最怕“内容写了,版本没加”或者“版本加了,内容没写成”。发布操作应该放进同一个事务:
BEGIN;
UPDATE posts
SET title = ?,
body = ?,
excerpt = ?,
public_version = public_version + 1,
updated_at = unixepoch()
WHERE id = ?;
INSERT INTO publish_events(post_id, version, created_at)
VALUES (?, (SELECT public_version FROM posts WHERE id = ?), unixepoch());
COMMIT;
publish_events 不是必需表,但很有用。它能回答“这篇文章什么时候从 v7 变成 v8”,也能给后台展示最近发布记录。缓存系统一旦出问题,事件表比日志更容易查。
如果发布流程还要更新聚合页版本,也应该在同一个事务里完成。事务提交前,读者仍然看到旧内容和旧版本;事务提交后,文章页、列表页、RSS 的版本一起进入新状态。这个边界清楚,线上排查才不会出现“正文是新的,列表还是旧的,事件表又查不到”的半完成状态。
取舍
版本号方案不能替代所有清缓存能力。首页、标签页、RSS、sitemap 这类聚合页仍然需要自己的版本,或者用发布事件驱动的失效策略。简单站点可以用全站 site_version 处理聚合页;内容量变大后,再按列表维度拆成 home:v12、tag:sqlite:v5、feed:v9。
真正要避免的是“所有页面共用一个版本”。那会让一次小更新击穿全站缓存。也不要把 TTL 当作发布系统的一部分:TTL 是兜底,版本才是契约。一个可维护的内容站,发布之后应该能明确回答三件事:数据库里当前公开版本是多少,源站返回的版本是多少,边缘节点返回的版本是多少。能回答这三件事,旧页面问题就从玄学变成了普通工程问题。