4. 缓存策略:本地缓存、远程缓存与命中率优化

缓存这东西,说白了就是用空间换时间。我见过太多团队一上来就上 Redis,结果发现大部分热点数据根本不需要跨进程共享。今天咱们把缓存策略拆开揉碎了讲,从本地到远程,再到命中率和失效策略,一条龙说清楚。

4.1 本地缓存:最快的路

本地缓存就是把数据放在进程内。没有网络开销,没有序列化成本,速度是纳秒级的。我个人习惯在以下场景优先考虑本地缓存:

  • 配置信息:应用启动时加载,运行中几乎不变
  • 字典数据:国家代码、状态枚举、权限列表
  • 热点只读数据:首页推荐、热门标签

常用的实现方案有 Guava Cache、Caffeine、Ehcache。我推荐 Caffeine,性能比 Guava 高一个量级。来看个例子:

// Caffeine 本地缓存示例
Cache<String, UserProfile> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .recordStats()
    .build();

// 查询时自动加载
UserProfile profile = cache.get("user_1001", key -> {
    return userService.loadFromDB(key);
});
小技巧:Caffeine 的 recordStats() 一定要开。我在生产上靠它发现过一个缓存容量设太小导致的频繁淘汰问题,命中率从 95% 掉到了 40%。

4.2 远程缓存:NFS 与 S3

本地缓存搞不定跨进程共享。比如多个实例需要访问同一份构建产物,或者共享一个大的模型文件。这时候就得用远程缓存。

4.2.1 NFS 缓存

NFS 适合小规模集群,延迟在毫秒级。我建议把它当作「共享磁盘」用,不适合高并发场景。曾经有个项目把 NFS 当 Redis 用,结果 IO 打满,整个构建集群都卡住了。

# 挂载 NFS 共享目录
mount -t nfs -o vers=4,hard,timeo=600,retrans=2 \
  192.168.1.100:/data/cache /mnt/build-cache
注意:NFS 的硬挂载(hard)在服务端宕机时会导致进程挂起。构建场景建议用 soft 或 intr 选项,避免构建任务被阻塞。

4.2.2 S3 缓存

S3 适合大规模、跨地域的缓存场景。延迟虽然比 NFS 高(几十毫秒),但吞吐量和可靠性强得多。我一般在 CI/CD 流水线里用它存构建产物:

# 使用 aws-cli 上传缓存
aws s3 cp ./dist/ s3://my-build-cache/v1.2.3/ \
  --recursive \
  --cache-control "public, max-age=31536000"

你想想看,S3 的另一个好处是自带版本管理。万一缓存写坏了,回滚也方便。

4.3 缓存命中率优化

命中率是缓存的核心指标。低于 80% 的缓存基本是浪费资源。我总结了几条实战经验:

  1. 热点数据优先:用 LRU 或 LFU 淘汰策略,别用 FIFO
  2. 缓存粒度要细:别把整个页面缓存,拆成组件级别
  3. 预热机制:系统启动时主动加载热点数据
  4. 避免缓存穿透:对不存在的数据也缓存空值,设置短过期时间

命中率计算公式:

命中率 = 缓存命中次数 / (缓存命中次数 + 缓存未命中次数) × 100%

建议用监控工具(如 Prometheus + Grafana)实时跟踪这个指标。

我曾经接手过一个项目,缓存命中率只有 30%。查了半天发现是缓存 key 设计有问题——每次请求都带随机参数,导致缓存永远命中不了。改成分段 key 后,命中率直接飙到 92%。

4.4 缓存失效与清理策略

缓存失效比缓存写入难十倍。我见过太多因为缓存没及时清理导致的线上事故。常见的策略有:

策略 原理 适用场景 风险
TTL 过期 写入时设置存活时间 数据变化不频繁 过期瞬间可能有雪崩
主动失效 数据更新时主动删除缓存 强一致性要求 实现复杂,容易漏删
版本号 缓存 key 带版本号 构建产物、静态资源 版本管理成本高
写穿透 更新数据库同时更新缓存 读写比例接近 并发写可能脏数据

4.4.1 缓存雪崩与击穿

这两个问题很常见,但很多人分不清:

  • 缓存雪崩:大量缓存同时过期,请求全部打到数据库
  • 缓存击穿:一个热点 key 过期,高并发请求瞬间压垮数据库

解决方案其实不复杂:

// 缓存雪崩:过期时间加随机偏移
int baseTTL = 300;
int randomOffset = new Random().nextInt(60);
int finalTTL = baseTTL + randomOffset;

// 缓存击穿:互斥锁
String key = "hot_data";
String value = cache.get(key);
if (value == null) {
    if (redis.setnx("lock:" + key, "1", 10)) {
        value = db.query(key);
        cache.set(key, value, 300);
        redis.del("lock:" + key);
    } else {
        Thread.sleep(50);
        return cache.get(key); // 重试
    }
}
避坑指南:我曾经在生产上遇到过缓存击穿,一个热门商品详情页的 key 过期后,瞬间 2000 个请求打到数据库,直接把 MySQL 打挂了。后来加了互斥锁和本地缓存兜底,再也没出过问题。

4.5 缓存策略全景图

下面这张图总结了本章的核心知识体系,从缓存层级到策略选择,一目了然:

缓存策略全景图 缓存策略全景图 本地缓存 远程缓存 混合缓存 Caffeine / Guava / Ehcache NFS / S3 本地 + 远程两级缓存 核心指标:命中率 ≥ 80% 命中率优化 热点优先 / 粒度控制 / 预热 / 防穿透 失效与清理 TTL / 主动失效 / 版本号 / 写穿透 风险应对:缓存雪崩 → 随机TTL | 缓存击穿 → 互斥锁 + 本地兜底 核心原则:没有万能的缓存策略,根据业务场景选择最合适的组合

4.6 实战建议

最后给几条实在的建议:

  • 先本地,后远程:能放本地的别放远程,减少网络开销
  • 监控先行:上线前就把命中率、缓存大小、淘汰次数都监控起来
  • 降级方案:缓存挂了要有兜底,别让用户看到 500
  • 定期清理:远程缓存(尤其是 S3)要设生命周期策略,避免存储成本失控
我的习惯:每次上线新缓存策略,我都会先在灰度环境跑 24 小时,观察命中率和延迟曲线。等曲线平稳了再全量发布。这个习惯帮我避过好几次坑。