知识模块
☕ Java 知识模块
十二、性能优化
缓存策略优化

缓存策略优化

面试高频考点:缓存穿透/击穿/雪崩、缓存一致性策略、缓存预热、多级缓存架构

一、缓存基础

为什么需要缓存?

没有缓存:
客户端 → API 服务 → 数据库查询 → 返回结果
响应时间:100-500ms

有缓存:
客户端 → API 服务 → 缓存命中 → 返回结果
响应时间:1-5ms

性能提升:20-100 倍

缓存应用场景

场景是否适合缓存原因
商品详情读多写少,数据变化少
用户信息访问频繁,更新较少
新闻列表读多写少
股票行情数据实时变化
验证码一次性数据
秒杀库存谨慎需要强一致性

二、缓存三大问题

1. 缓存穿透(Cache Penetration)

定义:查询不存在的数据,缓存和数据库都没有,导致每次都查询数据库。

请求 key="不存在的key"

缓存未命中

查询数据库

数据库也没有

返回 null(没有缓存 null)

下次请求继续穿透...

危害

  • 大量请求直达数据库
  • 可能导致数据库压力过大甚至宕机
  • 恶意攻击利用此漏洞

解决方案

// 方案一:缓存空值
public Object get(String key) {
    Object value = redis.get(key);
    if (value != null) {
        return "NULL".equals(value) ? null : value;  // 空值特殊处理
    }
    
    value = database.query(key);
    if (value == null) {
        redis.set(key, "NULL", 60);  // 缓存空值,短过期时间
    } else {
        redis.set(key, value, 3600);
    }
    return value;
}
 
// 方案二:布隆过滤器
@Component
public class CacheFilter {
    
    private BloomFilter<String> bloomFilter;
    
    @PostConstruct
    public void init() {
        // 预计元素 100 万,误判率 0.01%
        bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(Charset.defaultCharset()),
            1000000, 0.0001);
        
        // 预热:加载所有存在的 key
        List<String> allKeys = database.getAllKeys();
        allKeys.forEach(bloomFilter::put);
    }
    
    public Object get(String key) {
        // 先检查布隆过滤器
        if (!bloomFilter.mightContain(key)) {
            return null;  // 一定不存在,直接返回
        }
        
        // 可能存在,查询缓存和数据库
        return getFromCacheOrDb(key);
    }
}
 
// 方案三:接口层校验
public Object getData(String id) {
    // 参数校验,过滤非法请求
    if (StringUtils.isBlank(id) || !id.matches("^[0-9]+$")) {
        throw new IllegalArgumentException("Invalid id");
    }
    // ...
}

2. 缓存击穿(Cache Breakdown)

定义:热点 key 过期瞬间,大量并发请求同时查询数据库。

热点 key 过期

大量并发请求同时发现缓存不存在

同时查询数据库

数据库压力瞬间激增

解决方案

// 方案一:互斥锁(Mutex Lock)
public Object getWithLock(String key) {
    Object value = redis.get(key);
    if (value != null) {
        return value;
    }
    
    String lockKey = "lock:" + key;
    try {
        // 尝试获取锁
        boolean locked = redis.setnx(lockKey, "1", 10);  // 10 秒超时
        if (locked) {
            // 获取锁成功,查询数据库
            value = database.query(key);
            redis.set(key, value, 3600);
            return value;
        } else {
            // 获取锁失败,等待后重试
            Thread.sleep(50);
            return getWithLock(key);  // 递归重试
        }
    } finally {
        redis.del(lockKey);
    }
}
 
// 方案二:逻辑过期(不设置 TTL)
@Component
public class HotKeyCache {
    
    @Data
    public static class CacheData {
        private Object data;
        private Long expireTime;  // 逻辑过期时间
    }
    
    public Object getWithLogicalExpire(String key) {
        String json = redis.get(key);
        if (json == null) {
            // 缓存不存在,需要重建(理论上热点数据会预热)
            return rebuildCache(key);
        }
        
        CacheData cacheData = JSON.parseObject(json, CacheData.class);
        
        // 检查是否逻辑过期
        if (cacheData.getExpireTime() > System.currentTimeMillis()) {
            return cacheData.getData();  // 未过期,直接返回
        }
        
        // 已过期,异步重建
        asyncRebuildCache(key);
        
        // 返回旧数据(保证可用性)
        return cacheData.getData();
    }
    
