Java Redis 缓存写入后为何仍重复查询数据库?
结论
从现有代码和 Redis 中的键来看,@Cacheable 配置没有明显问题。Redis 输出中的反斜杠一般只是转义显示,不会造成缓存未命中。
每次请求都出现数据库查询,是因为控制器会先执行:
userService.findById(enterpriseId);
这条查询不受 getActiveJobDetailsWithStats() 上的 @Cacheable 控制。职位列表即使命中了缓存,用户查询仍会访问数据库。
因此,判断职位列表是否命中缓存,要看 getActiveJobDetailsWithStats() 方法内部的日志,不能只根据是否出现 SQL 来判断。
确认缓存是否真的失效
当前日志写在被缓存的方法内部:
log.info("NotFetching from cache");
命中 @Cacheable 后,Spring 不会执行方法体,这条日志也不会打印。可以把日志改得更准确:
log.info(
"Cache miss: enterpriseId={}, page={}, size={}",
enterpriseId,
page,
size
);
连续请求同一个 URL:
/listOfActiveJobsByEnterprise/enterpriseId/17?page=0&size=50
正常情况下:
- 第一次请求会打印
Cache miss,并查询职位、申请数和浏览数。 - 第二次请求仍可能出现
userService.findById(17)产生的 SQL。 - 第二次请求不会再打印
Cache miss,也不会执行职位列表方法中的数据库查询。
出现这种情况,说明缓存已经生效,剩余的 SQL 来自控制器中的用户查询。
处理控制器中的重复查询
如果还需要缓存用户是否存在,可以为这项检查单独建立缓存。相比直接缓存 JPA 实体,缓存布尔值更简单:
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Cacheable(
cacheNames = "enterpriseExists",
key = "#p0",
cacheManager = "customCacheManager"
)
public boolean existsByIdCached(Long enterpriseId) {
return userRepository.existsById(enterpriseId);
}
}
控制器可以改为:
@GetMapping("/listOfActiveJobsByEnterprise/enterpriseId/{enterpriseId}")
public ResponseEntity<List<JobWithStatsDTO>> getActiveJobPostsByEnterpriseId(
@PathVariable Long enterpriseId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
if (!userService.existsByIdCached(enterpriseId)) {
return ResponseEntity.notFound().build();
}
List<JobWithStatsDTO> response =
demandMvpJobDetailsService.getActiveJobDetailsWithStats(
enterpriseId,
page,
size
);
return ResponseEntity.ok(response);
}
还要为该缓存配置 TTL:
private static final Duration ENTERPRISE_EXISTS_TTL = Duration.ofMinutes(10);
cacheConfigs.put(
"enterpriseExists",
defaultConfig.entryTtl(ENTERPRISE_EXISTS_TTL)
);
创建或删除用户时,需要通过 @CacheEvict 清理对应的缓存。否则,在 TTL 到期前可能会读到旧状态:
@CacheEvict(
cacheNames = "enterpriseExists",
key = "#enterpriseId",
cacheManager = "customCacheManager"
)
public void deleteEnterprise(Long enterpriseId) {
userRepository.deleteById(enterpriseId);
}
使用与参数名无关的缓存键
当前表达式通常可以正常工作:
key = "#enterpriseId + '_' + #page + '_' + #size"
不过,它要求 Spring 能获取方法参数名。如果编译时没有保留参数名信息,可能会出问题。可以改用位置参数:
@Cacheable(
cacheNames = "activeJobsWithStats",
key = "#p0 + '_' + #p1 + '_' + #p2",
cacheManager = "customCacheManager"
)
public List<JobWithStatsDTO> getActiveJobDetailsWithStats(
Long enterpriseId,
int page,
int size) {
// ...
}
各位置参数对应如下:
#p0 = enterpriseId
#p1 = page
#p2 = size
也可以省略 key,直接使用 Spring 默认的 SimpleKey:
@Cacheable(
cacheNames = "activeJobsWithStats",
cacheManager = "customCacheManager"
)
每次调用的三个参数相同时,默认键也能命中缓存。
如果方法体每次都会执行
如果第二次请求仍然打印 Cache miss,可以从下面几个方面继续排查。
1. 是否绕过了 Spring 代理
@Cacheable 依靠 Spring AOP 代理工作。同一个类中的内部调用不会经过缓存拦截器,例如:
@Service
public class JobService {
public void someMethod() {
getActiveJobDetailsWithStats(17L, 0, 50);
}
@Cacheable(cacheNames = "activeJobsWithStats")
public List<JobWithStatsDTO> getActiveJobDetailsWithStats(
Long enterpriseId,
int page,
int size) {
// ...
}
}
缓存方法应由另一个 Spring Bean 通过注入的实例调用。题目中的控制器通过 demandMvpJobDetailsService 调用该方法,按照常规 Spring 配置会经过代理,但还需要确认以下几点:
- Service 类带有
@Service。 - Service 实例由 Spring 注入,没有通过
new手动创建。 - 同一个 Service 内没有直接调用该缓存方法。
- 缓存方法是
public。
2. Redis 连接是否指向同一个实例和数据库
检查应用与 redis-cli 使用的主机、端口和 Redis database 编号是否一致。假设应用使用 database 1,可以这样查询:
redis-cli -n 1 EXISTS 'activeJobsWithStats::17_0_50'
redis-cli -n 1 TTL 'activeJobsWithStats::17_0_50'
返回值的含义如下:
EXISTS = 1 键存在
TTL > 0 键将在指定秒数后过期
TTL = -1 键没有过期时间
TTL = -2 键不存在
请求参数也必须完全一致。下面三组参数会生成不同的缓存键:
17, 0, 50
17, 1, 50
17, 0, 20
3. 是否发生反序列化失败
Redis 中存在键,只能说明缓存写入成功,无法证明 Java 能将其读回为 List<JobWithStatsDTO>。
如果 DTO 缺少 Jackson 所需的构造方法或属性定义,读取时可能出现反序列化异常。日志中通常会看到 SerializationException、InvalidDefinitionException 或类型转换异常。如果项目配置了自定义 CacheErrorHandler,并忽略了读取错误,外部表现就可能是数据库被反复查询。
需要确认当前 ObjectMapper 能够还原这些 DTO。例如,为 DTO 提供无参构造方法和可写属性,或者使用合适的 @JsonCreator:
public class JobWithStatsDTO {
public JobWithStatsDTO() {
}
// fields, getters and setters
}
还要检查缓存中记录的类名是否与当前部署版本一致。移动 DTO 的包路径或修改类名后,旧缓存中的类型信息可能无法解析。模型结构升级后,可以清理相关旧键,再重新生成缓存。
反斜杠通常只是显示转义
redis-cli 会转义字符串中的换行、引号和反斜杠,例如:
\n
\"
\\
这只是字符串的显示方式,不参与缓存键比较。缓存能否命中取决于这个键:
activeJobsWithStats::17_0_50
与值中 jobpost、areaOfEvaluation 等字段包含多少反斜杠无关。
可以使用原始输出,并交给 jq 检查 JSON:
redis-cli --raw GET 'activeJobsWithStats::17_0_50' | jq .
如果 jq 能够解析,说明 Redis 中的 JSON 至少语法有效。之后仍要确认 Jackson 能否将其反序列化为目标 DTO。
开启缓存诊断日志
可以临时加入以下日志配置:
logging.level.org.springframework.cache.interceptor=TRACE
logging.level.org.springframework.data.redis.cache=TRACE
查看日志时,主要关注这些信息:
- Spring 计算出的缓存键。
- 查询的缓存名称。
- 当前调用是 cache hit 还是 cache miss。
- 读取缓存值时是否出现反序列化异常。
还可以临时使用 Redis 的 MONITOR 命令观察请求:
redis-cli MONITOR
该命令会输出 Redis 收到的操作。生产环境中应谨慎使用,因为流量较高时会产生大量输出,并带来额外开销。
transactionAware() 的影响
缓存管理器配置了:
.transactionAware()
缓存方法同时带有:
@Transactional(readOnly = true)
这种配置下,缓存写入可能会等到事务成功提交后才执行。第一次调用的方法返回时,缓存未必已经立即可见。事务提交完成后,下一次独立的 HTTP 请求应当能够命中缓存。
如果请求间隔正常,而第二次、第三次请求仍会执行整个方法体,就需要检查 Spring 代理、缓存键、Redis database 和反序列化。
根据现有代码,排查时应先区分两种情况:仍有一条用户查询,或者职位查询方法整体再次执行。前者来自 userService.findById(),是当前控制器逻辑造成的;后者才表示 @Cacheable 没有命中。
备注:内容仅供参考。
