Skip to content
第 24 章 架构 ⏱ 12 分钟阅读

第 24 章:缓存架构 ​

学习目标 ​

  • 掌握缓存三大模式:Cache-Aside、Read-Through、Write-Through
  • 学会解决缓存穿透、击穿、雪崩
  • 多级缓存架构设计
  • 避免缓存不一致的坑

一、缓存的三大问题 ​

text
缓存穿透:查询不存在的数据,绕过缓存
缓存击穿:热点 key 过期瞬间,大量请求直接打到 DB
缓存雪崩:大量 key 同时过期,DB 被打挂
java
// 缓存穿透
SELECT * FROM product WHERE id = 999999;  // 不存在,缓存没,DB 也没
java
// 缓存击穿
// 热门商品 cache 过期,1 万请求同时打到 DB
java
// 缓存雪崩
// 1 万个 key 都设了 30 分钟过期,30 分钟后同时失效

二、Cache-Aside 模式(最常用) ​

java
@Service
public class ProductService {

    @Resource
    private ProductRepository productRepository;

    @Resource
    private StringRedisTemplate redis;

    public Product getProduct(Long id) {
        String key = "product:" + id;

        // 1. 先查缓存
        String json = redis.opsForValue().get(key);
        if (json != null) {
            return JsonUtil.fromJson(json, Product.class);
        }

        // 2. 查 DB
        Product product = productRepository.findById(id).orElse(null);
        if (product == null) {
            // 缓存空值,防穿透
            redis.opsForValue().set(key, "", Duration.ofSeconds(60));
            return null;
        }

        // 3. 写入缓存
        redis.opsForValue().set(key, JsonUtil.toJson(product), Duration.ofSeconds(300));
        return product;
    }

    public void updateProduct(Product product) {
        // 1. 先更新 DB
        productRepository.save(product);

        // 2. 删除缓存(不更新,避免并发写不一致)
        redis.delete("product:" + product.getId());
    }
}

⚠️ 坑 1:先更新缓存再更新 DB,DB 失败回滚但缓存已更新 = 数据不一致。先 DB 后删缓存。

三、缓存穿透 ​

java
// 方案 1:缓存空值
public Product getProduct(Long id) {
    String key = "product:" + id;
    String json = redis.opsForValue().get(key);
    if (json != null) {
        return json.isEmpty() ? null : JsonUtil.fromJson(json, Product.class);
    }
    Product p = productRepository.findById(id).orElse(null);
    redis.opsForValue().set(key, p == null ? "" : JsonUtil.toJson(p), Duration.ofSeconds(60));
    return p;
}

// 方案 2:布隆过滤器
@Bean
public BloomFilter<Long> productBloomFilter() {
    BloomFilter<Long> filter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);
    productRepository.findAll().forEach(p -> filter.put(p.getId()));
    return filter;
}

public Product getProduct(Long id) {
    if (!bloomFilter.mightContain(id)) {
        return null;  // 一定不存在
    }
    // 走正常缓存逻辑
}

四、缓存击穿 ​

java
// 方案 1:分布式锁
public Product getProduct(Long id) {
    String key = "product:" + id;
    Product p = redis.get(key);
    if (p == null) {
        // 只让一个线程去查 DB
        RLock lock = redisson.getLock("lock:product:" + id);
        lock.lock();
        try {
            // 双重检查
            p = redis.get(key);
            if (p == null) {
                p = productRepository.findById(id).orElse(null);
                redis.set(key, p, Duration.ofSeconds(300));
            }
        } finally {
            lock.unlock();
        }
    }
    return p;
}
java
// 方案 2:逻辑过期(不设 TTL,业务判断)
public Product getProduct(Long id) {
    String key = "product:" + id;
    ProductCache cache = redis.get(key, ProductCache.class);
    if (cache != null && !cache.isExpired()) {
        return cache.getProduct();
    }
    // 异步重建(线程池)
    CompletableFuture.runAsync(() -> rebuildCache(id));
    return cache == null ? productRepository.findById(id).orElse(null) : cache.getProduct();
}

五、缓存雪崩 ​

java
// 方案 1:TTL 加随机
public void setProductCache(Long id, Product p) {
    long ttl = 300 + ThreadLocalRandom.current().nextInt(60);  // 300-360s
    redis.opsForValue().set("product:" + id, p, Duration.ofSeconds(ttl));
}