    @Async
    public void asyncRebuildCache(String key) {
        String lockKey = "lock:" + key;
        if (redis.setnx(lockKey, "1", 10)) {
            try {
                Object data = database.query(key);
                CacheData cacheData = new CacheData();
                cacheData.setData(data);
                cacheData.setExpireTime(System.currentTimeMillis() + 3600000);
                redis.set(key, JSON.toJSONString(cacheData));  // 不设置 TTL
            } finally {
                redis.del(lockKey);
            }
        }
    }
}
 
// 方案三:热点数据永不过期
public void cacheHotData(String key, Object value) {
    // 热点数据设置较长过期时间或不设置过期
    redis.set(key, value);  // 永不过期
    // 或
    redis.set(key, value, 7 * 24 * 3600);  // 7 天
}

3. 缓存雪崩(Cache Avalanche)

定义:大量缓存同时过期,或缓存服务宕机,导致所有请求直达数据库。

场景一:大量缓存同时过期
凌晨 0 点,一批缓存同时过期

大量请求同时查询数据库

数据库压力激增

场景二:缓存服务宕机
Redis 宕机

所有请求直达数据库

数据库瞬间崩溃

解决方案

// 方案一:过期时间加随机值
public void setWithRandomExpire(String key, Object value) {
    int baseExpire = 3600;  // 基础过期时间 1 小时
    int randomExpire = new Random().nextInt(600);  // 随机 0-10 分钟
    redis.set(key, value, baseExpire + randomExpire);
}
 
// 方案二:多级缓存
@Component
public class MultiLevelCache {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    private Cache<String, Object> localCache = Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(300, TimeUnit.SECONDS)  // 本地缓存 5 分钟
            .build();
    
    public Object get(String key) {
        // L1: 本地缓存
        Object value = localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }
        
        // L2: Redis 缓存
        value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            localCache.put(key, value);
            return value;
        }
        
        // L3: 数据库
        value = database.query(key);
        if (value != null) {
            redisTemplate.opsForValue().set(key, value, 3600);
            localCache.put(key, value);
        }
        return value;
    }
}
 
// 方案三:缓存高可用
// Redis Cluster 或 Sentinel 部署,保证缓存服务可用
 
// 方案四:熔断降级
@CircuitBreaker(name = "cache", fallbackMethod = "fallback")
public Object getWithCircuitBreaker(String key) {
    return cacheService.get(key);
}
 
public Object fallback(String key, Exception e) {
    // 降级策略:返回默认值或查询数据库
    return getDefaultData();
}

三、缓存一致性策略

缓存更新策略

策略一致性性能适用场景
Cache Aside通用
Read Through读多写少
Write Through写多
Write Behind写多、允许延迟

Cache Aside Pattern(旁路缓存)

读流程

1. 先查缓存
2. 缓存命中 → 返回
3. 缓存未命中 → 查数据库 → 写缓存 → 返回

写流程

1. 更新数据库
2. 删除缓存(推荐)/ 更新缓存
@Service
public class UserService {
    
    // 读操作
    public User getUser(Long id) {
        String key = "user:" + id;
        String json = redis.get(key);
        
        if (json != null) {
            return JSON.parseObject(json, User.class);
        }
        
        // 缓存未命中,查询数据库
        User user = userMapper.selectById(id);
        if (user != null) {
            redis.set(key, JSON.toJSONString(user), 3600);
        }
        return user;
    }
    
    // 写操作:先更新数据库,再删除缓存
    @Transactional
    public void updateUser(User user) {
        userMapper.updateById(user);
        
        // 删除缓存
        String key = "user:" + user.getId();
        redis.del(key);
    }
}

为什么是删除缓存而不是更新缓存?

场景:频繁更新
更新缓存方案:
- 每次更新都写缓存
- 如果更新频繁但读取少,浪费资源

删除缓存方案:
- 只删除,下次读取时再加载
- 延迟更新,减少写缓存次数

双写一致性问题的解决方案

问题:并发场景下,数据库和缓存可能不一致。

线程 A: 更新数据库 → 删除缓存
线程 B: 读取旧数据 → 写入缓存

时序:
1. 线程 A 更新数据库为 "新值"
2. 线程 B 读取数据库(还是旧值?)
3. 线程 B 写入缓存
4. 线程 A 删除缓存

结果:缓存中没有数据(已被删除),看似没问题

更复杂的场景

