Java缓存失效案例如何规避

wen java案例 30

本文目录导读:

Java缓存失效案例如何规避

  1. 三大经典缓存失效场景
  2. 综合实战:一个健壮的缓存模板
  3. 避坑清单(面试/开发自查)

Java缓存失效问题(如缓存雪崩、缓存击穿、缓存穿透)是高并发场景下的经典坑点,下面从问题现象根本原因规避方案进行系统梳理,并给出可落地的代码示例。


三大经典缓存失效场景

缓存雪崩(大规模同时失效)

现象:大量缓存Key在同一时间过期,请求全部打到数据库,导致DB压力激增甚至宕机。
典型原因

  • 所有缓存设置了相同的过期时间(如统一30分钟)
  • 缓存服务器宕机(如Redis集群故障)

规避方案
过期时间加随机偏移

// 原代码:set("user:1", user, 3600)
// 改进:在基础过期时间上增加随机值
int base = 3600;
int random = new Random().nextInt(600); // 0~600秒随机
redisTemplate.opsForValue().set("user:1", user, base + random, TimeUnit.SECONDS);

多级缓存(本地+远程)

  • 本地缓存(Caffeine/Guava)设置较短过期时间
  • Redis设置较长过期时间
  • DB作为最终保底

服务熔断/降级

  • 当DB负载过高时,直接返回默认值或空数据,避免连锁反应

缓存击穿(热点Key突然失效)

现象:一个极其热门的Key(如秒杀商品、热搜)在失效瞬间,大量并发请求同时穿透到DB。
典型原因

  • 热点Key过期,且重建缓存耗时较长(如复杂SQL查询)

规避方案
互斥锁(分布式锁)

public String getData(String key) {
    String value = redis.get(key);
    if (value == null) {
        // 尝试获取分布式锁,防止并发重建
        String lockKey = "lock:" + key;
        if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) {
            try {
                // 二次检查,防止重复查询(double-check)
                value = redis.get(key);
                if (value == null) {
                    value = queryFromDB(key); // 耗时操作
                    redis.set(key, value, 3600);
                }
            } finally {
                redis.del(lockKey);
            }
        } else {
            // 未获取到锁,等待后重试或直接返回默认值
            Thread.sleep(100);
            return getData(key);
        }
    }
    return value;
}

主动续期(热Key永不过期)

  • 不设置物理过期时间,改为逻辑过期
  • 后台线程定期检查并异步更新缓存
  • 查询时发现逻辑过期,返回旧数据同时触发异步更新

缓存穿透(请求不存在的数据)

现象:缓存和DB中都没有的数据(如非法ID查询),每次请求都穿透到DB。
典型原因

  • 恶意攻击或程序bug,不断请求不存在的数据

规避方案
缓存空值(布隆过滤器兜底)

public String getData(String key) {
    // 方案A:缓存空对象(设置较短过期时间)
    String value = redis.get(key);
    if (value == null) {
        value = queryFromDB(key);
        if (value == null) {
            // 缓存空值,避免反复穿透
            redis.set(key, "null", 60); // 空值过期时间短
            return null;
        }
        redis.set(key, value, 3600);
    }
    // 注意:空值标记不能直接返回,需特殊处理
    return "null".equals(value) ? null : value;
}

布隆过滤器(预过滤)

// 初始化布隆过滤器(所有合法ID的集合)
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnels(), 
    expectedInsertions, 
    falsePositiveRate);
// 查询前先判断
if (!bloomFilter.mightContain(key)) {
    return null; // 一定不存在,直接返回
}
// 再走缓存-数据库流程

注意:布隆过滤器有误判率(可能判断存在但实际不存在),需配合空值缓存兜底。


综合实战:一个健壮的缓存模板

@Service
public class CacheService {
    @Autowired
    private RedisTemplate<String, Object> redis;
    // 10分钟内,同一个Key最多只能重建一次
    private static final long REBUILD_LOCK_TIMEOUT = 10 * 60 * 1000L;
    // 缓存基础过期时间:30分钟
    private static final long CACHE_BASE_TTL = 30 * 60L;
    public <T> T getWithProtection(String key, Class<T> type, 
                                   Supplier<T> dbLoader) {
        // 1. 从缓存读取
        Object value = redis.opsForValue().get(key);
        if (value != null) {
            return handleNullValue((String) value, type);
        }
        // 2. 缓存不存在,尝试重建(分布式锁防击穿)
        String lockKey = "lock:rebuild:" + key;
        boolean locked = redis.opsForValue()
            .setIfAbsent(lockKey, "1", REBUILD_LOCK_TIMEOUT, TimeUnit.MILLISECONDS);
        if (locked) {
            try {
                // 3. double check:可能其他线程已经重建完
                value = redis.opsForValue().get(key);
                if (value != null) {
                    return handleNullValue((String) value, type);
                }
                // 4. 从数据库加载
                T dbResult = dbLoader.get();
                // 5. 策略:空值缓存+随机过期时间,避免雪崩
                String cacheValue = (dbResult == null) ? "NULL_MARK" : JsonUtil.toJson(dbResult);
                long ttl = CACHE_BASE_TTL + new Random().nextInt(600); // 0~10分钟随机偏移
                redis.opsForValue().set(key, cacheValue, ttl, TimeUnit.SECONDS);
                return dbResult;
            } finally {
                redis.delete(lockKey);
            }
        } else {
            // 6. 未获取到锁,等待其他线程完成
            try {
                Thread.sleep(50);
                return getWithProtection(key, type, dbLoader);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        return null;
    }
    private <T> T handleNullValue(String value, Class<T> type) {
        if ("NULL_MARK".equals(value)) {
            return null;
        }
        return JsonUtil.fromJson(value, type);
    }
}

避坑清单(面试/开发自查)

问题类型 常见错误做法 正确做法
雪崩 统一过期时间 基础时间 + 随机偏移 / 多级缓存
击穿 不加锁,直接重建 分布式锁 + double-check / 逻辑过期+异步更新
穿透 不判断null直接查DB 布隆过滤器 + 空值缓存(短TTL)
热点Key 单节点缓存 本地缓存(Caffeine)+ Redis + 冗余节点
大Key 直接缓存整个列表/对象 拆分为多个小Key / 压缩
缓存与DB一致性 先删缓存再更新DB 先更新DB,再删缓存(延迟双删)

关键原则:

  1. 先更新数据库,后删除缓存(避免并发写脏数据)
  2. 缓存设置过期时间(即使逻辑正确,也要有兜底TTL)
  3. 监控告警:Redis命中率、DB QPS异常波动要实时感知

如果需要针对具体业务场景(如秒杀、排行榜)的缓存优化策略,可以进一步细化说明。

抱歉,评论功能暂时关闭!