《互联网产品的服务端架构·产品、流量与基础能力》8.2 缓存位置与一致性窗口

从头像改完八分钟仍显示旧图的事故写起,说明缓存位置决定一致性窗口,而不是加了缓存就快;拆解本地缓存、Redis 与 CDN 的 TTL 如何叠加,并给出窗口求和与产品预算判断的可测试 Go 实现,以及版本化 URL 和分层失效的处理方式,减少重复上传。

头像改完,用户自己看不到

「回声」上线头像上传后,客服收到一类很集中的反馈:改完头像,自己的个人页还是旧图。用户以为没上传成功,又传一次,再传一次,最后在审核队列里留下三张几乎一样的图片,存储和人工审核成本都跟着上涨。

排查后发现,头像请求穿过了三层缓存:进程内缓存 60 秒、Redis 120 秒、CDN 300 秒。上传接口写完对象存储后,只删除了 Redis 里的键,本地缓存和 CDN 都没有动。更麻烦的是,即使删掉 Redis,用户自己的下一次请求仍可能命中本机 60 秒的旧值;而 CDN 边缘节点上那份旧图,要等到 300 秒后自然过期。

团队一开始的反应是“把 TTL 调短”。TTL 从 300 秒调到 30 秒后,源站带宽涨了三倍,旧头像的问题只是从八分钟变成一分半,并没有消失。真正的问题是:他们把缓存当成了一种加速手段,却没有把“缓存放在哪里”当成一致性设计的一部分。

缓存位置决定一致性窗口

每增加一层缓存,就多一个可能返回旧数据的地方。用户最终看到的数据有多旧,不取决于某一层的 TTL,而取决于请求路径上所有层的叠加。

缓存层典型 TTL(案例假设值)谁在命中失效难点对窗口的贡献
进程内 local60 秒当前实例处理的请求每台实例各存一份,需要广播或等待自然过期60 秒
Redis120 秒所有实例共享可以主动删除,但删完仍可能被旧 local 覆盖再加 120 秒
CDN300 秒边缘节点覆盖的用户节点多、purge 有延迟,且已发出的响应无法收回再加 300 秒

把三层叠起来,最坏情况下用户可能看到 480 秒之前的数据。这个数字不是平均值,而是一个用于产品评审的边界:如果产品能接受的最旧数据是 30 秒,那么这套缓存结构无论平均命中率多高都不合格。

窗口的第一层是进程内缓存。它最快,也最难失效,因为每个实例都有一份独立的副本。修改数据时,你只能删掉当前实例的副本,其他实例要等 TTL 到期,或者依赖一条广播消息。广播本身会丢、会延迟,实例越多,完全失效的时间越长。

第二层是 Redis 这类共享缓存。它比本地缓存容易失效,一次 DEL 就能让所有实例同时看不到旧值。但它不能解决“本地已经有一份旧值”的问题:请求可能先命中 local,根本走不到 Redis。所以共享缓存的失效必须和本地缓存的失效一起设计,只删 Redis 是不够的。

第三层是 CDN。它离用户最近,也最不受服务端控制。purge 通常有延迟,已经下发到边缘节点的响应可能还在有效期内;不同地区、不同运营商的节点刷新时间也不一致。CDN 适合缓存“可以旧几分钟”的内容,比如公开头像、文章封面和静态配置,不适合缓存“用户刚改完必须立刻看到”的内容。

产品能容忍的窗口差别很大。点赞数旧 5 秒,用户基本无感;评论数旧 30 秒,多数场景可以接受;头像和昵称旧 1 分钟,用户会怀疑操作失败;余额、库存、权限这类数据没有“可接受的旧值”,只能不缓存,或者用版本号、条件请求保证读到写之后的值。把所有这些数据放进同一套 TTL,是缓存设计里最常见的偷懒。

还有一个常被忽略的维度:读自己的写。用户改完资料后,下一次请求往往由同一个用户发起,产品对“自己看到自己刚改的内容”要求最高。常见做法是上传成功后返回一个带版本号的 URL,让新请求天然绕过旧缓存;或者对本人资料强制回源,只对其他用户使用长 TTL。这比全局调短 TTL 精准得多。

