本文目录导读:

- 目录导读
- 缓存过期的“三宗罪”
- 经典案例复盘:一次订单服务的缓存雪崩
- 过期策略深度解析
- 高并发下的四大杀手与对策
- 分布式环境中的一致性难题
- 实战代码模板:Spring Cache + Caffeine 自定义过期策略
- FAQ 问答精选
Java缓存过期实战:从脏数据事故到高并发穿透的全面治理方案
目录导读
- 缓存过期的“三宗罪” – 为什么看似简单的过期策略会引发线上故障
- 经典案例复盘:一次订单服务的缓存雪崩 – 事故时间线、根因与修复过程
- 过期策略深度解析 – 被动过期 vs 主动过期 vs 惰性删除的底层机制
- 高并发下的四大杀手 – 穿透、击穿、雪崩、脏读的完整代码级对策
- 分布式环境中的一致性难题 – Redis + 数据库的最终一致性与双删策略
- 实战代码模板 – Spring Cache + Caffeine 自定义过期策略
- FAQ 问答精选 – 程序员最常踩的 5 个缓存过期坑
缓存过期的“三宗罪”
很多开发者在引入缓存时,只关注“缓存命中率”,却忽略了过期时间的设置,它看似简单,却暗藏三大风险:
- 过期时间过短 → 频繁回源数据库,导致单点数据库压力飙升。
- 过期时间过长或永不过期 → 数据长期不一致,用户看到的是“过期”的脏数据。
- 所有key设置同一过期时间 → 造成“缓存雪崩”,瞬间大量请求打穿缓存。
核心公式:缓存价值 = 数据命中率 × 数据一致性,而过期策略正是这两个维度的平衡杆。
经典案例复盘:一次订单服务的缓存雪崩
事故时间线(某电商平台大促凌晨)
- 00:00 运营团队批量上架了 10000 个商品,每个商品在 Redis 中设置了相同的过期时间为
3600秒(凌晨1点整过期)。 - 00:58 大量用户开始抢购,缓存命中率正常。
- 01:00 所有商品缓存同时过期,Redis 中几乎无 key 存在。
- 01:00:02 全部请求绕过缓存,直接打到 MySQL 上,数据库连接池瞬间耗尽。
- 01:05 数据库 CPU 100%,产生大量慢查询,订单业务中断,持续40分钟。
根因分析
- 所有 key 过期时间相同(无随机偏移)。
- 没有设置“缓存空值”或“布隆过滤器”兜底。
- 热点 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后,延时双删” + “消息队列异步补偿”
- 服务A更新 DB → 删除缓存。
- 服务B在 DB 更新后,发一条
CacheInvalidationMsg到 MQ。 - 消费者消费消息,再次删除对应缓存。
- 若删除失败,则补偿重试(最多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) { ... }
注意:expireAfterAccess 和 expireAfterWrite 默认不能同时使用(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)持续观察命中率与过期峰值请求量。没有完美的过期策略,只有不断迭代的“动态调整”。