PAYATHON 2026

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

正常情况下:

  1. 第一次请求会打印 Cache miss,并查询职位、申请数和浏览数。
  2. 第二次请求仍可能出现 userService.findById(17) 产生的 SQL。
  3. 第二次请求不会再打印 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 没有命中。

备注:内容仅供参考。