副本在原本改变的那一刻就旧了
一句话总结
缓存策略是在陈旧与未命中之间走钢丝。它没有标准答案,只有符合数据特性的平衡点。
为什么需要了解这一点
缓存之所以能很好地工作,得益于两种特性。时间局部性——刚使用过的数据很可能很快再次使用。空间局部性——使用某项数据后,很可能很快也会使用其附近的数据。
问题源于缓存是原始数据的副本。原始数据一旦变化,副本就会过时。这里要区分两种不良情况。陈旧是缓存持有比原始数据更旧的值,导致用户看到错误值。未命中是缓存中没有值,必须访问原始数据,因此速度较慢。
为减少陈旧而频繁丢弃,会增加未命中;为减少未命中而长时间保留,又会增加陈旧。这种取舍是缓存所有困难的根源。
工作原理
最简单的失效方式是 TTL。为每个条目设置寿命,到期后丢弃。它的魅力在于简单且可预测——无需另写失效逻辑,即使在最坏情况下,陈旧时间也不会超过 TTL。因此,它适合短时间过时也无妨的数据,例如新闻列表、热门帖子、汇率。相反,账户余额等数据仅靠 TTL 不够,还需要显式失效。
显式失效分为两个方向。事件驱动方式会在原始数据变化时立即删除相关缓存。它的新鲜度较好,但很难追踪依赖关系——“商品 42”可能同时存在于商品详情缓存、分类列表缓存和搜索结果缓存中。漏掉一个依赖,就会形成无声的陈旧缺陷。
版本 key 是更稳健的实用技巧。把版本直接写进 key,将失效从删除变成迁移。使用 user:42:v7:profile 时,数据发生变化就把版本提升为 v8。因为再也没人查找 v7 key,它会通过 TTL 自然清理。无需直接删除,而且对失效与重新查询交错发生的竞争条件也有很强的抵抗力,因为旧 key 与新 key 在物理上彼此不同。
实际工作中的表现
失效真正困难的原因有三个。第一,分布式——缓存散布在多台服务器和多个层级,原子地使其失效会成为分布式共识问题。第二,竞争条件——删除缓存的时刻与另一个请求重新读入旧值的时刻交错后,刚删除的值会复活。第三,依赖关系复杂。
所以实际系统会放弃完美失效,先确定“能够容忍多长时间的陈旧”,再混合使用 TTL 与显式失效。
四种缓存模式
它们按由谁负责读写来区分。记住名称,可以缩短团队沟通。
| 模式 | 读取 | 写入 | 特点 |
|---|---|---|---|
| Cache-aside | 应用检查缓存 → 没有则访问 DB → 放入缓存 | 应用写入 DB 并使缓存失效 | 最常见,第一次请求总是较慢 |
| Read-through | 缓存代替应用读取 DB | — | 应用代码变简单 |
| Write-through | — | 同时写入缓存与 DB | 缓存始终最新,写入较慢 |
| Write-behind | — | 写入缓存,稍后写入 DB | 写入快,存在丢失风险 |
实际工作中 95% 都是第一种。其余模式出现在使用由缓存层封装 DB 访问的 library 时。 Write-behind 中,如果缓存终止,尚未写入 DB 的内容就会消失,因此不能用于 资金数据。
哪些内容不应缓存
- 经常变化且必须准确的内容——库存数量、余额。出现偏差就会引发事故。
- 因用户而异的大对象——命中率低,只会占用内存。
- 本来计算成本就低的内容——缓存往返(0.5ms)可能比计算更昂贵。
- 个人信息——缓存通常不加密,而且保留策略宽松。
第三点经常被忽略。如果把耗时 1μs 的内存计算放入 Redis,速度会慢 500 倍。 缓存只对慢操作(DB 查询、外部 API、繁重计算)有价值。
缓存 key 的设计规则
labhub:v3:user:42:profile
│ │ │ │ └ 무엇
│ │ │ └ 식별자
│ │ └ 종류
│ └ 스키마 버전 ← 형식이 바뀌면 올린다. 옛 키는 TTL 로 사라진다
└ 애플리케이션 접두 ← 같은 Redis 를 여러 앱이 쓸 때 충돌을 막는다
关键是加入版本。缓存中对象的结构变化后,读取旧值会导致错误;提升版本后,
系统会使用新 key,旧 key 则通过 TTL 自然消失。这比用 FLUSHALL 删除全部内容
更安全——后者会同时清空所有缓存,引发对 DB 的 stampede。
通配符删除也要谨慎。KEYS user:* 是遍历全部数据的 O(n) 操作,
会让 Redis 停顿。应使用 SCAN,或者从一开始就同时维护用于 tag 的集合(Set)。
下一项检查将看什么
首先通过测验检查陈旧与未命中的取舍关系,以及允许使用 TTL 的数据条件。从下一模块开始,逐一操作 Redis 数据结构,随后分别练习排行榜、缓存模式和 stampede 防御。