《互联网产品的服务端架构·产品、流量与基础能力》7.1 静态与动态的分界

从「回声」把个性化推荐错误地放进 CDN、A 用户看到 B 用户推荐的事故写起,说明静态与动态的分界依据是同一份内容对多少人相同,而不是文件后缀;再给出路径分类与缓存策略的 Go 实现和边界测试,并解释为什么 API 前缀必须优先于扩展名判断。

一张商品图和一个推荐接口走了同一条缓存

「回声」是一个内容社区。上线半年后,团队在 CDN 控制台看到图片命中率只有 71%,于是运营提了一个看起来很自然的优化:把除 /api/ 之外的所有路径都加上 10 分钟缓存。规则上线当天,首页加载快了约 200 毫秒(案例假设值)。

三天后,客服收到投诉:用户 A 打开首页,推荐流里出现了用户 B 的关注列表。排查发现,/feed/recommend 不在 /api/ 前缀下,被那条“除 API 外都缓存”的规则命中了。CDN 把 A 的个性化响应存了 10 分钟,期间请求落到同一边缘节点的用户都拿到了它。

复现也很困难:本地刷新十次正常,因为本地没有 CDN;测试环境清缓存后正常,因为请求量太小,边缘节点没有发生共享。只有生产环境的高并发把这个错误放大成事故。

复盘时团队说“CDN 规则写错了”。但真正的错误在分类:他们把“是不是文件”当成了静态与动态的分界。推荐接口没有扩展名,却被当成页面;页面有 .html 后缀,内容却可能因人而异。分界从来不是文件后缀。

分界的依据是同一份内容对多少人相同

判断一个路径能不能走共享缓存,只需要问一个问题:在缓存有效期内,这份响应对多少人是同一份?答案决定了它放在哪一层、缓存多久、失效时怎么办。

内容同一份内容对谁相同建议位置缓存时长失效方式
CSS、字体、图片所有人CDN天到年文件名带内容哈希
公开文章页所有人(可能含少量运营位)CDN + 源站分钟到小时发布时主动刷新
商品详情页所有人,价格和库存是例外CDN + 短 TTL30–60 秒价格变更推送刷新
个性化推荐流每个用户不同源站或用户级缓存不共享按用户键隔离
搜索结果每个查询不同源站短缓存秒级按查询键
登录、下单接口每次请求都可能不同源站不缓存禁止共享缓存

这张表里最容易误判的是商品详情页。标题、图片、描述对所有人相同,但价格和库存会变。正确做法不是二选一,而是拆开:静态部分走 CDN,价格和库存走短 TTL 或由客户端再拉一次。整页一个 TTL,要么价格更新慢,要么缓存频繁失效。

第二容易误判的是“看起来像文件”的路径。/export/report.pdf 可能是运营每天生成的静态报表,也可能是按当前登录用户实时生成的账单。只看 .pdf 就会把后者放进共享缓存。反过来,/article/123 没有扩展名,但正文对所有人相同,完全可以缓存。扩展名只是线索,不是判据。

还有一个常被忽略的维度是缓存键。即使内容对所有人相同,只要响应里带了 Set-Cookie、Vary: Cookie 或用户 ID,它就不再是共享内容。分类函数只能给出默认策略,真正的缓存键必须由响应头和业务语义共同决定。

失败场景:缓存命中后,A 看到 B 的推荐

把 /feed/recommend 放进共享缓存后,事故的传播路径比想象中长。

第一层是数据泄漏。推荐流里有用户关注的人、最近浏览和实验分组。B 的推荐被 A 看到,泄漏的不只是内容,还有“B 最近在看什么”这类行为信息。这类事故通常不属于传统的数据泄露事件,但用户感知很直接,公关和合规成本都不低。

第二层是错误放大。缓存命中时源站没有收到请求,监控上的 QPS 反而下降。值班同学看到“流量正常、错误率为零”,直到投诉进来才知道缓存里装的是错的内容。缓存把故障从“服务不可用”变成了“服务可用但结果是错的”,后者更难被指标发现。