线程 A: 删除缓存 → 更新数据库
线程 B: 读缓存未命中 → 读数据库 → 写缓存

时序:
1. 线程 A 删除缓存
2. 线程 B 读缓存未命中
3. 线程 B 读数据库(还是旧值)
4. 线程 B 写入缓存(旧值)
5. 线程 A 更新数据库

结果:缓存是旧值,数据库是新值 → 不一致!

解决方案

// 方案一:延迟双删
public void updateWithDoubleDelete(User user) {
    String key = "user:" + user.getId();
    
    // 1. 先删除缓存
    redis.del(key);
    
    // 2. 更新数据库
    userMapper.updateById(user);
    
    // 3. 延迟后再删除缓存
    try {
        Thread.sleep(500);  // 延迟时间大于读操作耗时
    } catch (InterruptedException e) {
        // ignore
    }
    redis.del(key);
}
 
// 方案二:消息队列保证最终一致性
@Transactional
public void updateUser(User user) {
    // 更新数据库
    userMapper.updateById(user);
    
    // 发送消息到 MQ
    mqProducer.send("cache-delete", "user:" + user.getId());
}
 
// MQ 消费者
@Consumer(topic = "cache-delete")
public void onMessage(String key) {
    redis.del(key);
}
 
// 方案三:Canal 监听 Binlog
// Canal 订阅 MySQL Binlog,自动同步到缓存
 
// 方案四:分布式锁(强一致性要求高时)
public void updateWithLock(User user) {
    String lockKey = "lock:user:" + user.getId();
    try {
        if (redis.setnx(lockKey, "1", 10)) {
            String key = "user:" + user.getId();
            
            // 删除缓存
            redis.del(key);
            
            // 更新数据库
            userMapper.updateById(user);
        }
    } finally {
        redis.del(lockKey);
    }
}

四、缓存预热

定义:系统启动时,提前加载热点数据到缓存。

@Component
public class CacheWarmup implements CommandLineRunner {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    @Override
    public void run(String... args) {
        log.info("开始缓存预热...");
        
        // 1. 加载热点商品
        List<Product> hotProducts = productMapper.selectHotProducts(100);
        for (Product product : hotProducts) {
            String key = "product:" + product.getId();
            redisTemplate.opsForValue().set(key, product, 24, TimeUnit.HOURS);
        }
        
        // 2. 加载布隆过滤器
        BloomFilter<String> bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(Charset.defaultCharset()),
            100000, 0.0001);
        
        List<String> allIds = productMapper.selectAllIds();
        allIds.forEach(bloomFilter::put);
        redisTemplate.opsForValue().set("bloom:product", bloomFilter);
        
        log.info("缓存预热完成,预热商品数量: {}", hotProducts.size());
    }
}
 
// 定时刷新热点数据
@Scheduled(cron = "0 0 * * * ?")  // 每小时刷新
public void refreshHotData() {
    List<Product> hotProducts = productMapper.selectHotProducts(100);
    for (Product product : hotProducts) {
        String key = "product:" + product.getId();
        redisTemplate.opsForValue().set(key, product, 24, TimeUnit.HOURS);
    }
}

五、多级缓存架构

┌─────────────────────────────────────────────────────────────┐
│                        请求流程                              │
│                                                             │
│  客户端请求                                                  │
│       ↓                                                     │
│  ┌─────────┐                                                │
│  │ CDN 缓存 │  ← 静态资源、图片                              │
│  └────┬────┘                                                │
│       ↓ 未命中                                              │
│  ┌─────────────┐                                            │
│  │ Nginx 缓存   │  ← 页面缓存、API 响应缓存                   │
│  └──────┬──────┘                                            │
│         ↓ 未命中                                            │
│  ┌─────────────┐                                            │
│  │ 本地缓存     │  ← Caffeine/Guava,毫秒级延迟               │
│  │ (JVM 内存)  │                                            │
│  └──────┬──────┘                                            │
│         ↓ 未命中                                            │
│  ┌─────────────┐                                            │
│  │ 分布式缓存   │  ← Redis Cluster,微秒级延迟                │
│  │ (Redis)     │                                            │
│  └──────┬──────┘                                            │
│         ↓ 未命中                                            │
│  ┌─────────────┐                                            │
│  │ 数据库      │  ← MySQL/PostgreSQL                        │
│  └─────────────┘                                            │
└─────────────────────────────────────────────────────────────┘

多级缓存实现