失败场景:本地 60 秒加 CDN 300 秒,旧头像挂了八分钟

假设头像的缓存配置是:本机 60 秒、Redis 120 秒、CDN 300 秒。用户 10:00:00 上传成功,CDN 上最后一次回源发生在 09:59:30,边缘节点把旧图缓存到 10:04:30;用户 10:00:05 刷新个人页,请求命中 CDN,看到旧图。10:04:30 之后 CDN 回源,但源站前面的 Redis 还存着 09:59:00 写入的旧值,直到 10:01:00 才过期;本地缓存又可能把这份旧值再留 60 秒。几层错位叠加,用户最晚到 10:08 左右才看到新头像。

后果不只是“看到旧图”。第一,用户会重复上传,对象存储多出无用的对象,审核队列被同一张图占满。第二,客服无法解释“为什么只有你自己看不到”,因为其他用户可能早就看到新图了,问题看起来像偶发。第三,团队为了缓解问题把 CDN TTL 调到 30 秒,源站带宽和回源 QPS 上升,首页图片加载变慢,故障从一个功能扩散到全站。

修复这套问题需要区分三种可见性:本人刚写完必须立刻看到;其他用户可以在几十秒内看到;未登录访客可以更晚。对应的手段也不同:

  • 上传接口返回带版本号的新 URL,例如在路径或查询串里带 v=timestamp,本人页面直接用新 URL,天然绕过旧缓存。
  • 对本人资料页关闭 CDN 缓存或使用 Cache-Control: private, no-store,只对其他用户开放长缓存。
  • Redis 删除键之后,再发一条本地失效消息;本地缓存设置一个很短的上限,作为广播丢失时的兜底。
  • CDN 对头像这类可版本化的资源使用长 TTL,对固定 URL 使用短 TTL 或主动 purge,不要用同一个策略覆盖两类资源。
  • 监控“上传成功到本人可见”的时延,把它当成产品指标,而不是只看缓存命中率。

关键不是把某一层 TTL 调到多小,而是让“谁能看到多旧的数据”变成一个显式的、分层的规定。

用 Window 与 Fits 显式表达预算

下面的实现来自 webapp-lab 的 internal/cache 包。Window 把各层 TTL 相加,得到一个用于评审的最坏情况窗口;Fits 再把它和产品给出的预算比较。两层都做输入校验:同一层重复出现、TTL 为负、层名为空都会报错,避免配置错误被静默累加。

// file: internal/cache/window.go
// tier: L1
// verify: go test ./internal/cache/

package cache

import "fmt"

// Layer 是缓存所在的物理位置。位置不同,失效难度不同。
type Layer string

const (
	LayerLocal Layer = "local"
	LayerRedis Layer = "redis"
	LayerCDN   Layer = "cdn"
)

// TTL 是一层缓存的存活时间。
type TTL struct {
	Layer   Layer
	Seconds int
}

// Window 返回用户可能看到旧数据的最长时间:各层 TTL 之和。
func Window(ttls []TTL) (int, error) {
	seen := make(map[Layer]bool, len(ttls))
	total := 0
	for _, ttl := range ttls {
		if ttl.Layer == "" {
			return 0, fmt.Errorf("cache layer must not be empty")
		}
		if ttl.Seconds < 0 {
			return 0, fmt.Errorf("layer %q ttl must be >= 0, got %d", ttl.Layer, ttl.Seconds)
		}
		if seen[ttl.Layer] {
			return 0, fmt.Errorf("layer %q appears more than once", ttl.Layer)
		}
		seen[ttl.Layer] = true
		total += ttl.Seconds
	}
	return total, nil
}

// Fits 判断叠加后的窗口是否落在产品可感知的预算内。
func Fits(ttls []TTL, budgetSeconds int) (bool, error) {
	if budgetSeconds < 0 {
		return false, fmt.Errorf("budget must be >= 0, got %d", budgetSeconds)
	}
	total, err := Window(ttls)
	if err != nil {
		return false, err
	}
	return total <= budgetSeconds, nil
}

