知识模块
☕ Java 知识模块
九、分布式系统
分布式缓存问题

分布式缓存问题

三大经典问题

问题说明
缓存穿透查询不存在的数据,缓存和数据库都没有
缓存击穿热点 Key 过期,大量请求直接打到数据库
缓存雪崩大量 Key 同时过期,数据库压力骤增

缓存穿透

场景

用户请求 id = -1 的数据

缓存未命中

查询数据库

数据库也没有

返回空

下次请求仍然穿透到数据库

解决方案

1. 缓存空对象

public User getUser(Long id) {
    String key = "user:" + id;
    
    // 查缓存
    String value = redis.get(key);
    if (value != null) {
        if ("NULL".equals(value)) {
            return null; // 缓存的空对象
        }
        return JSON.parseObject(value, User.class);
    }
    
    // 查数据库
    User user = userDao.findById(id);
    
    // 缓存结果(包括空对象)
    if (user == null) {
        redis.set(key, "NULL", 60, TimeUnit.SECONDS); // 短过期时间
    } else {
        redis.set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
    }
    
    return user;
}

2. 布隆过滤器

@Component
public class BloomFilterService {
    
    private BloomFilter<Long> userIdFilter;
    
    @PostConstruct
    public void init() {
        // 预计元素数量 100 万,误判率 0.01%
        userIdFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.0001);
        
        // 初始化:加载所有用户 ID
        List<Long> userIds = userDao.findAllIds();
        userIds.forEach(userIdFilter::put);
    }
    
    public boolean mightContain(Long id) {
        return userIdFilter.mightContain(id);
    }
}
 
// 使用
public User getUser(Long id) {
    // 布隆过滤器判断
    if (!bloomFilterService.mightContain(id)) {
        return null; // 一定不存在
    }
    
    // 可能存在,继续查询
    return getUserFromCacheOrDb(id);
}

布隆过滤器原理

位数组 + 多个哈希函数

添加元素:
1. 用 k 个哈希函数计算 k 个位置
2. 将这 k 个位置设为 1

查询元素:
1. 用 k 个哈希函数计算 k 个位置
2. 如果所有位置都是 1 → 可能存在
3. 如果有位置是 0 → 一定不存在

特点:
- 空间效率高
- 查询速度快
- 存在误判(可能误报存在)
- 不存在误判(不会误报不存在)

缓存击穿

场景

热点 Key(如秒杀商品)

Key 过期

大量请求同时查询数据库

数据库压力骤增

解决方案

1. 互斥锁

public User getUser(Long id) {
    String key = "user:" + id;
    String lockKey = "lock:user:" + id;
    
    // 查缓存
    String value = redis.get(key);
    if (value != null) {
        return JSON.parseObject(value, User.class);
    }
    
    // 获取锁
    try {
        boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        if (locked) {
            // 获取锁成功,查询数据库
            User user = userDao.findById(id);
            redis.set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
            return user;
        } else {
            // 获取锁失败,等待重试
            Thread.sleep(50);
            return getUser(id); // 递归重试
        }
    } finally {
        redis.delete(lockKey);
    }
}

2. 逻辑过期

public User getUser(Long id) {
    String key = "user:" + id;
    
    // 查缓存
    String value = redis.get(key);
    if (value != null) {
        RedisData redisData = JSON.parseObject(value, RedisData.class);
        
        // 判断是否过期
        if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
            return redisData.getData();
        }
        
        // 过期,异步刷新
        executor.submit(() -> refreshCache(id));
        
        // 返回旧数据
        return redisData.getData();
    }
    
    // 缓存不存在,查数据库
    return refreshCache(id);
}
 
private User refreshCache(Long id) {
    User user = userDao.findById(id);
    
    RedisData redisData = new RedisData();
    redisData.setData(user);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(3600));
    
    // 不设置 TTL,通过逻辑过期控制
    redis.set("user:" + id, JSON.toJSONString(redisData));
    
    return user;
}

3. 热点数据永不过期

// 设置热点数据永不过期
redis.set("hot:item:123", data);
 
// 后台定时刷新
@Scheduled(fixedRate = 300000) // 5分钟
public void refreshHotData() {
    List<String> hotKeys = getHotKeys();
    hotKeys.forEach(key -> {
        Object data = loadDataFromDb(key);
        redis.set(key, JSON.toJSONString(data));
    });
}

缓存雪崩

场景

大量 Key 同时过期

请求全部打到数据库

数据库压力骤增,可能宕机

解决方案

1. 过期时间加随机值

public void setCache(String key, Object value) {
    // 基础过期时间 + 随机值
    int baseExpire = 3600;
    int randomExpire = new Random().nextInt(300); // 0-300秒随机
    
    redis.set(key, JSON.toJSONString(value), baseExpire + randomExpire, TimeUnit.SECONDS);
}

2. 多级缓存

请求 → 本地缓存 → Redis → 数据库

      命中返回

           命中返回

                    命中返回
@Service
public class UserService {
    
    private LoadingCache<Long, User> localCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build(id -> getFromRedis(id));
    
    public User getUser(Long id) {
        return localCache.get(id);
    }
    
    private User getFromRedis(Long id) {
        String value = redis.get("user:" + id);
        if (value != null) {
            return JSON.parseObject(value, User.class);
        }
        
        User user = userDao.findById(id);
        if (user != null) {
            redis.set("user:" + id, JSON.toJSONString(user), 3600, TimeUnit.SECONDS);
        }
        return user;
    }
}

3. 熔断降级

@HystrixCommand(fallbackMethod = "getUserFallback")
public User getUser(Long id) {
    return getFromCacheOrDb(id);
}
 
public User getUserFallback(Long id) {
    // 返回默认值或空对象
    return new User(id, "默认用户");
}

缓存一致性问题

场景

更新数据库成功

更新缓存失败

缓存数据不一致

解决方案

1. 先更新数据库,再删除缓存

@Transactional
public void updateUser(User user) {
    // 1. 更新数据库
    userDao.update(user);
    
    // 2. 删除缓存
    redis.delete("user:" + user.getId());
}

2. 延迟双删

public void updateUser(User user) {
    // 1. 删除缓存
    redis.delete("user:" + user.getId());
    
    // 2. 更新数据库
    userDao.update(user);
    
    // 3. 延迟删除缓存(防止读请求把旧数据写入缓存)
    executor.schedule(() -> {
        redis.delete("user:" + user.getId());
    }, 500, TimeUnit.MILLISECONDS);
}

3. 订阅 Binlog

数据库更新 → Canal 监听 Binlog → 发送消息 → 消费者删除缓存

面试高频问题

Q1: 缓存穿透、击穿、雪崩的区别?

问题原因解决方案
穿透查询不存在的数据布隆过滤器、缓存空对象
击穿热点 Key 过期互斥锁、逻辑过期
雪崩大量 Key 同时过期随机过期时间、多级缓存

Q2: 如何保证缓存一致性?

  1. 先更新数据库,再删除缓存
  2. 延迟双删
  3. 订阅 Binlog 异步删除

Q3: 布隆过滤器的原理?

位数组 + 多个哈希函数,判断元素是否可能存在。

总结

分布式缓存问题核心要点:
1. 穿透:布隆过滤器、缓存空对象
2. 击穿:互斥锁、逻辑过期、热点永不过期
3. 雪崩:随机过期、多级缓存、熔断降级
4. 一致性:先更库再删缓存、延迟双删、Binlog