缓存策略与一致性
从 source of truth 出发组织 Cache-aside、TTL、失效、并发回源、穿透、击穿和雪崩,解释缓存如何用可控的不一致窗口换取延迟和容量。
[!info] related notes
- 所属 MOC:Caching MOC、Backend 常用中间件与基础设施 MOC
- 具体实现:Redis、Go 缓存策略
- 事实层:数据库 MOC
- 可靠性:Backpressure、Circuit Breaker
缓存策略与一致性
[!abstract] 核心结论 缓存保存的是为延迟和容量服务的派生副本。设计的第一问题不是“缓存多久”,而是明确 Source of Truth、允许读旧多久、写入与失效失败时如何收敛,以及缓存不可用时怎样保护事实层。
基本角色
Source of Truth
→ owns business facts and constraints
Cache
→ derived copy for faster reads / reduced load
除非系统明确按 Cache-primary 模型设计,否则缓存丢失后应该可以从事实层重建。
Cache-aside
读路径
GET key
├─ hit → return cached value
└─ miss → load source of truth
→ populate cache
→ return
写路径
常见做法:
update database transaction
→ after commit, invalidate cache
无并发旧回填且读取源足够新时,下一次 miss 会重新加载;实际还需考虑下面的竞争与数据库副本延迟。
[!warning] 不要在事务提交前更新缓存 如果数据库最终回滚,其他请求可能已经读到从未真正提交的值。
为什么常“删缓存”而不是“直接更新缓存”
直接更新需要同时维护两份事实,并可能出现并发覆盖:
T1 reads old value
T2 updates DB to v2 and cache to v2
T1 later updates cache with stale v1
删除缓存让下一次读回到事实层重新构建,逻辑更简单。但删除也不是天然无竞争,仍需分析故障窗口。
Cache-aside 的两个典型竞争
先删 Cache,再写 DB
T1 delete cache
T2 cache miss → reads old DB → writes old cache
T1 commits new DB
结果:Cache 长时间保留旧值。因此通常不优先先删后写。
先写 DB,再删 Cache
T1 cache miss → reads old DB
T2 commits new DB → deletes cache
T1 writes old value into cache
该窗口通常较窄但仍存在。可根据一致性要求采用:
- 短 TTL;
- Versioned Value / Compare Version;
- 延迟再次失效(不是通用万能方案);
- CDC / Event-driven Invalidation;
- 协调写入与回填的版本/锁协议;仅改名为 Write-through 或 Read-through 不会自动消除竞争;
- 对关键读绕过缓存或等待版本。
TTL 是一致性预算
TTL 约束单次缓存条目的有效期,并影响:
- 该条目被自然淘汰出读路径的时机;
- 清理策略下的 Memory 占用(惰性过期不等于释放内存);
- 回源频率;
- 热点失效风险;
- 故障后自动收敛速度。
short TTL → fresher, more backend load
long TTL → fewer misses, longer stale window
TTL 从回填起算,而源数据可能已经落后;并发旧回填、多级缓存与 stale-while-revalidate 都可能延长从事实更新到读新的时间。要证明最大旧数据窗口,需一起约束源版本、回填、失效与过期后返回旧值的策略。
不要为所有 Key 使用同一 TTL。按数据变化率、读取频率、重建成本和业务风险分层,并加入 Jitter 避免同时过期。
三类高频故障
| 故障 | 根因 | 常见治理 |
|---|---|---|
| 穿透 | 查询不存在对象,缓存无法形成有效命中 | 参数校验、Negative Cache、Bloom Filter |
| 击穿 / Stampede | 热点 Key 失效时大量请求并发回源 | Singleflight、Mutex、逻辑过期、预热 |
| 雪崩 | 大量 Key / 节点同时失效 | TTL Jitter、多级缓存、限流、降级、容量保护 |
三者根因不同,不要只回答“加锁”。
Negative Cache 与 Bloom Filter
Negative Cache
对确定不存在的结果使用短 TTL:
user:999 → NOT_FOUND (30s)
必须避免把临时查询失败缓存成“永久不存在”。
Bloom Filter
对正确维护的已插入集合,标准 Bloom Filter 的否定结果表示「一定不在该集合」,肯定结果只表示「可能存在」。它有假阳性,没有假阴性;肯定结果仍需查事实层。若过滤器尚未同步新数据,否定结果不能代表数据库当前不存在;必须处理更新顺序与重建窗口。
Stale-while-revalidate / 逻辑过期
cache entry contains value + logical expiry
→ expired reader may receive stale value
→ one worker refreshes in background
适合可接受短时旧数据、但不能让热点请求同时回源的场景。代价是必须明确最大 Staleness、Refresh Lock、失败重试和过期值是否还能继续服务。
多级缓存
L1 process cache
→ L2 Redis
→ Database
优点:降低网络延迟和 Redis 压力。
代价:
- 每个实例都有旧副本;
- 失效广播和版本更复杂;
- 发布后 Schema / Serialization 兼容;
- L1 容量和 GC 压力;
- 排障时难判断命中哪一层。
常用策略:L1 短 TTL、L2 稍长 TTL,加版本或 Invalidation Event。
Event / CDC 驱动失效
DB commit
→ Outbox / CDC event
→ invalidate or update cache
可以缩短旧值窗口并跨实例同步,但消息会延迟或重复,因此:
- Invalidation 必须幂等;
- Event 带 Entity Version;
- Cache Value 不能被旧事件覆盖;
- TTL 可让旧条目自然失效,但仍需确保回源最终变新、没有无限旧回填;它单独不证明端到端收敛时限。
缓存故障时的选择
Fail Open
缓存错误后直读数据库。适合低流量、数据库有余量的场景,但必须限流和合并请求,避免 Cache Outage 变成 DB Outage。
Fail Closed / Degrade
拒绝、返回旧值或关闭非关键功能是不同降级策略。数据库无法承受全量回源时可按业务选择;涉及权限、余额等安全或强一致判断时,返回旧值未必安全,应按该接口的失败合同决定。
策略应按接口定义,不能统一“Redis 挂了就查 DB”。
观测重点
hit / miss rate by cache layer
load / fill / invalidate latency
backend QPS caused by miss
stale read / version conflict
hot key / big key
expired / evicted keys
singleflight shared calls
refresh success / age of cached value
cache error and fallback rate
命中率高不等于缓存健康:可能命中大量过期或错误数据。
设计检查表
- Source of Truth 和缓存可重建性明确。
- 写入、事务提交与 Invalidation 顺序明确。
- 结合源数据、回填、TTL 与过期后读旧策略,验证业务 Staleness Budget,并按需要加入 Jitter。
- 穿透、击穿、雪崩分别有治理策略。
- 多级缓存有版本、失效和容量边界。
- 缓存故障不会无保护地打垮数据库。
- Invalidation Event 幂等并拒绝旧版本。
- 指标包含 Freshness 和 Backend Load,而不只看 Hit Rate。