一张商品图和一个推荐接口走了同一条缓存
「回声」是一个内容社区。上线半年后,团队在 CDN 控制台看到图片命中率只有 71%,于是运营提了一个看起来很自然的优化:把除 /api/ 之外的所有路径都加上 10 分钟缓存。规则上线当天,首页加载快了约 200 毫秒(案例假设值)。
三天后,客服收到投诉:用户 A 打开首页,推荐流里出现了用户 B 的关注列表。排查发现,/feed/recommend 不在 /api/ 前缀下,被那条“除 API 外都缓存”的规则命中了。CDN 把 A 的个性化响应存了 10 分钟,期间请求落到同一边缘节点的用户都拿到了它。
复现也很困难:本地刷新十次正常,因为本地没有 CDN;测试环境清缓存后正常,因为请求量太小,边缘节点没有发生共享。只有生产环境的高并发把这个错误放大成事故。
复盘时团队说“CDN 规则写错了”。但真正的错误在分类:他们把“是不是文件”当成了静态与动态的分界。推荐接口没有扩展名,却被当成页面;页面有 .html 后缀,内容却可能因人而异。分界从来不是文件后缀。
分界的依据是同一份内容对多少人相同
判断一个路径能不能走共享缓存,只需要问一个问题:在缓存有效期内,这份响应对多少人是同一份?答案决定了它放在哪一层、缓存多久、失效时怎么办。
| 内容 | 同一份内容对谁相同 | 建议位置 | 缓存时长 | 失效方式 |
|---|---|---|---|---|
| CSS、字体、图片 | 所有人 | CDN | 天到年 | 文件名带内容哈希 |
| 公开文章页 | 所有人(可能含少量运营位) | CDN + 源站 | 分钟到小时 | 发布时主动刷新 |
| 商品详情页 | 所有人,价格和库存是例外 | CDN + 短 TTL | 30–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却仍走共享缓存。缓存键没有包含用户,内容就会串号。 - 分类函数接受相对路径或
..。缓存键可能拼到预期之外的目录。 - 未知类别按静态处理。分类器的一次拼写错误会变成一次数据泄漏。
- 把分类函数当成缓存键生成器。响应头、语言、登录态仍需在响应阶段覆盖默认策略。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。