第4章:缓存策略详解

聊到高并发系统,缓存绝对是绕不开的话题。我经常跟团队说,没有缓存的系统就像没有水库的河流——流量一来就决堤。这一章,咱们把缓存策略掰开揉碎了讲清楚。

4.1 本地缓存:Guava Cache 实战

先说说本地缓存。说白了,就是把数据存在应用进程的内存里。我最早接触本地缓存是在一个电商项目里,当时商品分类信息几乎不变,但每次请求都要查数据库,那叫一个心疼。

Guava Cache 是我个人比较推荐的本地缓存方案。它不像 ConcurrentHashMap 那样裸奔,提供了自动加载、过期策略、最大容量等实用功能。

// 构建一个 Guava Cache
LoadingCache<String, User> userCache = CacheBuilder.newBuilder()
    .maximumSize(10000)          // 最多缓存1万个条目
    .expireAfterWrite(10, TimeUnit.MINUTES)  // 写入后10分钟过期
    .refreshAfterWrite(5, TimeUnit.MINUTES)  // 5分钟后自动刷新
    .build(new CacheLoader<String, User>() {
        @Override
        public User load(String key) {
            return getUserFromDB(key);  // 缓存未命中时,自动加载
        }
    });

// 使用
User user = userCache.get("userId_123");
我的经验:Guava Cache 的 refreshAfterWrite 是个好东西。它能在缓存过期前异步刷新,避免大量请求同时穿透到数据库。我曾经在一个秒杀系统里用这个特性,把数据库压力降了80%。

不过要注意,本地缓存有个硬伤——多实例部署时数据不一致。每个 JVM 各存各的,你更新了 A 机器,B 机器还是老数据。所以,本地缓存适合存那些变化不频繁、一致性要求不高的数据。

4.2 分布式缓存:Redis 核心玩法

当系统扩展到多台机器,就得请出分布式缓存了。Redis 是目前的主流选择,没有之一。

Redis 能火,说白了就两点:快(纯内存操作,单线程模型避免了并发问题),数据结构丰富(String、Hash、List、Set、Sorted Set 一应俱全)。

我习惯把 Redis 用在三个场景:

  • 热点数据缓存:用户信息、配置参数等
  • 分布式锁:用 SETNX 实现,注意要设置过期时间防止死锁
  • 计数器:比如文章阅读量、点赞数,INCR 命令原子操作
// Redis 分布式锁的正确姿势
String lockKey = "lock:order:123";
String requestId = UUID.randomUUID().toString();

// 加锁,设置过期时间防止死锁
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);

if (Boolean.TRUE.equals(locked)) {
    try {
        // 执行业务逻辑
        processOrder();
    } finally {
        // 释放锁,用 Lua 脚本保证原子性
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), 
            Arrays.asList(lockKey), requestId);
    }
}
避坑指南:我曾经犯过一个错误——Redis 缓存没设置过期时间。结果某个配置项改了,缓存一直不更新,线上出了半小时的数据不一致。从那以后,我所有缓存都强制要求设置 TTL,哪怕是一年。

4.3 缓存穿透、击穿、雪崩

这三个问题,面试必问,线上必踩。咱们一个一个说。

4.3.1 缓存穿透

什么是缓存穿透?就是请求的数据在缓存和数据库里都不存在。比如查一个不存在的用户 ID,每次请求都直接打到数据库。

解决方案我常用两种:

  • 缓存空对象:查不到数据时,也缓存一个空值,设置较短的过期时间(比如 5 分钟)
  • 布隆过滤器:把所有可能的 key 提前加载到布隆过滤器里,请求来了先判断 key 是否存在,不存在直接返回
// 布隆过滤器示例
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    1000000,  // 预计元素数量
    0.01      // 误判率
);

// 初始化时加载所有用户ID
for (String userId : allUserIds) {
    bloomFilter.put(userId);
}

// 请求时先判断
if (!bloomFilter.mightContain(requestUserId)) {
    return "用户不存在";  // 直接返回,不查数据库
}
我的建议:布隆过滤器有误判率,但可以接受。我一般在用户量千万级以上的系统里用,效果很好。小系统用缓存空对象就够了,别过度设计。

4.3.2 缓存击穿

缓存击穿和穿透不一样。击穿是指某个热点 key 过期了,恰好有大量并发请求访问这个 key,全部打到数据库上。

嗯,这里要注意,击穿只针对热点 key。我遇到过最典型的场景是:某个爆款商品的详情页,缓存过期瞬间,几千个请求同时涌入。

解决方案:

  • 互斥锁:缓存失效后,只让一个线程去查数据库,其他线程等待
  • 逻辑过期:缓存里不设物理过期时间,而是存一个逻辑过期时间,后台异步刷新
// 互斥锁方案
public String getProduct(String productId) {
    String cache = redisTemplate.opsForValue().get(productId);
    if (cache != null) {
        return cache;
    }
    
    // 缓存未命中,尝试获取锁
    String lockKey = "lock:product:" + productId;
    if (tryLock(lockKey)) {
        try {
            // 双重检查,防止其他线程已经更新了缓存
            cache = redisTemplate.opsForValue().get(productId);
            if (cache != null) {
                return cache;
            }
            // 查数据库
            String dbData = queryFromDB(productId);
            redisTemplate.opsForValue().set(productId, dbData, 30, TimeUnit.MINUTES);
            return dbData;
        } finally {
            unlock(lockKey);
        }
    } else {
        // 没拿到锁,等待一会儿再重试
        Thread.sleep(100);
        return getProduct(productId);  // 递归重试
    }
}

4.3.3 缓存雪崩

雪崩比击穿更可怕。它是指大量缓存同时过期,或者 Redis 挂了,导致所有请求直接打到数据库。

你想想看,数据库能扛住吗?大概率直接挂掉。

解决方案:

  • 过期时间加随机值:避免大量 key 同时过期
  • Redis 高可用:主从 + 哨兵,或者 Redis Cluster
  • 本地缓存兜底:Redis 挂了,还有 Guava Cache 顶着
  • 限流降级:请求量超过阈值时,直接返回默认值或错误提示
// 过期时间加随机值,避免雪崩
int baseExpire = 3600;  // 1小时
int randomExpire = new Random().nextInt(600);  // 0-10分钟随机
redisTemplate.opsForValue().set(key, value, baseExpire + randomExpire, TimeUnit.SECONDS);
核心总结:
  • 穿透:查不存在的数据 → 布隆过滤器或缓存空对象
  • 击穿:热点 key 过期 → 互斥锁或逻辑过期
  • 雪崩:大量 key 同时过期或 Redis 挂了 → 过期时间随机化 + 高可用 + 本地缓存兜底

最后说一句,缓存不是银弹。用得好,系统性能翻倍;用不好,数据一致性、内存溢出、缓存雪崩这些问题够你喝一壶的。我个人的原则是:能不缓存就不缓存,必须缓存就做好兜底方案

下一章,咱们聊聊数据库层面的优化——读写分离与分库分表。到时候见。