// 方案 2:多级缓存
// L1:本地(Caffeine) 1s 过期
// L2:Redis 5 min 过期
// L3:DB 兜底

// 方案 3:熔断降级
@HystrixCommand(fallbackMethod = "getProductFallback")
public Product getProduct(Long id) {
    return productRepository.findById(id).orElse(null);
}

public Product getProductFallback(Long id) {
    return new Product(id, "兜底商品", new BigDecimal("0"));
}

⚠️ 坑 2:Redis 挂了一瞬间,所有请求都打到 DB = 雪崩。缓存降级开关,Redis 挂了直接返回旧数据。

六、多级缓存架构 ​

text
客户端
  ↓
[本地缓存(Caffeine)]    100us
  ↓ 未命中
[Redis]                  1ms
  ↓ 未命中
[DB]                     10ms
java
// Caffeine 一级
LoadingCache<Long, Product> cache = Caffeine.newBuilder()
    .maximumSize(1000)
    .expireAfterWrite(Duration.ofSeconds(60))
    .build(this::loadFromRedis);

public Product getProduct(Long id) {
    return cache.get(id);
}

private Product loadFromRedis(Long id) {
    String json = redis.opsForValue().get("product:" + id);
    if (json != null) {
        return JsonUtil.fromJson(json, Product.class);
    }
    return productRepository.findById(id).orElse(null);
}
java
// 多级缓存不一致问题
// Caffeine 60s 过期,Redis 5min 过期
// 商品价格被改了,Caffeine 60s 内还是旧值
// 解决:Caffeine 失效订阅 Redis pubsub

七、缓存一致性 ​

java
// 1. 先 DB 后删缓存(推荐)
@Transactional
public void updateProduct(Product p) {
    productRepository.save(p);
    redis.delete("product:" + p.getId());
}

// 2. 监听 binlog 异步删除(Canal)
@Component
public class CanalListener {
    @CanalEventListener
    public void onUpdate(ProductPO old, ProductPO new) {
        redis.delete("product:" + new.getId());
    }
}

// 3. 延迟双删
public void updateProduct(Product p) {
    productRepository.save(p);
    redis.delete("product:" + p.getId());
    // 延迟 500ms 再删一次
    CompletableFuture.runAsync(() -> {
        Thread.sleep(500);
        redis.delete("product:" + p.getId());
    });
}

⚠️ 坑 3:"Cache-Aside" 后删缓存,理论上有 1ms 窗口不一致。强一致场景,直接读 DB。

八、热点缓存预热 ​

java
// 启动时加载热点数据
@PostConstruct
public void warmup() {
    log.info("开始缓存预热");
    // 1. 加载热门商品
    List<Product> hot = productRepository.findHot(1000);
    hot.forEach(p -> redis.opsForValue()
        .set("product:" + p.getId(), p, Duration.ofSeconds(3600)));

    // 2. 加载配置
    List<Config> configs = configRepository.findAll();
    configs.forEach(c -> redis.opsForValue()
        .set("config:" + c.getKey(), c.getValue()));

    log.info("缓存预热完成,已加载 {} 个商品", hot.size());
}

九、缓存监控 ​

java
// 监控指标
MeterRegistry registry = ...;

// 命中率
@EventListener
public void onCacheHit(CacheHitEvent event) {
    registry.counter("cache.hit", "cache", event.getCacheName()).increment();
}

@EventListener
public void onCacheMiss(CacheMissEvent event) {
    registry.counter("cache.miss", "cache", event.getCacheName()).increment();
}

// 告警:命中率 < 80% 告警

本章小结 ​

模式适用
Cache-Aside通用
Read-Through强一致
Write-Through写性能高
Write-Behind异步写,最终一致
关键点方案
穿透布隆过滤器 / 空值缓存
击穿分布式锁 / 逻辑过期
雪崩TTL 随机 / 多级缓存
一致性先 DB 后删缓存 / Canal

动手练习 ​

  1. Cache-Aside:实现商品查询,先 Redis 后 DB
  2. 穿透:用布隆过滤器防不存在 ID 打到 DB
  3. 击穿:用分布式锁模拟热点 key 重建
  4. 雪崩:对比 TTL 固定 vs TTL 加随机,观察 DB QPS

下一章:第 25 章:高可用设计 →

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