缓存分层体系
现代系统的缓存不是单一的,而是层次化的。每一层都在最接近用户的位置拦截请求,减少下一层的压力。
缓存层次从用户到数据库:
用户浏览器
┌─────────────────────────────────────────────────────┐
│ Browser Cache(浏览器缓存) │
│ Cache-Control: max-age=3600 │
│ 存:图片、CSS、JS、不频繁更新的 API 响应 │
└──────────────────────────┬──────────────────────────┘
│ Cache Miss
▼
┌─────────────────────────────────────────────────────┐
│ CDN(内容分发网络) │
│ Cloudflare / AWS CloudFront / Akamai │
│ 全球边缘节点缓存静态资源和可缓存的 API 响应 │
│ 命中率目标:> 90%(静态资源),30-60%(动态 API) │
└──────────────────────────┬──────────────────────────┘
│ Cache Miss
▼
┌─────────────────────────────────────────────────────┐
│ 应用层缓存(Redis / Memcached) │
│ 热点数据:用户 Profile、Feed、商品详情、计数器 │
│ 命中率目标:> 95% │
└──────────────────────────┬──────────────────────────┘
│ Cache Miss
▼
┌─────────────────────────────────────────────────────┐
│ 数据库查询缓存 / Buffer Pool │
│ MySQL InnoDB Buffer Pool、PostgreSQL Shared Buffers │
│ 热页面数据常驻内存,减少磁盘 I/O │
└──────────────────────────┬──────────────────────────┘
│ Cache Miss
▼
磁盘存储(最慢,最贵)
四种缓存读写策略
策略一:Cache-Aside(旁路缓存)
最常见的策略。应用程序自己负责管理缓存,缓存在应用侧,数据库在旁路。
读取流程:
Application Cache (Redis) Database
│ │ │
│──GET user:123──────▶│ │
│ │ │
┌──┤ Cache Hit? │ │
│ │◀──返回数据──────────│ │
│ │ 命中✓ │ │
│ │ │ │
└──┤ Cache Miss │ │
│──────────────────────────────SELECT──────▶│
│◀──────────────────────────────返回数据─────│
│──SET user:123, TTL=3600────────────────▶ │
│◀──OK────────────────────────────────── │
│ │ │
写入流程(先写DB,再删缓存):
Application Cache (Redis) Database
│ │ │
│──────────────────────────────UPDATE──────▶│
│◀──────────────────────────────OK──────────│
│──DEL user:123──────▶│ │
│◀──OK────────────────│ │
下次读取时从 DB 重新加载并回填缓存
⚠ 为什么写时删缓存而不是更新缓存?
如果先更新缓存再写 DB,并发场景下两个写操作可能导致缓存和 DB 数据不一致。删除比更新安全:删除是幂等的,最坏情况是下次读时 Cache Miss,从 DB 重新加载,不会产生脏数据。
策略二:Read-Through(读穿透)
Read-Through 缓存:
Application Cache Provider Database
│ │ │
│──GET user:123──────▶│ │
│ │ (Miss) 自动查DB─────▶│
│ │◀────────────────────│
│◀──返回数据──────────│ (自动缓存) │
│ │ │
区别:Cache Miss 时,由缓存层自动去 DB 加载,应用无需处理
代表产品:AWS ElastiCache(某些模式)、Ehcache(只读缓存模式)
策略三:Write-Through(写穿透)
Write-Through 缓存:
Application Cache Provider Database
│ │ │
│──WRITE user:123────▶│ │
│ │──同步写 DB──────────▶│
│ │◀──OK────────────────│
│◀──OK────────────────│ │
写缓存 = 写 DB(同步双写)
优点:缓存与DB强一致,读取永远是最新数据
缺点:写延迟高(要等 DB 返回),写入频繁时缓存浪费(很多数据写后不被读)
策略四:Write-Behind / Write-Back(异步回写)
Write-Behind 缓存:
Application Cache Provider Database
│ │ │
│──WRITE user:123────▶│ │
│◀──OK(立即返回)────│ │
│ │ 异步批量写 ·····▶ │
│ │ (几秒后) │
写缓存立即返回,异步批量刷写 DB
优点:写性能极高(内存速度),合并多次写减少 DB 压力
缺点:缓存宕机可能丢数据(DB 还未同步)
适用:计数器(点赞数、浏览量)、日志写入、非关键数据
| 策略 | 一致性 | 读性能 | 写性能 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 最终一致 | 高 | 正常 | 通用,最常用 |
| Read-Through | 最终一致 | 高 | 正常 | 读密集,缓存层封装 |
| Write-Through | 强一致 | 高 | 低 | 写少读多,数据敏感 |
| Write-Behind | 最终一致 | 高 | 极高 | 高频写,可容忍少量丢失 |
三大经典缓存问题
问题一:缓存击穿(Cache Breakdown)
一个热点 key 突然过期,大量并发请求同时打到数据库。
缓存击穿场景:
时刻 T:Redis 中「双11首页商品」的缓存 Key 过期
同时有 10,000 个请求:
Request 1 ──▶ Redis MISS ──▶ DB 查询 ...
Request 2 ──▶ Redis MISS ──▶ DB 查询 ...
Request 3 ──▶ Redis MISS ──▶ DB 查询 ...
...
Request 10000 ──▶ Redis MISS ──▶ DB 查询 ... 💥 DB 过载!
解决方案:
方案A:互斥锁(Mutex Lock)
只有第一个 Cache Miss 的请求能拿到锁去查 DB
其他请求等待或返回降级数据
伪代码:
value = redis.get(key)
if value == null:
if redis.setnx(lock_key, 1, TTL=5s): // 只有一个拿到锁
value = db.query(...)
redis.set(key, value, TTL=3600)
redis.del(lock_key)
else:
sleep(50ms)
return redis.get(key) // 等其他线程回填
方案B:逻辑过期(永不物理过期)
在缓存值中存入「逻辑过期时间」
过期时异步刷新,期间继续返回旧值
彻底避免 DB 被打,但读到的可能是稍旧的数据
问题二:缓存穿透(Cache Penetration)
查询根本不存在的数据,每次都打穿缓存直接到 DB。常见于恶意攻击(传入非法 ID)。
缓存穿透场景:
攻击者发送:GET /user?id=-1, id=-2, id=-999999...
每个 ID 都不存在 → Redis MISS → DB 查询 → 返回 null
→ 不写缓存(因为没数据)→ 下次同样 ID 继续打 DB
解决方案:
方案A:缓存空值
DB 返回 null,也在 Redis 中存入空值
redis.set("user:-1", "null", TTL=60s)
下次查询直接返回缓存的 null,不打 DB
注意:TTL 不要太长,否则若后来数据被创建会读到旧的 null
方案B:布隆过滤器(Bloom Filter)✓ 推荐大型系统
系统启动时将所有合法 ID 加入布隆过滤器
请求到来先查布隆过滤器:
→ 不存在(100%确定)→ 直接返回 404,不查 Redis/DB
→ 可能存在 → 再去 Redis/DB 查
布隆过滤器特性:
- 空间极省:1亿个元素只需约 125MB
- 查询极快:O(k) 哈希计算
- 有误判率(假阳性),但不会漏判(假阴性)
问题三:缓存雪崩(Cache Avalanche)
大量缓存 key 在同一时间集中过期,或 Redis 集群宕机,导致全部流量涌入 DB。
缓存雪崩场景:
凌晨2点批量缓存刷新,所有商品缓存 TTL=3600s(同时设置)
→ 一小时后,凌晨3点,所有缓存同时过期
→ 突发大量 DB 请求 💥
解决方案:
1. TTL 随机化(最简单!)
TTL = base_ttl + random(0, 600) // 加随机抖动
将集中过期分散到 60 分钟窗口内
2. 高可用 Redis 集群
Redis Sentinel(主从哨兵)
Redis Cluster(分片集群)
避免 Redis 单点宕机造成全量 Cache Miss
3. 多级缓存(本地 + 分布式)
本地内存缓存(Caffeine/Guava)作为第一层
Redis 作为第二层
即使 Redis 雪崩,本地缓存仍能抗住部分流量
4. 限流 + 熔断(最终防线)
即便缓存全部失效,DB 层也有限流保护
超过 DB 承载上限时,返回降级响应
▶ 面试要点:三者区分
- 击穿:热点 key 过期,某一时刻大量并发 → 锁 + 逻辑过期
- 穿透:查不存在的 key → 布隆过滤器 + 缓存空值
- 雪崩:大量 key 同时过期或缓存整体故障 → TTL 抖动 + Redis HA + 多级缓存
CDN 缓存策略
CDN(内容分发网络)是最外层的缓存,将内容推送到距离用户最近的边缘节点。
CDN 工作原理(以 Cloudflare 为例):
用户(东京) CDN 边缘(东京) 源站(美国)
│ │ │
│─GET /images/logo.png───────▶│ │
│ │ 缓存 HIT │
│◀────────────────────────────│ 延迟 <5ms │
│ │ │
│─GET /api/products──────────▶│ │
│ │ 缓存 MISS │
│ │──────────────────────▶│
│ │◀─────────────────────│
│◀──返回(约150ms RTT)────────│ │
CDN 缓存控制头:
Cache-Control: public, max-age=86400 // CDN 可以缓存,1天
Cache-Control: private, no-cache // 只有浏览器缓存,不让 CDN 缓存
Cache-Control: s-maxage=3600 // 专门针对 CDN 的缓存时间
Vary: Accept-Encoding, Accept-Language // 按请求头区分缓存版本
💡 Twitter/Netflix 的缓存实践
Twitter 的 Memcached 集群缓存了数十亿条推文,命中率超过 99%。Netflix 使用 EVCache(基于 Memcached)在多地区复制缓存数据,每秒处理数百万次缓存读取。他们的原则:让绝大多数读请求永远不到达数据库。