缓存的本质是「用空间换时间」:把算过/查过的结果暂存起来,下次直接用。但代价是——数据会过期。没有清晰失效策略的缓存,会 delivers 陈旧数据(stale data)、让用户困惑、把调试变成噩梦。
每条缓存条目都要回答一个问题:它什么时候不再正确? 这正是缓存设计里最难、也最容易被忽视的一环。下面 9 种策略,按「一致性强度 / 复杂度 / 适用场景」三维度帮你落地。
「计算机科学只有两件难事:缓存失效和命名。」缓存能极大提升 API 性能,但一旦失效策略没设计好,就会把过期数据喂给用户、把人逼疯。本文编译自 Codelit 工程系列《Cache Invalidation Strategies》(2026-03-29),系统梳理 9 种失效策略:TTL、事件驱动、Write-Through、Cache-Aside、标签、版本化键、Purge API、Stale-While-Revalidate、防缓存踩踏,并给出如何组合与选型。
缓存的本质是「用空间换时间」:把算过/查过的结果暂存起来,下次直接用。但代价是——数据会过期。没有清晰失效策略的缓存,会 delivers 陈旧数据(stale data)、让用户困惑、把调试变成噩梦。
每条缓存条目都要回答一个问题:它什么时候不再正确? 这正是缓存设计里最难、也最容易被忽视的一环。下面 9 种策略,按「一致性强度 / 复杂度 / 适用场景」三维度帮你落地。
最简单粗暴:每条缓存值设置一个固定存活时长,到点自动过期。
SET user:42:profile "{...}" EX 300 # 5 分钟后过期优点:简单、可预测、自带「自愈」——过期就重读。缺点:在 TTL 窗口内数据是陈旧的;TTL 太短浪费缓存、太长又一直喂旧数据。适用:读多、能容忍短暂陈旧的场景(商品列表、公开资料)。
进阶是自适应 TTL:数据越久没动,TTL 越长,减少无效回源:
def adaptive_ttl(last_modified, base_ttl=300):
age = time.time() - last_modified
if age > 86400: # 24h 没改过
return base_ttl * 4
elif age > 3600: # 1h 没改过
return base_ttl * 2
return base_ttl数据一变就立刻清缓存——通过发布事件触发订阅方删除对应键。
用户更新资料 -> publish "user:42:updated" -> 缓存订阅方删除 user:42:profile
优点:几乎零陈旧(near-zero staleness)。缺点:要引入事件基础设施(Kafka、Redis Pub/Sub、SNS),且写路径与缓存层产生耦合。适用:一致性要求高的数据(库存数量、账户余额)。即使用了事件驱动,也建议保留 TTL 作为兜底安全网。
每次写操作同时更新缓存和数据库:
def update_user(user_id, data):
db.update("users", user_id, data)
cache.set(f"user:{user_id}:profile", serialize(data), ex=3600)优点:写完后缓存立刻是最新的。缺点:写延迟变高;若缓存写入失败就会产生不一致。变体 Write-Behind(Write-Back):先写缓存、异步刷库,写更快但有丢数据风险。
补充:纯缓存模式还有 Read-Through(缓存 miss 时由缓存自己去查库并回填)与 Write-Around(写直接落库、不进缓存,避免冷数据占缓存),可据读写比组合使用。
应用自己管缓存,最经典也最常用:
def get_user(user_id):
cached = cache.get(f"user:{user_id}")
if cached:
return deserialize(cached)
user = db.query("SELECT * FROM users WHERE id=%s", user_id)
cache.set(f"user:{user_id}", serialize(user), ex=600)
return user
def update_user(user_id, data):
db.update("users", user_id, data)
cache.delete(f"user:{user_id}") # 失效,而不是更新关键洞察:写的时候要删(delete)缓存条目,而不是更新它。下次读会从数据源重新加载,拿到的是真值。更新容易因并发竞态写出脏值,删除更稳。
给缓存响应打标签,可以按组批量失效:
Cache-Tag: product, product:42, category:electronics
商品更新时 purge 掉所有 product:42 标签;类目变动时 purge category:electronics。CDN 普遍支持:Fastly(Surrogate-Key)、Cloudflare(Cache-Tag)、Varnish(xkey)。
curl -X POST "https://api.fastly.com/service/{id}/purge/product:42" \
-H "Fastly-Key: $TOKEN"适用:聚合了多个实体的页面或响应。标签失效能大幅简化 CDN 缓存管理。
把版本号直接塞进缓存键:
user:42:profile:v17
数据变化时版本号 +1,旧键靠 TTL 自然淘汰,无需显式删除:
def get_user(user_id):
version = db.query("SELECT cache_version FROM users WHERE id=%s", user_id)
key = f"user:{user_id}:profile:v{version}"
cached = cache.get(key)
if cached:
return deserialize(cached)
# ... 取数并用该键缓存优点:无竞态、无显式失效。缺点:旧版本在 TTL 到期前会占用内存。
暴露一个显式端点,用来手动或流水线清特定缓存:
POST /api/admin/cache/purge
{ "patterns": ["user:42:*", "feed:home"] }常用于人工干预和 CI/CD(部署触发 purge)。注意:Purge 端点必须鉴权 + 限流,否则会被滥用。
先立刻把旧值返回给用户,同时在后台异步拉取新副本:
Cache-Control: max-age=60, stale-while-revalidate=300
含义:60 秒内直接走缓存;之后 300 秒内仍先返回旧值,但后台重新校验。用户永远不用等缓存 miss。代价:重校验期间会短暂返回陈旧数据。适合对首屏延迟敏感的用户态接口。
热门键过期的瞬间,成百上千请求会同时砸向数据库——这就是缓存踩踏(Dogpile / Cache Stampede)。
解法一:加锁——只放一个请求去重算,其余等待或拿旧值:
def get_with_lock(key, fetch_fn, ttl=300):
value = cache.get(key)
if value:
return value
lock_key = f"lock:{key}"
if cache.set(lock_key, "1", nx=True, ex=10): # 抢到锁
value = fetch_fn()
cache.set(key, value, ex=ttl)
cache.delete(lock_key)
return value
else:
time.sleep(0.05)
return cache.get(key) # 稍后重试解法二:概率提前过期——在 TTL 到期前随机提前刷新;解法三:预热——定时在过期前刷新热门键。三者可叠加。
| 策略 | 一致性 | 复杂度 | 适合 |
|---|---|---|---|
| TTL | 最终一致 | 低 | 通用缓存 |
| 事件驱动 | 强 | 高 | 关键数据 |
| Write-Through | 强 | 中 | 写多 |
| Cache-Aside + 删 | 最终一致 | 低 | 读多 |
| 标签 | 按需 | 中 | CDN / 聚合页 |
| 版本化键 | 强 | 低 | 类不可变数据 |
| Stale-While-Revalidate | 最终一致 | 中 | 用户态延迟敏感 |
生产环境几乎不会只用一种,典型组合:CDN 层用「标签 + stale-while-revalidate」;应用缓存用「Cache-Aside + 事件驱动失效」;会话缓存用「TTL(短,15 分钟)」;配置缓存用「Write-Through + TTL(长,1 小时)」。
要点回顾:① 写时删缓存比更新更安全;② 即便用事件驱动,TTL 也得留作安全网;③ 热门键必须防 Dogpile;④ 标签失效极大简化 CDN 管理;⑤ Stale-While-Revalidate 让用户不等 miss 又不牺牲新鲜度。