🚀 缓存策略

缓存是性能优化的核武器——用空间换时间,用稍旧的数据换取极致的响应速度。 但缓存也是一把双刃剑:用好了吞吐量提升10倍,用不好数据不一致导致严重故障。 本指南涵盖多级缓存架构、Redis最佳实践、一致性方案与经典问题应对。

1. 多级缓存架构

单一缓存层难以兼顾速度、容量、一致性的三角矛盾。业界普遍采用多级缓存架构, 让不同缓存层各司其职。

🏗️ 四级缓存金字塔

     ┌─────────────────────────────────────────┐
     │  L1: 本地缓存 (Caffeine / Guava Cache)  │  纳秒级 | < 1GB
     │  JVM堆内,最快,容量有限                  │
     ├─────────────────────────────────────────┤
     │  L2: 分布式缓存 (Redis / Memcached)      │  毫秒级 | TB级
     │  跨实例共享,容量可按需扩展               │
     ├─────────────────────────────────────────┤
     │  L3: 数据库缓存 (MySQL Buffer Pool)      │  毫秒级 | 取决于内存
     │  InnoDB缓冲池,热数据自动缓存             │
     ├─────────────────────────────────────────┤
     │  L4: 本地磁盘 / CDN                       │  毫秒-秒 | PB级
     │  静态资源、不变数据                       │
     └─────────────────────────────────────────┘

     缓存命中流程: L1 → L2 → L3 → 源数据
     每层Miss时回源加载,并回填上层缓存

📊 各级缓存技术选型

层级推荐技术容量延迟适用数据一致性
L1 本地Caffeine + Spring Cache百MB级纳秒-微秒热点配置、字典、Token弱(需主动失效)
L2 分布式Redis Cluster / Sentinel百GB-TB1~5ms用户Session、商品详情、接口缓存中等(可用MQ通知)
L3 数据库MySQL InnoDB Buffer Pool取决于内存配置0.1~1ms热表数据强(由DB保证)
L4 CDN/磁盘CDN + Nginx本地文件PB级10~100ms图片、静态资源、日志极弱

2. Redis 最佳实践

2.1 数据结构选型

Redis 不只是 key-value 存储——选择正确的数据结构可以大幅减少内存和网络开销。

🗂️ 数据结构选择指南

场景错误选择推荐选择原因
用户积分排行String + ZRANGE(每次都序列化)Sorted Set原生排序,O(logN)
对象缓存多个 String Key(user:1:name, user:1:age)Hash内存节省30%+,支持部分字段更新
消息队列String + RPOP(轮询)List / Stream阻塞读取 (BLPOP),Stream 支持消费组
去重/UV统计Set(全量存储)HyperLogLog12KB 可统计 2^64 个UV,误差0.81%
布隆过滤Set(内存爆炸)Bitmap / RedisBloom极低内存判断元素是否存在
地理位置String + 手动计算Geo原生经纬度距离计算
计数器/限流String + INCR(单Key)String + Lua脚本原子操作,支持滑动窗口限流

2.2 Redis 性能调优参数

⚙️ Redis 关键配置优化

参数默认值推荐值说明
maxmemory无限制物理内存的60-70%务必设置,避免OOM被系统Kill
maxmemory-policynoevictionallkeys-lru / volatile-lru逐出策略,不要用noeviction(会拒绝写入)
save周期性RDB关闭(纯缓存场景)缓存可关闭持久化,用AOF+RDB混合或用主从保证数据安全
tcp-backlog5112048高并发下避免连接丢失
timeout0300空闲连接超时关闭,避免僵尸连接
hash-max-ziplist-entries512512小Hash使用ziplist压缩编码,节省内存
slowlog-log-slower-than10000(微秒)10000记录慢操作日志,单位微秒
activerehashingyesyes渐进式rehash,避免大面积阻塞

2.3 Redis 集群模式选型

🔗 集群方案对比

方案容量可用性复杂度适用场景
主从(单机)受单机内存限制手动切换开发/测试环境
Sentinel 哨兵受单机内存限制自动故障转移中小规模,读多写少
Redis Cluster水平扩展至1000节点自动故障转移大规模、高吞吐、大数据量
Proxy 代理层按分片规则扩展依赖代理层HA需要透明分片、多语言客户端