第三层是清理困难。边缘节点分布在多个地区,每台机器的缓存键和过期时间独立。团队执行了一次全量刷新,但部分节点因为回源失败保留了旧内容,旧内容又被用户截图传播。最后只能临时把整个路径改成不共享,用性能换正确性。

修复并不复杂:把 /feed/ 和 /api/ 一起归为动态,商品详情页拆成静态壳和动态数据,所有带用户上下文的响应统一加 Cache-Control: private。真正花时间的是把“哪些路径属于哪一类”从口头约定变成代码里的分类函数和测试。

用路径分类表达分界

下面的实现来自 webapp-lab 的 internal/edge 包。ClassifyPath 只回答“这是什么类型”,PolicyFor 再把它翻译成缓存参数。两者分开,是因为分类是产品语义,缓存参数是运维策略,后续调整 TTL 不应该改动分类逻辑。

// file: internal/edge/static.go
// tier: L1
// verify: go test ./internal/edge/

package edge

import (
	"errors"
	"fmt"
	"path"
	"strings"
)

// PathKind 表示一个请求路径属于哪类内容,分类结果决定缓存与回源策略。
type PathKind string

const (
	// KindStatic 是同一份内容对所有人都相同的资源。
	KindStatic PathKind = "static"
	// KindDynamic 是需要按用户或请求上下文生成的内容。
	KindDynamic PathKind = "dynamic"
	// KindAPI 是程序接口,不应被共享缓存保存。
	KindAPI PathKind = "api"
)

var (
	// ErrEmptyPath 表示调用方没有提供路径。
	ErrEmptyPath = errors.New("edge: empty path")
	// ErrRelativePath 表示路径不是以 / 开头。
	ErrRelativePath = errors.New("edge: path must start with /")
	// ErrDotDot 表示路径包含 .. 片段,可能是目录穿越。
	ErrDotDot = errors.New("edge: path contains ..")
)

// staticDirs 是产品约定为静态资源的目录。即使目录里的文件没有扩展名,
// 也按静态处理。
var staticDirs = []string{"/css/", "/images/", "/fonts/"}

// staticExts 是允许边缘长期缓存的扩展名集合。
var staticExts = map[string]bool{
	".css": true, ".js": true, ".mjs": true, ".map": true,
	".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
	".svg": true, ".webp": true, ".avif": true, ".ico": true,
	".woff": true, ".woff2": true, ".ttf": true, ".pdf": true,
}

// ClassifyPath 判断路径的内容类别。查询串会被忽略,但路径本身必须是
// 以 / 开头的绝对路径,且不能包含 .. 片段。
func ClassifyPath(p string) (PathKind, error) {
	if p == "" {
		return "", ErrEmptyPath
	}
	if !strings.HasPrefix(p, "/") {
		return "", fmt.Errorf("%w: %q", ErrRelativePath, p)
	}
	if strings.Contains(p, "..") {
		return "", fmt.Errorf("%w: %q", ErrDotDot, p)
	}

	pathOnly, _, _ := strings.Cut(p, "?")
	switch {
	case pathOnly == "/api" || strings.HasPrefix(pathOnly, "/api/"):
		return KindAPI, nil
	case isStaticPath(pathOnly):
		return KindStatic, nil
	default:
		return KindDynamic, nil
	}
}

// isStaticPath 只看目录约定和扩展名。API 前缀在调用方已经先行判断,
// 所以 /api/export.csv 不会被这里误判成静态文件。
func isStaticPath(p string) bool {
	for _, dir := range staticDirs {
		if strings.HasPrefix(p, dir) {
			return true
		}
	}
	return staticExts[strings.ToLower(path.Ext(p))]
}

// Policy 是一次边缘响应的缓存策略。MaxAgeSeconds 为 0 表示不缓存,
// Revalidate 表示每次使用前必须回源确认。
type Policy struct {
	Kind          PathKind
	MaxAgeSeconds int
	Revalidate    bool
}

