分布式缓存设计实战:Java 场景下的一致性、穿透防护与可观测性

现实世界引入:把分布式缓存想象成全国连锁超市的货架。中央仓库(数据库)提供真实库存,前端门店(微服务)通过货架(缓存)来快速响应顾客购物请求。若货架信息过期,门店就会给顾客错误的库存信息,造成“买不到货”(穿透)或“大家都抢同一个货架上同一件商品”(击穿)。本文以此类比,深入讲解分布式缓存在 Java 场景下的设计要点,并给出可落地的代码实现。


场景化引入:从门店缓存说起

  • 数据源:关系型数据库中的商品库存。
  • 缓存:Redis 作为分布式缓存,负责快速返回库存信息。
  • 请求路径:微服务处理请求时,优先查询缓存;未命中时,从 DB 读取并回写缓存。
  • 问题点:缓存失效、缓存穿透、缓存击穿、以及缓存雪崩等。

通过这个比喻,我们来拆解 2-3 个关键知识点。


1) 缓存设计与一致性模型:Read-Through/Cache-Aside 的实战

核心要点:在分布式环境中,缓存最常用的模式是 Cache-Aside(外附缓存,按需加载)。通过这种模式,缓存只作为加速层,数据的一致性取决于写操作对数据库的影响以及缓存更新的策略。

  • 优点:简单、回放容易、对历史数据友好。
  • 约束:存在短暂不一致;需要显式的缓存失效或更新策略。

实现要点

  • 使用 Redis 的 TTL 来控制缓存有效期,防止缓存长期陈旧。
  • 写操作时同时更新数据库并清除相关缓存,确保下次查询走新值。
  • 读取时若缓存未命中再从数据库查询,并将结果写回缓存。

代码示例(Java,基于 Redis Redisson 实现的 Cache-Aside)

import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;

public class StockCacheService {
    private final RedissonClient redisson;
    private final ProductRepository repo;

    public StockCacheService(RedissonClient redisson, ProductRepository repo) {
        this.redisson = redisson;
        this.repo = repo;
    }

    private String key(long id) { return "product:stock:" + id; }

    public int getStock(long id) {
        RBucket<Integer> bucket = redisson.getBucket(key(id));
        Integer stock = bucket.get();
        if (stock != null) {
            return stock;
        }
        // 缓存未命中,读取数据库并回写缓存
        stock = repo.findStockById(id);
        if (stock != null) {
            bucket.set(stock, 10, java.util.concurrent.TimeUnit.MINUTES); // TTL 10
        }
        return stock == null ? 0 : stock;
    }

    public void updateStock(long id, int delta) {
        repo.updateStock(id, delta);
        // 写操作后失效缓存,确保下次查询走新值
        redisson.getBucket(key(id)).delete();
    }
}

解析:- 当缓存命中时,返回快速结果,降低对数据库压力。

  • 当缓存未命中时,读取数据库并回填缓存,确保后续请求命中缓存。
  • 更新库存时,先写数据库,再清除缓存,避免读到脏数据。

边界情况与注意点:TTL 设置要与业务一致性要求匹配;高并发场景下,单点缓存击穿问题仍可能出现,需结合分布式锁或读写穿透策略。


2) 防穿透、击穿与雪崩:缓存失效时的同频防护

为了应对缓存穿透(查询不存在的 key 直接击穿到数据库)和缓存击穿(高并发时刻同一个未命中 key 真正刷新数据库压力过大)以及缓存雪崩(大量 key 同时失效)等问题,我们可以引入以下策略。

  • 使用布隆过滤器(Bloom Filter)拦截几乎确定不存在的请求。
  • 对“空值”缓存:对于数据库返回的空结果,也写一个短 TTL 的占位缓存,避免重复访问。
  • 引入分布式锁:缓存未命中时,只有一个请求获取数据库并刷新缓存,其他请求等待或轮询。
  • 设定合理的 TTL:不同商品设定不同的 TTL,降低同一时间大面积失效的风险。

