Java缓存过期案例

wen java案例 2

本文目录导读:

Java缓存过期案例

  1. 目录导读
  2. 缓存过期的“三宗罪”
  3. 经典案例复盘:一次订单服务的缓存雪崩
  4. 过期策略深度解析
  5. 高并发下的四大杀手与对策
  6. 分布式环境中的一致性难题
  7. 实战代码模板:Spring Cache + Caffeine 自定义过期策略
  8. FAQ 问答精选

Java缓存过期实战:从脏数据事故到高并发穿透的全面治理方案

目录导读

  1. 缓存过期的“三宗罪” – 为什么看似简单的过期策略会引发线上故障
  2. 经典案例复盘:一次订单服务的缓存雪崩 – 事故时间线、根因与修复过程
  3. 过期策略深度解析 – 被动过期 vs 主动过期 vs 惰性删除的底层机制
  4. 高并发下的四大杀手 – 穿透、击穿、雪崩、脏读的完整代码级对策
  5. 分布式环境中的一致性难题 – Redis + 数据库的最终一致性与双删策略
  6. 实战代码模板 – Spring Cache + Caffeine 自定义过期策略
  7. FAQ 问答精选 – 程序员最常踩的 5 个缓存过期坑

缓存过期的“三宗罪”

很多开发者在引入缓存时,只关注“缓存命中率”,却忽略了过期时间的设置,它看似简单,却暗藏三大风险:

  1. 过期时间过短 → 频繁回源数据库,导致单点数据库压力飙升。
  2. 过期时间过长或永不过期 → 数据长期不一致,用户看到的是“过期”的脏数据。
  3. 所有key设置同一过期时间 → 造成“缓存雪崩”,瞬间大量请求打穿缓存。

核心公式缓存价值 = 数据命中率 × 数据一致性,而过期策略正是这两个维度的平衡杆。


经典案例复盘:一次订单服务的缓存雪崩

事故时间线(某电商平台大促凌晨)

  • 00:00 运营团队批量上架了 10000 个商品,每个商品在 Redis 中设置了相同的过期时间为 3600秒(凌晨1点整过期)。
  • 00:58 大量用户开始抢购,缓存命中率正常。
  • 01:00 所有商品缓存同时过期,Redis 中几乎无 key 存在。
  • 01:00:02 全部请求绕过缓存,直接打到 MySQL 上,数据库连接池瞬间耗尽。
  • 01:05 数据库 CPU 100%,产生大量慢查询,订单业务中断,持续40分钟。

根因分析

  1. 所有 key 过期时间相同(无随机偏移)。
  2. 没有设置“缓存空值”或“布隆过滤器”兜底。
  3. 热点 key 在过期瞬间无锁保护,导致重复查询数据库。

修复方案

// 过期时间增加随机偏移,避免集体失效
int baseTtl = 3600;
int randomTtl = baseTtl + new Random().nextInt(300); // 上下浮动5分钟
redisTemplate.opsForValue().set(key, value, randomTtl, TimeUnit.SECONDS);

过期策略深度解析

Java 环境下常用的缓存框架(Redis、Caffeine、Guava Cache)都基于以下三种过期机制:

策略 触发方式 优点 缺点 典型实现
被动过期 每次访问时检查是否过期 无额外开销 过期不清理,占用内存 Redis 的 expireIfNeeded
主动过期 后台定时任务扫描 内存及时释放 CPU 开销高 Redis 的 activeExpireCycle
惰性删除 获取数据时才判断 实现简单 可能返回过期的脏数据 Caffeine 的 expireAfterWrite

关键点:在 Redis 中,被动 + 主动组合使用,但 **如果你用 Java 的 @Cacheable 注解而不设置 timeToLive,Spring 默认使用永不失效SimpleKeyGenerator,这会导致内存无限增长。


高并发下的四大杀手与对策

缓存穿透(查询不存在的数据)

现象:恶意请求查询一个不存在的 id,缓存永远不命中,直接打 DB。 对策

// 1. 缓存空值,设置短过期(30~60秒)
// 2. 使用布隆过滤器(BitMap)预先拦截不存在的 key
if (!bloomFilter.mightContain(id)) {
    return null; // 直接拒绝
}

缓存击穿(热点 key 过期)

现象:某个超高并发 key 过期的瞬间,大量请求同时回源。 对策互斥锁(双检锁)

