跳到主内容
创建时间: 2026-10-04最后更新: 2026-10-04

监控页面可能反复读取同一项服务的名称、分组和启用状态。这些元数据变化较少,可以用缓存减少数据库查询。但是,探测目标是否存活、应用是否就绪,都需要反映当下的事实,不能用缓存中的 UP 代替实际检查。

本章在 Java 21、Spring Boot 4.1.1 的项目中为 monitored_service 增加 Redis 缓存,沿用 JPA 关联、审计与乐观锁章节的实体和 V1 至 V5 迁移。Redis 保存可重建的副本,PostgreSQL 仍然是事实来源,不新增数据库迁移。

1. 先定义允许缓存的边界

数据本章是否缓存原因
服务名称、分组编号、启用状态是页面展示可以接受短暂旧值
实时探测结果否缓存会掩盖刚发生的故障
Actuator readiness/liveness否平台需要根据当前状态决定流量和重启
用户是否拥有分组访问权否撤销权限应立即影响下一次请求

这里的 enabled 只供页面展示。调度器决定是否执行探测时继续读取数据库,不能因为缓存尚未过期而继续检查已停用的目标。权限校验也必须在访问缓存之前独立执行,每次命中缓存不能跳过资源归属检查。

采用的读取路径是:先查 Redis,未命中再查 PostgreSQL,把查询结果存入 Redis,最后返回 DTO。缓存最多保留 30 秒,更新提交后主动删除对应条目。这个约定是允许短暂旧值的缓存方案,不是数据库和 Redis 之间的强一致事务。

Spring Cache 负责方法拦截,Redis 负责存储。@Cacheable 命中时方法体不会执行,这也是不能把权限校验和缓存查询一起放在被缓存方法内部的原因。Spring Boot 缓存文档

2. 依赖与连接配置

保留现有 JDBC、JPA、Web MVC 和 PostgreSQL 依赖,再加入两个 Starter,版本仍交给 Boot Parent 管理:

pom.xml
1
<dependency>
2
<groupId>org.springframework.boot</groupId>
3
<artifactId>spring-boot-starter-cache</artifactId>
4
</dependency>
5
<dependency>
6
<groupId>org.springframework.boot</groupId>
7
<artifactId>spring-boot-starter-data-redis</artifactId>
8
</dependency>

在现有 Compose 文件的 services 下合并 Redis 服务,不要覆盖 PostgreSQL 配置:

compose.yaml
1
services:
2
redis:
3
image: redis:7.4-alpine
4
ports:
5
- "127.0.0.1:6379:6379"

这个服务只用于本地学习,不开放到公网。生产环境使用私有网络、认证和必要的 TLS;缓存可重建,因此本例没有为 Redis 配置业务数据卷。

src/main/resources/application.yaml
1
spring:
2
data:
3
redis:
4
host: ${REDIS_HOST:localhost}
5
port: ${REDIS_PORT:6379}
6
connect-timeout: 500ms
7
timeout: 500ms

这两个超时限制 Redis 故障对正常请求的拖累,最终数值应结合部署网络测量。已有 spring 配置应合并到同一节点,不重复声明顶层键。

3. 用明确类型保存 JSON

不要直接缓存 JPA 实体。实体可能含延迟代理、双向关联和持久化上下文状态,跨请求反序列化之后也不再是受管理实体。为这个缓存单独定义一个小型读取结果:

src/main/java/cn/usehook/spring/monitoring/MonitoredServiceMetadata.java
1
package cn.usehook.spring.monitoring;
2
3
public record MonitoredServiceMetadata(
4
long id, String name, long groupId, boolean enabled, long version
5
) {}

独立配置类启用缓存,并指定键前缀、有效期和序列化方式:

src/main/java/cn/usehook/spring/config/MetadataCacheConfiguration.java
01
package cn.usehook.spring.config;
02
03
import cn.usehook.spring.monitoring.MonitoredServiceMetadata;
04
import java.time.Duration;
05
import java.util.Map;
06
import org.springframework.cache.annotation.EnableCaching;
07
import org.springframework.context.annotation.Bean;
08
import org.springframework.context.annotation.Configuration;
09
import org.springframework.data.redis.cache.RedisCacheConfiguration;
10
import org.springframework.data.redis.cache.RedisCacheManager;
11
import org.springframework.data.redis.cache.RedisCacheWriter;
12
import org.springframework.data.redis.connection.RedisConnectionFactory;
13
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
14
import org.springframework.data.redis.serializer.RedisSerializationContext;
15
import tools.jackson.databind.json.JsonMapper;
16
17
@Configuration(proxyBeanMethods = false)
18
@EnableCaching
19
public class MetadataCacheConfiguration {
20
@Bean
21
RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
22
var serializer = new JacksonJsonRedisSerializer<>(
23
JsonMapper.builder().build(), MonitoredServiceMetadata.class
24
);
25
var config = RedisCacheConfiguration.defaultCacheConfig()
26
.entryTtl(Duration.ofSeconds(30))
27
.disableCachingNullValues()
28
.prefixCacheNameWith("spring:metadata:v1:")
29
.serializeValuesWith(
30
RedisSerializationContext.SerializationPair.fromSerializer(serializer)
31
);
32
var writer = RedisCacheWriter.create(
33
connectionFactory, options -> options.immediateWrites()
34
);
35
return RedisCacheManager.builder(writer)
36
.withInitialCacheConfigurations(Map.of("service-metadata", config))
37
.disableCreateOnMissingCache()
38
.build();
39
}
40
}

