本文目录导读:

Java缓存调用流程如何规范:从设计到落地的完整指南
目录导读
为什么需要规范缓存调用?
在许多Java项目中,缓存的使用往往很随意:开发者在业务逻辑里直接调用Redis.set(),或者在DAO层里写一段if (cache.exists())的判断,这种“谁用谁写”的模式,短期看起来很快,但长期会导致四个问题:
- 数据不一致:更新操作未清除对应缓存,导致脏数据。
- 性能不可控:缓存层被频繁读写,熔断、降级策略缺失。
- 可维护性差:缓存key分散在各处,无法全局管理。
- 排查困难:无法快速判断是缓存数据还是DB数据。
需要一套标准化的Java缓存调用流程,以确保系统稳定、可观测且易于扩展。
缓存的分类与适用场景
标准流程的第一步,是根据数据特征选择缓存类型:
| 缓存类型 | 存储介质 | 适用场景 |
|---|---|---|
| 本地缓存 | HashMap/Caffeine | 少量、不变、高频访问(如配置) |
| 分布式缓存 | Redis | 跨JVM实例共享数据(如用户会话) |
| 二级缓存 | 本地+Redis | 兼顾速度与数据共享 |
关键规范:不要在业务代码中混合使用多个缓存中间件,推荐统一抽象成
CacheTemplate接口,底层可切换实现。
标准化缓存调用流程设计
一个规范的缓存调用流程应当遵循 “先查缓存 -> 查到返回 -> 查库 -> 缓存写入并返回” 的基本模式,许多开发者忽略了回写策略和异常处理。
核心流程步骤:
- 根据key查询缓存(使用统一的key前缀+业务ID)。
- 若命中,返回缓存数据(注意序列化/反序列化的一致性)。
- 若未命中,查询数据库(加锁防止并发穿透)。
- 写入缓存(设置过期时间、序列化方式)。
- 返回数据。
实战代码示例(使用Spring Cache注解):
@Cacheable(value = "user", key = "#userId", unless = "#result == null")
public User getUserById(Long userId) {
return userMapper.selectById(userId);
}
陷阱说明:
@Cacheable默认不会在更新操作后主动清空缓存,因此需要配合@CacheEvict使用。
缓存穿透、击穿、雪崩的防御机制
规范流程必须内置防御措施,否则高并发下缓存层瞬间被打满。
缓存穿透(请求不存在的数据)
- 防御:缓存NULL值并设置短过期时间(30秒)。
- 防御:布隆过滤器(BloomFilter),先判断key是否存在。
缓存击穿(热点key失效)
- 防御:互斥锁(Synchronized + Redis分布式锁)。
- 防御:热点数据永不过期,后台异步刷新。
缓存雪崩(大量key同时过期)
- 防御:基础过期时间 + 随机偏移(+ 随机秒数)。
- 防御:多级缓存(二级缓存兜底)。
示例:使用Redis分布式锁防止击穿
String lockKey = "lock:user:" + userId;
boolean locked = redisClient.lock(lockKey, 3, TimeUnit.SECONDS);
if (locked) {
User user = userMapper.selectById(userId);
cacheClient.set("user:" + userId, user, 3600);
redisClient.unlock(lockKey);
return user;
}
// 等待100ms后重试或降级
return fallbackUser();
缓存与数据库的一致性保障
很多团队失败在“先更新数据库,再删除缓存”还是“先删缓存,再更新DB”,标准的规范推荐:
推荐方案:延迟双删 + 消息队列补偿
- 业务操作:先删除缓存(避免旧缓存污染)。
- 更新数据库。
- 延迟N秒(500ms)后再次删除缓存(因并发可能导致其它线程将旧数据写入)。
- 结合MQ异步重试:若删除失败,重试三次。
代码逻辑示例:
@Service
public class UserService {
public void updateUser(User user) {
String cacheKey = "user:" + user.getId();
// 1. 先删缓存
cacheClient.delete(cacheKey);
// 2. 更新数据库
userMapper.updateById(user);
// 3. 延迟删除(异步)
asyncDelayedDelete(cacheKey, 500);
}
}
注意:对于最终一致性场景,允许短暂不一致,若要求强一致性,建议不缓存或使用读写锁。
监控与退化策略
规范流程中的最后一步,是监控和降级,没有监控的缓存,等于没有眼睛。
- 监控指标:缓存命中率(<80%报警)、缓存平均耗时、缓存超时次数。
- 退化策略:若缓存服务不可用(如Redis宕机),应立刻通过
@Hystrix或Resilience4j熔断降级到数据库查询,而非抛出异常。
配置示例:
resilience4j.circuitbreaker:
instances:
redisCache:
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
QA问答:常见缓存规范陷阱
Q1:为什么不能直接在业务代码中写 redisTemplate.opsForValue().get(key)?
A:这会导致key的管理分散,规范做法是使用统一前缀和过期时间管理,且尽量通过注解或服务层封装,便于全局修改(如切换缓存组件)。
Q2:更新数据时,为什么建议“先删缓存再更新DB”,而不是“先更新DB再删缓存”?
A:若先更新DB,缓存依然是旧数据,此时若有并发读请求,读取到旧值,而先删缓存,后续的读请求会重新查DB并写入新缓存,一致性更好(结合延迟双删可解决并发写旧值问题)。
Q3:布隆过滤器能完全防止缓存穿透吗?
A:不能,布隆过滤器有误判率(可能认为存在的key实际不存在),但可极大降低穿透概率,配合缓存NULL值可基本防住。
Q4:本地缓存和Redis缓存一起使用时,怎么保证一致性?
A:通过消息通知(如Redis Pub/Sub)或配置中心,当Redis中数据变化时,主动失效所有实例的本地缓存,或者,设置本地缓存时间非常短(秒级)。
最佳实践与代码示例
以下是一个符合规范的缓存调用工具类示例:
@Component
public class StandardCacheUtil {
@Autowired
private StringRedisTemplate redisTemplate;
public <T> T getOrSetCache(String key, long ttlSeconds, Supplier<T> dbLoader) {
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, ...);
}
// 加锁防止击穿
String lockKey = "lock:" + key;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
T data = dbLoader.get();
if (data != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(data), ttlSeconds, TimeUnit.SECONDS);
} else {
// 缓存NULL防止穿透
redisTemplate.opsForValue().set(key, "", 30, TimeUnit.SECONDS);
}
return data;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 阻塞等待重试
Thread.sleep(100);
return getOrSetCache(key, ttlSeconds, dbLoader);
}
}
}
| 原则 | 描述 |
|---|---|
| 统一封装 | 所有缓存操作通过一个服务类或注解完成,不直接操作Redis/Jedis |
| 声明式配置 | 使用Spring Cache注解定义缓存行为,而非手写逻辑 |
| 防御性编程 | 内置布隆过滤器、互斥锁、NULL缓存、降级开关 |
| 可观测 | 暴露缓存命中率、错误率指标到Prometheus |
| 自动补偿 | 缓存删除失败时,通过MQ异步重试 |