Window 的返回值是 int 而不是 time.Duration,因为这里的输入本来就是秒级的配置值,直接比较更不容易在单位上出错。真正接入缓存客户端时,可以在边界处把 time.Duration 转成秒,而不是让核心逻辑同时处理两种单位。

同一层重复出现必须报错,而不是取最大值或相加。重复通常意味着配置合并出了问题:比如默认配置里已经有 CDN,环境配置又追加了一条。静默处理会让窗口算错,而且错误很难在评审时发现。

空层名也值得报错。它通常来自结构体没填全,或者从 YAML 反序列化时字段名写错。让它在启动阶段失败,比上线后在某些请求路径上返回一个来路不明的窗口要好。

Fits 把“是否在预算内”单独暴露出来,是为了让产品评审有一个可以引用的布尔值。预算为负同样报错,因为它意味着调用方传错了单位或符号。Fits 不负责决定超预算后怎么办,那是降级策略的事。

测试:重复层、负 TTL 与预算边界

窗口计算的 bug 往往藏在配置的边角:同一层写了两遍、某个环境的 TTL 是负数、预算刚好等于窗口。下面的测试把这些情况逐个固定。

// file: internal/cache/window_test.go
// tier: L1
// verify: go test ./internal/cache/

package cache

import "testing"

func TestWindowSumsLayers(t *testing.T) {
	got, err := Window([]TTL{
		{Layer: LayerLocal, Seconds: 60},
		{Layer: LayerRedis, Seconds: 300},
		{Layer: LayerCDN, Seconds: 600},
	})
	if err != nil {
		t.Fatalf("Window returned error: %v", err)
	}
	if got != 960 {
		t.Fatalf("Window = %d, want 960", got)
	}
}

func TestWindowEmptyIsZero(t *testing.T) {
	got, err := Window(nil)
	if err != nil {
		t.Fatalf("Window returned error: %v", err)
	}
	if got != 0 {
		t.Fatalf("Window = %d, want 0", got)
	}
}

func TestWindowRejectsDuplicateLayer(t *testing.T) {
	_, err := Window([]TTL{
		{Layer: LayerRedis, Seconds: 10},
		{Layer: LayerRedis, Seconds: 20},
	})
	if err == nil {
		t.Fatal("expected an error for a duplicated layer")
	}
}

func TestWindowRejectsNegativeTTL(t *testing.T) {
	_, err := Window([]TTL{{Layer: LayerLocal, Seconds: -1}})
	if err == nil {
		t.Fatal("expected an error for a negative ttl")
	}
}

func TestWindowRejectsEmptyLayer(t *testing.T) {
	_, err := Window([]TTL{{Seconds: 30}})
	if err == nil {
		t.Fatal("expected an error for an empty layer")
	}
}

func TestFitsComparesAgainstBudget(t *testing.T) {
	layers := []TTL{
		{Layer: LayerLocal, Seconds: 60},
		{Layer: LayerRedis, Seconds: 300},
	}

	fits, err := Fits(layers, 360)
	if err != nil {
		t.Fatalf("Fits returned error: %v", err)
	}
	if !fits {
		t.Fatal("Fits = false, want true at the exact budget")
	}

	fits, err = Fits(layers, 359)
	if err != nil {
		t.Fatalf("Fits returned error: %v", err)
	}
	if fits {
		t.Fatal("Fits = true, want false above the budget")
	}
}

func TestFitsRejectsNegativeBudget(t *testing.T) {
	if _, err := Fits(nil, -1); err == nil {
		t.Fatal("expected an error for a negative budget")
	}
}

func TestFitsPropagatesWindowError(t *testing.T) {
	_, err := Fits([]TTL{{Layer: LayerCDN, Seconds: -5}}, 100)
	if err == nil {
		t.Fatal("expected the Window error to propagate")
	}
}

TestWindowSumsLayers 用 60、300、600 三层的组合验证求和,结果是 960。这个用例故意让三层的顺序和大小都不同,防止实现只取了最大值或最后一层。