// PolicyFor 返回某类内容的默认策略。未知类别按 dynamic 处理,
// 宁可回源也不要误缓存。
func PolicyFor(kind PathKind) Policy {
	switch kind {
	case KindStatic:
		return Policy{Kind: KindStatic, MaxAgeSeconds: 86400, Revalidate: false}
	case KindAPI:
		return Policy{Kind: KindAPI, MaxAgeSeconds: 0, Revalidate: true}
	default:
		return Policy{Kind: KindDynamic, MaxAgeSeconds: 0, Revalidate: true}
	}
}

实现里有三个刻意选择。第一,API 前缀先于扩展名判断:/api/export.csv 的扩展名是 .csv,但它仍然是接口,不应该被共享缓存,顺序本身就是一条安全规则。

第二,路径必须是以 / 开头的绝对路径,且不能包含 ..。分类函数通常在请求进入边缘时调用,如果它接受相对路径或目录穿越,后续的缓存键拼接就可能落到预期之外的位置。这里选择直接报错,而不是尝试清洗,因为清洗会让调用方误以为输入是安全的。

第三,未知类别按 dynamic 处理。PolicyFor 收到不认识的 PathKind 时返回 0 TTL 和必须回源,而不是 panic,也不是猜一个看似安全的值。边缘是故障域的第一层,分类器宁可多回源,也不能把不确定的内容放进共享缓存。

这段代码不判断响应头,也不生成缓存键。它只负责最基础的一层:给路径一个默认类别。Set-Cookie、Vary、用户 ID 这些条件仍然需要在响应阶段覆盖默认策略。

测试:API 前缀优先于扩展名

分类逻辑的 bug 往往出现在两个规则的交叉点,所以测试重点不是“CSS 是静态”,而是“同时满足两条规则时谁赢”,以及非法输入是否被拒绝。

// file: internal/edge/static_test.go
// tier: L1
// verify: go test ./internal/edge/

package edge

import (
	"errors"
	"testing"
)

func TestClassifyPath(t *testing.T) {
	cases := []struct {
		path string
		want PathKind
	}{
		{"/css/app.css", KindStatic},
		{"/images/home/hero.avif", KindStatic},
		{"/fonts/inter.woff2", KindStatic},
		{"/downloads/report.pdf", KindStatic},
		{"/images/logo.png?v=3", KindStatic},
		{"/api", KindAPI},
		{"/api/user/42", KindAPI},
		{"/api/export.csv", KindAPI},
		{"/", KindDynamic},
		{"/posts/edge-cache", KindDynamic},
		{"/search?q=cache", KindDynamic},
	}
	for _, tc := range cases {
		got, err := ClassifyPath(tc.path)
		if err != nil {
			t.Fatalf("ClassifyPath(%q) error: %v", tc.path, err)
		}
		if got != tc.want {
			t.Errorf("ClassifyPath(%q) = %q, want %q", tc.path, got, tc.want)
		}
	}
}

func TestClassifyPathRejectsBadInput(t *testing.T) {
	cases := []struct {
		path string
		want error
	}{
		{"", ErrEmptyPath},
		{"css/app.css", ErrRelativePath},
		{"/images/../etc/passwd", ErrDotDot},
		{"/../secret", ErrDotDot},
	}
	for _, tc := range cases {
		if _, err := ClassifyPath(tc.path); !errors.Is(err, tc.want) {
			t.Errorf("ClassifyPath(%q) error = %v, want %v", tc.path, err, tc.want)
		}
	}
}

func TestPolicyFor(t *testing.T) {
	static := PolicyFor(KindStatic)
	if static.Kind != KindStatic || static.MaxAgeSeconds == 0 || static.Revalidate {
		t.Fatalf("static policy = %+v", static)
	}
	for _, kind := range []PathKind{KindDynamic, KindAPI} {
		p := PolicyFor(kind)
		if p.Kind != kind {
			t.Errorf("PolicyFor(%q).Kind = %q", kind, p.Kind)
		}
		if p.MaxAgeSeconds != 0 {
			t.Errorf("PolicyFor(%q).MaxAgeSeconds = %d, want 0", kind, p.MaxAgeSeconds)
		}
		if !p.Revalidate {
			t.Errorf("PolicyFor(%q).Revalidate = false, want true", kind)
		}
	}
}