缓存名称只有一个,禁止拼错名称时自动创建新缓存。默认的字符串键序列化器会把编号转成字符串,例如编号 77 对应 spring:metadata:v1:service-metadata::77。部署多个环境时,应使用不同 Redis 实例或不同环境前缀;改变 DTO 的不兼容结构时升级 v1,避免新代码读取旧结构。

本例使用 Jackson 3 的 tools.jackson 类型和 JacksonJsonRedisSerializer,不是旧教程中的 Jackson2JsonRedisSerializer。指定目标 record 后无需开启任意类型反序列化,也不需要缓存实体类名。序列化器 API

Spring Data Redis 4 的部分默认写操作可以异步完成。这里显式使用 immediateWrites(),使写入错误能够在当前调用中处理,便于理解后面的故障策略。Redis 4 迁移说明

4. 缓存读取,提交后删除

创建独立查询 Bean,由已有 Controller 或通过权限校验的 Service 调用;不能通过同一对象的 this.get() 调用绕过代理:

src/main/java/cn/usehook/spring/monitoring/MetadataQueryService.java
01
package cn.usehook.spring.monitoring;
02
03
import org.springframework.cache.annotation.Cacheable;
04
import org.springframework.http.HttpStatus;
05
import org.springframework.stereotype.Service;
06
import org.springframework.transaction.annotation.Transactional;
07
import org.springframework.web.server.ResponseStatusException;
08
09
@Service
10
public class MetadataQueryService {
11
private final MonitoredServiceJpaRepository repository;
12
13
public MetadataQueryService(MonitoredServiceJpaRepository repository) {
14
this.repository = repository;
15
}
16
17
@Cacheable(cacheNames = "service-metadata", key = "#p0")
18
@Transactional(readOnly = true)
19
public MonitoredServiceMetadata get(long id) {
20
var entity = repository.findById(id)
21
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
22
"Monitoring resource not found"));
23
return new MonitoredServiceMetadata(
24
entity.getId(), entity.getName(), entity.getGroup().getId(),
25
entity.isEnabled(), entity.getVersion()
26
);
27
}
28
}

#p0 指第一个参数,不依赖编译器是否保留参数名。查询不到时抛出带 404 状态的 ResponseStatusException,不缓存空值。前文统一异常处理器会重新抛出实现了 ErrorResponse 的异常,由问题详情处理器保留这个状态;普通 IllegalArgumentException 会进入兜底 500,不能用它表示资源不存在。大量随机编号造成的数据库访问仍需限流,不能只靠“缓存空值”抵挡所有异常流量。

写入服务继续使用 JPA 脏检查和乐观锁,在同一事务里发布“元数据已修改”事件:

src/main/java/cn/usehook/spring/monitoring/MetadataChanged.java
1
package cn.usehook.spring.monitoring;
2
3
public record MetadataChanged(long serviceId) {}
src/main/java/cn/usehook/spring/monitoring/MonitoringService.java
01
package cn.usehook.spring.monitoring;
02
03
import org.springframework.context.ApplicationEventPublisher;
04
import org.springframework.http.HttpStatus;
05
import org.springframework.stereotype.Service;
06
import org.springframework.transaction.annotation.Transactional;
07
import org.springframework.web.server.ResponseStatusException;
08
09
@Service
10
public class MonitoringService {
11
private final MonitoredServiceJpaRepository repository;
12
private final ApplicationEventPublisher events;
13
14
public MonitoringService(
15
MonitoredServiceJpaRepository repository, ApplicationEventPublisher events
16
) {
17
this.repository = repository;
18
this.events = events;
19
}
20
21
@Transactional
22
public void changeEnabled(long id, boolean enabled) {
23
var entity = repository.findById(id)
24
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
25
"Monitoring resource not found"));
26
entity.changeEnabled(enabled);
27
events.publishEvent(new MetadataChanged(id));
28
}
29
}