代码示例(带分布式锁的缓存穿透防护)

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;

public class SafeStockCacheService {
    private final RedissonClient redisson;
    private final ProductRepository repo;

    public int getStockWithLock(long id) {
        String cacheKey = "product:stock:" + id;
        RLock lock = redisson.getLock("lock:stock:" + id);
        // 先尝试从缓存获取
        Integer stock = redisson.getBucket(cacheKey).get();
        if (stock != null) return stock;
        // 布隆过滤在外层封装,示意性展示
        // 获取锁,防止穿透和击穿
        lock.lock();
        try {
            stock = redisson.getBucket(cacheKey).get();
            if (stock != null) return stock;
            stock = repo.findStockById(id);
            if (stock != null) {
                redisson.getBucket(cacheKey).set(stock, 10, java.util.concurrent.TimeUnit.MINUTES);
            } else {
                // 空值缓存,防止击穿
                redisson.getBucket(cacheKey).set(0, 2, java.util.concurrent.TimeUnit.MINUTES);
            }
        } finally {
            lock.unlock();
        }
        return stock == null ? 0 : stock;
    }
}

解读:- 通过分布式锁避免同一时间多请求同时回源查询数据库,减轻数据库压力。

  • 空值缓存策略有效防止高并发访问不存在数据时对数据库的重复压力。
  • Bloom 过滤需要在应用侧与数据源协作部署,适用于查询存在性极低的数据集。

3) 可观测性:监控、告警与调优的闭环

  • 指标建议:缓存命中率、平均响应时间、P95/P99 延时、失效请求比例、以及 Redis 的命中-未命中分布。
  • 变更策略对比:对高热商品是否应设较短 TTL 以确保新数据及时生效,还是对冷数据长期缓存以提升命中率。
  • 事件驱动的缓存失效:通过消息队列/事件总线后端驱动缓存刷新,减少直连数据库的压力。

代码示例(Spring Boot + Micrometer 指标暴露)

// 在 Spring Boot 应用中,使用 Micrometer 收集缓存命中率等指标
@Service
public class MetricsStockService {
    private final StockCacheService cache;

    public void handleRequest(long id) {
        long t0 = System.nanoTime();
        int stock = cache.getStock(id);
        long dt = System.nanoTime() - t0;
        // 通过 Micrometer 注入自定义指标
        meterRegistry.timer("cache.get.stock.latency", "id", String.valueOf(id)).record(dt, java.util.concurrent.TimeUnit.NANOSECONDS);
        // 统计命中/未命中需要在缓存实现中暴露
        // 简化示例:忽略具体实现细节
    }
}

要点:把缓存与监控绑定到业务入口,形成可观测的性能闭环。对于缓存命中率的提升,往往也是最具性价比的优化方向。


实战要点与最佳实践

  • 选择合适的缓存粒度和 TTL:粒度过细导致缓存失效频繁,粒度过粗导致数据不一致。
  • 读写路径的幂等性:确保写操作幂等以避免重复扣减、重复计数等问题。
  • 逐步滚动发布缓存策略:先对非关键数据尝试新策略,逐步推广到全量数据。
  • 监控与告警:设定阈值触发告警,避免缓存成为系统瓶颈。

要点总结

  • 缓存设计核心:Read-Through/Cache-Aside 模式,合理 TTL 与失效策略。
  • 面对穿透/击穿/雪崩,结合布隆过滤、空值缓存、以及分布式锁实现防护。
  • 可观测性是缓存优化的关键,命中率和延迟是判断是否需要调整 TTL 的关键指标。

—— 结束语:在分布式系统中,缓存不是越多越好,而是要以一致性、可用性与运维成本的平衡为目标。通过上述 Java 实战,我们可以在真实场景中快速识别瓶颈、实现高效缓存,并在面试中自信回答相关设计题。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