@Service
public class ProductCacheService {
    
    // L1: 本地缓存 (Caffeine)
    private Cache<String, Product> localCache = Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .recordStats()  // 开启统计
            .build();
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    public Product getProduct(Long id) {
        String key = "product:" + id;
        
        // L1: 本地缓存
        Product product = localCache.getIfPresent(key);
        if (product != null) {
            log.debug("L1 cache hit: {}", key);
            return product;
        }
        
        // L2: Redis 缓存
        product = (Product) redisTemplate.opsForValue().get(key);
        if (product != null) {
            log.debug("L2 cache hit: {}", key);
            localCache.put(key, product);  // 回填 L1
            return product;
        }
        
        // L3: 数据库
        product = productMapper.selectById(id);
        if (product != null) {
            // 回填缓存
            redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
            localCache.put(key, product);
        }
        
        return product;
    }
    
    // 更新时清除多级缓存
    public void updateProduct(Product product) {
        String key = "product:" + product.getId();
        
        // 更新数据库
        productMapper.updateById(product);
        
        // 清除 L1 缓存
        localCache.invalidate(key);
        
        // 清除 L2 缓存
        redisTemplate.delete(key);
    }
    
    // 获取缓存统计
    public CacheStats getStats() {
        return localCache.stats();
    }
}

本地缓存 vs 分布式缓存

特性本地缓存分布式缓存
延迟纳秒级微秒级
容量受 JVM 内存限制可扩展
一致性弱(各节点独立)强(共享)
可靠性进程重启丢失持久化
适用场景热点数据、配置共享数据、会话

六、Redis 缓存最佳实践

Key 命名规范

格式:业务:模块:id[:属性]

示例:
user:info:1001           # 用户信息
user:followers:1001      # 用户粉丝列表
product:detail:2001      # 商品详情
order:status:3001        # 订单状态
cache:config:system      # 系统配置

过期时间设计

// 根据数据热度设置不同过期时间
public void setCache(String key, Object value, CacheLevel level) {
    int expire;
    switch (level) {
        case HOT:      // 热点数据:长过期
            expire = 24 * 3600;
            break;
        case WARM:     // 温数据:中等过期
            expire = 3600;
            break;
        case COLD:     // 冷数据:短过期
            expire = 300;
            break;
        default:
            expire = 600;
    }
    // 加随机值防止雪崩
    expire += new Random().nextInt(60);
    redis.set(key, value, expire);
}

大 Key 问题

// 问题:单个 Key 的 Value 过大
// 影响:阻塞其他操作、网络传输慢
 
// 检测大 Key
redis-cli --bigkeys
 
// 解决方案
// 1. 拆分大 Key
// 原:user:1001:detail -> 整个用户对象
// 拆:user:1001:info, user:1001:profile, user:1001:settings
 
// 2. 压缩
String compressed = compress(JSON.toJSONString(value));
redis.set(key, compressed);
 
// 3. 使用 Hash 结构
redis.hset("user:1001", "name", "张三");
redis.hset("user:1001", "age", "25");

七、面试要点

Q1: 什么是缓存穿透、击穿、雪崩?

回答要点

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

Q2: 如何保证缓存和数据库一致性?

回答要点

  1. 采用 Cache Aside 模式:先更新数据库,再删除缓存
  2. 延迟双删保证最终一致性
  3. 使用消息队列异步删除缓存
  4. 强一致性要求高时使用分布式锁

Q3: 为什么删除缓存而不是更新缓存?

回答要点

  1. 更新缓存可能有并发问题
  2. 频繁更新但读取少时浪费资源
  3. 删除后延迟加载,减少写次数
  4. 复杂数据更新成本高

Q4: 什么是多级缓存?

回答要点

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

优势:
1. 减少网络开销
2. 降低延迟
3. 提高可用性
4. 减轻数据库压力

Q5: 如何设计缓存预热?

回答要点

  1. 系统启动时加载热点数据
  2. 定时任务刷新缓存
  3. 监控访问量动态调整热点数据
  4. 结合布隆过滤器防止穿透

小结

  • 缓存穿透:布隆过滤器、缓存空值
  • 缓存击穿:互斥锁、逻辑过期
  • 缓存雪崩:随机过期时间、多级缓存、熔断降级
  • 缓存一致性:先更新数据库,再删除缓存
  • 多级缓存:本地缓存 + 分布式缓存
  • 缓存预热:启动加载、定时刷新