3. 缓存一致性方案

缓存与数据库的一致性问题是分布式系统的经典难题。CAP理论告诉我们:一致性和可用性不可兼得, 需要根据业务场景选择合适的方案。

3.1 主流一致性方案

📐 六种缓存一致性方案对比

方案原理一致性复杂度适用场景
Cache-Aside
(旁路缓存)
读:先查缓存,Miss则查DB并回填
写:先更新DB,再删除缓存
最终一致 ⭐⭐ 最通用方案,90%场景适用
Read/Write Through 缓存层代理所有读写,对应用透明 强一致 ⭐⭐⭐ 需要缓存中间件(如RedisGear)
Write Behind
(异步回写)
先写缓存,异步批量写DB 最终一致 ⭐⭐⭐⭐ 写密集、可容忍数据丢失
双删策略 写前删缓存 → 更新DB → 延迟再删缓存 最终一致 ⭐⭐ 高并发写场景、防止脏读
订阅Binlog Canal/Debezium 订阅 MySQL Binlog,异步更新/删除缓存 最终一致 ⭐⭐⭐⭐ 多系统缓存同步、跨机房
分布式锁 写操作获取锁:更新DB → 更新缓存 → 释放锁 强一致 ⭐⭐⭐ 一致性要求极高(如余额、库存)
💡 为什么是「删缓存」而不是「更新缓存」?

删除缓存是惰性加载(Lazy Loading)策略:只有下次读取时才从DB加载最新值。 相比更新缓存,删除操作避免了并发写时的覆盖问题,也避免了缓存「不读的数据」占用空间。

3.2 Cache-Aside 核心流程

📋 标准 Cache-Aside 实现

// 读取:缓存优先
public Order getOrder(Long id) {
    String key = "order:" + id;
    Order order = redis.get(key);
    if (order != null) return order;

    // 缓存未命中,查DB
    order = db.findById(id);
    if (order != null) {
        redis.setex(key, 3600, order);  // 回填缓存,设置过期时间
    }
    return order;
}

// 写入:先更新DB,再删除缓存
@Transactional
public void updateOrder(Order order) {
    db.update(order);                  // 1. 先更新数据库
    redis.del("order:" + order.id);    // 2. 再删除缓存
}

// 删除操作双删策略(高并发优化)
@Transactional
public void deleteOrder(Long id) {
    redis.del("order:" + id);          // 1. 先删缓存
    db.delete(id);                      // 2. 更新DB
    Thread.sleep(500);                  // 3. 等待其他并发读完成
    redis.del("order:" + id);          // 4. 二次删除,防止并发写导致的脏缓存
}

4. 缓存穿透 / 击穿 / 雪崩

这三个问题是缓存架构中最高频的故障模式,名字相似但成因和解决方案完全不同。 面试和故障复盘必考——必须搞清楚。

⚠️ 三大问题对比总览

维度缓存穿透缓存击穿缓存雪崩
定义查询不存在的数据,缓存和DB都没有热点Key过期瞬间,大量请求打到DB大量Key同时过期,或Redis宕机
后果无效查询穿透到DB,可能被恶意攻击DB瞬时压力激增,可能拖垮DBDB被打垮,引发全链路雪崩
典型场景查询不存在的商品ID、恶意攻击秒杀商品缓存过期、热点新闻批量导入缓存统一TTL、Redis集群故障
解决方案布隆过滤器、缓存空对象、参数校验互斥锁、永不过期+异步更新、逻辑过期随机化TTL、多级缓存、熔断降级

4.1 缓存穿透应对

🛡️ 穿透防护三层防线

防线方案原理优缺点
L1: 参数校验 业务层过滤非法参数 ID范围、格式校验,直接拒绝非法请求 简单有效,但无法防御合法ID的恶意遍历
L2: 布隆过滤器 RedisBloom / Guava BloomFilter 先判断Key是否可能存在,不存在则直接返回 内存效率高(1亿Key约100MB),但有误判率
L3: 缓存空值 对不存在的Key也缓存(value=null, TTL短) 将「不存在」作为一个结果缓存,避免重复查DB 简单,但消耗缓存空间,需控制TTL