这是审计与乐观锁章节 MonitoringService 的替换版本,仅展示受信任的内部写入。对用户开放的入口继续使用资源级权限章节的 AuthorizedMonitoringService.changeEnabled,在它完成成员检查的事务中追加 MetadataChanged 发布,不要用这里的无身份参数方法替换受保护入口。重命名、修改分组、删除等影响 DTO 的路径也应发布相同事件。不要在写事务里调用缓存读取方法,它可能观察并缓存尚未提交的修改。

src/main/java/cn/usehook/spring/monitoring/MetadataCacheInvalidator.java
01
package cn.usehook.spring.monitoring;
02
03
import java.util.Objects;
04
import org.slf4j.Logger;
05
import org.slf4j.LoggerFactory;
06
import org.springframework.cache.Cache;
07
import org.springframework.cache.CacheManager;
08
import org.springframework.stereotype.Component;
09
import org.springframework.transaction.event.TransactionPhase;
10
import org.springframework.transaction.event.TransactionalEventListener;
11
12
@Component
13
public class MetadataCacheInvalidator {
14
private static final Logger log = LoggerFactory.getLogger(MetadataCacheInvalidator.class);
15
private final Cache cache;
16
17
public MetadataCacheInvalidator(CacheManager manager) {
18
this.cache = Objects.requireNonNull(manager.getCache("service-metadata"));
19
}
20
21
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
22
public void onChanged(MetadataChanged event) {
23
try {
24
cache.evictIfPresent(event.serviceId());
25
} catch (RuntimeException failure) {
26
log.warn("Metadata cache eviction failed, serviceId={}", event.serviceId(), failure);
27
}
28
}
29
}

必须在提交成功后删除。提前删除会让并发请求从尚未更新的数据库读出旧值,再把它放回 Redis;事务回滚时也不应该因为一次未生效的写入改变缓存策略。AFTER_COMMIT 不会让 Redis 删除与 PostgreSQL 提交组成一个原子操作;进程在二者之间退出时仍可能遗漏删除,TTL 是本例的最终兜底。

5. 明确 Redis 故障时的行为

默认缓存异常可能让业务请求失败。本章允许元数据展示退回数据库,因此只把缓存读写异常降级;数据库查询失败仍直接向上抛出:

src/main/java/cn/usehook/spring/config/CacheFailureConfiguration.java
01
package cn.usehook.spring.config;
02
03
import org.slf4j.Logger;
04
import org.slf4j.LoggerFactory;
05
import org.springframework.cache.Cache;
06
import org.springframework.cache.annotation.CachingConfigurer;
07
import org.springframework.cache.interceptor.CacheErrorHandler;
08
import org.springframework.cache.interceptor.SimpleCacheErrorHandler;
09
import org.springframework.context.annotation.Configuration;
10
11
@Configuration(proxyBeanMethods = false)
12
public class CacheFailureConfiguration implements CachingConfigurer {
13
private static final Logger log = LoggerFactory.getLogger(CacheFailureConfiguration.class);
14
15
@Override
16
public CacheErrorHandler errorHandler() {
17
return new SimpleCacheErrorHandler() {
18
@Override
19
public void handleCacheGetError(RuntimeException error, Cache cache, Object key) {
20
log.warn("Cache read failed, cache={}", cache.getName(), error);
21
}
22
23
@Override
24
public void handleCachePutError(
25
RuntimeException error, Cache cache, Object key, Object value
26
) {
27
log.warn("Cache write failed, cache={}", cache.getName(), error);
28
}
29
};
30
}
31
}

手动调用 cache.evictIfPresent() 不经过缓存注解的错误处理器,所以前一节在监听器内部单独捕获了失效错误。失败必须进入告警和监控,不能只吞掉异常。数据库已经提交,不能向用户承诺“更新失败已回滚”;此时页面可能暂时读取旧缓存。

Redis 长时间故障时,每次请求都可能等待超时并访问数据库。生产环境应限制入口并发、监控数据库压力,并根据故障率临时禁用缓存访问。不要无限重试 Redis,更不要自动切换为每个实例各自永久保存的一份本地缓存。

6. 验证命中、回滚与提交失效

验证应经过 Spring 代理和真实 Redis,直接 new MetadataQueryService() 无法验证 @Cacheable。沿用 PostgreSQL 的 TestcontainersConfiguration,另外导入下面的 Redis 容器配置;容器依赖已经由 Testcontainers 章节提供:

src/test/java/cn/usehook/spring/RedisTestConfiguration.java
01
package cn.usehook.spring;
02
03
import org.springframework.boot.test.context.TestConfiguration;
04
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
05
import org.springframework.context.annotation.Bean;
06
import org.testcontainers.containers.GenericContainer;
07
import org.testcontainers.utility.DockerImageName;
08
09
@TestConfiguration(proxyBeanMethods = false)
10
public class RedisTestConfiguration {
11
@Bean
12
@ServiceConnection(name = "redis")
13
GenericContainer<?> redisContainer() {
14
return new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))
15
.withExposedPorts(6379);
16
}
17
}