TestWindowEmptyIsZero 固定空输入返回 0 而不是报错。没有缓存层时窗口就是 0,这是一个合法状态,调用方不需要为“没配缓存”写特殊分支。

TestWindowRejectsDuplicateLayer、TestWindowRejectsNegativeTTL 和 TestWindowRejectsEmptyLayer 覆盖三类配置错误。它们只断言“必须报错”,不绑定具体错误文案,方便后续改进提示信息。

TestFitsComparesAgainstBudget 检查边界:窗口 360 秒、预算 360 秒时应该通过,预算 359 秒时应该失败。边界包含还是排除,是这类函数最容易出现分歧的地方,必须用测试写死。

TestFitsRejectsNegativeBudget 和 TestFitsPropagatesWindowError 验证错误路径。后者尤其重要:Fits 不能吞掉 Window 的错误然后返回一个看似正常的 false,否则调用方会把配置错误当成“预算不够”。

这些测试不验证 CDN purge 的延迟,也不验证本地广播是否可靠。那些是集成和运维层面的事实,单元测试只负责把窗口求和的语义固定下来。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/cache/ -count=1 -run 'TestWindow|TestFits' -v

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

=== RUN   TestWindowSumsLayers
--- PASS: TestWindowSumsLayers (0.00s)
=== RUN   TestWindowEmptyIsZero
--- PASS: TestWindowEmptyIsZero (0.00s)
=== RUN   TestWindowRejectsDuplicateLayer
--- PASS: TestWindowRejectsDuplicateLayer (0.00s)
=== RUN   TestWindowRejectsNegativeTTL
--- PASS: TestWindowRejectsNegativeTTL (0.00s)
=== RUN   TestWindowRejectsEmptyLayer
--- PASS: TestWindowRejectsEmptyLayer (0.00s)
=== RUN   TestFitsComparesAgainstBudget
--- PASS: TestFitsComparesAgainstBudget (0.00s)
=== RUN   TestFitsRejectsNegativeBudget
--- PASS: TestFitsRejectsNegativeBudget (0.00s)
=== RUN   TestFitsPropagatesWindowError
--- PASS: TestFitsPropagatesWindowError (0.00s)
PASS
ok  	webapp/internal/cache	0.240s

-run 的正则同时匹配窗口求和与预算判断两组测试。0.240s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。

小结

缓存位置决定一致性窗口。进程内、Redis 和 CDN 各贡献一段旧数据时间,叠加起来才是用户可能看到的最坏情况。产品能不能接受这个窗口,取决于数据本身:点赞可以等几秒,头像最好几十秒内更新,余额和权限不能有旧值。

Window 把各层 TTL 显式相加,Fits 把结果和产品预算比较。两个函数都很小,但它们把“加缓存”从一句口号变成了一个可以评审的数字。真正难的部分不在代码,而在承认不同数据需要不同的窗口。

下一节会讨论另一种更尖的故障:不是所有数据一起变旧,而是单个键被瞬间打满,或者所有键在同一时刻一起失效。

阅读导航:上一节:8.1 读多写少的产品事实 · 下一节:8.3 热点与失效的产品表现 。

常见错误与边界

  • 认为“加了缓存就快”。缓存同时引入了旧数据窗口,位置越多,窗口越难估。
  • 只按单层 TTL 评估一致性。用户看到的旧数据是各层叠加的结果,不是某一层的配置值。
  • 上传成功后只删 Redis。本地缓存和 CDN 仍然可能返回旧值,用户自己最先撞上。
  • 为了修一致性问题全局调短 TTL。源站回源量会上升,问题往往只是从几分钟变成几十秒。
  • 所有数据共用一套 TTL。点赞、头像、余额的容忍度差几个数量级,应该分层设计。
  • 忽略“读自己的写”。本人刚改完的可见性要求最高,应该用版本化 URL 或强制回源处理。
  • 把 CDN purge 当成同步操作。边缘节点多、刷新有延迟,purge 完成前旧响应仍可能被返回。
  • 重复层静默合并。配置里同一层出现两次通常是合并错误,应该在启动时报错而不是猜一个值。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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