问题场景
用户访问首页 200ms 内必须返回,DB 查询一次需要 80ms,单查首页要 7 个查询——不缓存就是死。但缓存不是『加个 Redis』那么简单:Facebook 2010 年因为一次 Memcached 失效风暴丢了 2.5 小时全站可用性;Discord 2017 年发现消息列表的 LRU 在热点频道下表现糟糕,换 LFU 后命中率从 78% 提到 96%;GitHub 2020 年 PR 页面用 Russian Doll caching,命中率 99.3%。
本期讲三件事:缓存放在哪一层、怎么淘汰、怎么写。最关键的是『缓存失效』,被 Phil Karlton 称为计算机科学两大难题之一(另一个是命名)。
需求与约束(面试必问)
- 读写比:100:1 适合 cache,1:1 写多读少基本没收益(缓存命中率太低)。
- 数据热度分布:是否 80/20 Pareto?长尾分布缓存效果差,热点严重则单 key 风暴。
- 容忍 stale 多久:用户头像 1 小时旧没事;账户余额 1 秒旧也是事故。
- 对象大小:1KB 还是 1MB?Memcached 默认 slab 上限 1MB,超过要切片或换 Redis。
- 命中率目标:< 80% 通常意味着缓存层没价值;> 95% 才能真正卸载后端。
- 失效策略:TTL、显式 invalidate、还是 write-through?
高层架构(多层缓存)
graph LR
B["Browser cache
localStorage / SW"]
CDN["CDN Edge
Cloudflare / Akamai"]
RP["Reverse Proxy
Varnish / Nginx"]
APP["App + L1
in-proc · Caffeine"]
L2["Redis / Memcached
跨进程共享 L2"]
DB[("DB + buffer pool")]
B -->|①| CDN -->|②| RP -->|③| APP -->|④| DB
APP <-.->|miss → 回填| L2
L2 -.miss.-> DB
classDef client fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef edge fill:#0e2030,stroke:#5eead4,color:#e8eef5
classDef cache fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef origin fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class B client
class CDN,RP edge
class APP,L2 cache
class DB origin
命中率从左到右递增;返回时反向回填每一层,下一次请求短路
关键技术点
1. 缓存层次:Browser / CDN / App / DB Buffer Pool
原理:每一层缓存解决不同的延迟问题。Browser cache 省网络(0ms vs 50ms);CDN 把静态资产放到用户 50 公里以内的 PoP,p50 延迟 10ms;app 进程内 cache(Guava / Caffeine / Python functools.lru_cache)省 Redis 一次 RTT(0.1ms vs 1ms);Redis 跨进程共享,命中后省 DB 查询(1ms vs 50ms);DB buffer pool(Postgres shared_buffers, InnoDB buffer pool)省磁盘 IO。
Trade-off:
- 进程内 cache:✅ 0.1ms 级延迟、无网络故障;❌ N 个进程 N 份副本(内存浪费)、失效要广播(很难做对)。
- 分布式 cache (Redis):✅ 一致视图、好失效;❌ 多一次 RTT、Redis 本身要扩容/HA。
- CDN:✅ 全球分布、抗 DDoS;❌ 只能缓存可公共复用的内容,失效慢(Cloudflare 全球 purge ~30s)。
现实案例:
- Facebook:TAO(in-memory graph cache)+ Memcached 海洋(数万台),命中率 > 99%,每秒处理 10 亿+ 次读。论文《Scaling Memcache at Facebook》是必读。
- Twitter:用户的 timeline 整页用 Redis cache,热门用户被 fanout 预生成。
- GitHub Rails:用 Russian Doll caching(嵌套片段缓存),PR 详情页命中率 99.3%。
- Stack Overflow:单数据中心 9 台服务器扛全球访问,靠 5 层缓存(HTTP / Redis / SQL Server buffer / .NET CLR / 客户端)。
2. 淘汰策略:LRU vs LFU vs ARC vs TinyLFU
原理:缓存容量有限,必须选一个『谁先死』规则。
| 策略 | 淘汰对象 | 适合场景 | 命中率 |
| LRU | 最久未访问的 | 时间局部性强(最近访问 = 即将再访问) | 中 |
| LFU | 访问次数最少的 | 频率局部性强、热点稳定 | 高(稳态) |
| ARC | 自适应 LRU+LFU | 访问模式变化、不想调参 | 高 |
| W-TinyLFU | 带准入控制的 LFU | 有突发流量、扫描型访问 | 极高 |
| FIFO/Random | 先进/随机 | O(1) 极简、纯 streaming | 低 |
Trade-off:
- LRU:✅ 实现简单(哈希表 + 双向链表 = O(1));❌ 被一次性扫描污染(『扫描抗性差』),如有人爬全站。
- LFU:✅ 抗扫描;❌ 老数据『沉淀』下来,新热点进不来(需要 aging / 衰减计数)。
- ARC:✅ 自适应;❌ IBM 专利(Linux 因此用 2Q 替代)、实现复杂。
- W-TinyLFU(Caffeine 用):✅ 用 Count-Min Sketch 估频率,准入控制让稀有 key 进不来污染;❌ 概率结构有误差。
# Python LRU 极简实现 (用 OrderedDict)
from collections import OrderedDict
class LRU:
def __init__(self, cap): self.cap, self.d = cap, OrderedDict()
def get(self, k):
if k not in self.d: return None
self.d.move_to_end(k) # 最近访问 → 末尾
return self.d[k]
def put(self, k, v):
if k in self.d: self.d.move_to_end(k)
self.d[k] = v
if len(self.d) > self.cap:
self.d.popitem(last=False) # 淘汰头部(最久未访问)
# Redis 实际用『近似 LRU』:每次淘汰从 5 个随机 key 中挑最久的
# maxmemory-policy allkeys-lru / allkeys-lfu / volatile-ttl
现实案例:
- Redis:默认
allkeys-lru,但实现是近似 LRU(采样 5-10 个 key 比较,节省维护链表的开销)。Redis 4.0+ 支持 LFU。
- Caffeine (Java):用 W-TinyLFU,被 Cassandra、HBase、Kafka Streams 默认采用,命中率比 LRU 高 5-15%。
- Linux Page Cache:用 2Q(active/inactive 双链表),近似 LRU 但抗扫描污染。
- Discord:消息缓存早期用 LRU,热门频道下『最近未访问』误判严重,切到 LFU 后命中率 78% → 96%。
3. 写策略:Cache-aside vs Write-through vs Write-back
原理:写数据时缓存和 DB 谁先谁后、谁负责,决定了一致性和复杂度。
| Cache-aside (lazy) | Write-through | Write-back |
| 读 | miss → DB → 回填 cache | cache hit(永远) | cache hit |
| 写 | 写 DB → 删 cache | 写 cache → 同步写 DB | 只写 cache → 异步刷 DB |
| 一致性 | 有窗口(最终一致) | 强(cache = DB) | 弱(宕机丢未刷数据) |
| 写延迟 | 低(只写 DB) | 高(两次写) | 极低(只写内存) |
| 适用 | Redis + DB 最常见 | 金融、强一致读 | CPU cache、SSD FTL |
# Cache-aside 标准写法 (Python)
def get_user(uid):
user = redis.get(f"u:{uid}")
if user: return json.loads(user)
user = db.query("SELECT * FROM users WHERE id=%s", uid)
redis.setex(f"u:{uid}", 3600, json.dumps(user)) # TTL 兜底
return user
def update_user(uid, data):
db.update("UPDATE users SET ... WHERE id=%s", uid, data)
redis.delete(f"u:{uid}") # ⚠️ 删而不是更新,避免并发写错乱
# ⚠️ 还要小心: 删之后立刻有人读, 可能再次回填旧值 → 双删 / 延迟双删
Trade-off:
- Cache-aside:✅ 简单、cache 挂了不影响写;❌ 第一次 miss 慢、需手动 invalidate(容易写错)。
- Write-through:✅ cache 永远是真相;❌ 每次写都贵;cache 挂了写不进去。
- Write-back:✅ 最快;❌ cache 宕机 = 丢数据,账户余额绝不能用。但 CPU L1/L2 cache、SSD FTL 都用这个。
现实案例:
- Instagram:feed 用 cache-aside + Redis,写帖子后删相关 cache key。
- LinkedIn:消息中心用 write-through 到 Voldemort(自家 KV),保证读总是最新。
- MySQL InnoDB:buffer pool 是 write-back,靠 redo log + checkpoint 保证持久化。
- Apple iCloud Photos:缩略图用 write-through 到 S3,保证 CDN 拉取永远有效。
4. 失效难题:TTL、惊群、雪崩、穿透
原理:四个经典坑,每个都在生产环境炸过。
- Thundering herd(缓存击穿):一个热 key 过期瞬间,1 万个请求同时穿到 DB。解法:互斥锁(single-flight)、逻辑过期(永不真过期,后台异步刷)、提前续约。
- Cache avalanche(雪崩):大批 key 同一时刻过期,DB 突然被打爆。解法:TTL 加随机抖动(
3600 + random(0, 300))。
- Cache penetration(穿透):查询不存在的 key,每次都回源。解法:缓存空值(短 TTL)、Bloom Filter 前置判断。
- Stale-while-revalidate:返回旧值同时后台刷新(CDN / HTTP / Next.js ISR 都用),延迟 0、最终一致。
# Single-flight 防击穿 (Go)
var g singleflight.Group
func GetHot(key string) (any, error) {
v, err, _ := g.Do(key, func() (any, error) {
if v := redis.Get(key); v != nil { return v, nil }
v := db.Query(key)
redis.SetEX(key, v, ttlWithJitter(3600))
return v, nil
})
return v, err
}
现实案例:
- Facebook Memcached lease:miss 时只发一个 lease token 给一个客户端去回源,其他人等待。论文中详述。
- Cloudflare:用 stale-while-revalidate,过期内容继续返回 60s 同时后台拉新,p99 几乎无毛刺。
- Pinterest:曾因 Memcached 全集群同 TTL 雪崩挂掉 30 分钟,事后加 jitter 修复。
- Vercel ISR:Next.js Incremental Static Regeneration 就是 stale-while-revalidate 的产品化。
扩展与优化(增长后怎么办)
- 命中率上不去:分析访问分布。如果是长尾,考虑预热(pre-warming)或更大容量;如果是热点,分片到多 key(
user:123:part:{0..15})。
- Redis 单实例顶不住:上 Redis Cluster(16384 slot)或 Twemproxy / Codis 中间层;写热点要 consistent hashing。
- 跨区延迟:每区独立 Redis 集群,DB 写后异步广播 invalidation(Facebook 用 mcsqueal)。
- 对象太大:换 Redis(支持 512MB / value)或切片;考虑只缓存 ID 列表,详情仍走 DB(节省内存)。
- 瓶颈识别:监控『命中率』『p99 延迟』『回源 QPS』,命中率下跌往往是新功能没缓存或 invalidation bug。
常见陷阱(资深工程师也常翻车)
1. 更新缓存还是删除缓存? 答案永远是删除。更新会让两个并发写产生乱序(A 写值 v1 → B 写值 v2 → B 更新 cache → A 更新 cache,cache 永远停在 v1)。
2. 先写 DB 还是先删 cache? 标准是『先 DB 后删 cache』。先删 cache 的话,删完到 DB 写完之间有窗口,别人读会回填旧值。极端情况用『延迟双删』:删 → 写 DB → sleep 500ms → 再删一次。
3. 缓存 vs 数据库的最终一致窗口:永远不可能 0。承认这一点,给业务定『stale 容忍度』。
4. 把 Redis 当 DB 用:Redis 持久化(RDB / AOF)不是设计目标,宕机 RPO 至少几秒。账户余额这种数据,Redis 是 cache 不是 source of truth。
5. 忽略冷启动:服务重启或 cache cluster 替换瞬间,命中率 0%,DB 直接被打爆。要预热或慢启动放量。
面试问题示例
- 设计 Instagram 首页 feed 缓存,用户量 10 亿,p99 < 200ms。
- Redis 一个 hot key(如顶流明星的粉丝列表)QPS 100w,怎么扛?
- 缓存一致性怎么做?说说『先写 DB 还是先删 cache』各自的失败场景。
- LRU 和 LFU 的实现细节,时间复杂度,分别什么场景下选哪个?
- Cache penetration(恶意查不存在的 key)怎么防?Bloom Filter 的误判率怎么算?
关键资源
- 《Designing Data-Intensive Applications》Ch 1 + Ch 5(Kleppmann):缓存与一致性的本质讨论。
- 《Scaling Memcache at Facebook》(NSDI 2013):分布式缓存的圣经,lease、gutter pool、跨区复制都在里面。
- Caffeine wiki:W-TinyLFU 设计细节,作者 Ben Manes 的 Benchmark 数据非常硬核。
English Summary
Caching is a multi-layer art spanning browser, CDN, reverse proxy, in-process, distributed (Redis/Memcached), and DB buffer pools. Choose eviction policy by access pattern: LRU for recency, LFU for frequency, W-TinyLFU (Caffeine) when in doubt. Write strategies trade consistency for performance: cache-aside is the default, write-through ensures correctness, write-back maximizes throughput at the cost of durability. The hardest part is invalidation — beware thundering herd, avalanche (use TTL jitter), penetration (bloom filter), and the eternal question of delete vs update cache (always delete).
深入思考(点击展开答案)
1. 命中率从 95% 降到 90%,DB 负载会变成几倍?为什么不是简单的 2 倍?
表面算法:miss rate 5% → 10%,回源 QPS 翻倍 = DB 2 倍负载。
实际更严重,至少 3 个放大效应:
- 缓存被打穿时往往伴随写入:miss 后要回填 cache,多一次 Redis SET;高并发下还会 thundering herd——同一 key 1000 个并发请求同时回源,DB 收到的是 1000 次而不是 1 次。
- DB 自身的 cache 也连锁失效:Postgres shared_buffers / MySQL buffer pool 命中率会跟着掉,磁盘 IO 飙升,p99 不止 2 倍。
- 连接池争抢:DB connection pool 通常按 95% 命中设计的容量,突然 2 倍 QPS 直接打满,新请求排队,p99 雪崩。
真实数字:90% 命中率下 DB 实际负载通常是 3-5 倍。所以监控里看到命中率掉 5 个百分点就要警觉。
2. cache-aside『先写 DB → 再删 cache』,生产仍偶尔读到旧值。至少列 3 种可能原因及对应修法。
- 并发写读交错:A 读 cache miss → A 读 DB(拿到旧值 v1)→ B 写 DB(v2)→ B 删 cache → A 回填 cache(v1)。cache 永远是旧值。修法:延迟双删(B 删 → sleep 500ms → 再删一次);或用 single-flight 限制并发回填。
- 主从复制延迟:写主库后删 cache,但有人立刻读,路由到 replica(还没复制完),拿到旧值回填 cache。修法:删 cache 后强制读主一段时间;或者『先写主 → wait for replica → 再删 cache』。
- 删除失败:DB 写成功但 Redis 网络抖动删除失败,没有重试。修法:用 outbox / CDC 模式,把 invalidation 事件落地(Debezium 监听 binlog → 发到 Kafka → consumer 删 cache),保证 at-least-once。
- 多区缓存不同步:US-East 删了 cache,US-West 没删。修法:跨区广播 invalidation(Facebook 用 mcsqueal)。
- 客户端本地缓存:app 进程内 Caffeine 还在用旧值,远端 Redis 删了也没用。修法:本地 cache TTL 短一点,或订阅 Redis pub/sub 主动失效。
3. Redis Cluster 不支持跨 slot 的 MULTI / 事务。这个限制怎么影响 key 设计?hash tag({user:123})解决了什么、引入了什么新问题?
影响:你不能在一个事务里同时操作 user:123:profile 和 user:123:posts——它们 hash 到不同 slot 在不同节点上,MULTI 会被拒绝(CROSSSLOT 错误)。同理 MGET、MSET 跨 slot 也不行。
Hash tag 怎么用:把要放一起的 key 写成 {user:123}:profile 和 {user:123}:posts,{} 里的部分作为 hash 输入,保证两个 key 落同一 slot。
引入的新问题:
- 数据倾斜:一个 user 所有 key 都在同一节点,顶流用户那个 slot 变 hot spot,单节点 CPU 飙红。
- 大 key 风险:把多个 collection 绑到一起,单个用户数据膨胀就是单节点内存爆炸。
- resharding 痛苦:cluster 扩容时 slot 迁移要把整组 key 一起搬,迁移期间这组 key 的写操作要排队。
- 反模式诱惑:开发者会把『可能一起读』的所有 key 都打 tag,最后退化成单节点 Redis。
实战建议:hash tag 只用在真正必须原子的场景(购物车 + 库存);展示型多 key 拼装,让 app 端做并发 GET 而不是依赖事务。
4. Hot key 单点 QPS 100w,单 Redis 实例顶不住。至少列 3 种解法(不同方向),各自代价?
- ① Key 分片(写时拆、读时合并):把
celebrity:123:followers_count 拆成 celebrity:123:followers_count:{0..15},写时随机选一个 +1,读时 16 个 SUM。代价:读放大 16 倍、强一致性丢失(瞬时 sum 可能短暂偏小)、只适合可累加数据。
- ② 多副本读(local replica):把 hot key 复制到多个 Redis 节点,client 随机选一个读。代价:写要 fanout 到 N 个副本、副本一致性(用 master-replica 异步同步会有 lag)。Redis Cluster 的 READONLY 模式就是这个思路。
- ③ 多级缓存:app 进程内 L1 cache:每个 app 实例本地 Caffeine 缓存这个 hot key,TTL 1s。100 台 app × 每秒 1 次回源 = 100 QPS,Redis 压力立刻消失。代价:最长 1s stale、内存浪费(100 份副本)、invalidation 困难。
- ④ CDN / edge cache:如果是公开数据(明星粉丝数),直接放 CDN,边缘节点扛 100w QPS。代价:只适合可公开缓存的内容、失效慢(30s+)。
- ⑤ Pre-compute + 推送:根本不让用户实时查,把数据预生成在每个用户的 timeline cache 里(Twitter fanout-on-write)。代价:写放大极大,对 1 亿粉丝的明星不适用。
实战组合:通常 ① + ③ 一起上——分片承担写、本地 L1 承担读。
5. 服务重启时命中率瞬间 0%,DB 直接被打爆。预热怎么做?预热过程本身如何不把 DB 打挂?
预热时机:
- 新版本上线前(蓝绿部署的 green 环境预热好再切流量);
- Redis cluster 替换时(旧集群导出 → 新集群导入);
- 大促前夜(提前把可预测的热数据灌进去)。
怎么预热:
- 离线分析 + 批量灌入:从过去 7 天访问日志算 top 10w key,离线 dump 出 KV,用 RESTORE 或 pipeline 批量写 Redis(避免一条条 SET)。
- 线上慢启动放量:新实例上线时 LB 只给 1% 流量,cache 慢慢填,5 分钟后线性放到 100%。Envoy 的 slow_start_config 直接支持。
- 双 cache 切换:旧 Redis 还在服务,新 Redis 后台 mirror 一份流量预热,切流时 hit rate 已经高了。
预热本身不打挂 DB:
- 限速:预热脚本用 token bucket,比如 5k QPS 上限,避免把 DB connection 打满。
- 读 replica:预热从 replica 读,不挤占主库的事务能力。
- 错峰:在低谷时段(凌晨 3-5 点)预热;或者用线上自然流量『顺路』预热(first user 触发回源,后面的人受益)。
- Single-flight:预热过程也要防 thundering herd,同一 key 只让一个 worker 回源。
真实故事:Pinterest 2014 年因 Memcached 整集群替换没预热,DB 被打挂 30 分钟,事后改成『新旧集群并行运行 1 小时』。