Skip to content
第 61 / 250 章后端⏱ 10 分钟阅读

第 61 章:缓存三大问题

学习目标

  • 识别缓存穿透、雪崩、击穿三种典型故障
  • 掌握每种问题的解决方案
  • 学会用布隆过滤器、互斥锁、随机 TTL 等工具

一、问题总览

问题现象危害本质
穿透查不存在的 key每次都打到 DB绕过缓存
击穿热点 key 过期大量请求打到 DB单点失效
雪崩大量 key 同时过期DB 瞬时压力批量失效

二、缓存穿透:查不存在的数据

场景

java
// 攻击者请求 id = -1, -2, -3, ...
userService.getById(-1L);   // 每次都查 DB,缓存里没有

解决方案

方案 1:缓存空值

java
@Cacheable(value = "user", key = "#id", unless = "#result == null")
public User getById(Long id) {
    return userMapper.selectById(id);   // 返回 null
}

// ✅ 改:把 null 也缓存(短期)
@Cacheable(value = "user", key = "#id")
public User getById(Long id) {
    User user = userMapper.selectById(id);
    if (user == null) {
        // 缓存一个空标记(5 分钟过期),短 TTL 防止占内存
        cacheManager.getCache("user").put(id, new NullUser());
        return null;
    }
    return user;
}

方案 2:布隆过滤器(更专业)

xml
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.0.0-jre</version>
</dependency>
java
@Component
public class BloomFilterHelper {

    private final BloomFilter<Long> filter;

    public BloomFilterHelper(List<Long> existingIds) {
        // ① 初始化:把已有 ID 加进去(启动时执行)
        this.filter = BloomFilter.create(
                Funnels.longFunnel(),
                10_000_000L,            // 预计元素数
                0.001                   // 误判率 0.1%
        );
        existingIds.forEach(filter::put);
    }

    public boolean mightContain(Long id) {
        return filter.mightContain(id);
    }
}

@Service
@RequiredArgsConstructor
public class UserServiceImpl {

    private final BloomFilterHelper bloomFilter;

    public User getById(Long id) {
        // ② 布隆过滤器说不存在,就一定不存在
        if (!bloomFilter.mightContain(id)) {
            return null;                   // 直接返回,不查 Redis 也不查 DB
        }
        return userMapper.selectById(id);
    }
}

布隆过滤器的特性

  • 可能存在 → 不一定真存在(误判),需要二次确认
  • 一定不存在 → 一定不存在(没错)
  • 不支持删除(标准布隆过滤器),可以用 Counting Bloom Filter 变体

Redis 版布隆过滤器(生产推荐)

bash
# Redis 4.0+ 自带 Bloom Filter 模块
BF.RESERVE user_filter 0.001 10000000
BF.ADD user_filter 1001
BF.EXISTS user_filter 1001
java
@Component
@RequiredArgsConstructor
public class RedisBloomFilter {

    private final RedisTemplate<String, Object> redis;

    public void add(String key, Long id) {
        redis.opsForValue().setBit(key, id, true);   // 简化版,实际用 RedisBloom
    }

    public boolean mightContain(String key, Long id) {
        Boolean bit = redis.opsForValue().getBit(key, id);
        return Boolean.TRUE.equals(bit);
    }
}

三、缓存击穿:热点 key 过期瞬间

场景

java
// 秒杀商品详情,缓存设了 1 小时
@Cacheable(value = "seckill", key = "#skuId")
public SeckillProduct getSeckillProduct(Long skuId) {
    return productMapper.selectSeckill(skuId);
}

// 缓存刚好过期 → 1 万个请求同时涌入 → DB 被打爆

解决方案

方案 1:互斥锁(分布式锁)

java
@Cacheable(value = "seckill", key = "#skuId")
public SeckillProduct getSeckillProduct(Long skuId) {
    return productMapper.selectSeckill(skuId);
}

// ✅ 自定义 CacheManager 加锁逻辑
@Component
public class SeckillCacheManager implements CacheManager {

    private final RedisTemplate<String, Object> redis;
    private final RedisLock lock;

    @Override
    public Cache getCache(String name) {
        return new SeckillCache(name, redis, lock);
    }
}