func TestPolicyForUnknownKindIsConservative(t *testing.T) {
	p := PolicyFor(PathKind("unknown"))
	if p.Kind != KindDynamic || p.MaxAgeSeconds != 0 || !p.Revalidate {
		t.Fatalf("unknown policy = %+v", p)
	}
}

TestClassifyPath 里的 /api/export.csv 是最关键的一行:它同时满足 API 前缀和静态扩展名,期望结果是 API。/images/logo.png?v=3 验证查询串不影响分类,/downloads/report.pdf 验证扩展名规则不只覆盖前端资源。

TestClassifyPathRejectsBadInput 覆盖空路径、相对路径和 ..。这里用 errors.Is 而不是比较错误字符串,是为了让上层可以按错误类型决定返回 400 还是 404,而不依赖文案。

TestPolicyFor 把“API 不缓存”钉死为 MaxAgeSeconds == 0。这条断言看起来简单,但它防止未来有人为了提升命中率给 API 加一个短 TTL;一旦加上,个性化响应就会重新进入共享缓存。

TestPolicyForUnknownKindIsConservative 是防御性测试:即使调用方传入了拼错的类别,策略也必须是保守的。它不验证错误处理,因为 PolicyFor 的签名没有 error,函数必须自己给出安全默认值。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/edge/ -count=1 -v -run 'TestClassifyPath|TestPolicyFor'

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestClassifyPath
--- PASS: TestClassifyPath (0.00s)
=== RUN   TestClassifyPathRejectsBadInput
--- PASS: TestClassifyPathRejectsBadInput (0.00s)
=== RUN   TestPolicyFor
--- PASS: TestPolicyFor (0.00s)
=== RUN   TestPolicyForUnknownKindIsConservative
--- PASS: TestPolicyForUnknownKindIsConservative (0.00s)
PASS
ok  	webapp/internal/edge	0.224s

输出里的 0.224s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。四个测试覆盖了分类、非法输入、策略映射和未知类别四条路径。

小结

静态与动态的分界不是文件后缀,而是“同一份内容对多少人相同”。共享缓存只应该存放对所有人相同的响应;一旦响应里带用户上下文,它就必须按用户隔离,或者干脆不缓存。

代码层面,ClassifyPath 把产品语义固化成可测试的分类,PolicyFor 把分类翻译成缓存参数。API 前缀优先于扩展名、非法路径直接报错、未知类别保守处理,这三条选择共同保证了“不确定时不缓存”。

下一节会走到网关:当请求已经分好类,网关如何在一次连接里完成认证、路由和限流,以及为什么这些判断不能塞进业务服务。

阅读导航:上一节:目录与学习路径 · 下一节:7.2 网关的认证、路由与限流 。

常见错误与边界

  • 用文件后缀判断静态与动态。/export/bill.pdf 可能是按用户生成的账单。
  • 把“除 API 外都缓存”当成通用规则。没有 /api/ 前缀的个性化页面会进入共享缓存。
  • 静态资源不带内容哈希。长 TTL 下发布新版本,用户会继续拿到旧文件。
  • 商品详情页整页一个 TTL。价格更新慢和缓存频繁失效必居其一。
  • 响应带 Set-Cookie 却仍走共享缓存。缓存键没有包含用户,内容就会串号。
  • 分类函数接受相对路径或 ..。缓存键可能拼到预期之外的目录。
  • 未知类别按静态处理。分类器的一次拼写错误会变成一次数据泄漏。
  • 把分类函数当成缓存键生成器。响应头、语言、登录态仍需在响应阶段覆盖默认策略。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查