public String getData(String key) {
    String value = redis.get(key);
    if (value == null) {
        synchronized (this) { // 集群环境用分布式锁
            value = redis.get(key); // 再次检查
            if (value == null) {
                value = db.query(); // 回源
                redis.set(key, value, ttl);
            }
        }
    }
    return value;
}

缓存雪崩(大量 key 同时过期)

对策

  • 过期时间加随机偏移(如上面案例)。
  • 多级缓存:本地 Caffeine(短 TTL)+ Redis(长 TTL)。
  • 永不过期 + 后台线程主动更新(逻辑过期)。

脏读(数据库更新后缓存未失效)

经典场景:用户修改个人信息,DB 更新成功,但缓存仍是旧值。 对策

@Transactional
public void updateUser(User user) {
    userMapper.update(user);
    // 先更新 DB,再删除缓存
    redisTemplate.delete("user:" + user.getId());
    // 或者延迟双删:等待 500ms 再删一次,处理极端并发
    Thread.sleep(500);
    redisTemplate.delete("user:" + user.getId());
}

分布式环境中的一致性难题

问题:在微服务架构下,两个服务同时读写同一个缓存,如何保证最终一致?

推荐方案:“更新DB后,延时双删” + “消息队列异步补偿”

  1. 服务A更新 DB → 删除缓存。
  2. 服务B在 DB 更新后,发一条 CacheInvalidationMsg 到 MQ。
  3. 消费者消费消息,再次删除对应缓存。
  4. 若删除失败,则补偿重试(最多3次),保证最终一致性。

实战代码模板:Spring Cache + Caffeine 自定义过期策略

@Configuration
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(5, TimeUnit.MINUTES)  // 写后5分钟过期
            .expireAfterAccess(2, TimeUnit.MINUTES) // 2分钟未访问则过期
            .maximumSize(10_000));                 // 最大条目数
        return manager;
    }
}
// 使用方式
@Cacheable(cacheNames = "userCache", key = "#id",
           unless = "#result == null") // 不缓存空值
public User getUserById(Long id) { ... }

注意expireAfterAccessexpireAfterWrite 默认不能同时使用(Caffeine要求二选一),除非指定 expireAfter(Expiry) 自定义实现。


FAQ 问答精选

Q1: 缓存设置多长过期时间最合适?

A:没有固定值,建议基于业务容忍度,商品库存 5 秒;用户基本信息 30 分钟;配置类数据 1 小时,原则:越频繁变化的数据 TTL 越短

Q2: Redis 过期 key 没被清除,内存涨了怎么办?

A:使用 redis-cli --scan --pattern "*" | xargs -L 1000 redis-cli del 手动清理,同时开启 maxmemory-policy allkeys-lru

Q3: 如何在 Java 中监控缓存命中率与过期数量?

A:集成 Micrometer 或 Actuator,Redis 原生提供 INFO stats 中的 expired_keys,Caffeine 提供 CacheStats

Q4: 缓存和数据库双写时,先删缓存还是先更新DB?

A推荐“先更新 DB → 再删缓存”,先删缓存会导致在 DB 更新期间,其他线程读到旧 DB 值并把旧值写回缓存,导致缓存永远是脏数据。

Q5: 分布式锁实现缓存击穿时,锁的粒度如何控制?

A:锁的 key 建议使用 lock:cache:user:{id},粒度细到具体业务 key 级别,避免锁整体缓存名空间导致并发下降。

Q6: 如何防止缓存值过大导致 Redis 网络 IO 阻塞?

A:限制缓存值大小(如 JSON 压缩),超过 10KB 时考虑拆分成多个 key,或使用 Redis 的 memory 限额配合逐出策略。

Q7: 为什么加了过期时间,还是偶尔出现数据不一致?

A:因为逻辑时钟问题,数据库更新耗时 > 缓存 TTL,建议采用 version 版本号字段,每次更新 version+1,缓存 key 带版本号,读取时校验版本。


缓存过期不是“设个时间”那么简单,它涉及内存管理、并发控制、分布式一致性等多个层面,建议在项目上线前,对缓存 TTL 做压测,并用监控平台(Prometheus + Grafana)持续观察命中率与过期峰值请求量。没有完美的过期策略,只有不断迭代的“动态调整”

抱歉,评论功能暂时关闭!