class SeckillCache implements Cache {

    private static final String LOCK_PREFIX = "lock:";

    public ValueWrapper get(Object key) {
        String redisKey = name + ":" + key;
        ValueWrapper value = redis.get(redisKey);
        if (value != null) return value;

        // ① 没拿到值 → 抢锁
        String lockKey = LOCK_PREFIX + redisKey;
        if (lock.tryLock(lockKey, 10, TimeUnit.SECONDS)) {   // 10 秒超时
            try {
                // ② 抢到锁 → 查 DB,回填缓存
                value = loadFromDb(key);
                redis.set(redisKey, value, 60, TimeUnit.SECONDS);
                return value;
            } finally {
                lock.unlock(lockKey);
            }
        } else {
            // ③ 没抢到锁 → 短暂睡眠后重试
            Thread.sleep(50);
            return redis.get(redisKey);
        }
    }
}

方案 2:逻辑过期(不真正过期)

java
// 缓存 value 里带一个 expires 字段
@Data
public class CacheData<T> {
    private T data;
    private Long expires;     // 逻辑过期时间
}

@Cacheable(value = "seckill", key = "#skuId")
public SeckillProduct getSeckillProduct(Long skuId) {
    // ① 从 DB 加载
    SeckillProduct product = productMapper.selectSeckill(skuId);

    // ② 封装成带过期时间的数据
    CacheData<SeckillProduct> data = new CacheData<>();
    data.setData(product);
    data.setExpires(System.currentTimeMillis() + 60_000);  // 逻辑 1 分钟

    return data;   // ⚠️ 注意:这里返回的是包装对象
}

// 实际读时:
public SeckillProduct readSeckill(Long skuId) {
    CacheData<SeckillProduct> data = redis.get("seckill:" + skuId);

    if (data == null) {
        // 缓存里没 → 同步查 DB 并回填
        return getSeckillProduct(skuId).getData();
    }

    if (System.currentTimeMillis() < data.getExpires()) {
        return data.getData();                    // 没过期,直接返回
    }

    // 过期了 → 异步重建(不阻塞当前请求)
    threadPool.submit(() -> refreshCache(skuId));
    return data.getData();                        // 返回旧数据
}

逻辑过期的精髓:宁可短暂返回旧数据,也不要让请求阻塞等待重建。

方案 3:永不过期 + 后台刷新

java
// 缓存设成逻辑过期(看上面)
// 或者:设置一个非常长的 TTL(比如 24 小时),后台定时刷新

@Scheduled(fixedDelay = 60000)
public void refreshHotCache() {
    List<Long> hotSkuIds = getHotSkuIds();
    for (Long skuId : hotSkuIds) {
        SeckillProduct p = productMapper.selectSeckill(skuId);
        redis.set("seckill:" + skuId, p, 24, TimeUnit.HOURS);
    }
}

方案 4:@Cacheable(sync = true)(最简单)

java
@Cacheable(value = "seckill", key = "#skuId", sync = true)
public SeckillProduct getSeckillProduct(Long skuId) {
    return productMapper.selectSeckill(skuId);
}

sync = true 让 Spring 用本地锁保证只有一个线程查 DB。但只对单机有效,集群下还是要用分布式锁。

四、缓存雪崩:大量 key 同时过期

场景

java
// 凌晨 3 点所有缓存同时过期(设了相同 TTL)
// → 1 万个请求同时查 DB → DB 挂了

解决方案

方案 1:随机过期时间

java
// ❌ 固定 1 小时
redis.set(key, value, 3600, TimeUnit.SECONDS);

// ✅ 加随机扰动
int base = 3600;
int random = new Random().nextInt(600);     // 0-10 分钟随机
redis.set(key, value, base + random, TimeUnit.SECONDS);

方案 2:分散缓存重建时间

java
// 不同 key 用不同的过期时间
if ("hot".equals(type)) {
    redis.set(key, value, 30 + random(10), TimeUnit.MINUTES);
} else if ("normal".equals(type)) {
    redis.set(key, value, 120 + random(30), TimeUnit.MINUTES);
}

方案 3:高可用 + 熔断降级

