4. API缓存策略:HTTP缓存头、本地缓存与分布式缓存
缓存是API性能调优中最立竿见影的手段,但也是最容易引发数据不一致和雪崩风险的环节。本章从HTTP协议缓存、应用层本地缓存、分布式缓存三个维度展开,并重点剖析缓存穿透、击穿、雪崩三大经典问题的成因与解决方案。
4.1 HTTP缓存头:让浏览器和网关替你扛流量
HTTP缓存是距离用户最近的缓存层,合理配置Cache-Control和ETag可以大幅减少后端请求量。
| 指令 | 取值示例 | 作用 |
|---|---|---|
max-age | max-age=3600 | 资源在缓存中可保存的最大秒数 |
no-cache | no-cache | 每次使用前必须向源服务器验证 |
no-store | no-store | 禁止任何缓存(敏感数据) |
public | public | 允许中间代理缓存 |
private | private | 仅允许浏览器缓存 |
ETag | "abc123" | 资源版本标识,配合If-None-Match做条件请求 |
最佳实践:静态资源(图片、CSS、JS)设置
public, max-age=31536000, immutable;API响应中对于变化不频繁的数据(如配置、字典)设置private, max-age=60,并配合ETag实现零带宽验证。
// 服务端设置示例(Spring Boot)
@GetMapping("/config")
public ResponseEntity<Config> getConfig() {
Config config = configService.get();
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS).cachePrivate())
.eTag(config.getVersion())
.body(config);
}
4.2 本地缓存 vs 分布式缓存:选型与协同
本地缓存(如Caffeine、Guava Cache)和分布式缓存(如Redis)各有适用场景,实践中常组合使用形成多级缓存。
| 维度 | 本地缓存 | 分布式缓存(Redis) |
|---|---|---|
| 访问延迟 | 纳秒级(堆内) | 毫秒级(网络IO) |
| 容量 | 受限于JVM堆内存 | 可水平扩展至TB级 |
| 数据一致性 | 各节点独立,易出现不一致 | 集中存储,强一致(单节点) |
| 典型场景 | 热点数据、字典、配置 | 会话、计数器、排行榜、分布式锁 |
| 淘汰策略 | LRU/LFU/定时过期 | LRU/LFU/TTL/惰性删除 |
多级缓存策略:优先查询本地缓存(L1),未命中则查询Redis(L2),仍未命中再回源到数据库。写入时采用“先更新数据库,再删除缓存”的模式,配合消息队列异步补偿,保证最终一致性。
4.3 缓存穿透、击穿、雪崩:成因与解决方案
这三个问题是缓存系统最典型的故障模式,理解其区别是制定治理策略的前提。
| 问题 | 现象 | 根因 | 核心解决方案 |
|---|---|---|---|
| 缓存穿透 | 请求大量不存在的数据,直接打到DB | 恶意攻击或查询不存在key | 布隆过滤器 + 空值缓存 |
| 缓存击穿 | 热点key过期瞬间,高并发请求击穿到DB | 热点key过期 + 并发 | 互斥锁 + 逻辑过期 |
| 缓存雪崩 | 大量key同时过期或Redis宕机,DB被打垮 | 批量过期 / 缓存节点故障 | 过期时间打散 + 多级缓存 + 限流降级 |
4.3.1 缓存穿透:布隆过滤器与空值缓存
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它允许一定的误判率(假阳性),但绝不漏判(假阴性率为0)。
// 布隆过滤器使用示例(Guava)
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
100_000, // 预期插入数量
0.01 // 期望误判率
);
// 预热:将所有合法ID插入布隆过滤器
for (Long id : validIds) {
bloomFilter.put(id.toString());
}
// 查询时先检查布隆过滤器
public Object getData(String key) {
if (!bloomFilter.mightContain(key)) {
return null; // 直接返回,避免穿透
}
// 继续查询缓存和数据库
}
注意:布隆过滤器无法删除元素。如果业务需要频繁删除ID,建议使用布隆过滤器的变种(如Counting Bloom Filter)或换用Redis的BitMap自行实现。空值缓存策略中,对不存在的key缓存一个特殊标记(如
NULL),TTL设置较短(30~60秒),防止恶意攻击持续穿透。
4.3.2 缓存击穿:互斥锁与逻辑过期
热点key过期瞬间,大量并发请求同时回源到数据库。解决方案有两种主流思路:
- 互斥锁(Mutex Lock):只允许一个线程去加载数据,其他线程等待。适用于对一致性要求高的场景。
- 逻辑过期(Logical Expiration):缓存永不过期,但数据中携带过期时间字段。异步线程发现过期后主动更新。适用于允许短暂不一致的场景。
// 互斥锁方案(Redis SETNX实现)
public String getWithLock(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
// 尝试获取分布式锁
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS)) {
try {
// 双重检查:可能其他线程已经加载完成
value = redis.get(key);
if (value != null) return value;
// 回源数据库
value = db.query(key);
redis.set(key, value, 300, TimeUnit.SECONDS);
return value;
} finally {
redis.del(lockKey);
}
} else {
// 等待并重试
Thread.sleep(50);
return getWithLock(key); // 递归重试
}
}
4.3.3 缓存雪崩:过期时间打散与多级防护
缓存雪崩的破坏力最大,需要从多个层面联合治理:
- 过期时间打散:在基础TTL上增加随机偏移量(如±10%~30%),避免大量key同时过期。
- 多级缓存:本地缓存(L1)作为Redis(L2)的降级方案,即使Redis宕机,L1仍能扛住部分流量。
- 限流降级:对回源数据库的请求做限流(如Sentinel、Hystrix),超出阈值直接返回降级数据或错误提示。
- Redis高可用:部署主从+哨兵或Redis Cluster,避免单点故障。
4.4 缓存治理最佳实践清单
- 预热:系统启动时主动加载热点数据到缓存,避免冷启动穿透。
- 监控:监控缓存命中率、回源QPS、Redis内存使用率、大key和热key。
- 大key治理:拆分大value(如超过10KB的字符串或超过5000个元素的集合),避免阻塞Redis单线程。
- 热key发现:使用Redis的
hotkeys命令或客户端侧统计,对热key做本地缓存分摊。 - 缓存与DB一致性:优先采用“先更新DB,后删除缓存”策略,配合延迟双删或Binlog监听补偿。
总结:缓存策略不是“一刀切”的配置,而是根据数据特征(热点分布、更新频率、一致性要求)动态组合HTTP缓存、本地缓存、分布式缓存三层体系。穿透、击穿、雪崩的治理核心在于“分层防御、限流兜底、异步补偿”。