头像改完,用户自己看不到
「回声」上线头像上传后,客服收到一类很集中的反馈:改完头像,自己的个人页还是旧图。用户以为没上传成功,又传一次,再传一次,最后在审核队列里留下三张几乎一样的图片,存储和人工审核成本都跟着上涨。
排查后发现,头像请求穿过了三层缓存:进程内缓存 60 秒、Redis 120 秒、CDN 300 秒。上传接口写完对象存储后,只删除了 Redis 里的键,本地缓存和 CDN 都没有动。更麻烦的是,即使删掉 Redis,用户自己的下一次请求仍可能命中本机 60 秒的旧值;而 CDN 边缘节点上那份旧图,要等到 300 秒后自然过期。
团队一开始的反应是“把 TTL 调短”。TTL 从 300 秒调到 30 秒后,源站带宽涨了三倍,旧头像的问题只是从八分钟变成一分半,并没有消失。真正的问题是:他们把缓存当成了一种加速手段,却没有把“缓存放在哪里”当成一致性设计的一部分。
缓存位置决定一致性窗口
每增加一层缓存,就多一个可能返回旧数据的地方。用户最终看到的数据有多旧,不取决于某一层的 TTL,而取决于请求路径上所有层的叠加。
| 缓存层 | 典型 TTL(案例假设值) | 谁在命中 | 失效难点 | 对窗口的贡献 |
|---|---|---|---|---|
| 进程内 local | 60 秒 | 当前实例处理的请求 | 每台实例各存一份,需要广播或等待自然过期 | 60 秒 |
| Redis | 120 秒 | 所有实例共享 | 可以主动删除,但删完仍可能被旧 local 覆盖 | 再加 120 秒 |
| CDN | 300 秒 | 边缘节点覆盖的用户 | 节点多、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 完成前旧响应仍可能被返回。
- 重复层静默合并。配置里同一层出现两次通常是合并错误,应该在启动时报错而不是猜一个值。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。