yaml
resilience4j:
  circuitbreaker:
    instances:
      cache:
        failureRateThreshold: 50            # 失败率超 50% 熔断
        waitDurationInOpenState: 30s        # 熔断 30 秒
        slidingWindowSize: 100
java
@CircuitBreaker(name = "cache", fallbackMethod = "fallback")
public User getById(Long id) {
    return userMapper.selectById(id);
}

// 熔断后调用此方法
public User fallback(Long id, Throwable t) {
    log.warn("DB 故障,返回降级数据 id={}", id);
    return new User("系统繁忙,请稍后再试");
}

方案 4:缓存预热

java
// 启动时就把热点数据加载进缓存,避免冷启动
@Component
public class CacheWarmer implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        // 把热门商品、配置、字典全部加载
    }
}

五、数据不一致问题

场景

java
@Transactional
public void updateUser(User user) {
    userMapper.updateById(user);
    redis.del("user:" + user.getId());   // 删缓存
}
// 问题:先更 DB 再删缓存中间,有个窗口期可能不一致

解决方案

方案 1:延迟双删

java
@Transactional
public void updateUser(User user) {
    userMapper.updateById(user);

    // ① 先删一次
    redis.del("user:" + user.getId());

    // ② 延迟再删一次(清除回填的脏数据)
    executor.schedule(() -> {
        redis.del("user:" + user.getId());
    }, 500, TimeUnit.MILLISECONDS);   // 500ms 后再删
}

方案 2:基于消息队列的最终一致

java
@Transactional
public void updateUser(User user) {
    userMapper.updateById(user);

    // 发送消息给消费者去删缓存(消费者保证一定能删掉)
    mqClient.send("user.update", user.getId());
}

@RocketMQMessageListener(topic = "user.update")
public void onMessage(Long userId) {
    redis.del("user:" + userId);
}

方案 3:使用 Canal 订阅 binlog

业务代码不改,缓存同步交给 Canal 监听 MySQL binlog 自动完成。详见后续 Canal 章节。

六、热点 Key 发现

bash
# ① redis-cli 命令(Redis 4.0+)
redis-cli --hotkeys

# ② redis-cli 监控命令
redis-cli --latency-history

# ③ 大 key 查找
redis-cli --bigkeys

# ④ 用 MONITOR 抓命令统计(注意性能影响)
redis-cli MONITOR | head -n 10000 | awk '{print $4}' | sort | uniq -c | sort -rn | head
java
// Java 端:用 redisTemplate 统计访问频次
@Component
public class HotKeyDetector {

    private final LoadingCache<String, AtomicLong> counter = Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofSeconds(10))
            .build(CaffeineCacheLoader.create(k -> new AtomicLong(0)));

    public void recordAccess(String key) {
        counter.get(key).incrementAndGet();
    }

    @Scheduled(fixedDelay = 10000)
    public void report() {
        Map<String, Long> snapshot = counter.asMap().entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get()));
        log.info("热点 key TOP10: {}",
                snapshot.entrySet().stream()
                        .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
                        .limit(10)
                        .toList());
    }
}

七、本章小结

问题解决方案
穿透缓存空值 / 布隆过滤器 / 参数校验
击穿互斥锁 / 逻辑过期 / sync = true / 永不过期+后台刷新
雪崩随机 TTL / 熔断降级 / 缓存预热 / 多级缓存
不一致延迟双删 / MQ 异步删 / Canal 订阅 binlog
热点发现redis-cli --hotkeys / MONITOR / 应用层计数

动手练习

练习 1:基础题

实现空值缓存 + 布隆过滤器组合方案:先用布隆过滤器拦截,再用空值缓存兜底。

练习 2:进阶题

为热点商品详情实现"逻辑过期 + 异步重建"模式,验证:

  1. 缓存过期瞬间请求仍能返回(用旧数据)
  2. 只有一个后台线程在重建缓存

练习 3:思考题

你的系统里发生了一次缓存雪崩,请写一份故障复盘文档:

  • 现象(监控截图、QPS 曲线)
  • 根因分析(时间线)
  • 解决方案(短期止血 + 长期改进)

下一章第 62 章:消息队列与 RabbitMQ

本站基于 VitePress 构建 · 由 Codebook 团队维护