这个测试隔离 JPA 数据读取,只验证真实缓存与真实事务提交之间的边界;JPA 更新本身仍由前文数据库测试覆盖:

src/test/java/cn/usehook/spring/monitoring/MetadataCacheTests.java
01
package cn.usehook.spring.monitoring;
02
03
import cn.usehook.spring.RedisTestConfiguration;
04
import cn.usehook.spring.TestcontainersConfiguration;
05
import java.util.Optional;
06
import org.junit.jupiter.api.Test;
07
import org.springframework.beans.factory.annotation.Autowired;
08
import org.springframework.boot.test.context.SpringBootTest;
09
import org.springframework.context.ApplicationEventPublisher;
10
import org.springframework.context.annotation.Import;
11
import org.springframework.security.oauth2.jwt.JwtDecoder;
12
import org.springframework.test.context.bean.override.mockito.MockitoBean;
13
import org.springframework.transaction.PlatformTransactionManager;
14
import org.springframework.transaction.support.TransactionTemplate;
15
import static org.assertj.core.api.Assertions.assertThat;
16
import static org.mockito.Mockito.*;
17
18
@SpringBootTest(properties = {
19
"app.monitoring.initial-delay=1h",
20
"app.monitoring.jobs.enabled=false",
21
"app.alerts.enabled=false",
22
"app.history.retention.enabled=false"
23
})
24
@Import({TestcontainersConfiguration.class, RedisTestConfiguration.class})
25
class MetadataCacheTests {
26
@Autowired MetadataQueryService query;
27
@Autowired ApplicationEventPublisher events;
28
@Autowired PlatformTransactionManager transactions;
29
@MockitoBean MonitoredServiceJpaRepository repository;
30
@MockitoBean JwtDecoder jwtDecoder;
31
32
@Test
33
void onlyCommittedChangesInvalidateTheCache() {
34
var entity = mock(MonitoredServiceEntity.class);
35
var group = mock(MonitoringGroupEntity.class);
36
when(entity.getId()).thenReturn(77L);
37
when(entity.getName()).thenReturn("inventory");
38
when(entity.getGroup()).thenReturn(group);
39
when(group.getId()).thenReturn(3L);
40
when(entity.isEnabled()).thenReturn(true);
41
when(repository.findById(77L)).thenReturn(Optional.of(entity));
42
assertThat(query.get(77).enabled()).isTrue();
43
assertThat(query.get(77).enabled()).isTrue();
44
verify(repository, times(1)).findById(77L);
45
46
when(entity.isEnabled()).thenReturn(false);
47
var tx = new TransactionTemplate(transactions);
48
tx.executeWithoutResult(status -> {
49
events.publishEvent(new MetadataChanged(77));
50
status.setRollbackOnly();
51
});
52
assertThat(query.get(77).enabled()).isTrue();
53
tx.executeWithoutResult(status -> events.publishEvent(new MetadataChanged(77)));
54
assertThat(query.get(77).enabled()).isFalse();
55
verify(repository, times(2)).findById(77L);
56
}
57
}

本测试不要添加覆盖整个方法的 @Transactional,否则事件始终等不到真正提交。测试延后原有调度任务,同时关闭后续数据库任务调度器和告警投递;只调整 initial-delay 不会影响新调度器独立声明的轮询时间。已有 @MockitoBean JwtDecoder 使测试无需访问真实身份提供方,保留它以隔离外部认证边界。可在本机具备 Docker 时运行 ./mvnw -Dtest=MetadataCacheTests test。

另做一次故障演练:停止本地 Redis 后读取元数据,预期仍从数据库返回;更新元数据时数据库应提交并留下失效失败日志。恢复 Redis 后,旧条目应随 TTL 消失,不能永久保持旧值。

7. 多实例的一致性边界

共享 Redis 使实例使用同一缓存空间,但并不自动解决“旧查询晚到”的竞争:请求 A 从数据库读出旧值,请求 B 提交更新并删除缓存,A 最后把旧值写回 Redis。这种旧值可在写回后的 30 秒内继续存在;如果 A 本身运行很慢,总体陈旧时间还包含这段查询时间。

因此,TTL 不是从数据库提交时刻起严格计算的最大延迟,@Cacheable(sync = true) 也不是跨实例的业务分布式锁。权限判断、扣款和停用控制等需要立即生效的路径应直接读数据库。必须提高元数据缓存的一致性时,再采用带版本条件的写入、可靠失效任务或数据库变更订阅,并验证乱序处理。

后面的应用事件与告警章节会介绍 Outbox,可把“必须最终执行的缓存失效”也保存成事务内任务。即便如此,缓存仍存在传播窗口,不能把消息重试误称为强一致。Redis Cache 配置与过期语义