💻 布隆过滤器实现示例

// 使用 RedisBloom 模块
// BF.RESERVE product_filter 0.01 10000000   // 误判率1%,预计1000万元素

public Product getProduct(Long id) {
    // 1. 布隆过滤器检查
    if (!redis.bfExists("product_filter", String.valueOf(id))) {
        return null;  // 一定不存在,直接返回
    }
    // 2. 查缓存
    Product product = redis.get("product:" + id);
    if (product != null) return product;
    // 3. 查DB
    product = db.findById(id);
    if (product != null) {
        redis.setex("product:" + id, 3600, product);
    } else {
        // 4. 缓存空值(防止布隆过滤器误判导致穿透)
        redis.setex("product:" + id, 60, "NULL");
    }
    return product;
}

4.2 缓存击穿应对

热点Key过期瞬间,多个并发请求同时查到缓存Miss,然后并发去查DB——这就是击穿。 核心思路:只让一个请求去加载数据,其余等待

🔐 互斥锁方案(Redis SETNX)

public Product getHotProduct(Long id) {
    String key = "hot:product:" + id;
    Product product = redis.get(key);
    if (product != null) return product;

    // 加互斥锁,只让一个线程去加载
    String lockKey = "lock:product:" + id;
    try {
        if (redis.setnx(lockKey, "1", 10)) {  // 获取锁,10秒过期
            // 双重检查
            product = redis.get(key);
            if (product != null) return product;

            product = db.findById(id);
            redis.setex(key, 3600, product);
        } else {
            // 未获取锁,短暂等待后重试
            Thread.sleep(100);
            return getHotProduct(id);  // 递归重试
        }
    } finally {
        redis.del(lockKey);
    }
    return product;
}

4.3 缓存雪崩应对

雪崩通常由两类原因引发:大量Key同一时刻过期Redis服务不可用。 应对需要从「预防」和「降级」两个层面入手。

🛡️ 雪崩防护四层体系

层级策略实现方式
1. 过期时间随机化将缓存过期时间打散,避免集中过期TTL = base + random(0, 600) 秒
2. 多级缓存本地缓存兜底,Redis挂了走本地Caffeine + Redis 双层架构
3. 熔断降级Redis不可用时直接返回降级数据Sentinel / Hystrix 熔断 → 返回默认值/静态页面
4. Redis高可用Redis自身高可用,避免单点故障Redis Sentinel / Cluster + 持久化

💻 随机TTL + 多级缓存实现

// Caffeine 本地缓存配置
Cache<String, Object> localCache = Caffeine.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(30, TimeUnit.SECONDS)
    .recordStats()
    .build();

public User getWithMultiLevelCache(Long userId) {
    String key = "user:" + userId;

    // L1: 本地缓存
    User user = localCache.getIfPresent(key);
    if (user != null) return user;

    try {
        // L2: Redis分布式缓存
        user = redis.get(key);
        if (user != null) {
            localCache.put(key, user);  // 回填本地缓存
            return user;
        }
    } catch (Exception e) {
        // Redis故障,尝试降级
        log.warn("Redis unavailable, using fallback", e);
        return getFallbackUser(userId);
    }

    // L3: 数据库
    user = db.findById(userId);
    if (user != null) {
        // 设置随机过期时间,避免雪崩
        int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
        redis.setex(key, ttl, user);
        localCache.put(key, user);
    }
    return user;
}
🚨 缓存设计反模式
  1. 所有数据都缓存:缓存空间宝贵,只缓存热点数据和频繁读取的数据。遵循 80/20 原则。
  2. 缓存无过期时间:会导致内存无限增长,Redis OOM。每个Key必须有TTL。
  3. 大Key(Big Key):单个Key超过10KB(String)或元素超过5000(集合),拆分为多个小Key。
  4. 热Key(Hot Key):单个Key的QPS超过单分片承载能力(通常>5000),需要本地缓存或Key拆分。
  5. 缓存与DB操作不在同一事务:先更新DB成功但删缓存失败,导致长期不一致。